Public Access
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
545 lines
22 KiB
C++
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
|