diff --git a/src/apps/gemini_app.cpp b/src/apps/gemini_app.cpp new file mode 100644 index 0000000..a9b20a3 --- /dev/null +++ b/src/apps/gemini_app.cpp @@ -0,0 +1,358 @@ +#include "gemini_app.h" + +#include + +#include + +#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(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(nowMs - statusMs_) > 4000) { + status_.clear(); + requestRedraw(); + } +} + +int GeminiApp::rowsOnScreen() const { return (theme::kContent.h - kUrlBar) / theme::kLineHeight; } + +LineType typeOf(const std::vector& types, size_t i) { return static_cast(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 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(c.textWidth(s.c_str())); }; + int width = theme::kWidth - 2 * kMargin; + std::string indent(st.indent, ' '); + std::vector 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 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(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(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(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(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(firstRow_[i]); + int lineRows = static_cast(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(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 rows = rowsOf(c, i); + c.setFont(st.font); + c.setTextDatum(top_left); + for (int r = firstRow; r < static_cast(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(text.size()) ? text.substr(hscroll_) : ""; + c.drawString(text.c_str(), kMargin, ry + 1); + } +} + +} // namespace roro diff --git a/src/apps/gemini_app.h b/src/apps/gemini_app.h new file mode 100644 index 0000000..5f16ae8 --- /dev/null +++ b/src/apps/gemini_app.h @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include + +#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 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 types_; // per source line: gemini::LineType + std::vector firstRow_; // per source line, plus the total at the end + std::vector 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 history_; + bool loading_ = false; + std::string loadingUrl_, status_; + uint32_t statusMs_ = 0; + bool addressOpen_ = false; + LineEditor address_{1024}; + std::unique_ptr certDialog_; + GeminiPage pendingCert_; +}; + +} // namespace roro diff --git a/src/main.cpp b/src/main.cpp index ca7be09..0d0a2b1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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)});