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
+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