feat: split Wikipedia app into separate files, change icon to cog
This commit is contained in:
@@ -71,7 +71,7 @@ LIBS := $(TLS_LIB)/libtls.a $(BEARSSL)/libbearssl.a $(LIBC_LIB)/liblibc.a
|
||||
|
||||
# ---- Source files ----
|
||||
|
||||
SRCS := main.cpp stb_truetype_impl.cpp
|
||||
SRCS := main.cpp state.cpp theme.cpp text.cpp document.cpp network.cpp render.cpp stb_truetype_impl.cpp
|
||||
OBJS := $(addprefix $(OBJDIR)/,$(SRCS:.cpp=.o))
|
||||
|
||||
# ---- Target ----
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* document.cpp
|
||||
* Display line building for the Wikipedia client
|
||||
* Copyright (c) 2026 Daniel Hammer
|
||||
*/
|
||||
|
||||
#include "wikipedia.h"
|
||||
|
||||
void add_line(const char* text, int len, Color color, int size,
|
||||
TrueTypeFont* font, int indent) {
|
||||
if (g_line_count >= MAX_LINES) return;
|
||||
WikiLine* l = &g_lines[g_line_count++];
|
||||
int copy = len < 255 ? len : 255;
|
||||
memcpy(l->text, text, copy);
|
||||
l->text[copy] = '\0';
|
||||
l->color = color;
|
||||
l->font_size = size;
|
||||
l->font = font;
|
||||
l->indent = indent;
|
||||
}
|
||||
|
||||
void add_spacer(int size) {
|
||||
if (g_line_count >= MAX_LINES) return;
|
||||
WikiLine* l = &g_lines[g_line_count++];
|
||||
l->text[0] = '\0';
|
||||
l->color = reader_palette().text;
|
||||
l->font_size = size;
|
||||
l->font = current_body_font();
|
||||
l->indent = 0;
|
||||
}
|
||||
|
||||
// Word-wrap a text segment into display lines using pixel-width measurement.
|
||||
void wrap_text(TrueTypeFont* font, int size, const char* text, int textLen,
|
||||
int max_px, Color color, int indent) {
|
||||
if (!font || !text || textLen <= 0) return;
|
||||
static constexpr int MAX_LINE = 255;
|
||||
char cur[MAX_LINE + 1];
|
||||
int cur_len = 0;
|
||||
const char* p = text;
|
||||
const char* end = text + textLen;
|
||||
|
||||
while (p < end) {
|
||||
while (p < end && *p == ' ') p++;
|
||||
if (p >= end) break;
|
||||
|
||||
const char* word_start = p;
|
||||
while (p < end && *p != ' ') p++;
|
||||
int word_len = (int)(p - word_start);
|
||||
if (word_len <= 0) continue;
|
||||
|
||||
char test[MAX_LINE + 1];
|
||||
int test_len = cur_len;
|
||||
memcpy(test, cur, cur_len);
|
||||
if (cur_len > 0 && test_len < MAX_LINE) test[test_len++] = ' ';
|
||||
int avail = MAX_LINE - test_len;
|
||||
int copy = word_len < avail ? word_len : avail;
|
||||
if (copy > 0) {
|
||||
memcpy(test + test_len, word_start, copy);
|
||||
test_len += copy;
|
||||
}
|
||||
test[test_len] = '\0';
|
||||
|
||||
int test_w = font->measure_text(test, size);
|
||||
if (test_w <= max_px || cur_len == 0) {
|
||||
memcpy(cur, test, test_len + 1);
|
||||
cur_len = test_len;
|
||||
} else {
|
||||
if (cur_len > 0) add_line(cur, cur_len, color, size, font, indent);
|
||||
int wl = word_len < MAX_LINE ? word_len : MAX_LINE;
|
||||
memcpy(cur, word_start, wl);
|
||||
cur_len = wl;
|
||||
cur[cur_len] = '\0';
|
||||
}
|
||||
}
|
||||
if (cur_len > 0) add_line(cur, cur_len, color, size, font, indent);
|
||||
}
|
||||
|
||||
void build_display_lines(const char* title, const char* extract, int extractLen,
|
||||
bool preserve_scroll) {
|
||||
ReaderPalette palette = reader_palette();
|
||||
TrueTypeFont* body_font = current_body_font();
|
||||
TrueTypeFont* heading_font = current_heading_font();
|
||||
int old_scroll = g_scroll_y;
|
||||
|
||||
if (!body_font) return;
|
||||
|
||||
g_line_count = 0;
|
||||
g_scroll_y = 0;
|
||||
|
||||
Rect column = article_column_rect();
|
||||
int max_px = column.w;
|
||||
|
||||
if (title && title[0]) {
|
||||
TrueTypeFont* tf = heading_font ? heading_font : body_font;
|
||||
wrap_text(tf, TITLE_SIZE, title, (int)strlen(title), max_px, palette.heading);
|
||||
add_spacer(gui_max(FONT_SIZE, 12));
|
||||
}
|
||||
|
||||
const char* p = extract;
|
||||
const char* end = extract + extractLen;
|
||||
bool pending_paragraph_break = false;
|
||||
|
||||
while (p < end && g_line_count < MAX_LINES) {
|
||||
const char* line_start = p;
|
||||
while (p < end && *p != '\n') p++;
|
||||
int line_len = (int)(p - line_start);
|
||||
if (p < end) p++;
|
||||
|
||||
if (line_len == 0) {
|
||||
pending_paragraph_break = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Section header: == Title ==
|
||||
if (line_len >= 4 && line_start[0] == '=' && line_start[1] == '=') {
|
||||
int si = 0, ei = line_len;
|
||||
while (si < line_len && line_start[si] == '=') si++;
|
||||
while (si < line_len && line_start[si] == ' ') si++;
|
||||
while (ei > si && line_start[ei-1] == '=') ei--;
|
||||
while (ei > si && line_start[ei-1] == ' ') ei--;
|
||||
if (ei > si) {
|
||||
if (g_line_count > 0)
|
||||
add_spacer(gui_max(FONT_SIZE - 2, 12));
|
||||
TrueTypeFont* tf = heading_font ? heading_font : body_font;
|
||||
wrap_text(tf, SECTION_SIZE, line_start + si, ei - si, max_px, palette.heading);
|
||||
add_spacer(gui_max(FONT_SIZE - 6, 8));
|
||||
pending_paragraph_break = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pending_paragraph_break && g_line_count > 0) {
|
||||
add_spacer(gui_max(FONT_SIZE - 6, 8));
|
||||
pending_paragraph_break = false;
|
||||
}
|
||||
|
||||
wrap_text(body_font, FONT_SIZE, line_start, line_len, max_px, palette.text);
|
||||
}
|
||||
|
||||
if (preserve_scroll)
|
||||
g_scroll_y = gui_clamp(old_scroll, 0, max_scroll_lines());
|
||||
}
|
||||
|
||||
void build_welcome_lines(bool preserve_scroll) {
|
||||
ReaderPalette palette = reader_palette();
|
||||
TrueTypeFont* body_font = current_body_font();
|
||||
TrueTypeFont* heading_font = current_heading_font();
|
||||
int old_scroll = g_scroll_y;
|
||||
|
||||
if (!body_font) return;
|
||||
|
||||
g_line_count = 0;
|
||||
g_scroll_y = 0;
|
||||
|
||||
Rect column = document_column_rect();
|
||||
int max_px = column.w;
|
||||
TrueTypeFont* tf = heading_font ? heading_font : body_font;
|
||||
|
||||
const char* title = "Welcome to the Montauk Wikipedia reader";
|
||||
wrap_text(tf, TITLE_SIZE, title, (int)strlen(title), max_px, palette.heading);
|
||||
add_spacer(gui_max(FONT_SIZE - 4, 10));
|
||||
|
||||
const char* intro = "Search for an article above.";
|
||||
wrap_text(body_font, FONT_SIZE, intro, (int)strlen(intro), max_px, palette.text);
|
||||
add_spacer(gui_max(FONT_SIZE + 2, 16));
|
||||
|
||||
if (g_welcome_feature_loaded && g_welcome_feature_title[0]) {
|
||||
wrap_text(tf, SECTION_SIZE, "Featured article today",
|
||||
(int)strlen("Featured article today"), max_px, palette.heading);
|
||||
add_spacer(gui_max(FONT_SIZE - 6, 10));
|
||||
|
||||
wrap_text(tf, gui_max(FONT_SIZE + 2, 16),
|
||||
g_welcome_feature_title,
|
||||
(int)strlen(g_welcome_feature_title),
|
||||
max_px, palette.accent);
|
||||
add_spacer(gui_max(FONT_SIZE - 4, 10));
|
||||
|
||||
if (g_welcome_feature_extract[0]) {
|
||||
wrap_text(body_font, FONT_SIZE,
|
||||
g_welcome_feature_extract,
|
||||
(int)strlen(g_welcome_feature_extract),
|
||||
max_px, palette.text);
|
||||
add_spacer(gui_max(FONT_SIZE - 6, 8));
|
||||
}
|
||||
|
||||
char hint[360];
|
||||
snprintf(hint, sizeof(hint),
|
||||
"To open the full article, search for \"%s\".",
|
||||
g_welcome_feature_title);
|
||||
add_spacer(gui_max(FONT_SIZE - 8, 8));
|
||||
wrap_text(body_font, gui_max(FONT_SIZE - 2, 12),
|
||||
hint, (int)strlen(hint), max_px, palette.muted);
|
||||
} else {
|
||||
const char* section = "Start reading";
|
||||
wrap_text(tf, SECTION_SIZE, section, (int)strlen(section), max_px, palette.heading);
|
||||
add_spacer(gui_max(FONT_SIZE - 8, 8));
|
||||
|
||||
if (g_welcome_status[0]) {
|
||||
add_spacer(gui_max(FONT_SIZE - 6, 8));
|
||||
wrap_text(body_font, gui_max(FONT_SIZE - 2, 12),
|
||||
g_welcome_status,
|
||||
(int)strlen(g_welcome_status),
|
||||
max_px, palette.muted);
|
||||
}
|
||||
}
|
||||
|
||||
if (preserve_scroll)
|
||||
g_scroll_y = gui_clamp(old_scroll, 0, max_scroll_lines());
|
||||
}
|
||||
|
||||
void rebuild_current_document(bool preserve_scroll) {
|
||||
if (g_phase == AppPhase::DONE && g_extract_len > 0)
|
||||
build_display_lines(g_title, g_extract_buf, g_extract_len, preserve_scroll);
|
||||
else if (g_phase == AppPhase::WELCOME)
|
||||
build_welcome_lines(preserve_scroll);
|
||||
}
|
||||
|
||||
void apply_reader_setting_change() {
|
||||
g_scroll_dragging = false;
|
||||
apply_reader_preferences();
|
||||
update_toolbar_height();
|
||||
rebuild_current_document(true);
|
||||
clamp_scroll();
|
||||
}
|
||||
|
||||
bool handle_reader_option_click(int mx, int my) {
|
||||
ReaderToolbarLayout lo = toolbar_layout();
|
||||
if (!g_display_controls_open) return false;
|
||||
|
||||
for (int i = 0; i < READER_TEXT_SIZE_COUNT; i++) {
|
||||
if (!lo.text_size_buttons[i].contains(mx, my)) continue;
|
||||
ReaderTextSize next = (ReaderTextSize)i;
|
||||
if (g_reader_text_size != next) {
|
||||
g_reader_text_size = next;
|
||||
apply_reader_setting_change();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
for (int i = 0; i < READER_FONT_FACE_COUNT; i++) {
|
||||
if (!lo.font_buttons[i].contains(mx, my)) continue;
|
||||
ReaderFontFace next = (ReaderFontFace)i;
|
||||
if (g_reader_font_face != next) {
|
||||
g_reader_font_face = next;
|
||||
apply_reader_setting_change();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
+124
-1409
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* network.cpp
|
||||
* TLS fetch, daily-feature fetch, and article search for the Wikipedia client
|
||||
* Copyright (c) 2026 Daniel Hammer
|
||||
*/
|
||||
|
||||
#include "wikipedia.h"
|
||||
|
||||
int wiki_fetch(const char* path, char* respBuf, int respMax) {
|
||||
static char request[2560];
|
||||
int reqLen = snprintf(request, sizeof(request),
|
||||
"GET %s HTTP/1.0\r\n"
|
||||
"Host: %s\r\n"
|
||||
"User-Agent: MontaukOS/1.0 wikipedia\r\n"
|
||||
"Accept: application/json\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n",
|
||||
path, WIKI_HOST);
|
||||
return tls::https_fetch(WIKI_HOST, g_server_ip, 443,
|
||||
request, reqLen, g_tas,
|
||||
respBuf, respMax);
|
||||
}
|
||||
|
||||
bool ensure_wiki_tls_ready(char* err, int err_len) {
|
||||
if (!g_tls_ready) {
|
||||
g_server_ip = montauk::resolve(WIKI_HOST);
|
||||
if (g_server_ip == 0) {
|
||||
if (err && err_len > 0)
|
||||
snprintf(err, err_len, "Error: could not resolve en.wikipedia.org");
|
||||
return false;
|
||||
}
|
||||
g_tas = tls::load_trust_anchors();
|
||||
if (g_tas.count == 0) {
|
||||
if (err && err_len > 0)
|
||||
snprintf(err, err_len, "Error: no CA certificates loaded");
|
||||
return false;
|
||||
}
|
||||
g_tls_ready = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool welcome_feed_path(char* path, int path_len) {
|
||||
Montauk::DateTime dt {};
|
||||
montauk::gettime(&dt);
|
||||
|
||||
if (dt.Year < 2001 || dt.Month < 1 || dt.Month > 12 ||
|
||||
dt.Day < 1 || dt.Day > 31) {
|
||||
snprintf(g_welcome_status, sizeof(g_welcome_status),
|
||||
"Daily featured article unavailable until the system date is set.");
|
||||
return false;
|
||||
}
|
||||
|
||||
snprintf(g_welcome_date, sizeof(g_welcome_date),
|
||||
"%04u-%02u-%02u",
|
||||
(unsigned)dt.Year, (unsigned)dt.Month, (unsigned)dt.Day);
|
||||
snprintf(path, path_len,
|
||||
"/api/rest_v1/feed/featured/%04u/%02u/%02u",
|
||||
(unsigned)dt.Year, (unsigned)dt.Month, (unsigned)dt.Day);
|
||||
return true;
|
||||
}
|
||||
|
||||
void do_welcome_fetch() {
|
||||
char err[192] = {};
|
||||
if (!ensure_wiki_tls_ready(err, sizeof(err))) {
|
||||
snprintf(g_welcome_status, sizeof(g_welcome_status), "%s", err);
|
||||
build_welcome_lines(true);
|
||||
return;
|
||||
}
|
||||
|
||||
static char path[128];
|
||||
if (!welcome_feed_path(path, sizeof(path))) {
|
||||
build_welcome_lines(true);
|
||||
return;
|
||||
}
|
||||
|
||||
int respLen = wiki_fetch(path, g_resp_buf, RESP_MAX);
|
||||
if (respLen <= 0) {
|
||||
snprintf(g_welcome_status, sizeof(g_welcome_status),
|
||||
"Daily featured article unavailable; search is ready.");
|
||||
build_welcome_lines(true);
|
||||
return;
|
||||
}
|
||||
g_resp_buf[respLen] = '\0';
|
||||
|
||||
int headerEnd = find_header_end(g_resp_buf, respLen);
|
||||
if (headerEnd < 0) {
|
||||
snprintf(g_welcome_status, sizeof(g_welcome_status),
|
||||
"Daily featured article unavailable; search is ready.");
|
||||
build_welcome_lines(true);
|
||||
return;
|
||||
}
|
||||
|
||||
int status = parse_status_code(g_resp_buf, headerEnd);
|
||||
const char* body = g_resp_buf + headerEnd;
|
||||
int bodyLen = respLen - headerEnd;
|
||||
if (status < 200 || status >= 300) {
|
||||
snprintf(g_welcome_status, sizeof(g_welcome_status),
|
||||
"Daily featured article unavailable; search is ready.");
|
||||
build_welcome_lines(true);
|
||||
return;
|
||||
}
|
||||
|
||||
int tfa = find_substr(body, bodyLen, "\"tfa\"");
|
||||
if (tfa < 0) {
|
||||
snprintf(g_welcome_status, sizeof(g_welcome_status),
|
||||
"Daily featured article unavailable; search is ready.");
|
||||
build_welcome_lines(true);
|
||||
return;
|
||||
}
|
||||
|
||||
int tfaLen = bodyLen - tfa;
|
||||
extract_json_string(body + tfa, tfaLen, "title",
|
||||
g_welcome_feature_title,
|
||||
sizeof(g_welcome_feature_title));
|
||||
extract_json_string(body + tfa, tfaLen, "extract",
|
||||
g_welcome_feature_extract,
|
||||
sizeof(g_welcome_feature_extract));
|
||||
|
||||
underscores_to_spaces(g_welcome_feature_title);
|
||||
g_welcome_feature_loaded = g_welcome_feature_title[0] != '\0';
|
||||
if (g_welcome_feature_loaded)
|
||||
g_welcome_status[0] = '\0';
|
||||
else
|
||||
snprintf(g_welcome_status, sizeof(g_welcome_status),
|
||||
"Daily featured article unavailable; search is ready.");
|
||||
|
||||
build_welcome_lines(true);
|
||||
}
|
||||
|
||||
void do_search(const char* query) {
|
||||
if (is_main_page_query(query)) {
|
||||
g_phase = AppPhase::WELCOME;
|
||||
build_welcome_lines(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ensure_wiki_tls_ready(g_status, sizeof(g_status))) {
|
||||
g_phase = AppPhase::ERR;
|
||||
return;
|
||||
}
|
||||
|
||||
static char encoded[1024];
|
||||
url_encode_title(query, encoded, sizeof(encoded));
|
||||
|
||||
static char path[2048];
|
||||
snprintf(path, sizeof(path),
|
||||
"/w/api.php?action=query&format=json&formatversion=2"
|
||||
"&prop=extracts&explaintext=1&titles=%s", encoded);
|
||||
|
||||
int respLen = wiki_fetch(path, g_resp_buf, RESP_MAX);
|
||||
if (respLen <= 0) {
|
||||
snprintf(g_status, sizeof(g_status), "Error: no response from Wikipedia");
|
||||
g_phase = AppPhase::ERR; return;
|
||||
}
|
||||
g_resp_buf[respLen] = '\0';
|
||||
|
||||
int headerEnd = find_header_end(g_resp_buf, respLen);
|
||||
if (headerEnd < 0) {
|
||||
snprintf(g_status, sizeof(g_status), "Error: malformed HTTP response");
|
||||
g_phase = AppPhase::ERR; return;
|
||||
}
|
||||
|
||||
int status = parse_status_code(g_resp_buf, headerEnd);
|
||||
const char* body = g_resp_buf + headerEnd;
|
||||
int bodyLen = respLen - headerEnd;
|
||||
|
||||
if (status == 404) {
|
||||
snprintf(g_status, sizeof(g_status), "Article not found: %s", query);
|
||||
g_phase = AppPhase::ERR; return;
|
||||
}
|
||||
|
||||
extract_json_string(body, bodyLen, "title", g_title, sizeof(g_title));
|
||||
g_extract_len = extract_json_string(body, bodyLen, "extract",
|
||||
g_extract_buf, RESP_MAX - 1);
|
||||
if (g_extract_len == 0) {
|
||||
snprintf(g_status, sizeof(g_status), "No content found for: %s", query);
|
||||
g_phase = AppPhase::ERR; return;
|
||||
}
|
||||
|
||||
build_display_lines(g_title, g_extract_buf, g_extract_len);
|
||||
g_phase = AppPhase::DONE;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* render.cpp
|
||||
* Drawing for the Wikipedia client
|
||||
* Copyright (c) 2026 Daniel Hammer
|
||||
*/
|
||||
|
||||
#include "wikipedia.h"
|
||||
|
||||
void draw_display_toggle_button(Canvas& canvas,
|
||||
const Rect& bounds,
|
||||
const mtk::WidgetState& state,
|
||||
const mtk::Theme& theme) {
|
||||
mtk::ButtonColors colors = mtk::resolve_button_colors(mtk::BUTTON_SECONDARY, state, theme);
|
||||
mtk::draw_rounded_frame(canvas, bounds, theme.radius_md, colors.bg, colors.border);
|
||||
|
||||
const SvgIcon& icon = (state.active && g_display_icon_active.pixels)
|
||||
? g_display_icon_active
|
||||
: g_display_icon;
|
||||
if (icon.pixels) {
|
||||
int ix = bounds.x + (bounds.w - icon.width) / 2;
|
||||
int iy = bounds.y + (bounds.h - icon.height) / 2;
|
||||
canvas.icon(ix, iy, icon);
|
||||
return;
|
||||
}
|
||||
|
||||
int glyph_w = gui_min(bounds.w - 10, 16);
|
||||
int glyph_h = 12;
|
||||
int gx = bounds.x + (bounds.w - glyph_w) / 2;
|
||||
int gy = bounds.y + (bounds.h - glyph_h) / 2;
|
||||
canvas.fill_rect(gx, gy, glyph_w - 4, 2, colors.fg);
|
||||
canvas.fill_rect(gx, gy + 5, glyph_w, 2, colors.fg);
|
||||
canvas.fill_rect(gx, gy + 10, glyph_w - 6, 2, colors.fg);
|
||||
canvas.fill_rect(gx + glyph_w - 3, gy - 1, 3, 4, colors.fg);
|
||||
}
|
||||
|
||||
void render_message(Canvas& canvas, const Rect& content, const char* text, Color color) {
|
||||
if (!text || !text[0]) return;
|
||||
Rect column = article_column_rect();
|
||||
canvas.text(column.x, content.y + 16, text, color);
|
||||
}
|
||||
|
||||
void render(Canvas& canvas) {
|
||||
mtk::Theme theme = wiki_theme();
|
||||
ReaderToolbarLayout lo = toolbar_layout();
|
||||
Rect toolbar = {0, 0, g_win_w, TOOLBAR_H};
|
||||
Rect content = content_rect();
|
||||
Rect column = document_column_rect();
|
||||
bool searching = (g_phase == AppPhase::LOADING);
|
||||
|
||||
canvas.fill(theme.window_bg);
|
||||
|
||||
// ---- Toolbar ----
|
||||
canvas.fill_rect(toolbar.x, toolbar.y, toolbar.w, toolbar.h, theme.surface_alt);
|
||||
mtk::draw_text_field(canvas, lo.search_field, g_query,
|
||||
g_query_input.cursor,
|
||||
g_search_focused, false, theme,
|
||||
g_query_input.selection_anchor);
|
||||
draw_display_toggle_button(canvas, lo.display_button,
|
||||
mtk::widget_state(g_display_controls_open,
|
||||
mouse_in_rect(lo.display_button), true),
|
||||
theme);
|
||||
mtk::draw_button(canvas, lo.search_button, searching ? "Searching..." : "Search",
|
||||
mtk::BUTTON_PRIMARY,
|
||||
mtk::widget_state(false, mouse_in_rect(lo.search_button), !searching),
|
||||
theme);
|
||||
|
||||
if (g_display_controls_open) {
|
||||
int row_top = reader_row_y() - 4;
|
||||
canvas.hline(8, row_top, gui_max(g_win_w - 16, 0), theme.border);
|
||||
|
||||
if (lo.show_labels) {
|
||||
canvas.text(lo.text_size_label.x, lo.text_size_label.y, "Text", theme.text_muted);
|
||||
canvas.text(lo.font_face_label.x, lo.font_face_label.y, "Font", theme.text_muted);
|
||||
}
|
||||
|
||||
for (int i = 0; i < READER_TEXT_SIZE_COUNT; i++) {
|
||||
mtk::draw_button(canvas, lo.text_size_buttons[i], g_text_size_labels[i], mtk::BUTTON_SECONDARY,
|
||||
mtk::widget_state((int)g_reader_text_size == i,
|
||||
mouse_in_rect(lo.text_size_buttons[i]), true),
|
||||
theme);
|
||||
}
|
||||
for (int i = 0; i < READER_FONT_FACE_COUNT; i++) {
|
||||
mtk::draw_button(canvas, lo.font_buttons[i], g_font_face_labels[i], mtk::BUTTON_SECONDARY,
|
||||
mtk::widget_state((int)g_reader_font_face == i,
|
||||
mouse_in_rect(lo.font_buttons[i]), true),
|
||||
theme);
|
||||
}
|
||||
|
||||
int sep_y = reader_row_y() + 3;
|
||||
int sep_h = gui_max(reader_button_h() - 6, 1);
|
||||
canvas.vline(lo.sep_x, sep_y, sep_h, theme.border);
|
||||
}
|
||||
mtk::draw_separator(canvas, 0, TOOLBAR_H, g_win_w, theme);
|
||||
|
||||
// ---- Content area ----
|
||||
if (g_phase == AppPhase::IDLE) {
|
||||
render_message(canvas, content,
|
||||
"Type a topic and press Enter or click Search.",
|
||||
theme.text_muted);
|
||||
} else if (g_phase == AppPhase::LOADING) {
|
||||
render_message(canvas, content, "Searching Wikipedia...", theme.text_muted);
|
||||
} else if (g_phase == AppPhase::ERR) {
|
||||
render_message(canvas, content, g_status, theme.danger);
|
||||
} else if (document_scrollable() && g_line_count > 0) {
|
||||
int y = content.y + document_top_pad();
|
||||
|
||||
for (int i = g_scroll_y; i < g_line_count && y < g_win_h; i++) {
|
||||
WikiLine& l = g_lines[i];
|
||||
TrueTypeFont* line_font = l.font ? l.font : current_body_font();
|
||||
int lh = line_font ? (line_font->get_line_height(l.font_size) + 4) : g_line_h;
|
||||
if (y + lh > g_win_h) break;
|
||||
if (l.text[0] != '\0' && line_font) {
|
||||
line_font->draw_to_buffer(canvas.pixels, canvas.w, canvas.h,
|
||||
column.x + l.indent, y, l.text, l.color, l.font_size);
|
||||
}
|
||||
y += lh;
|
||||
}
|
||||
|
||||
Rect track = scrollbar_track_rect();
|
||||
if (!track.empty()) {
|
||||
Rect thumb = scrollbar_thumb_rect();
|
||||
mtk::draw_scrollbar(canvas, track, thumb,
|
||||
mouse_in_rect(track),
|
||||
g_scroll_dragging,
|
||||
theme);
|
||||
}
|
||||
}
|
||||
|
||||
mtk::draw_text_input_context_menu(
|
||||
canvas, g_query_input, theme,
|
||||
mtk::text_input_has_selection(g_query_input,
|
||||
g_query, (int)sizeof(g_query)));
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* state.cpp
|
||||
* Global state definitions for the Wikipedia client
|
||||
* Copyright (c) 2026 Daniel Hammer
|
||||
*/
|
||||
|
||||
#include "wikipedia.h"
|
||||
|
||||
int TOOLBAR_H = 42;
|
||||
int FONT_SIZE = 18;
|
||||
int TITLE_SIZE = 32;
|
||||
int SECTION_SIZE = 24;
|
||||
|
||||
AppPhase g_phase = AppPhase::WELCOME;
|
||||
char g_query[256] = {};
|
||||
mtk::TextInputState g_query_input = {};
|
||||
char g_status[256] = {};
|
||||
int g_scroll_y = 0;
|
||||
int g_line_count = 0;
|
||||
int g_line_h = 24;
|
||||
int g_win_w = INIT_W;
|
||||
int g_win_h = INIT_H;
|
||||
int g_mouse_x = -1;
|
||||
int g_mouse_y = -1;
|
||||
bool g_search_focused = true;
|
||||
bool g_scroll_dragging = false;
|
||||
int g_scroll_drag_offset = 0;
|
||||
bool g_display_controls_open = false;
|
||||
ReaderTextSize g_reader_text_size = ReaderTextSize::MEDIUM;
|
||||
ReaderFontFace g_reader_font_face = ReaderFontFace::BOOK;
|
||||
char g_title[512] = {};
|
||||
int g_extract_len = 0;
|
||||
int g_base_font_size = 18;
|
||||
int g_base_title_size = 32;
|
||||
int g_base_section_size = 24;
|
||||
Color g_accent = colors::ACCENT;
|
||||
bool g_welcome_feature_loaded = false;
|
||||
char g_welcome_feature_title[256] = {};
|
||||
char g_welcome_feature_extract[1024] = {};
|
||||
char g_welcome_status[192] = "Loading today's featured article...";
|
||||
char g_welcome_date[16] = {};
|
||||
|
||||
WikiLine* g_lines = nullptr;
|
||||
char* g_resp_buf = nullptr;
|
||||
char* g_extract_buf = nullptr;
|
||||
|
||||
TrueTypeFont* g_font = nullptr;
|
||||
TrueTypeFont* g_font_serif = nullptr;
|
||||
TrueTypeFont* g_body_fonts[READER_FONT_FACE_COUNT] = {};
|
||||
TrueTypeFont* g_heading_fonts[READER_FONT_FACE_COUNT] = {};
|
||||
SvgIcon g_display_icon = {};
|
||||
SvgIcon g_display_icon_active = {};
|
||||
|
||||
bool g_tls_ready = false;
|
||||
uint32_t g_server_ip = 0;
|
||||
tls::TrustAnchors g_tas = {nullptr, 0, 0};
|
||||
|
||||
const char* g_text_size_labels[READER_TEXT_SIZE_COUNT] = {
|
||||
"A-", "A", "A+"
|
||||
};
|
||||
|
||||
const char* g_font_face_labels[READER_FONT_FACE_COUNT] = {
|
||||
"Sans", "Serif", "Book"
|
||||
};
|
||||
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* text.cpp
|
||||
* Text encoding, HTTP / URL / JSON parsing for the Wikipedia client
|
||||
* Copyright (c) 2026 Daniel Hammer
|
||||
*/
|
||||
|
||||
#include "wikipedia.h"
|
||||
|
||||
// ============================================================================
|
||||
// Codepoint and UTF-8 helpers
|
||||
// ============================================================================
|
||||
|
||||
int encode_single_byte_codepoint(unsigned codepoint) {
|
||||
if (codepoint < 0x80) return (int)codepoint;
|
||||
if (codepoint >= 0xA0 && codepoint <= 0xFF) return (int)codepoint;
|
||||
|
||||
switch (codepoint) {
|
||||
case 0x20AC: return 0x80;
|
||||
case 0x201A: return 0x82;
|
||||
case 0x0192: return 0x83;
|
||||
case 0x201E: return 0x84;
|
||||
case 0x2026: return 0x85;
|
||||
case 0x2020: return 0x86;
|
||||
case 0x2021: return 0x87;
|
||||
case 0x02C6: return 0x88;
|
||||
case 0x2030: return 0x89;
|
||||
case 0x0160: return 0x8A;
|
||||
case 0x2039: return 0x8B;
|
||||
case 0x0152: return 0x8C;
|
||||
case 0x017D: return 0x8E;
|
||||
case 0x2018: return 0x91;
|
||||
case 0x2019: return 0x92;
|
||||
case 0x201C: return 0x93;
|
||||
case 0x201D: return 0x94;
|
||||
case 0x2022: return 0x95;
|
||||
case 0x2013: return 0x96;
|
||||
case 0x2014: return 0x97;
|
||||
case 0x02DC: return 0x98;
|
||||
case 0x2122: return 0x99;
|
||||
case 0x0161: return 0x9A;
|
||||
case 0x203A: return 0x9B;
|
||||
case 0x0153: return 0x9C;
|
||||
case 0x017E: return 0x9E;
|
||||
case 0x0178: return 0x9F;
|
||||
default: return -1;
|
||||
}
|
||||
}
|
||||
|
||||
void append_codepoint(char* out, int* j, int maxOut, unsigned codepoint) {
|
||||
if (!out || !j || *j >= maxOut - 1) return;
|
||||
|
||||
int encoded = encode_single_byte_codepoint(codepoint);
|
||||
if (encoded >= 0) {
|
||||
out[(*j)++] = (char)encoded;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (codepoint) {
|
||||
case 0x00A0:
|
||||
out[(*j)++] = ' ';
|
||||
return;
|
||||
case 0x2010:
|
||||
case 0x2011:
|
||||
case 0x2212:
|
||||
out[(*j)++] = '-';
|
||||
return;
|
||||
default:
|
||||
out[(*j)++] = '?';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void underscores_to_spaces(char* text) {
|
||||
if (!text) return;
|
||||
for (int i = 0; text[i]; i++) {
|
||||
if (text[i] == '_') text[i] = ' ';
|
||||
}
|
||||
}
|
||||
|
||||
bool is_main_page_query(const char* query) {
|
||||
if (!query) return false;
|
||||
|
||||
char normalized[16];
|
||||
int out = 0;
|
||||
for (int i = 0; query[i] && out < (int)sizeof(normalized) - 1; i++) {
|
||||
char ch = query[i];
|
||||
if (ch == ' ' || ch == '_' || ch == '-') continue;
|
||||
if (ch >= 'A' && ch <= 'Z') ch = (char)(ch - 'A' + 'a');
|
||||
normalized[out++] = ch;
|
||||
}
|
||||
normalized[out] = '\0';
|
||||
return strcmp(normalized, "mainpage") == 0;
|
||||
}
|
||||
|
||||
unsigned decode_utf8_codepoint(const char* buf, int len, int* consumed) {
|
||||
if (!buf || len <= 0) {
|
||||
if (consumed) *consumed = 0;
|
||||
return '?';
|
||||
}
|
||||
|
||||
unsigned char c0 = (unsigned char)buf[0];
|
||||
if (c0 < 0x80) {
|
||||
if (consumed) *consumed = 1;
|
||||
return c0;
|
||||
}
|
||||
|
||||
auto continuation = [](unsigned char c) -> bool {
|
||||
return (c & 0xC0) == 0x80;
|
||||
};
|
||||
|
||||
if ((c0 & 0xE0) == 0xC0 && len >= 2) {
|
||||
unsigned char c1 = (unsigned char)buf[1];
|
||||
if (continuation(c1)) {
|
||||
if (consumed) *consumed = 2;
|
||||
return ((unsigned)(c0 & 0x1F) << 6) | (unsigned)(c1 & 0x3F);
|
||||
}
|
||||
} else if ((c0 & 0xF0) == 0xE0 && len >= 3) {
|
||||
unsigned char c1 = (unsigned char)buf[1];
|
||||
unsigned char c2 = (unsigned char)buf[2];
|
||||
if (continuation(c1) && continuation(c2)) {
|
||||
if (consumed) *consumed = 3;
|
||||
return ((unsigned)(c0 & 0x0F) << 12) |
|
||||
((unsigned)(c1 & 0x3F) << 6) |
|
||||
(unsigned)(c2 & 0x3F);
|
||||
}
|
||||
} else if ((c0 & 0xF8) == 0xF0 && len >= 4) {
|
||||
unsigned char c1 = (unsigned char)buf[1];
|
||||
unsigned char c2 = (unsigned char)buf[2];
|
||||
unsigned char c3 = (unsigned char)buf[3];
|
||||
if (continuation(c1) && continuation(c2) && continuation(c3)) {
|
||||
if (consumed) *consumed = 4;
|
||||
return ((unsigned)(c0 & 0x07) << 18) |
|
||||
((unsigned)(c1 & 0x3F) << 12) |
|
||||
((unsigned)(c2 & 0x3F) << 6) |
|
||||
(unsigned)(c3 & 0x3F);
|
||||
}
|
||||
}
|
||||
|
||||
if (consumed) *consumed = 1;
|
||||
return '?';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HTTP parsing
|
||||
// ============================================================================
|
||||
|
||||
int find_header_end(const char* buf, int len) {
|
||||
for (int i = 0; i + 3 < len; i++)
|
||||
if (buf[i]=='\r' && buf[i+1]=='\n' && buf[i+2]=='\r' && buf[i+3]=='\n')
|
||||
return i + 4;
|
||||
return -1;
|
||||
}
|
||||
|
||||
int parse_status_code(const char* buf, int len) {
|
||||
int i = 0;
|
||||
while (i < len && buf[i] != ' ') i++;
|
||||
if (i >= len || i + 3 >= len) return -1;
|
||||
i++;
|
||||
if (buf[i] < '0' || buf[i] > '9') return -1;
|
||||
return (buf[i]-'0')*100 + (buf[i+1]-'0')*10 + (buf[i+2]-'0');
|
||||
}
|
||||
|
||||
int find_substr(const char* buf, int len, const char* needle) {
|
||||
int nlen = (int)strlen(needle);
|
||||
if (!buf || !needle || nlen <= 0 || nlen > len) return -1;
|
||||
for (int i = 0; i <= len - nlen; i++) {
|
||||
if (memcmp(buf + i, needle, nlen) == 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// URL encoding
|
||||
// ============================================================================
|
||||
|
||||
int url_encode_title(const char* in, char* out, int maxLen) {
|
||||
const char hex[] = "0123456789ABCDEF";
|
||||
int j = 0;
|
||||
for (int i = 0; in[i] && j < maxLen - 4; i++) {
|
||||
char c = in[i];
|
||||
if (c == ' ') {
|
||||
out[j++] = '_';
|
||||
} else if ((c>='A'&&c<='Z')||(c>='a'&&c<='z')||(c>='0'&&c<='9')||
|
||||
c=='-'||c=='_'||c=='.'||c=='~'||c=='('||c==')'||c==',') {
|
||||
out[j++] = c;
|
||||
} else {
|
||||
out[j++] = '%';
|
||||
out[j++] = hex[(unsigned char)c >> 4];
|
||||
out[j++] = hex[(unsigned char)c & 0x0F];
|
||||
}
|
||||
}
|
||||
out[j] = '\0';
|
||||
return j;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JSON string extraction
|
||||
// ============================================================================
|
||||
|
||||
int extract_json_string(const char* buf, int len, const char* key,
|
||||
char* out, int maxOut) {
|
||||
int klen = (int)strlen(key);
|
||||
for (int i = 0; i < len - klen - 3; i++) {
|
||||
if (buf[i] != '"') continue;
|
||||
if (memcmp(buf + i + 1, key, klen) != 0) continue;
|
||||
if (buf[i + 1 + klen] != '"') continue;
|
||||
if (buf[i + 2 + klen] != ':') continue;
|
||||
|
||||
int p = i + 3 + klen;
|
||||
while (p < len && (buf[p]==' ' || buf[p]=='\t')) p++;
|
||||
if (p >= len || buf[p] != '"') continue;
|
||||
p++;
|
||||
|
||||
int j = 0;
|
||||
while (p < len && j < maxOut - 4) {
|
||||
if (buf[p] == '"') break;
|
||||
if (buf[p] == '\\' && p + 1 < len) {
|
||||
p++;
|
||||
switch (buf[p]) {
|
||||
case '"': out[j++] = '"'; break;
|
||||
case '\\': out[j++] = '\\'; break;
|
||||
case 'n': out[j++] = '\n'; break;
|
||||
case 'r': break;
|
||||
case 't': out[j++] = '\t'; break;
|
||||
case '/': out[j++] = '/'; break;
|
||||
case 'u': {
|
||||
if (p + 4 < len) {
|
||||
unsigned val = 0;
|
||||
for (int k = 1; k <= 4; k++) {
|
||||
char h = buf[p + k]; val <<= 4;
|
||||
if (h>='0'&&h<='9') val |= h-'0';
|
||||
else if (h>='a'&&h<='f') val |= h-'a'+10;
|
||||
else if (h>='A'&&h<='F') val |= h-'A'+10;
|
||||
}
|
||||
p += 4;
|
||||
append_codepoint(out, &j, maxOut, val);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: out[j++] = buf[p]; break;
|
||||
}
|
||||
} else {
|
||||
unsigned char uc = (unsigned char)buf[p];
|
||||
if (uc < 0x80) {
|
||||
out[j++] = buf[p];
|
||||
} else {
|
||||
int consumed = 0;
|
||||
unsigned codepoint = decode_utf8_codepoint(buf + p, len - p, &consumed);
|
||||
append_codepoint(out, &j, maxOut, codepoint);
|
||||
p += consumed > 0 ? consumed - 1 : 0;
|
||||
}
|
||||
}
|
||||
p++;
|
||||
}
|
||||
out[j] = '\0';
|
||||
return j;
|
||||
}
|
||||
out[0] = '\0';
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
* theme.cpp
|
||||
* Palette, theme, layout, and scaling for the Wikipedia client
|
||||
* Copyright (c) 2026 Daniel Hammer
|
||||
*/
|
||||
|
||||
#include "wikipedia.h"
|
||||
|
||||
// ============================================================================
|
||||
// Palette / theme
|
||||
// ============================================================================
|
||||
|
||||
ReaderPalette reader_palette() {
|
||||
return {
|
||||
WHITE,
|
||||
WHITE,
|
||||
Color::from_rgb(0xF5, 0xF5, 0xF5),
|
||||
colors::BORDER,
|
||||
colors::TEXT_COLOR,
|
||||
Color::from_rgb(0x22, 0x22, 0x22),
|
||||
Color::from_rgb(0x77, 0x77, 0x77),
|
||||
g_accent,
|
||||
mtk::darken(g_accent, 32),
|
||||
mtk::lighten(g_accent, 214),
|
||||
Color::from_rgb(0xD0, 0x3E, 0x3E),
|
||||
Color::from_rgb(0xDD, 0x44, 0x44),
|
||||
Color::from_rgb(0xCC, 0xCC, 0xCC),
|
||||
WHITE,
|
||||
};
|
||||
}
|
||||
|
||||
TrueTypeFont* current_body_font() {
|
||||
return g_font ? g_font : g_body_fonts[(int)ReaderFontFace::SANS];
|
||||
}
|
||||
|
||||
TrueTypeFont* current_heading_font() {
|
||||
return g_font_serif ? g_font_serif : current_body_font();
|
||||
}
|
||||
|
||||
void apply_reader_preferences() {
|
||||
static const int body_adjust[READER_TEXT_SIZE_COUNT] = {-2, 0, 4};
|
||||
static const int title_adjust[READER_TEXT_SIZE_COUNT] = {-4, 0, 6};
|
||||
static const int section_adjust[READER_TEXT_SIZE_COUNT] = {-3, 0, 5};
|
||||
|
||||
int font_index = (int)g_reader_font_face;
|
||||
g_font = g_body_fonts[font_index] ? g_body_fonts[font_index]
|
||||
: g_body_fonts[(int)ReaderFontFace::SANS];
|
||||
g_font_serif = g_heading_fonts[font_index] ? g_heading_fonts[font_index]
|
||||
: g_heading_fonts[(int)ReaderFontFace::SERIF];
|
||||
|
||||
FONT_SIZE = g_base_font_size + body_adjust[(int)g_reader_text_size];
|
||||
TITLE_SIZE = g_base_title_size + title_adjust[(int)g_reader_text_size];
|
||||
SECTION_SIZE = g_base_section_size + section_adjust[(int)g_reader_text_size];
|
||||
if (current_body_font())
|
||||
g_line_h = current_body_font()->get_line_height(FONT_SIZE) + 4;
|
||||
}
|
||||
|
||||
mtk::Theme wiki_theme() {
|
||||
ReaderPalette palette = reader_palette();
|
||||
mtk::Theme theme = mtk::make_theme(palette.accent);
|
||||
theme.window_bg = palette.page_bg;
|
||||
theme.surface = palette.control_bg;
|
||||
theme.surface_alt = palette.toolbar_bg;
|
||||
theme.surface_hover = mtk::mix(palette.toolbar_bg, palette.accent, 26);
|
||||
theme.border = palette.border;
|
||||
theme.text = palette.text;
|
||||
theme.text_muted = palette.muted;
|
||||
theme.text_subtle = palette.muted;
|
||||
theme.accent = palette.accent;
|
||||
theme.accent_hover = palette.accent_hover;
|
||||
theme.accent_soft = palette.accent_soft;
|
||||
theme.selection = palette.accent;
|
||||
theme.danger = palette.danger;
|
||||
theme.danger_hover = palette.danger_hover;
|
||||
theme.disabled_bg = palette.disabled_bg;
|
||||
theme.disabled_fg = palette.disabled_fg;
|
||||
return theme;
|
||||
}
|
||||
|
||||
void apply_scale(int scale) {
|
||||
switch (scale) {
|
||||
case 0:
|
||||
g_base_font_size = 14;
|
||||
g_base_title_size = 26;
|
||||
g_base_section_size = 20;
|
||||
break;
|
||||
case 2:
|
||||
g_base_font_size = 22;
|
||||
g_base_title_size = 40;
|
||||
g_base_section_size = 30;
|
||||
break;
|
||||
default:
|
||||
g_base_font_size = 18;
|
||||
g_base_title_size = 32;
|
||||
g_base_section_size = 24;
|
||||
break;
|
||||
}
|
||||
apply_reader_preferences();
|
||||
update_toolbar_height();
|
||||
reload_display_icons();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Toolbar metrics
|
||||
// ============================================================================
|
||||
|
||||
int search_control_h() {
|
||||
return gui_clamp(g_base_font_size + 12, 24, 32);
|
||||
}
|
||||
|
||||
int reader_button_h() {
|
||||
return gui_clamp(search_control_h() - 4, 20, 28);
|
||||
}
|
||||
|
||||
int display_toggle_icon_size() {
|
||||
return gui_clamp(search_control_h() - 12, 14, 20);
|
||||
}
|
||||
|
||||
int display_toggle_button_w() {
|
||||
return gui_max(search_control_h(), display_toggle_icon_size() + 12);
|
||||
}
|
||||
|
||||
int search_row_y() {
|
||||
return 6;
|
||||
}
|
||||
|
||||
int reader_row_y() {
|
||||
return search_row_y() + search_control_h() + 6;
|
||||
}
|
||||
|
||||
void update_toolbar_height() {
|
||||
int h = search_row_y() + search_control_h() + 6;
|
||||
if (g_display_controls_open)
|
||||
h += reader_button_h() + 6;
|
||||
TOOLBAR_H = h;
|
||||
}
|
||||
|
||||
void reload_display_icons() {
|
||||
if (g_display_icon.pixels) svg_free(g_display_icon);
|
||||
if (g_display_icon_active.pixels) svg_free(g_display_icon_active);
|
||||
|
||||
int icon_size = display_toggle_icon_size();
|
||||
ReaderPalette palette = reader_palette();
|
||||
g_display_icon = svg_load(DISPLAY_ICON_PATH, icon_size, icon_size, palette.text);
|
||||
g_display_icon_active = svg_load(DISPLAY_ICON_PATH, icon_size, icon_size, WHITE);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Layout helpers
|
||||
// ============================================================================
|
||||
|
||||
static int segmented_button_w(const char* label, int min_w) {
|
||||
return gui_max(text_width(label) + 16, min_w);
|
||||
}
|
||||
|
||||
static int button_group_width(const char* const* labels, int count, int min_w, int gap) {
|
||||
int w = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (i > 0) w += gap;
|
||||
w += segmented_button_w(labels[i], min_w);
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
static void layout_button_group(Rect* out, int count,
|
||||
const char* const* labels,
|
||||
int min_w, int gap,
|
||||
int* x, int y, int h) {
|
||||
for (int i = 0; i < count; i++) {
|
||||
int w = segmented_button_w(labels[i], min_w);
|
||||
out[i] = {*x, y, w, h};
|
||||
*x += w + gap;
|
||||
}
|
||||
*x -= gap;
|
||||
}
|
||||
|
||||
ReaderToolbarLayout toolbar_layout() {
|
||||
ReaderToolbarLayout lo {};
|
||||
int pad = 8;
|
||||
int search_h = search_control_h();
|
||||
int reader_h = reader_button_h();
|
||||
int display_w = display_toggle_button_w();
|
||||
int search_btn_w = 104;
|
||||
|
||||
lo.search_button = {g_win_w - pad - search_btn_w, search_row_y(), search_btn_w, search_h};
|
||||
lo.display_button = {lo.search_button.x - 8 - display_w, search_row_y(), display_w, search_h};
|
||||
int field_w = lo.display_button.x - pad - 8;
|
||||
if (field_w < 80) field_w = 80;
|
||||
lo.search_field = {pad, search_row_y(), field_w, search_h};
|
||||
|
||||
if (!g_display_controls_open)
|
||||
return lo;
|
||||
|
||||
int gap = 4;
|
||||
int group_gap = 18;
|
||||
int label_gap = 8;
|
||||
int size_w = button_group_width(g_text_size_labels, READER_TEXT_SIZE_COUNT, 36, gap);
|
||||
int font_w = button_group_width(g_font_face_labels, READER_FONT_FACE_COUNT, 54, gap);
|
||||
int text_label_w = text_width("Text");
|
||||
int font_label_w = text_width("Font");
|
||||
int available_w = g_win_w - pad * 2;
|
||||
int total_w = text_label_w + label_gap + size_w + group_gap +
|
||||
font_label_w + label_gap + font_w;
|
||||
lo.show_labels = total_w <= available_w;
|
||||
if (!lo.show_labels) {
|
||||
total_w = size_w + group_gap + font_w;
|
||||
text_label_w = 0;
|
||||
font_label_w = 0;
|
||||
}
|
||||
|
||||
int x = total_w < available_w ? (g_win_w - total_w) / 2 : pad;
|
||||
int y = reader_row_y();
|
||||
int label_y = y + (reader_h - system_font_height()) / 2;
|
||||
|
||||
if (lo.show_labels) {
|
||||
lo.text_size_label = {x, label_y, text_label_w, system_font_height()};
|
||||
x += text_label_w + label_gap;
|
||||
}
|
||||
|
||||
layout_button_group(lo.text_size_buttons, READER_TEXT_SIZE_COUNT, g_text_size_labels, 36, gap, &x, y, reader_h);
|
||||
x += group_gap;
|
||||
lo.sep_x = x - group_gap / 2;
|
||||
|
||||
if (lo.show_labels) {
|
||||
lo.font_face_label = {x, label_y, font_label_w, system_font_height()};
|
||||
x += font_label_w + label_gap;
|
||||
}
|
||||
|
||||
layout_button_group(lo.font_buttons, READER_FONT_FACE_COUNT, g_font_face_labels, 54, gap, &x, y, reader_h);
|
||||
return lo;
|
||||
}
|
||||
|
||||
Rect content_rect() {
|
||||
int y = TOOLBAR_H + 1;
|
||||
return {0, y, g_win_w, gui_max(g_win_h - y, 0)};
|
||||
}
|
||||
|
||||
Rect article_column_rect() {
|
||||
Rect content = content_rect();
|
||||
int available_w = gui_max(content.w - TEXT_PAD * 2 - SCROLLBAR_W - 12, 80);
|
||||
int column_w = gui_min(available_w, 700);
|
||||
int x = TEXT_PAD + gui_max((available_w - column_w) / 2, 0);
|
||||
return {x, content.y, column_w, content.h};
|
||||
}
|
||||
|
||||
int document_top_pad() {
|
||||
return g_phase == AppPhase::WELCOME ? 24 : 8;
|
||||
}
|
||||
|
||||
Rect document_column_rect() {
|
||||
Rect column = article_column_rect();
|
||||
if (g_phase == AppPhase::WELCOME) {
|
||||
int inset = gui_min(24, column.w / 12);
|
||||
if (column.w - inset * 2 >= 320) {
|
||||
column.x += inset;
|
||||
column.w -= inset * 2;
|
||||
}
|
||||
}
|
||||
return column;
|
||||
}
|
||||
|
||||
Rect search_button_rect() { return toolbar_layout().search_button; }
|
||||
Rect search_field_rect() { return toolbar_layout().search_field; }
|
||||
|
||||
// ============================================================================
|
||||
// Scrolling
|
||||
// ============================================================================
|
||||
|
||||
int visible_line_count() {
|
||||
Rect content = content_rect();
|
||||
if (g_line_h <= 0) return 0;
|
||||
return content.h / g_line_h;
|
||||
}
|
||||
|
||||
int max_scroll_lines() {
|
||||
return mtk::scrollbar_max_offset(g_line_count, visible_line_count());
|
||||
}
|
||||
|
||||
bool document_scrollable() {
|
||||
return g_phase == AppPhase::WELCOME || g_phase == AppPhase::DONE;
|
||||
}
|
||||
|
||||
void clamp_scroll() {
|
||||
g_scroll_y = gui_clamp(g_scroll_y, 0, max_scroll_lines());
|
||||
}
|
||||
|
||||
bool mouse_in_rect(const Rect& rect) {
|
||||
return rect.contains(g_mouse_x, g_mouse_y);
|
||||
}
|
||||
|
||||
Rect scrollbar_track_rect() {
|
||||
Rect content = content_rect();
|
||||
int visible = visible_line_count();
|
||||
if (visible <= 0 || g_line_count <= visible) return {0, 0, 0, 0};
|
||||
return mtk::scrollbar_track_rect(content);
|
||||
}
|
||||
|
||||
Rect scrollbar_thumb_rect() {
|
||||
Rect track = scrollbar_track_rect();
|
||||
if (track.empty()) return {0, 0, 0, 0};
|
||||
|
||||
int visible = visible_line_count();
|
||||
if (visible <= 0 || g_line_count <= visible) return {0, 0, 0, 0};
|
||||
|
||||
return mtk::scrollbar_thumb_rect(track, g_line_count, visible, g_scroll_y);
|
||||
}
|
||||
|
||||
void set_scroll_from_thumb_top(int thumb_top) {
|
||||
Rect track = scrollbar_track_rect();
|
||||
Rect thumb = scrollbar_thumb_rect();
|
||||
if (track.empty() || thumb.empty()) return;
|
||||
|
||||
g_scroll_y = mtk::scrollbar_offset_from_thumb_top(track,
|
||||
g_line_count,
|
||||
visible_line_count(),
|
||||
thumb.h,
|
||||
thumb_top);
|
||||
clamp_scroll();
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* wikipedia.h
|
||||
* Shared header for the MontaukOS Wikipedia GUI client
|
||||
* Copyright (c) 2026 Daniel Hammer
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <montauk/syscall.h>
|
||||
#include <montauk/string.h>
|
||||
#include <montauk/heap.h>
|
||||
#include <gui/mtk.hpp>
|
||||
#include <gui/mtk/settings.hpp>
|
||||
#include <gui/standalone.hpp>
|
||||
#include <gui/svg.hpp>
|
||||
#include <gui/truetype.hpp>
|
||||
#include <tls/tls.hpp>
|
||||
|
||||
extern "C" {
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
}
|
||||
|
||||
using namespace gui;
|
||||
using namespace gui::colors;
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
static constexpr int INIT_W = 820;
|
||||
static constexpr int INIT_H = 580;
|
||||
static constexpr int SCROLLBAR_W = mtk::SCROLLBAR_W;
|
||||
static constexpr int TEXT_PAD = 16;
|
||||
static constexpr int RESP_MAX = 131072;
|
||||
static constexpr int MAX_LINES = 2000;
|
||||
|
||||
inline const char WIKI_HOST[] = "en.wikipedia.org";
|
||||
inline const char DISPLAY_ICON_PATH[] = "0:/icons/cog.svg";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
struct WikiLine {
|
||||
char text[256];
|
||||
Color color;
|
||||
int font_size;
|
||||
TrueTypeFont* font;
|
||||
int indent;
|
||||
};
|
||||
|
||||
enum class AppPhase { WELCOME, IDLE, LOADING, DONE, ERR };
|
||||
enum class ReaderTextSize : uint8_t { SMALL = 0, MEDIUM, LARGE, COUNT };
|
||||
enum class ReaderFontFace : uint8_t { SANS = 0, SERIF, BOOK, COUNT };
|
||||
|
||||
struct ReaderPalette {
|
||||
Color page_bg;
|
||||
Color control_bg;
|
||||
Color toolbar_bg;
|
||||
Color border;
|
||||
Color text;
|
||||
Color heading;
|
||||
Color muted;
|
||||
Color accent;
|
||||
Color accent_hover;
|
||||
Color accent_soft;
|
||||
Color danger;
|
||||
Color danger_hover;
|
||||
Color disabled_bg;
|
||||
Color disabled_fg;
|
||||
};
|
||||
|
||||
struct ReaderToolbarLayout {
|
||||
Rect search_field;
|
||||
Rect display_button;
|
||||
Rect search_button;
|
||||
Rect text_size_buttons[(int)ReaderTextSize::COUNT];
|
||||
Rect font_buttons[(int)ReaderFontFace::COUNT];
|
||||
Rect text_size_label;
|
||||
Rect font_face_label;
|
||||
int sep_x;
|
||||
bool show_labels;
|
||||
};
|
||||
|
||||
static constexpr int READER_TEXT_SIZE_COUNT = (int)ReaderTextSize::COUNT;
|
||||
static constexpr int READER_FONT_FACE_COUNT = (int)ReaderFontFace::COUNT;
|
||||
|
||||
// ============================================================================
|
||||
// Global state (extern -- defined in state.cpp)
|
||||
// ============================================================================
|
||||
|
||||
extern int TOOLBAR_H;
|
||||
extern int FONT_SIZE;
|
||||
extern int TITLE_SIZE;
|
||||
extern int SECTION_SIZE;
|
||||
|
||||
extern AppPhase g_phase;
|
||||
extern char g_query[256];
|
||||
extern mtk::TextInputState g_query_input;
|
||||
extern char g_status[256];
|
||||
extern int g_scroll_y;
|
||||
extern int g_line_count;
|
||||
extern int g_line_h;
|
||||
extern int g_win_w;
|
||||
extern int g_win_h;
|
||||
extern int g_mouse_x;
|
||||
extern int g_mouse_y;
|
||||
extern bool g_search_focused;
|
||||
extern bool g_scroll_dragging;
|
||||
extern int g_scroll_drag_offset;
|
||||
extern bool g_display_controls_open;
|
||||
extern ReaderTextSize g_reader_text_size;
|
||||
extern ReaderFontFace g_reader_font_face;
|
||||
extern char g_title[512];
|
||||
extern int g_extract_len;
|
||||
extern int g_base_font_size;
|
||||
extern int g_base_title_size;
|
||||
extern int g_base_section_size;
|
||||
extern Color g_accent;
|
||||
extern bool g_welcome_feature_loaded;
|
||||
extern char g_welcome_feature_title[256];
|
||||
extern char g_welcome_feature_extract[1024];
|
||||
extern char g_welcome_status[192];
|
||||
extern char g_welcome_date[16];
|
||||
|
||||
extern WikiLine* g_lines;
|
||||
extern char* g_resp_buf;
|
||||
extern char* g_extract_buf;
|
||||
|
||||
extern TrueTypeFont* g_font;
|
||||
extern TrueTypeFont* g_font_serif;
|
||||
extern TrueTypeFont* g_body_fonts[READER_FONT_FACE_COUNT];
|
||||
extern TrueTypeFont* g_heading_fonts[READER_FONT_FACE_COUNT];
|
||||
extern SvgIcon g_display_icon;
|
||||
extern SvgIcon g_display_icon_active;
|
||||
|
||||
extern bool g_tls_ready;
|
||||
extern uint32_t g_server_ip;
|
||||
extern tls::TrustAnchors g_tas;
|
||||
|
||||
extern const char* g_text_size_labels[READER_TEXT_SIZE_COUNT];
|
||||
extern const char* g_font_face_labels[READER_FONT_FACE_COUNT];
|
||||
|
||||
// ============================================================================
|
||||
// theme.cpp -- palette, theme, layout, scaling
|
||||
// ============================================================================
|
||||
|
||||
ReaderPalette reader_palette();
|
||||
mtk::Theme wiki_theme();
|
||||
TrueTypeFont* current_body_font();
|
||||
TrueTypeFont* current_heading_font();
|
||||
void apply_reader_preferences();
|
||||
void apply_scale(int scale);
|
||||
void update_toolbar_height();
|
||||
void reload_display_icons();
|
||||
|
||||
int search_control_h();
|
||||
int reader_button_h();
|
||||
int display_toggle_icon_size();
|
||||
int display_toggle_button_w();
|
||||
int search_row_y();
|
||||
int reader_row_y();
|
||||
|
||||
ReaderToolbarLayout toolbar_layout();
|
||||
Rect content_rect();
|
||||
Rect article_column_rect();
|
||||
Rect document_column_rect();
|
||||
Rect search_button_rect();
|
||||
Rect search_field_rect();
|
||||
Rect scrollbar_track_rect();
|
||||
Rect scrollbar_thumb_rect();
|
||||
|
||||
int visible_line_count();
|
||||
int max_scroll_lines();
|
||||
bool document_scrollable();
|
||||
void clamp_scroll();
|
||||
bool mouse_in_rect(const Rect& rect);
|
||||
void set_scroll_from_thumb_top(int thumb_top);
|
||||
int document_top_pad();
|
||||
|
||||
// ============================================================================
|
||||
// text.cpp -- encoding, HTTP/URL/JSON parsing
|
||||
// ============================================================================
|
||||
|
||||
int encode_single_byte_codepoint(unsigned codepoint);
|
||||
void append_codepoint(char* out, int* j, int maxOut, unsigned codepoint);
|
||||
void underscores_to_spaces(char* text);
|
||||
bool is_main_page_query(const char* query);
|
||||
unsigned decode_utf8_codepoint(const char* buf, int len, int* consumed);
|
||||
|
||||
int find_header_end(const char* buf, int len);
|
||||
int parse_status_code(const char* buf, int len);
|
||||
int find_substr(const char* buf, int len, const char* needle);
|
||||
|
||||
int url_encode_title(const char* in, char* out, int maxLen);
|
||||
|
||||
int extract_json_string(const char* buf, int len, const char* key,
|
||||
char* out, int maxOut);
|
||||
|
||||
// ============================================================================
|
||||
// document.cpp -- display line building
|
||||
// ============================================================================
|
||||
|
||||
void add_line(const char* text, int len, Color color, int size,
|
||||
TrueTypeFont* font, int indent = 0);
|
||||
void add_spacer(int size);
|
||||
void wrap_text(TrueTypeFont* font, int size, const char* text, int textLen,
|
||||
int max_px, Color color, int indent = 0);
|
||||
void build_display_lines(const char* title, const char* extract, int extractLen,
|
||||
bool preserve_scroll = false);
|
||||
void build_welcome_lines(bool preserve_scroll = false);
|
||||
void rebuild_current_document(bool preserve_scroll = true);
|
||||
void apply_reader_setting_change();
|
||||
bool handle_reader_option_click(int mx, int my);
|
||||
|
||||
// ============================================================================
|
||||
// network.cpp -- TLS fetch, welcome fetch, search
|
||||
// ============================================================================
|
||||
|
||||
int wiki_fetch(const char* path, char* respBuf, int respMax);
|
||||
bool ensure_wiki_tls_ready(char* err, int err_len);
|
||||
bool welcome_feed_path(char* path, int path_len);
|
||||
void do_welcome_fetch();
|
||||
void do_search(const char* query);
|
||||
|
||||
// ============================================================================
|
||||
// render.cpp -- drawing
|
||||
// ============================================================================
|
||||
|
||||
void draw_display_toggle_button(Canvas& canvas,
|
||||
const Rect& bounds,
|
||||
const mtk::WidgetState& state,
|
||||
const mtk::Theme& theme);
|
||||
void render_message(Canvas& canvas, const Rect& content, const char* text, Color color);
|
||||
void render(Canvas& canvas);
|
||||
Reference in New Issue
Block a user