feat: split Wikipedia app into separate files, change icon to cog

This commit is contained in:
2026-05-26 07:51:32 +02:00
parent c52e42337c
commit dc03f610fc
9 changed files with 1572 additions and 1410 deletions
+1 -1
View File
@@ -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 ----
+251
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+183
View File
@@ -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;
}
+133
View File
@@ -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)));
}
+64
View File
@@ -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"
};
+260
View File
@@ -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;
}
+319
View File
@@ -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();
}
+237
View File
@@ -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);