diff --git a/programs/src/wikipedia/Makefile b/programs/src/wikipedia/Makefile index 113c9d5..8e834ef 100644 --- a/programs/src/wikipedia/Makefile +++ b/programs/src/wikipedia/Makefile @@ -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 ---- diff --git a/programs/src/wikipedia/document.cpp b/programs/src/wikipedia/document.cpp new file mode 100644 index 0000000..2c90489 --- /dev/null +++ b/programs/src/wikipedia/document.cpp @@ -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; +} diff --git a/programs/src/wikipedia/main.cpp b/programs/src/wikipedia/main.cpp index a82eb06..b0bff94 100644 --- a/programs/src/wikipedia/main.cpp +++ b/programs/src/wikipedia/main.cpp @@ -1,1315 +1,24 @@ /* - * main.cpp - * MontaukOS Wikipedia GUI client - standalone Window Server process - * Fetches articles via TLS (BearSSL), renders with Roboto TTF - * Copyright (c) 2026 Daniel Hammer -*/ + * main.cpp + * Entry point and event loop for the MontaukOS Wikipedia GUI client + * Copyright (c) 2026 Daniel Hammer + */ -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include "wikipedia.h" -extern "C" { -#include -#include -#include +static TrueTypeFont* load_font(const char* path) { + TrueTypeFont* f = (TrueTypeFont*)montauk::malloc(sizeof(TrueTypeFont)); + if (!f) return nullptr; + montauk::memset(f, 0, sizeof(TrueTypeFont)); + if (!f->init(path)) { montauk::mfree(f); return nullptr; } + return f; } -using namespace gui; -using namespace gui::colors; - -// ============================================================================ -// Constants -// ============================================================================ - -static constexpr int INIT_W = 820; -static constexpr int INIT_H = 580; -static int TOOLBAR_H = 42; -static constexpr int SCROLLBAR_W = mtk::SCROLLBAR_W; -static int FONT_SIZE = 18; -static int TITLE_SIZE = 32; -static int SECTION_SIZE = 24; -static constexpr int TEXT_PAD = 16; -static constexpr int RESP_MAX = 131072; -static constexpr int MAX_LINES = 2000; - -static const char WIKI_HOST[] = "en.wikipedia.org"; -static const char DISPLAY_ICON_PATH[] = "0:/icons/text_line_spacing.svg"; - -// ============================================================================ -// Display line -// ============================================================================ - -struct WikiLine { - char text[256]; - Color color; - int font_size; - TrueTypeFont* font; // which font to render with - int indent; -}; - -// ============================================================================ -// App state -// ============================================================================ - -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; -}; - -static constexpr int READER_TEXT_SIZE_COUNT = (int)ReaderTextSize::COUNT; -static constexpr int READER_FONT_FACE_COUNT = (int)ReaderFontFace::COUNT; - -static AppPhase g_phase = AppPhase::WELCOME; -static char g_query[256] = {}; -static mtk::TextInputState g_query_input = {}; -static char g_status[256] = {}; -static int g_scroll_y = 0; -static int g_line_count = 0; -static int g_line_h = 24; // updated after font load -static int g_win_w = INIT_W; -static int g_win_h = INIT_H; -static int g_mouse_x = -1; -static int g_mouse_y = -1; -static bool g_search_focused = true; -static bool g_scroll_dragging = false; -static int g_scroll_drag_offset = 0; -static bool g_display_controls_open = false; -static ReaderTextSize g_reader_text_size = ReaderTextSize::MEDIUM; -static ReaderFontFace g_reader_font_face = ReaderFontFace::BOOK; -static char g_title[512] = {}; -static int g_extract_len = 0; -static int g_base_font_size = 18; -static int g_base_title_size = 32; -static int g_base_section_size = 24; -static Color g_accent = colors::ACCENT; -static bool g_welcome_feature_loaded = false; -static char g_welcome_feature_title[256] = {}; -static char g_welcome_feature_extract[1024] = {}; -static char g_welcome_status[192] = "Loading today's featured article..."; -static char g_welcome_date[16] = {}; - -// Large buffers — heap allocated in _start -static WikiLine* g_lines = nullptr; -static char* g_resp_buf = nullptr; -static char* g_extract_buf = nullptr; - -// Fonts -static TrueTypeFont* g_font = nullptr; // Roboto Medium -static TrueTypeFont* g_font_serif = nullptr; // NotoSerif SemiBold (headings) -static TrueTypeFont* g_body_fonts[READER_FONT_FACE_COUNT] = {}; -static TrueTypeFont* g_heading_fonts[READER_FONT_FACE_COUNT] = {}; -static SvgIcon g_display_icon = {}; -static SvgIcon g_display_icon_active = {}; - -// TLS state (lazy-init on first search) -static bool g_tls_ready = false; -static uint32_t g_server_ip = 0; -static tls::TrustAnchors g_tas = {nullptr, 0, 0}; - -static const char* g_text_size_labels[READER_TEXT_SIZE_COUNT] = { - "A-", "A", "A+" -}; - -static const char* g_font_face_labels[READER_FONT_FACE_COUNT] = { - "Sans", "Serif", "Book" -}; - -// ============================================================================ -// UI scale -// ============================================================================ - -struct ReaderToolbarLayout { - Rect search_field; - Rect display_button; - Rect search_button; - Rect text_size_buttons[READER_TEXT_SIZE_COUNT]; - Rect font_buttons[READER_FONT_FACE_COUNT]; - Rect text_size_label; - Rect font_face_label; - int sep_x; - bool show_labels; -}; - -static void apply_reader_preferences(); -static void update_toolbar_height(); -static void reload_display_icons(); - -static 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(); -} - -// ============================================================================ -// Theme and layout -// ============================================================================ - -static 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, - }; -} - -static TrueTypeFont* current_body_font() { - return g_font ? g_font : g_body_fonts[(int)ReaderFontFace::SANS]; -} - -static TrueTypeFont* current_heading_font() { - return g_font_serif ? g_font_serif : current_body_font(); -} - -static 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; -} - -static 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; -} - -static int search_control_h() { - return gui_clamp(g_base_font_size + 12, 24, 32); -} - -static int reader_button_h() { - return gui_clamp(search_control_h() - 4, 20, 28); -} - -static int display_toggle_icon_size() { - return gui_clamp(search_control_h() - 12, 14, 20); -} - -static int display_toggle_button_w() { - return gui_max(search_control_h(), display_toggle_icon_size() + 12); -} - -static int search_row_y() { - return 6; -} - -static int reader_row_y() { - return search_row_y() + search_control_h() + 6; -} - -static 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; -} - -static 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); -} - -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; -} - -static 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; -} - -static Rect content_rect() { - int y = TOOLBAR_H + 1; - return {0, y, g_win_w, gui_max(g_win_h - y, 0)}; -} - -static 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}; -} - -static Rect search_button_rect() { - return toolbar_layout().search_button; -} - -static Rect search_field_rect() { - return toolbar_layout().search_field; -} - -static 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); -} - -static int visible_line_count() { - Rect content = content_rect(); - if (g_line_h <= 0) return 0; - return content.h / g_line_h; -} - -static int max_scroll_lines() { - return mtk::scrollbar_max_offset(g_line_count, visible_line_count()); -} - -static bool document_scrollable() { - return g_phase == AppPhase::WELCOME || g_phase == AppPhase::DONE; -} - -static void clamp_scroll() { - g_scroll_y = gui_clamp(g_scroll_y, 0, max_scroll_lines()); -} - -static bool mouse_in_rect(const Rect& rect) { - return rect.contains(g_mouse_x, g_mouse_y); -} - -static 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); -} - -static 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); -} - -static 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(); -} - -// ============================================================================ -// HTTPS fetch wrapper -// ============================================================================ - -static 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); -} - -// ============================================================================ -// Text encoding helpers -// ============================================================================ - -static 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; - } -} - -static 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; - } -} - -static void underscores_to_spaces(char* text) { - if (!text) return; - for (int i = 0; text[i]; i++) { - if (text[i] == '_') text[i] = ' '; - } -} - -static 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; -} - -static 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 -// ============================================================================ - -static 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; -} - -static 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'); -} - -static 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 -// ============================================================================ - -static 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 -// ============================================================================ - -static 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; -} - -// ============================================================================ -// Display line building -// ============================================================================ - -static void add_line(const char* text, int len, Color color, int size, - TrueTypeFont* font, int indent = 0) { - 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; -} - -static 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. -static void wrap_text(TrueTypeFont* font, int size, const char* text, int textLen, - int max_px, Color color, int indent = 0) { - 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; - - // Build candidate: current line + space + word - 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) { - // Fits — append to current line - memcpy(cur, test, test_len + 1); - cur_len = test_len; - } else { - // Emit current line and start fresh - 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); -} - -static void build_display_lines(const char* title, const char* extract, int extractLen, - bool preserve_scroll = false) { - 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; - - // Title — large, serif, black - 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)); - } - - // Process extract line-by-line - 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++; // consume '\n' - - 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; - } - - // Regular body text - 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()); - } -} - -static int document_top_pad() { - return g_phase == AppPhase::WELCOME ? 24 : 8; -} - -static 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; -} - -static void build_welcome_lines(bool preserve_scroll = false) { - 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()); -} - -static void rebuild_current_document(bool preserve_scroll = true) { - 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); -} - -static void apply_reader_setting_change() { - g_scroll_dragging = false; - apply_reader_preferences(); - update_toolbar_height(); - rebuild_current_document(true); - clamp_scroll(); -} - -static 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; -} - -// ============================================================================ -// Network search (blocking) -// ============================================================================ - -static 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; -} - -static 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; -} - -static 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); -} - -static 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; -} - -// ============================================================================ -// Rendering -// ============================================================================ - -static 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); -} - -static 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); - - // Background - 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; - } - - // Scrollbar - 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))); -} - -// ============================================================================ -// Entry point -// ============================================================================ - -extern "C" void _start() { - if (!fonts::init()) - montauk::exit(1); - - // Allocate large buffers from heap +static bool init_buffers_and_fonts() { g_lines = (WikiLine*)montauk::malloc(MAX_LINES * sizeof(WikiLine)); g_resp_buf = (char*)malloc(RESP_MAX + 1); g_extract_buf = (char*)malloc(RESP_MAX + 1); - if (!g_lines || !g_resp_buf || !g_extract_buf) montauk::exit(1); - - // Load fonts - auto load_font = [](const char* path) -> TrueTypeFont* { - TrueTypeFont* f = (TrueTypeFont*)montauk::malloc(sizeof(TrueTypeFont)); - if (!f) return nullptr; - montauk::memset(f, 0, sizeof(TrueTypeFont)); - if (!f->init(path)) { montauk::mfree(f); return nullptr; } - return f; - }; + if (!g_lines || !g_resp_buf || !g_extract_buf) return false; g_body_fonts[(int)ReaderFontFace::SANS] = load_font("0:/fonts/Roboto-Regular.ttf"); if (!g_body_fonts[(int)ReaderFontFace::SANS]) @@ -1322,12 +31,117 @@ extern "C" void _start() { g_body_fonts[(int)ReaderFontFace::BOOK] = load_font("0:/fonts/C059-Roman.ttf"); g_heading_fonts[(int)ReaderFontFace::BOOK] = load_font("0:/fonts/C059-Bold.ttf"); - if (!g_body_fonts[(int)ReaderFontFace::SANS]) - montauk::exit(1); + if (!g_body_fonts[(int)ReaderFontFace::SANS]) return false; for (int i = 0; i < READER_FONT_FACE_COUNT; i++) { if (!g_heading_fonts[i]) g_heading_fonts[i] = g_body_fonts[i]; } + return true; +} + +static void handle_key_event(const Montauk::WinEvent& ev, bool& search_pending) { + uint8_t ascii = ev.key.ascii; + uint8_t scan = ev.key.scancode; + + if (g_search_focused) { + if (ascii == '\n' || ascii == '\r') { + search_pending = true; + } else { + mtk::text_input_key(g_query_input, g_query, + (int)sizeof(g_query), ev.key); + } + return; + } + + if (!document_scrollable()) return; + + int visible = visible_line_count(); + int max_sc = max_scroll_lines(); + if (scan == 0x48) { if (g_scroll_y > 0) g_scroll_y--; } + else if (scan == 0x50) { if (g_scroll_y < max_sc) g_scroll_y++; } + else if (scan == 0x49) { g_scroll_y -= visible; if (g_scroll_y < 0) g_scroll_y = 0; } + else if (scan == 0x51) { g_scroll_y += visible; if (g_scroll_y > max_sc) g_scroll_y = max_sc; } + else if (scan == 0x47) { g_scroll_y = 0; } + else if (scan == 0x4F) { g_scroll_y = max_sc; } +} + +static void handle_mouse_event(const Montauk::WinEvent& ev, bool& search_pending) { + int mx = ev.mouse.x, my = ev.mouse.y; + g_mouse_x = mx; + g_mouse_y = my; + bool left_down = (ev.mouse.buttons & 1) != 0; + bool just_clicked = (ev.mouse.buttons & 1) && !(ev.mouse.prev_buttons & 1); + bool just_right_clicked = (ev.mouse.buttons & 2) && !(ev.mouse.prev_buttons & 2); + bool just_released = !(ev.mouse.buttons & 1) && (ev.mouse.prev_buttons & 1); + + ReaderToolbarLayout lo = toolbar_layout(); + Rect scrollbar_track = scrollbar_track_rect(); + Rect scrollbar_thumb = scrollbar_thumb_rect(); + + if (g_query_input.context.open || g_query_input.dragging) { + int result = mtk::text_input_handle_mouse(g_query_input, lo.search_field, + g_query, (int)sizeof(g_query), + mx, my, ev.mouse.buttons, + ev.mouse.prev_buttons, + g_win_w, g_win_h, + g_search_focused); + if (result & mtk::TEXT_INPUT_CONSUMED) { + just_clicked = false; + just_right_clicked = false; + } + } + + if (just_released) g_scroll_dragging = false; + + if (g_scroll_dragging && left_down) + set_scroll_from_thumb_top(my - g_scroll_drag_offset); + + if (just_clicked || just_right_clicked) { + if (lo.search_field.contains(mx, my)) { + g_search_focused = true; + mtk::text_input_handle_mouse(g_query_input, lo.search_field, + g_query, (int)sizeof(g_query), + mx, my, ev.mouse.buttons, ev.mouse.prev_buttons, + g_win_w, g_win_h, true); + } else if (scrollbar_thumb.contains(mx, my)) { + g_search_focused = false; + g_scroll_dragging = true; + g_scroll_drag_offset = my - scrollbar_thumb.y; + } else if (scrollbar_track.contains(mx, my)) { + g_search_focused = false; + g_scroll_dragging = true; + g_scroll_drag_offset = scrollbar_thumb.h / 2; + set_scroll_from_thumb_top(my - g_scroll_drag_offset); + } else if (lo.display_button.contains(mx, my)) { + g_search_focused = false; + g_display_controls_open = !g_display_controls_open; + update_toolbar_height(); + rebuild_current_document(true); + clamp_scroll(); + } else if (handle_reader_option_click(mx, my)) { + g_search_focused = false; + } else { + g_search_focused = false; + } + } + + if (just_clicked && lo.search_button.contains(mx, my) && + g_phase != AppPhase::LOADING) { + search_pending = true; + } + + if (ev.mouse.scroll != 0 && document_scrollable()) { + g_scroll_y += ev.mouse.scroll * 3; + clamp_scroll(); + } +} + +extern "C" void _start() { + if (!fonts::init()) + montauk::exit(1); + + if (!init_buffers_and_fonts()) + montauk::exit(1); g_accent = mtk::load_system_accent(); apply_scale(montauk::win_getscale()); @@ -1358,10 +172,9 @@ extern "C" void _start() { Montauk::WinEvent ev; int r = win.poll(&ev); - if (r < 0) break; // window closed / error + if (r < 0) break; if (r == 0) { - // No event — idle at ~60 fps montauk::sleep_ms(16); canvas = host.canvas(); render(canvas); @@ -1369,7 +182,6 @@ extern "C" void _start() { continue; } - // ---- Handle event ---- if (ev.type == 3) break; // close if (ev.type == 4) { @@ -1386,109 +198,12 @@ extern "C" void _start() { rebuild_current_document(true); clamp_scroll(); } - } else if (ev.type == 0 && ev.key.pressed) { - uint8_t ascii = ev.key.ascii; - uint8_t scan = ev.key.scancode; - - if (g_search_focused) { - if (ascii == '\n' || ascii == '\r') { - search_pending = true; - } else { - mtk::text_input_key(g_query_input, g_query, - (int)sizeof(g_query), ev.key); - } - } else if (document_scrollable()) { - // Navigation keys - int visible = visible_line_count(); - int max_sc = max_scroll_lines(); - if (scan == 0x48) { if (g_scroll_y > 0) g_scroll_y--; } - else if (scan == 0x50) { if (g_scroll_y < max_sc) g_scroll_y++; } - else if (scan == 0x49) { g_scroll_y -= visible; if (g_scroll_y < 0) g_scroll_y = 0; } - else if (scan == 0x51) { g_scroll_y += visible; if (g_scroll_y > max_sc) g_scroll_y = max_sc; } - else if (scan == 0x47) { g_scroll_y = 0; } - else if (scan == 0x4F) { g_scroll_y = max_sc; } - } - + handle_key_event(ev, search_pending); } else if (ev.type == 1) { - // Mouse - int mx = ev.mouse.x, my = ev.mouse.y; - g_mouse_x = mx; - g_mouse_y = my; - bool left_down = (ev.mouse.buttons & 1) != 0; - bool just_clicked = (ev.mouse.buttons & 1) && !(ev.mouse.prev_buttons & 1); - bool just_right_clicked = (ev.mouse.buttons & 2) && !(ev.mouse.prev_buttons & 2); - bool just_released = !(ev.mouse.buttons & 1) && (ev.mouse.prev_buttons & 1); - - ReaderToolbarLayout lo = toolbar_layout(); - Rect scrollbar_track = scrollbar_track_rect(); - Rect scrollbar_thumb = scrollbar_thumb_rect(); - - if (g_query_input.context.open || g_query_input.dragging) { - int result = mtk::text_input_handle_mouse(g_query_input, lo.search_field, - g_query, (int)sizeof(g_query), - mx, my, ev.mouse.buttons, - ev.mouse.prev_buttons, - g_win_w, g_win_h, - g_search_focused); - if (result & mtk::TEXT_INPUT_CONSUMED) { - just_clicked = false; - just_right_clicked = false; - } - } - - if (just_released) { - g_scroll_dragging = false; - } - - if (g_scroll_dragging && left_down) { - set_scroll_from_thumb_top(my - g_scroll_drag_offset); - } - - if (just_clicked || just_right_clicked) { - if (lo.search_field.contains(mx, my)) { - g_search_focused = true; - mtk::text_input_handle_mouse(g_query_input, lo.search_field, - g_query, (int)sizeof(g_query), - mx, my, ev.mouse.buttons, ev.mouse.prev_buttons, - g_win_w, g_win_h, true); - } else if (scrollbar_thumb.contains(mx, my)) { - g_search_focused = false; - g_scroll_dragging = true; - g_scroll_drag_offset = my - scrollbar_thumb.y; - } else if (scrollbar_track.contains(mx, my)) { - g_search_focused = false; - g_scroll_dragging = true; - g_scroll_drag_offset = scrollbar_thumb.h / 2; - set_scroll_from_thumb_top(my - g_scroll_drag_offset); - } else if (lo.display_button.contains(mx, my)) { - g_search_focused = false; - g_display_controls_open = !g_display_controls_open; - update_toolbar_height(); - rebuild_current_document(true); - clamp_scroll(); - } else if (handle_reader_option_click(mx, my)) { - g_search_focused = false; - // Reader controls handled. - } else { - g_search_focused = false; - } - } - - // Search button hit-test - if (just_clicked && lo.search_button.contains(mx, my) && - g_phase != AppPhase::LOADING) { - search_pending = true; - } - - // Scroll wheel - if (ev.mouse.scroll != 0 && document_scrollable()) { - g_scroll_y += ev.mouse.scroll * 3; - clamp_scroll(); - } + handle_mouse_event(ev, search_pending); } - // Trigger search if requested and query is non-empty if (search_pending && g_query[0] != '\0') { search_pending = false; if (is_main_page_query(g_query)) { @@ -1499,7 +214,7 @@ extern "C" void _start() { canvas = host.canvas(); render(canvas); host.present(); - do_search(g_query); // blocking + do_search(g_query); } clamp_scroll(); } diff --git a/programs/src/wikipedia/network.cpp b/programs/src/wikipedia/network.cpp new file mode 100644 index 0000000..b73ba33 --- /dev/null +++ b/programs/src/wikipedia/network.cpp @@ -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; +} diff --git a/programs/src/wikipedia/render.cpp b/programs/src/wikipedia/render.cpp new file mode 100644 index 0000000..4715a32 --- /dev/null +++ b/programs/src/wikipedia/render.cpp @@ -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))); +} diff --git a/programs/src/wikipedia/state.cpp b/programs/src/wikipedia/state.cpp new file mode 100644 index 0000000..fa44730 --- /dev/null +++ b/programs/src/wikipedia/state.cpp @@ -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" +}; diff --git a/programs/src/wikipedia/text.cpp b/programs/src/wikipedia/text.cpp new file mode 100644 index 0000000..e2a0209 --- /dev/null +++ b/programs/src/wikipedia/text.cpp @@ -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; +} diff --git a/programs/src/wikipedia/theme.cpp b/programs/src/wikipedia/theme.cpp new file mode 100644 index 0000000..94416be --- /dev/null +++ b/programs/src/wikipedia/theme.cpp @@ -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(); +} diff --git a/programs/src/wikipedia/wikipedia.h b/programs/src/wikipedia/wikipedia.h new file mode 100644 index 0000000..19fc38b --- /dev/null +++ b/programs/src/wikipedia/wikipedia.h @@ -0,0 +1,237 @@ +/* + * wikipedia.h + * Shared header for the MontaukOS Wikipedia GUI client + * Copyright (c) 2026 Daniel Hammer + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +#include +} + +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);