Files
roro9stack/src/apps/gemini_app.cpp
T
twislaandClaude Opus 5.5 34e6714785
CI / build (pull_request) Successful in 7m14s
Site / build (pull_request) Successful in 8s
Keys: one table file for the device's help panel and the website's key tables (#72)
Every screen's keys are constant tables in lib/core/src/app_keys.h (52 of
them, each under an `// id: Title` comment). An App's help() picks the table
of the state it is in. site/tools/gen_dev_docs.py reads the same file and
writes site/data/keys.toml; the `keys` shortcode shows a screen's tables on
its guide page, and /guide/keys/ shows all of them.

The Site job fails when the data file is out of date or a page asks for a
table that doesn't exist, and now also runs when app_keys.h changes. A key
added to an App shows up on the website without anyone editing a page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 01:42:48 +02:00

545 lines
22 KiB
C++

#include "app_keys.h"
#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 = GeminiService::kStartUrl;
constexpr size_t kMaxHistory = 20; // Q77
// 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_) navigate(kStartUrl, false);
requestRedraw();
}
// The page's first heading, for a bookmark's label.
std::string GeminiApp::titleOf() const {
for (size_t i = 0; i < page_.text.lineCount() && i < 40; i++)
if (static_cast<LineType>(types_[i]) == LineType::Heading1) return lineAt(i).text;
return "";
}
void GeminiApp::follow(const std::string& link) {
std::string url = resolve(page_.base(), link);
navigate(url, true, isSaved()); // inside a Saved Page, its saved links open offline (Q84)
}
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, bool preferSaved) {
bool local = target == kStartUrl || target.rfind("file://", 0) == 0;
if (!isGemini(target) && !local) {
status_ = "Not Gemini: " + target;
statusMs_ = millis();
requestRedraw();
return;
}
if (!gemini_.fetch(target, preferSaved)) {
status_ = "Busy: try again in a moment";
statusMs_ = millis();
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);
windowLoading_ = false;
analyse();
topRow_ = hscroll_ = 0;
selected_ = -1;
}
void GeminiApp::analyse() {
// Sized exactly: the Service budgeted these bytes per line (kBytesPerLine), growth by
// doubling would take up to twice that.
std::vector<uint8_t>().swap(types_);
std::vector<uint32_t>().swap(links_);
types_.reserve(page_.text.lineCount());
// A window may start inside a preformatted block: the index says (bit 31).
size_t entry = page_.windowStart / GeminiService::kIndexEvery;
bool pre = page_.windowed && entry < page_.lineIndex.size() && (page_.lineIndex[entry] & 0x80000000u);
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);
}
links_.shrink_to_fit();
layoutDirty_ = true;
requestRedraw();
}
size_t GeminiApp::topLine() const {
if (firstRow_.size() < 2) return page_.windowStart;
size_t i = std::upper_bound(firstRow_.begin(), firstRow_.end() - 1, static_cast<uint32_t>(topRow_)) - firstRow_.begin();
return page_.windowStart + (i > 0 ? i - 1 : 0);
}
void GeminiApp::requestWindow(size_t firstLine) {
if (windowLoading_ || !gemini_.loadWindow(page_, firstLine)) return;
windowLoading_ = true;
keepLine_ = static_cast<long>(topLine());
status_ = "Reading from the card...";
statusMs_ = millis();
}
// A new window of the same page: same URL, history and position, other lines.
void GeminiApp::applyWindow(GeminiPage&& window) {
windowLoading_ = false;
status_.clear();
page_.text = std::move(window.text);
page_.windowStart = window.windowStart;
page_.lineIndex = std::move(window.lineIndex);
selected_ = -1;
analyse();
}
void GeminiApp::update(uint32_t nowMs) {
GeminiPage result;
bool got = gemini_.takeResult(result);
if (got && !result.windowOf.empty()) {
windowLoading_ = false;
if (result.windowOf == page_.backing) applyWindow(std::move(result)); // else: we've moved on
} else if (got) {
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 {
std::string mime = result.header.mimeType();
showMessage(url, "# Not a text page\nThis is " + mime +
", which the Cardputer can't show. Saving it puts it on the card, in "
"/gemini/downloads.\n");
page_.header.meta = mime; // so s knows to download it
}
break;
case Category::Input:
// Q72: ask, then request the same URL with the answer as its query.
inputOpen_ = true;
inputSensitive_ = result.header.sensitiveInput();
inputPrompt_ = result.header.meta;
inputUrl_ = url;
input_.setText("");
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).
std::string message;
if (gemini_.takeMessage(message)) {
status_ = message;
statusMs_ = millis();
if (!reopen_.empty()) { // a refreshed Saved Page: show the new copy
navigate(reopen_, false);
reopen_.clear();
} else if (message == "Deleted") navigate(kStartUrl, false);
requestRedraw();
}
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: the exact rest of the text (wrapText keeps every space but the one it
// breaks at), wrapped again, narrower, behind the indent.
if (rows.size() > 1 && st.indent > 0) {
std::string full = st.prefix + displayText(g.text);
std::string rest = full.substr(rows[0].size());
if (!rest.empty() && rest[0] == ' ') rest.erase(0, 1);
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());
// A windowed page: near either end of the window, read the next part from the card. The new
// window starts a block before the line on top, so what's on screen stays.
const size_t block = GeminiService::kIndexEvery;
if (row + rowsOnScreen() > total - 2 && moreBelow()) {
size_t line = topLine();
requestWindow(line >= block ? line - line % block : 0);
} else if (row < 0 && moreAbove()) {
size_t line = topLine();
size_t back = page_.text.lineCount() / 2; // about half a window above
requestWindow(line > back ? line - back : 0);
}
topRow_ = std::max(0, std::min(row, std::max(0, total - rowsOnScreen())));
requestRedraw();
}
void GeminiApp::selectLink(int direction) {
// A windowed page: past the window's last (or first) link, page on instead of wrapping.
int last = static_cast<int>(links_.size()) - 1;
if ((direction > 0 && moreBelow() && (links_.empty() || selected_ == last)) ||
(direction < 0 && moreAbove() && (links_.empty() || selected_ == 0))) {
selected_ = -1;
scrollTo(topRow_ + direction * (rowsOnScreen() - 1));
return;
}
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();
}
void GeminiApp::help(std::vector<KeyHelp>& out) const {
if (certDialog_ || deleteDialog_) return keys::add(out, keys::kDialog);
if (inputOpen_) return keys::add(out, keys::kGeminiAnswer);
if (addressOpen_) return keys::add(out, keys::kGeminiAddress);
if (isSaved()) keys::add(out, keys::kGeminiSaved);
else keys::add(out, keys::kGemini);
}
const char* GeminiApp::helpTitle() const {
return inputOpen_ ? "Gemini: an answer" : addressOpen_ ? "Gemini: an address" : nullptr;
}
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 (deleteDialog_) {
deleteDialog_->onKey(e);
if (deleteDialog_->result() == DialogModel::kPending) return true;
if (deleteDialog_->result() == 1) gemini_.deleteSaved(savedPathOf());
deleteDialog_.reset();
return true;
}
if (inputOpen_) {
switch (e.key) {
case Key::Char: input_.insert(e.ch); break;
case Key::Delete: input_.backspace(); break;
case Key::Left: input_.left(); break;
case Key::Right: input_.right(); break;
case Key::Back: inputOpen_ = false; break;
case Key::Select:
inputOpen_ = false;
navigate(resolve(inputUrl_, "?" + encodeQuery(input_.text())));
break;
default: break;
}
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) follow(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();
navigate(v.url, false);
restoreRow_ = v.topRow;
}
return true;
case Key::Char:
switch (e.ch) {
case ' ': scrollTo(topRow_ + rowsOnScreen() - 1); break;
case 'g':
address_.setText(page_.url == kStartUrl || isSaved() ? "" : page_.url);
addressOpen_ = true;
break;
case 'b': // Q77
if (isGemini(page_.base())) gemini_.addBookmark(page_.base(), titleOf());
break;
case 's': // Q82, Q73
if (isSaved()) status_ = "Already saved: it can be refreshed";
else if (!isGemini(page_.url)) status_ = "Only Gemini pages can be saved";
else if (page_.header.mimeType().rfind("text/", 0) == 0) gemini_.save(page_.url);
else gemini_.download(page_.url);
statusMs_ = millis();
break;
case 'S': { // Q83: this page and the gemtext it links to on the same capsule
Url here;
if (isSaved() || !parseUrl(page_.url, here)) break;
std::vector<std::string> links;
for (uint32_t i : links_) {
std::string target = resolve(page_.url, lineAt(i).url);
Url u;
if (isGemini(target) && parseUrl(target, u) && u.host == here.host && target != page_.url &&
std::find(links.begin(), links.end(), target) == links.end())
links.push_back(target);
if (static_cast<int>(links.size()) >= GeminiService::kMaxLinkedPages - 1) break;
}
gemini_.saveWithLinks(page_.url, links);
break;
}
case 'r': // Q84: a fresh copy of a Saved Page
if (isSaved() && gemini_.refresh(savedPathOf())) reopen_ = page_.url;
break;
case 'd': // Q85
if (isSaved()) deleteDialog_.reset(new DialogModel({"Keep", "Delete"}));
break;
default: break;
}
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;
}
if (keepLine_ >= 0 && !windowLoading_) { // a new window: back to the line that was on top
long local = keepLine_ - static_cast<long>(page_.windowStart);
if (local >= 0 && local < static_cast<long>(page_.text.lineCount())) topRow_ = firstRow_[local];
keepLine_ = -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_
: isSaved() ? "saved " + page_.savedOn + ": " + page_.savedFrom
: 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: over the whole page, also when only a window is in memory.
int total = static_cast<int>(firstRow_.back());
int h = area.h - kUrlBar;
if (page_.windowed && page_.totalLines > 0) {
int barH = std::max(6, static_cast<int>(h * page_.text.lineCount() / page_.totalLines / 3));
int y = static_cast<int>((h - barH) * topLine() / std::max<size_t>(1, page_.totalLines - 1));
c.fillRect(area.w - 2, y0 + y, 2, barH, theme::kMuted);
} else if (total > rows) {
int 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", box.x, box.y - 10);
widgets::lineEditor(c, address_, box);
}
if (inputOpen_) {
theme::Rect box{2, area.y + area.h / 2 - 4, area.w - 4, theme::kLineHeight + 4};
c.fillRect(0, box.y - 26, area.w, box.h + 28, theme::kBackground);
c.setFont(&fonts::body);
c.setTextColor(theme::kText);
c.drawString(displayText(inputPrompt_).substr(0, 39).c_str(), box.x, box.y - 22);
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
if (inputSensitive_) c.drawString("(hidden)", box.x, box.y - 9);
if (inputSensitive_) {
LineEditor masked(1024);
masked.setText(std::string(input_.text().size(), '*'));
widgets::lineEditor(c, masked, box);
} else widgets::lineEditor(c, input_, box);
}
if (deleteDialog_) widgets::dialog(c, "Delete this Saved Page?", page_.savedFrom, *deleteDialog_);
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