G1 step 4: the Gemini App (rendering, links, history, address line)

Gemtext as Q75 has it: headings bold (# in the accent colour), lists
with a middle dot, quotes muted, links as » labels, preformatted lines
unwrapped and scrolled sideways together; other text/* as is. Pages are
wrapped once for each line's first row (4 bytes a line) and only the
lines on screen are wrapped again to draw. Tab and Shift+Tab move
between links, Enter follows (relative links resolved), Back or Delete
go back to where the page was scrolled, g opens the address line.
Non-Gemini links say so in the URL bar; a changed certificate opens a
dialog; errors and refusals get a page with a "Try again" link; a page
only partly in memory says why at its end.

A status message timed with millis() after the loop's clock read was
cleared before it was drawn (unsigned wrap): now a signed comparison,
as for the toasts in M0.

Verified on the device: start page, Project Gemini, its relative news/
link, Back with the scroll restored, and the YouTube link refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-05 01:07:23 +02:00
co-authored by Claude Opus 5.5
parent 7b8d9391a4
commit 10f3ff7a4c
3 changed files with 424 additions and 0 deletions
+358
View File
@@ -0,0 +1,358 @@
#include "gemini_app.h"
#include <Arduino.h>
#include <algorithm>
#include "gemini_url.h"
#include "text_wrap.h"
#include "ui/fonts.h"
#include "ui/theme.h"
#include "ui/widgets.h"
namespace roro {
using namespace gemini;
namespace {
constexpr int kUrlBar = 10; // the small line with the URL at the top
constexpr int kMargin = 3;
constexpr const char* kStartUrl = "about:start";
constexpr size_t kMaxHistory = 20; // Q77
const char* const kStartPage =
"# Gemini\n"
"Tab picks a link, Enter follows it, Back returns, g types an address.\n"
"## Start here\n"
"=> gemini://geminiprotocol.net/ Project Gemini\n"
"=> gemini://kennedy.gemi.dev/ Kennedy: search Geminispace\n"
"=> gemini://skyjake.fi/~Cosmos/ Cosmos: what's new on Geminispace\n"
"=> gemini://bbs.geminispace.org/ Bubble: a bulletin board\n";
// How each line type looks: its prefix, the indent of its wrapped rows, font and colour.
struct Style {
const char* prefix;
int indent; // characters
const lgfx::U8g2font* font;
uint16_t color;
};
Style styleOf(LineType t) {
switch (t) {
case LineType::Heading1: return {"", 0, &fonts::bold, theme::kAccent};
case LineType::Heading2: return {"", 0, &fonts::bold, theme::kText};
case LineType::Heading3: return {"", 0, &fonts::bold, theme::kMuted};
case LineType::ListItem: return {"\xC2\xB7 ", 2, &fonts::body, theme::kText}; // ·
case LineType::Quote: return {"> ", 2, &fonts::body, theme::kMuted};
case LineType::Link: return {"\xC2\xBB ", 2, &fonts::body, theme::kAccent}; // »
case LineType::Preformatted: return {"", 0, &fonts::body, theme::kText};
default: return {"", 0, &fonts::body, theme::kText};
}
}
} // namespace
void GeminiApp::onEnter() {
if (page_.url.empty() && !loading_) showMessage(kStartUrl, kStartPage);
requestRedraw();
}
void GeminiApp::showMessage(const std::string& url, const std::string& gemtext) {
GeminiPage p;
p.outcome = GeminiPage::Outcome::Ok;
p.url = url;
p.header.status = 20;
p.text.append(gemtext.data(), gemtext.size());
p.text.finish();
show(std::move(p));
}
void GeminiApp::navigate(const std::string& target, bool remember) {
if (!isGemini(target)) {
status_ = "Not Gemini: " + target;
statusMs_ = millis();
requestRedraw();
return;
}
if (!gemini_.fetch(target)) return;
if (remember && !page_.url.empty()) {
history_.push_back({page_.url, topRow_});
if (history_.size() > kMaxHistory) history_.erase(history_.begin());
}
loading_ = true;
loadingUrl_ = target;
status_.clear();
requestRedraw();
}
void GeminiApp::show(GeminiPage&& page) {
page_ = std::move(page);
types_.clear();
links_.clear();
bool pre = false;
for (size_t i = 0; i < page_.text.lineCount(); i++) {
LineType t = parseGemLine(page_.text.line(i), pre).type;
if (page_.header.mimeType() != "text/gemini") t = LineType::Preformatted; // other text/*: as is
types_.push_back(static_cast<uint8_t>(t));
if (t == LineType::Link) links_.push_back(i);
}
layoutDirty_ = true;
topRow_ = hscroll_ = 0;
selected_ = -1;
requestRedraw();
}
void GeminiApp::update(uint32_t nowMs) {
GeminiPage result;
if (gemini_.takeResult(result)) {
loading_ = false;
std::string url = result.url;
switch (result.outcome) {
case GeminiPage::Outcome::CertificateChanged:
pendingCert_ = std::move(result);
certDialog_.reset(new DialogModel({"Cancel", "Trust it"}));
break;
case GeminiPage::Outcome::Failed:
showMessage(url, "# Can't open this page\n" + result.error + "\n=> " + url + " Try again\n");
break;
case GeminiPage::Outcome::Ok:
switch (result.header.category()) {
case Category::Success:
if (result.header.mimeType().rfind("text/", 0) == 0) show(std::move(result));
else showMessage(url, "# Not a text page\nThis is " + result.header.mimeType() +
", which the Cardputer can't show.\n");
break;
case Category::Input:
showMessage(url, "# " + result.header.meta + "\nInput prompts come in the next step.\n");
break;
default:
showMessage(url, "# " + std::to_string(result.header.status) + " " + result.header.meta +
"\nThe server refused this page.\n=> " + url + " Try again\n");
break;
}
break;
}
requestRedraw();
}
// Signed: statusMs_ may be a few ms ahead of this pass's nowMs (set by a key handled after it).
if (!status_.empty() && static_cast<int32_t>(nowMs - statusMs_) > 4000) {
status_.clear();
requestRedraw();
}
}
int GeminiApp::rowsOnScreen() const { return (theme::kContent.h - kUrlBar) / theme::kLineHeight; }
LineType typeOf(const std::vector<uint8_t>& types, size_t i) { return static_cast<LineType>(types[i]); }
GemLine GeminiApp::lineAt(size_t i) const {
bool pre = typeOf(types_, i) == LineType::Preformatted;
GemLine g = parseGemLine(page_.text.line(i), pre);
if (typeOf(types_, i) == LineType::Preformatted) {
g.type = LineType::Preformatted;
g.text = page_.text.line(i);
}
return g;
}
// A line's rows, as drawn: prefix on the first, indent on the rest; preformatted lines don't wrap.
std::vector<std::string> GeminiApp::rowsOf(Canvas& c, size_t i) const {
GemLine g = lineAt(i);
if (g.type == LineType::PreToggle) return {};
if (g.type == LineType::Preformatted) return {displayText(g.text)};
Style st = styleOf(g.type);
c.setFont(st.font);
MeasureText measure = [&c](const std::string& s) { return static_cast<int>(c.textWidth(s.c_str())); };
int width = theme::kWidth - 2 * kMargin;
std::string indent(st.indent, ' ');
std::vector<std::string> rows = wrapText(st.prefix + displayText(g.text), width, measure);
if (rows.empty()) rows.push_back("");
// Rows after the first: wrap again, narrower, behind the indent.
if (rows.size() > 1 && st.indent > 0) {
std::string rest;
for (size_t r = 1; r < rows.size(); r++) rest += (r > 1 ? " " : "") + rows[r];
std::vector<std::string> more = wrapText(rest, width - measure(indent), measure);
rows.resize(1);
for (auto& m : more) rows.push_back(indent + m);
}
return rows;
}
void GeminiApp::layout(Canvas& c) {
firstRow_.assign(page_.text.lineCount() + 1, 0);
uint32_t row = 0;
for (size_t i = 0; i < page_.text.lineCount(); i++) {
firstRow_[i] = row;
row += rowsOf(c, i).size();
}
firstRow_.back() = row;
layoutDirty_ = false;
}
void GeminiApp::scrollTo(int row) {
int total = firstRow_.empty() ? 0 : static_cast<int>(firstRow_.back());
topRow_ = std::max(0, std::min(row, std::max(0, total - rowsOnScreen())));
requestRedraw();
}
void GeminiApp::selectLink(int direction) {
if (links_.empty()) return;
int count = static_cast<int>(links_.size());
if (selected_ < 0) {
// The first link on screen, or after it when going forward.
selected_ = direction > 0 ? 0 : count - 1;
for (int k = 0; k < count; k++)
if (static_cast<int>(firstRow_[links_[k]]) >= topRow_) {
selected_ = direction > 0 ? k : std::max(0, k - 1);
break;
}
} else selected_ = (selected_ + direction + count) % count;
int row = firstRow_[links_[selected_]];
if (row < topRow_ || row >= topRow_ + rowsOnScreen()) scrollTo(row - 1);
requestRedraw();
}
bool GeminiApp::onKey(const KeyEvent& e) {
requestRedraw();
if (certDialog_) {
certDialog_->onKey(e);
if (certDialog_->result() == DialogModel::kPending) return true;
if (certDialog_->result() == 1) {
gemini_.trust(pendingCert_.host, pendingCert_.port, pendingCert_.presented);
navigate(pendingCert_.requested, false);
}
certDialog_.reset();
return true;
}
if (addressOpen_) {
switch (e.key) {
case Key::Char: address_.insert(e.ch); break;
case Key::Delete: address_.backspace(); break;
case Key::Left: address_.left(); break;
case Key::Right: address_.right(); break;
case Key::Back: addressOpen_ = false; break;
case Key::Select: {
std::string url = address_.text();
if (url.find("://") == std::string::npos) url = "gemini://" + url;
addressOpen_ = false;
navigate(url);
break;
}
default: break;
}
return true;
}
switch (e.key) {
case Key::Up: scrollTo(topRow_ - 1); return true;
case Key::Down: scrollTo(topRow_ + 1); return true;
case Key::Left: hscroll_ = std::max(0, hscroll_ - 8); return true;
case Key::Right: hscroll_ += 8; return true;
case Key::Tab: selectLink(e.shift ? -1 : 1); return true;
case Key::Select:
if (selected_ >= 0) navigate(resolve(page_.url, lineAt(links_[selected_]).url));
return true;
case Key::Back:
case Key::Delete:
if (loading_) {
gemini_.cancel();
return true;
}
if (history_.empty()) return e.key == Key::Delete; // Back leaves the App
{
Visit v = history_.back();
history_.pop_back();
if (v.url == kStartUrl) showMessage(kStartUrl, kStartPage);
else navigate(v.url, false);
restoreRow_ = v.topRow;
}
return true;
case Key::Char:
if (e.ch == ' ') scrollTo(topRow_ + rowsOnScreen() - 1);
else if (e.ch == 'g' || e.ch == 'G') {
address_.setText(page_.url == kStartUrl ? "" : page_.url);
addressOpen_ = true;
}
return true;
default: return false;
}
}
void GeminiApp::draw(Canvas& c) {
const auto& area = theme::kContent;
if (layoutDirty_) {
layout(c);
if (restoreRow_ >= 0 && !loading_) {
scrollTo(restoreRow_);
restoreRow_ = -1;
}
}
// The URL bar: what's shown, or what's loading, or a passing message.
c.setFont(&fonts::small);
c.setTextDatum(top_left);
std::string bar = loading_ ? "Loading " + loadingUrl_ : !status_.empty() ? status_ : page_.url;
c.setTextColor(loading_ || !status_.empty() ? theme::kWarning : theme::kMuted);
c.drawString(displayText(bar).substr(0, 47).c_str(), kMargin, area.y + 1);
int rows = rowsOnScreen(), y0 = area.y + kUrlBar;
// The first source line on screen: the last whose first row is at or above topRow_.
size_t i = std::upper_bound(firstRow_.begin(), firstRow_.end() - 1, static_cast<uint32_t>(topRow_)) -
firstRow_.begin();
i = i > 0 ? i - 1 : 0;
int row = topRow_;
while (i < page_.text.lineCount() && row < topRow_ + rows) {
int skip = row - static_cast<int>(firstRow_[i]);
int lineRows = static_cast<int>(firstRow_[i + 1] - firstRow_[i]);
if (lineRows > 0) {
drawLine(c, i, skip, y0 + (row - topRow_) * theme::kLineHeight, topRow_ + rows - row);
row += lineRows - skip;
}
i++;
}
if (page_.truncated && row < topRow_ + rows) {
c.setFont(&fonts::small);
c.setTextColor(theme::kWarning);
c.drawString(("Cut short: " + page_.truncatedWhy).substr(0, 47).c_str(), kMargin,
y0 + (row - topRow_) * theme::kLineHeight + 2);
}
// Scroll position, on the right edge.
int total = static_cast<int>(firstRow_.back());
if (total > rows) {
int h = area.h - kUrlBar, barH = std::max(6, h * rows / total);
c.fillRect(area.w - 2, y0 + (h - barH) * topRow_ / std::max(1, total - rows), 2, barH, theme::kMuted);
}
if (addressOpen_) {
theme::Rect box{2, area.y + area.h / 2 - 12, area.w - 4, theme::kLineHeight + 4};
c.fillRect(box.x - 2, box.y - 12, box.w + 4, box.h + 14, theme::kBackground);
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString("Go to (Enter, or ` to cancel)", box.x, box.y - 10);
widgets::lineEditor(c, address_, box);
}
if (certDialog_)
widgets::dialog(c, "Certificate changed",
pendingCert_.host + " now shows a different certificate. Was " + pendingCert_.pinned.substr(0, 16) +
"..., now " + pendingCert_.presented.substr(0, 16) + "...",
*certDialog_);
}
void GeminiApp::drawLine(Canvas& c, size_t i, int firstRow, int y, int rowsLeft) {
GemLine g = lineAt(i);
Style st = styleOf(g.type);
bool isSelected = selected_ >= 0 && links_[selected_] == i;
std::vector<std::string> rows = rowsOf(c, i);
c.setFont(st.font);
c.setTextDatum(top_left);
for (int r = firstRow; r < static_cast<int>(rows.size()) && r - firstRow < rowsLeft; r++) {
int ry = y + (r - firstRow) * theme::kLineHeight;
if (isSelected) c.fillRect(0, ry, theme::kWidth - 3, theme::kLineHeight, theme::kSelection);
c.setTextColor(isSelected ? theme::kText : st.color);
std::string text = rows[r];
if (g.type == LineType::Preformatted) text = hscroll_ < static_cast<int>(text.size()) ? text.substr(hscroll_) : "";
c.drawString(text.c_str(), kMargin, ry + 1);
}
}
} // namespace roro
+64
View File
@@ -0,0 +1,64 @@
#pragma once
#include <memory>
#include <string>
#include <vector>
#include "app.h"
#include "dialog_model.h"
#include "gemtext.h"
#include "line_editor.h"
#include "services/gemini_service.h"
#include "ui/canvas.h"
namespace roro {
// Geminispace on the Cardputer (docs/milestones/G1.md). Pages come from the GeminiService as
// lines; the App wraps them once (keeping only each line's first row), then wraps just the lines
// on screen when it draws, so a long page costs 4 bytes a line on top of its text.
class GeminiApp : public App {
public:
explicit GeminiApp(GeminiService& gemini) : gemini_(gemini) {}
void onEnter() override;
bool onKey(const KeyEvent& e) override;
bool textEntryActive() const override { return addressOpen_; }
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
private:
struct Visit {
std::string url;
int topRow;
};
void navigate(const std::string& url, bool remember = true);
void show(GeminiPage&& page);
void showMessage(const std::string& url, const std::string& gemtext);
void layout(Canvas& c);
void drawLine(Canvas& c, size_t index, int firstRow, int y, int rows);
std::vector<std::string> rowsOf(Canvas& c, size_t index) const;
gemini::GemLine lineAt(size_t index) const;
int rowsOnScreen() const;
void selectLink(int direction);
void scrollTo(int row);
GeminiService& gemini_;
GeminiPage page_;
std::vector<uint8_t> types_; // per source line: gemini::LineType
std::vector<uint32_t> firstRow_; // per source line, plus the total at the end
std::vector<uint32_t> links_; // source lines that are links
bool layoutDirty_ = true;
int topRow_ = 0, hscroll_ = 0;
int selected_ = -1; // into links_
int restoreRow_ = -1; // going back: where that page was scrolled to, applied after layout
std::vector<Visit> history_;
bool loading_ = false;
std::string loadingUrl_, status_;
uint32_t statusMs_ = 0;
bool addressOpen_ = false;
LineEditor address_{1024};
std::unique_ptr<DialogModel> certDialog_;
GeminiPage pendingCert_;
};
} // namespace roro
+2
View File
@@ -8,6 +8,7 @@
#include "app_manager.h"
#include "apps/demo_app.h"
#include "apps/gemini_app.h"
#include "apps/gnss_app.h"
#include "apps/irc_app.h"
#include "apps/launcher_app.h"
@@ -174,6 +175,7 @@ void setup() {
apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus)});
apps->registerApp({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi, *storageService, *clockService)});
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings)});
apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService)});
apps->registerApp({"settings", "Settings", false,
new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update})});
apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)});