From ee47e5d68f435b041377055070554275d2cd9e6d Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Fri, 17 Apr 2026 22:43:14 +0200 Subject: [PATCH] feat: port wikipedia to MTK toolkit, add formatting options --- programs/src/wikipedia/main.cpp | 846 +++++++++++++++++++++++++++----- scripts/copy_icons.sh | 1 + 2 files changed, 711 insertions(+), 136 deletions(-) diff --git a/programs/src/wikipedia/main.cpp b/programs/src/wikipedia/main.cpp index 126c5e9..3274e56 100644 --- a/programs/src/wikipedia/main.cpp +++ b/programs/src/wikipedia/main.cpp @@ -8,8 +8,9 @@ #include #include #include -#include +#include #include +#include #include #include @@ -38,6 +39,7 @@ 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/dialog-text-and-font.svg"; // ============================================================================ // Display line @@ -48,6 +50,7 @@ struct WikiLine { Color color; int font_size; TrueTypeFont* font; // which font to render with + int indent; }; // ============================================================================ @@ -55,6 +58,28 @@ struct WikiLine { // ============================================================================ enum class AppPhase { 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::IDLE; static char g_query[256] = {}; @@ -64,8 +89,19 @@ 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::SANS; 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; // Large buffers — heap allocated in _start static WikiLine* g_lines = nullptr; @@ -74,25 +110,340 @@ static char* g_extract_buf = nullptr; // Fonts static TrueTypeFont* g_font = nullptr; // Roboto Medium -static TrueTypeFont* g_font_bold = nullptr; // Roboto Bold 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]; + int sep_x; +}; + +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: FONT_SIZE=14; TITLE_SIZE=26; SECTION_SIZE=20; TOOLBAR_H=34; break; - case 2: FONT_SIZE=22; TITLE_SIZE=40; SECTION_SIZE=30; TOOLBAR_H=52; break; - default: FONT_SIZE=18; TITLE_SIZE=32; SECTION_SIZE=24; TOOLBAR_H=42; break; + 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; } - if (g_font) g_line_h = g_font->get_line_height(FONT_SIZE) + 4; + apply_reader_preferences(); + update_toolbar_height(); + reload_display_icons(); +} + +// ============================================================================ +// Theme and layout +// ============================================================================ + +static ReaderPalette reader_palette() { + return { + WHITE, + WHITE, + Color::from_rgb(0xF6, 0xF7, 0xF9), + Color::from_rgb(0xD5, 0xDB, 0xE3), + Color::from_rgb(0x20, 0x23, 0x29), + Color::from_rgb(0x11, 0x14, 0x18), + Color::from_rgb(0x7A, 0x84, 0x92), + Color::from_rgb(0x33, 0x66, 0xCC), + Color::from_rgb(0x24, 0x52, 0xAF), + Color::from_rgb(0xE5, 0xEE, 0xFF), + Color::from_rgb(0xC8, 0x32, 0x32), + Color::from_rgb(0xA9, 0x24, 0x24), + Color::from_rgb(0xE7, 0xEB, 0xF0), + Color::from_rgb(0x97, 0x9E, 0xA8), + }; +} + +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 8; +} + +static int reader_row_y() { + return search_row_y() + search_control_h() + 8; +} + +static void update_toolbar_height() { + int h = search_row_y() + search_control_h() + 8; + if (g_display_controls_open) + h += reader_button_h() + 8; + 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) + 18, 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(); + + lo.search_button = {g_win_w - pad - 108, search_row_y(), 108, 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 = 6; + int group_gap = 20; + int size_w = button_group_width(g_text_size_labels, READER_TEXT_SIZE_COUNT, 34, gap); + int font_w = button_group_width(g_font_face_labels, READER_FONT_FACE_COUNT, 50, gap); + int total_w = size_w + font_w + group_gap; + int x = gui_max((g_win_w - total_w) / 2, pad); + int y = reader_row_y(); + + layout_button_group(lo.text_size_buttons, READER_TEXT_SIZE_COUNT, g_text_size_labels, 34, gap, &x, y, reader_h); + x += group_gap; + lo.sep_x = x - group_gap / 2; + + layout_button_group(lo.font_buttons, READER_FONT_FACE_COUNT, g_font_face_labels, 50, 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; + } + + const char* fallback = "Aa"; + int tw = text_width(fallback); + int ty = bounds.y + (bounds.h - system_font_height()) / 2; + canvas.text(bounds.x + (bounds.w - tw) / 2, ty, fallback, 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() { + int max_scroll = g_line_count - visible_line_count(); + return max_scroll > 0 ? max_scroll : 0; +} + +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 { + g_win_w - SCROLLBAR_W - 4, + content.y + 8, + SCROLLBAR_W, + gui_max(content.h - 16, 0) + }; +} + +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}; + + int max_sc = max_scroll_lines(); + int thumb_h = (visible * track.h) / g_line_count; + thumb_h = gui_clamp(thumb_h, 24, track.h); + int travel = gui_max(track.h - thumb_h, 0); + int thumb_y = track.y + (max_sc > 0 ? (g_scroll_y * travel) / max_sc : 0); + return {track.x + 2, thumb_y, track.w - 4, thumb_h}; +} + +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; + + int max_sc = max_scroll_lines(); + int travel = gui_max(track.h - thumb.h, 0); + if (max_sc <= 0 || travel <= 0) { + g_scroll_y = 0; + return; + } + + int rel = gui_clamp(thumb_top - track.y, 0, travel); + g_scroll_y = (rel * max_sc + travel / 2) / travel; + clamp_scroll(); } // ============================================================================ @@ -114,6 +465,118 @@ static int wiki_fetch(const char* path, char* respBuf, int respMax) { 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 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 // ============================================================================ @@ -198,12 +661,7 @@ static int extract_json_string(const char* buf, int len, const char* key, else if (h>='A'&&h<='F') val |= h-'A'+10; } p += 4; - if (val < 128) out[j++] = (char)val; - else if (val==0x2013||val==0x2014) out[j++] = '-'; - else if (val==0x2018||val==0x2019) out[j++] = '\''; - else if (val==0x201C||val==0x201D) out[j++] = '"'; - else if (val==0x2026) { out[j++]='.'; out[j++]='.'; out[j++]='.'; } - else out[j++] = '?'; + append_codepoint(out, &j, maxOut, val); } break; } @@ -214,11 +672,10 @@ static int extract_json_string(const char* buf, int len, const char* key, if (uc < 0x80) { out[j++] = buf[p]; } else { - // Skip multi-byte UTF-8 sequence (non-ASCII) - if (uc >= 0xF0) p += 3; // 4-byte seq: skip 3 continuation bytes - else if (uc >= 0xE0) p += 2; // 3-byte seq: skip 2 continuation bytes - else if (uc >= 0xC0) p += 1; // 2-byte seq: skip 1 continuation byte - // else: stray continuation byte (0x80-0xBF), just skip it + 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++; @@ -234,7 +691,8 @@ static int extract_json_string(const char* buf, int len, const char* key, // Display line building // ============================================================================ -static void add_line(const char* text, int len, Color color, int size, TrueTypeFont* font) { +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; @@ -243,20 +701,23 @@ static void add_line(const char* text, int len, Color color, int size, TrueTypeF l->color = color; l->font_size = size; l->font = font; + l->indent = indent; } -static void add_empty_line() { +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 = TEXT_COLOR; - l->font_size = FONT_SIZE; - l->font = g_font; + 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 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; @@ -292,33 +753,42 @@ static void wrap_text(TrueTypeFont* font, int size, const char* text, int textLe cur_len = test_len; } else { // Emit current line and start fresh - if (cur_len > 0) add_line(cur, cur_len, color, size, font); + 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); + 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) { +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; - // Max pixel width for text (accounting for left pad, right pad, scrollbar) - int max_px = g_win_w - TEXT_PAD - SCROLLBAR_W - TEXT_PAD; + Rect column = article_column_rect(); + int max_px = column.w; // Title — large, serif, black if (title && title[0]) { - TrueTypeFont* tf = g_font_serif ? g_font_serif : g_font; - wrap_text(tf, TITLE_SIZE, title, (int)strlen(title), max_px, BLACK); - add_empty_line(); + 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; @@ -327,7 +797,7 @@ static void build_display_lines(const char* title, const char* extract, int extr if (p < end) p++; // consume '\n' if (line_len == 0) { - add_empty_line(); + pending_paragraph_break = true; continue; } @@ -339,16 +809,68 @@ static void build_display_lines(const char* title, const char* extract, int extr while (ei > si && line_start[ei-1] == '=') ei--; while (ei > si && line_start[ei-1] == ' ') ei--; if (ei > si) { - add_empty_line(); - TrueTypeFont* tf = g_font_serif ? g_font_serif : g_font; - wrap_text(tf, SECTION_SIZE, line_start + si, ei - si, max_px, BLACK); + 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(g_font, FONT_SIZE, line_start, line_len, max_px, TEXT_COLOR); + 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 void rebuild_article(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); +} + +static void apply_reader_setting_change() { + g_scroll_dragging = false; + apply_reader_preferences(); + update_toolbar_height(); + rebuild_article(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; } // ============================================================================ @@ -418,86 +940,93 @@ static void do_search(const char* query) { // 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) { - static constexpr Color TOOLBAR_BG = Color::from_rgb(0xF5, 0xF5, 0xF5); - static constexpr Color HINT_COLOR = Color::from_rgb(0x99, 0x99, 0x99); + mtk::Theme theme = wiki_theme(); + ReaderToolbarLayout lo = toolbar_layout(); + Rect toolbar = {0, 0, g_win_w, TOOLBAR_H}; + Rect content = content_rect(); + Rect column = article_column_rect(); + bool searching = (g_phase == AppPhase::LOADING); // Background - canvas.fill(WINDOW_BG); + canvas.fill(theme.window_bg); // ---- Toolbar ---- - canvas.fill_rect(0, 0, g_win_w, TOOLBAR_H, TOOLBAR_BG); - canvas.hline(0, TOOLBAR_H, g_win_w, BORDER); + canvas.fill_rect(toolbar.x, toolbar.y, toolbar.w, toolbar.h, theme.surface_alt); + mtk::draw_text_field(canvas, lo.search_field, g_query, (int)strlen(g_query), + g_search_focused, false, theme); + 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); - // Search box geometry - int sb_y = 8, sb_h = TOOLBAR_H - 16; - int btn_w = 80, btn_gap = 8; - int sb_x = 8; - int sb_w = g_win_w - sb_x - btn_gap - btn_w - 8; - if (sb_w < 80) sb_w = 80; + if (g_display_controls_open) { + 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); + } - canvas.fill_rect(sb_x, sb_y, sb_w, sb_h, WHITE); - canvas.rect(sb_x, sb_y, sb_w, sb_h, BORDER); - - // Search box text + cursor - if (g_font) { - int ty = sb_y + (sb_h - FONT_SIZE) / 2; - draw_text(canvas, g_font, sb_x + 6, ty, g_query, TEXT_COLOR, FONT_SIZE); - int qw = g_font->measure_text(g_query, FONT_SIZE); - int cx = sb_x + 6 + qw + 1; - if (cx < sb_x + sb_w - 4) - canvas.vline(cx, ty + 1, FONT_SIZE - 2, TEXT_COLOR); - } - - // Search button - int btn_x = sb_x + sb_w + btn_gap; - canvas.fill_rect(btn_x, sb_y, btn_w, sb_h, ACCENT); - if (g_font) { - int stw = g_font->measure_text("Search", FONT_SIZE); - draw_text(canvas, g_font, - btn_x + (btn_w - stw) / 2, - sb_y + (sb_h - FONT_SIZE) / 2, - "Search", WHITE, FONT_SIZE); + 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 ---- - int cy = TOOLBAR_H + 1; - int ch = g_win_h - cy; - if (!g_font) return; - if (g_phase == AppPhase::IDLE) { - draw_text(canvas, g_font, TEXT_PAD, cy + 16, - "Type a topic and press Enter or click Search.", - HINT_COLOR, FONT_SIZE); + render_message(canvas, content, + "Type a topic and press Enter or click Search.", + theme.text_muted); } else if (g_phase == AppPhase::LOADING) { - draw_text(canvas, g_font, TEXT_PAD, cy + 16, - "Searching Wikipedia...", HINT_COLOR, FONT_SIZE); + render_message(canvas, content, "Searching Wikipedia...", theme.text_muted); } else if (g_phase == AppPhase::ERR) { - draw_text(canvas, g_font, TEXT_PAD, cy + 16, g_status, CLOSE_BTN, FONT_SIZE); + render_message(canvas, content, g_status, theme.danger); } else if (g_phase == AppPhase::DONE && g_line_count > 0) { - int visible = ch / g_line_h; // approximate using body line height - int y = cy + 8; + int y = content.y + 8; for (int i = g_scroll_y; i < g_line_count && y < g_win_h; i++) { WikiLine& l = g_lines[i]; - int lh = g_font->get_line_height(l.font_size) + 4; + 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') { - l.font->draw_to_buffer(canvas.pixels, canvas.w, canvas.h, - TEXT_PAD, y, l.text, l.color, l.font_size); + 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 - if (g_line_count > visible) { - int sbx = g_win_w - SCROLLBAR_W; - canvas.fill_rect(sbx, cy, SCROLLBAR_W, ch, SCROLLBAR_BG); - int max_sc = g_line_count - visible; - int thumb_h = (visible * ch) / g_line_count; - if (thumb_h < 20) thumb_h = 20; - int thumb_y = cy + (g_scroll_y * (ch - thumb_h)) / (max_sc > 0 ? max_sc : 1); - canvas.fill_rect(sbx + 2, thumb_y, SCROLLBAR_W - 4, thumb_h, SCROLLBAR_FG); + Rect track = scrollbar_track_rect(); + if (!track.empty()) { + Rect thumb = scrollbar_thumb_rect(); + Color thumb_color = (g_scroll_dragging || mouse_in_rect(thumb)) + ? theme.accent_hover + : theme.selection; + + canvas.fill_rounded_rect(track.x, track.y, track.w, track.h, + theme.radius_sm, theme.surface_alt); + canvas.rect(track.x, track.y, track.w, track.h, theme.border); + canvas.fill_rounded_rect(thumb.x, thumb.y, thumb.w, thumb.h, + theme.radius_sm, thumb_color); } } } @@ -507,6 +1036,9 @@ static void render(Canvas& canvas) { // ============================================================================ extern "C" void _start() { + if (!fonts::init()) + montauk::exit(1); + // Allocate large buffers from heap g_lines = (WikiLine*)montauk::malloc(MAX_LINES * sizeof(WikiLine)); g_resp_buf = (char*)malloc(RESP_MAX + 1); @@ -521,12 +1053,24 @@ extern "C" void _start() { if (!f->init(path)) { montauk::mfree(f); return nullptr; } return f; }; - g_font = load_font("0:/fonts/Roboto-Medium.ttf"); - g_font_bold = load_font("0:/fonts/Roboto-Bold.ttf"); - g_font_serif = load_font("0:/fonts/NotoSerif-SemiBold.ttf"); - if (!g_font) montauk::exit(1); - g_line_h = g_font->get_line_height(FONT_SIZE) + 4; + g_body_fonts[(int)ReaderFontFace::SANS] = load_font("0:/fonts/Roboto-Regular.ttf"); + if (!g_body_fonts[(int)ReaderFontFace::SANS]) + g_body_fonts[(int)ReaderFontFace::SANS] = load_font("0:/fonts/Roboto-Medium.ttf"); + g_heading_fonts[(int)ReaderFontFace::SANS] = load_font("0:/fonts/Roboto-Bold.ttf"); + + g_body_fonts[(int)ReaderFontFace::SERIF] = load_font("0:/fonts/NotoSerif-Regular.ttf"); + g_heading_fonts[(int)ReaderFontFace::SERIF] = load_font("0:/fonts/NotoSerif-SemiBold.ttf"); + + 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); + + for (int i = 0; i < READER_FONT_FACE_COUNT; i++) { + if (!g_heading_fonts[i]) g_heading_fonts[i] = g_body_fonts[i]; + } apply_scale(montauk::win_getscale()); @@ -534,9 +1078,10 @@ extern "C" void _start() { if (!win.create("Wikipedia", INIT_W, INIT_H)) montauk::exit(1); - Canvas canvas = win.canvas(); + mtk::StandaloneHost host(&win); + Canvas canvas = host.canvas(); render(canvas); - win.present(); + host.present(); bool search_pending = false; @@ -549,9 +1094,9 @@ extern "C" void _start() { if (r == 0) { // No event — idle at ~60 fps montauk::sleep_ms(16); - canvas = win.canvas(); + canvas = host.canvas(); render(canvas); - win.present(); + host.present(); continue; } @@ -560,38 +1105,37 @@ extern "C" void _start() { if (ev.type == 4) { apply_scale(win.scale_factor); - if (g_phase == AppPhase::DONE && g_line_count > 0) { - build_display_lines(g_title, g_extract_buf, g_extract_len); - } + g_scroll_dragging = false; + rebuild_article(true); } if (ev.type == 2) { if (win.width > 0 && win.height > 0) { g_win_w = win.width; g_win_h = win.height; - canvas = win.canvas(); - if (g_phase == AppPhase::DONE && g_line_count > 0) { - build_display_lines(g_title, g_extract_buf, g_extract_len); - } + g_scroll_dragging = false; + rebuild_article(true); + clamp_scroll(); } } else if (ev.type == 0 && ev.key.pressed) { uint8_t ascii = ev.key.ascii; uint8_t scan = ev.key.scancode; - if (ascii == '\n' || ascii == '\r') { - search_pending = true; - } else if (ascii == '\b' || scan == 0x0E) { - int len = (int)strlen(g_query); - if (len > 0) g_query[len - 1] = '\0'; - } else if (ascii >= 32 && ascii < 127) { - int len = (int)strlen(g_query); - if (len < 254) { g_query[len] = ascii; g_query[len + 1] = '\0'; } + if (g_search_focused) { + if (ascii == '\n' || ascii == '\r') { + search_pending = true; + } else if (ascii == '\b' || scan == 0x0E) { + int len = (int)strlen(g_query); + if (len > 0) g_query[len - 1] = '\0'; + } else if (ascii >= 32 && ascii < 127) { + int len = (int)strlen(g_query); + if (len < 254) { g_query[len] = ascii; g_query[len + 1] = '\0'; } + } } else if (g_phase == AppPhase::DONE) { // Navigation keys - int visible = (g_win_h - TOOLBAR_H - 1) / g_line_h; - int max_sc = g_line_count - visible; - if (max_sc < 0) max_sc = 0; + 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; } @@ -603,27 +1147,54 @@ extern "C" void _start() { } 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_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 (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) { + g_search_focused = lo.search_field.contains(mx, my); + + if (scrollbar_thumb.contains(mx, my)) { + g_scroll_dragging = true; + g_scroll_drag_offset = my - scrollbar_thumb.y; + } else if (scrollbar_track.contains(mx, my)) { + 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_display_controls_open = !g_display_controls_open; + update_toolbar_height(); + rebuild_article(true); + clamp_scroll(); + } else if (handle_reader_option_click(mx, my)) { + // Reader controls handled. + } + } // Search button hit-test - int sb_h = TOOLBAR_H - 16; - int btn_w = 80; - int sb_w = g_win_w - 8 - 8 - btn_w - 8; - if (sb_w < 80) sb_w = 80; - int btn_x = 8 + sb_w + 8; - if (just_clicked && mx >= btn_x && mx < btn_x + btn_w && - my >= 8 && my < 8 + sb_h) { + if (just_clicked && lo.search_button.contains(mx, my) && + g_phase != AppPhase::LOADING) { search_pending = true; } // Scroll wheel if (ev.mouse.scroll != 0 && g_phase == AppPhase::DONE) { - int visible = (g_win_h - TOOLBAR_H - 1) / g_line_h; - int max_sc = g_line_count - visible; - if (max_sc < 0) max_sc = 0; g_scroll_y += ev.mouse.scroll * 3; - if (g_scroll_y < 0) g_scroll_y = 0; - if (g_scroll_y > max_sc) g_scroll_y = max_sc; + clamp_scroll(); } } @@ -631,17 +1202,20 @@ extern "C" void _start() { if (search_pending && g_query[0] != '\0') { search_pending = false; g_phase = AppPhase::LOADING; - canvas = win.canvas(); + canvas = host.canvas(); render(canvas); - win.present(); + host.present(); do_search(g_query); // blocking + clamp_scroll(); } - canvas = win.canvas(); + canvas = host.canvas(); render(canvas); - win.present(); + host.present(); } + if (g_display_icon.pixels) svg_free(g_display_icon); + if (g_display_icon_active.pixels) svg_free(g_display_icon_active); win.destroy(); montauk::exit(0); } diff --git a/scripts/copy_icons.sh b/scripts/copy_icons.sh index dbed94e..dca1651 100755 --- a/scripts/copy_icons.sh +++ b/scripts/copy_icons.sh @@ -27,6 +27,7 @@ ICONS=( "actions/symbolic/document-export-symbolic.svg" "actions/symbolic/edit-undo-symbolic.svg" "actions/symbolic/edit-redo-symbolic.svg" + "actions/16/dialog-text-and-font.svg" "actions/symbolic/format-justify-left-symbolic.svg" "actions/symbolic/format-justify-center-symbolic.svg" "actions/symbolic/format-justify-right-symbolic.svg"