#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