#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 = 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(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() { types_.clear(); links_.clear(); // 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(t)); if (t == LineType::Link) links_.push_back(i); } 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(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(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. s saves it to 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(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: 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 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()); // 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(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(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 (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; r refreshes it"; 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 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(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(page_.windowStart); if (local >= 0 && local < static_cast(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(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: over the whole page, also when only a window is in memory. int total = static_cast(firstRow_.back()); int h = area.h - kUrlBar; if (page_.windowed && page_.totalLines > 0) { int barH = std::max(6, static_cast(h * page_.text.lineCount() / page_.totalLines / 3)); int y = static_cast((h - barH) * topLine() / std::max(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 (Enter, or ` to cancel)", 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); c.drawString(inputSensitive_ ? "(hidden) Enter sends, ` cancels" : "Enter sends, ` cancels", 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 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