From d0ceefd8efda35f4582b45c5c74ea1b7ba69fd59 Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Thu, 26 Mar 2026 18:05:14 +0100 Subject: [PATCH] feat: split word processor app out into standalone Window Server app --- programs/GNUmakefile | 13 +- programs/src/desktop/apps/app_filemanager.cpp | 6 + .../src/desktop/apps/app_wordprocessor.cpp | 1774 +---------------- programs/src/wordprocessor/Makefile | 74 + programs/src/wordprocessor/document.cpp | 872 ++++++++ programs/src/wordprocessor/input.cpp | 378 ++++ programs/src/wordprocessor/main.cpp | 99 + programs/src/wordprocessor/manifest.toml | 8 + programs/src/wordprocessor/render.cpp | 290 +++ .../src/wordprocessor/stb_truetype_impl.cpp | 33 + programs/src/wordprocessor/wordprocessor.hpp | 259 +++ scripts/install_apps.sh | 1 + 12 files changed, 2033 insertions(+), 1774 deletions(-) create mode 100644 programs/src/wordprocessor/Makefile create mode 100644 programs/src/wordprocessor/document.cpp create mode 100644 programs/src/wordprocessor/input.cpp create mode 100644 programs/src/wordprocessor/main.cpp create mode 100644 programs/src/wordprocessor/manifest.toml create mode 100644 programs/src/wordprocessor/render.cpp create mode 100644 programs/src/wordprocessor/stb_truetype_impl.cpp create mode 100644 programs/src/wordprocessor/wordprocessor.hpp diff --git a/programs/GNUmakefile b/programs/GNUmakefile index 3a5b503..9f7b75e 100644 --- a/programs/GNUmakefile +++ b/programs/GNUmakefile @@ -59,7 +59,7 @@ BINDIR := bin PROGRAMS := $(notdir $(wildcard src/*)) # Programs with custom Makefiles (built separately). -CUSTOM_BUILDS := doom fetch wiki wikipedia weather imageviewer fontpreview spreadsheet pdfviewer disks devexplorer installer volume music bluetooth desktop login shell rpgdemo paint tcc screenshot texteditor +CUSTOM_BUILDS := doom fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer volume music bluetooth desktop login shell rpgdemo paint tcc screenshot texteditor SYSTEM_PROGRAMS := $(filter-out $(CUSTOM_BUILDS),$(PROGRAMS)) # Build targets: system programs go to bin/os/, apps go to bin/apps//. @@ -81,9 +81,9 @@ CA_CERTS := $(BINDIR)/etc/ca-certificates.crt # Common shared assets (wallpapers, etc.) COMMONKEEP := $(BINDIR)/common/.keep -.PHONY: all clean doom fetch wiki wikipedia weather imageviewer fontpreview spreadsheet pdfviewer disks devexplorer installer volume music bluetooth login desktop shell rpgdemo paint tcc screenshot texteditor icons fonts bearssl libc tls libjpeg libjpegwrite install-apps +.PHONY: all clean doom fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer volume music bluetooth login desktop shell rpgdemo paint tcc screenshot texteditor icons fonts bearssl libc tls libjpeg libjpegwrite install-apps -all: bearssl libc libjpeg libjpegwrite tls $(TARGETS) fetch wiki wikipedia weather imageviewer fontpreview spreadsheet pdfviewer disks devexplorer installer volume music bluetooth doom rpgdemo paint tcc screenshot texteditor login desktop shell icons fonts install-apps $(MANDST) $(WWWDST) $(CA_CERTS) $(COMMONKEEP) +all: bearssl libc libjpeg libjpegwrite tls $(TARGETS) fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer volume music bluetooth doom rpgdemo paint tcc screenshot texteditor login desktop shell icons fonts install-apps $(MANDST) $(WWWDST) $(CA_CERTS) $(COMMONKEEP) # Build BearSSL static library (cross-compiled for freestanding x86_64). BEARSSL_INCLUDES := -isystem $(shell cd .. && pwd)/kernel/freestnd-c-hdrs/x86_64/include -isystem $(abspath include/libc) @@ -140,6 +140,10 @@ fontpreview: spreadsheet: libc $(MAKE) -C src/spreadsheet +# Build word processor standalone GUI client (depends on libc). +wordprocessor: libc + $(MAKE) -C src/wordprocessor + # Build PDF viewer standalone GUI client (depends on libc). pdfviewer: libc $(MAKE) -C src/pdfviewer @@ -218,7 +222,7 @@ tcc: libc cp include/libc/sys/*.h $(BINDIR)/lib/tcc/include/sys/ # Install app bundles (manifests, icons, data files) into bin/apps//. -install-apps: doom rpgdemo paint spreadsheet weather wikipedia imageviewer fontpreview pdfviewer disks devexplorer installer volume music bluetooth screenshot texteditor +install-apps: doom rpgdemo paint spreadsheet wordprocessor weather wikipedia imageviewer fontpreview pdfviewer disks devexplorer installer volume music bluetooth screenshot texteditor ../scripts/install_apps.sh # Copy man pages into bin/man/ so mkramdisk.sh picks them up. @@ -264,6 +268,7 @@ clean: $(MAKE) -C src/imageviewer clean $(MAKE) -C src/fontpreview clean $(MAKE) -C src/spreadsheet clean + $(MAKE) -C src/wordprocessor clean $(MAKE) -C src/pdfviewer clean $(MAKE) -C src/disks clean $(MAKE) -C src/devexplorer clean diff --git a/programs/src/desktop/apps/app_filemanager.cpp b/programs/src/desktop/apps/app_filemanager.cpp index 22d001c..e054b83 100644 --- a/programs/src/desktop/apps/app_filemanager.cpp +++ b/programs/src/desktop/apps/app_filemanager.cpp @@ -155,6 +155,10 @@ static bool is_spreadsheet_file(const char* name) { return str_ends_with(name, ".mss"); } +static bool is_wordprocessor_file(const char* name) { + return str_ends_with(name, ".mwp"); +} + static bool is_video_file(const char* name) { return str_ends_with(name, ".mp4") || str_ends_with(name, ".m4v"); } @@ -1070,6 +1074,8 @@ static void filemanager_open_entry(FileManagerState* fm, int idx) { montauk::spawn("0:/apps/pdfviewer/pdfviewer.elf", fullpath); } else if (is_spreadsheet_file(fm->entry_names[idx])) { montauk::spawn("0:/apps/spreadsheet/spreadsheet.elf", fullpath); + } else if (is_wordprocessor_file(fm->entry_names[idx])) { + montauk::spawn("0:/apps/wordprocessor/wordprocessor.elf", fullpath); } else if (is_video_file(fm->entry_names[idx])) { montauk::spawn("0:/apps/video/video.elf", fullpath); } else if (is_audio_file(fm->entry_names[idx])) { diff --git a/programs/src/desktop/apps/app_wordprocessor.cpp b/programs/src/desktop/apps/app_wordprocessor.cpp index c3bf3d5..b1a4210 100644 --- a/programs/src/desktop/apps/app_wordprocessor.cpp +++ b/programs/src/desktop/apps/app_wordprocessor.cpp @@ -1,1779 +1,13 @@ /* * app_wordprocessor.cpp - * Rich-text editor + * MontaukOS Desktop - Word Processor launcher + * Spawns standalone wordprocessor.elf Window Server app * Copyright (c) 2026 Daniel Hammer */ #include "apps_common.hpp" -// ============================================================================ -// Constants -// ============================================================================ - -static constexpr int WP_TOOLBAR_H = 36; -static constexpr int WP_PATHBAR_H = 32; -static constexpr int WP_STATUS_H = 24; -static constexpr int WP_SCROLLBAR_W = 12; -static constexpr int WP_MARGIN = 16; -static constexpr int WP_MAX_RUNS = 1024; -static constexpr int WP_MAX_TEXT = 262144; // 256KB total text -static constexpr int WP_DEFAULT_SIZE = 18; - -// Font IDs -static constexpr int FONT_ROBOTO = 0; -static constexpr int FONT_NOTOSERIF = 1; -static constexpr int FONT_C059 = 2; -static constexpr int FONT_COUNT = 3; - -// Style flags -static constexpr uint8_t STYLE_BOLD = 0x01; -static constexpr uint8_t STYLE_ITALIC = 0x02; - -// ============================================================================ -// Font table — loaded on demand per word processor instance -// ============================================================================ - -struct WPFontTable { - // [font_id][variant]: variant = 0=regular, 1=bold, 2=italic, 3=bolditalic - TrueTypeFont* fonts[FONT_COUNT][4]; - bool loaded; -}; - -static WPFontTable wp_fonts = { {{nullptr}}, false }; - -static void wp_load_fonts() { - if (wp_fonts.loaded) return; - - auto load = [](const char* path) -> TrueTypeFont* { - TrueTypeFont* f = (TrueTypeFont*)montauk::malloc(sizeof(TrueTypeFont)); - montauk::memset(f, 0, sizeof(TrueTypeFont)); - if (!f->init(path)) { - montauk::mfree(f); - return nullptr; - } - return f; - }; - - // Roboto variants - wp_fonts.fonts[FONT_ROBOTO][0] = fonts::system_font; // Roboto-Medium (regular) - wp_fonts.fonts[FONT_ROBOTO][1] = fonts::system_bold; // Roboto-Bold - wp_fonts.fonts[FONT_ROBOTO][2] = load("0:/fonts/Roboto-Italic.ttf"); - wp_fonts.fonts[FONT_ROBOTO][3] = load("0:/fonts/Roboto-BoldItalic.ttf"); - - // NotoSerif variants - wp_fonts.fonts[FONT_NOTOSERIF][0] = load("0:/fonts/NotoSerif-Regular.ttf"); - wp_fonts.fonts[FONT_NOTOSERIF][1] = load("0:/fonts/NotoSerif-SemiBold.ttf"); - wp_fonts.fonts[FONT_NOTOSERIF][2] = load("0:/fonts/NotoSerif-Italic.ttf"); - wp_fonts.fonts[FONT_NOTOSERIF][3] = load("0:/fonts/NotoSerif-BoldItalic.ttf"); - - // C059 variants (no BoldItalic — falls back to Bold) - wp_fonts.fonts[FONT_C059][0] = load("0:/fonts/C059-Roman.ttf"); - wp_fonts.fonts[FONT_C059][1] = load("0:/fonts/C059-Bold.ttf"); - wp_fonts.fonts[FONT_C059][2] = load("0:/fonts/C059-Italic.ttf"); - wp_fonts.fonts[FONT_C059][3] = load("0:/fonts/C059-Bold.ttf"); // fallback: bold for bolditalic - - wp_fonts.loaded = true; -} - -static TrueTypeFont* wp_get_font(int font_id, uint8_t flags) { - if (font_id < 0 || font_id >= FONT_COUNT) font_id = 0; - int variant = 0; - if ((flags & STYLE_BOLD) && (flags & STYLE_ITALIC)) variant = 3; - else if (flags & STYLE_BOLD) variant = 1; - else if (flags & STYLE_ITALIC) variant = 2; - - TrueTypeFont* f = wp_fonts.fonts[font_id][variant]; - if (f && f->valid) return f; - // Fallback: try regular variant - f = wp_fonts.fonts[font_id][0]; - if (f && f->valid) return f; - // Last resort: system font - return fonts::system_font; -} - -// ============================================================================ -// Document model -// ============================================================================ - -struct StyledRun { - char* text; - int len; - int cap; - uint8_t font_id; - uint8_t size; - uint8_t flags; // STYLE_BOLD | STYLE_ITALIC -}; - -// Word-wrapped line info for rendering -struct WrapLine { - int run_idx; // which run this line starts in - int run_offset; // byte offset within that run - int char_count; // total chars in this line (across runs) - int y; // pixel y position in content space - int height; // line height in pixels - int baseline; // max ascent — shared baseline offset from y for all runs -}; - -static constexpr int WP_MAX_WRAP_LINES = 4096; - -// ============================================================================ -// Word Processor state -// ============================================================================ - -struct WordProcessorState { - // Document - StyledRun runs[WP_MAX_RUNS]; - int run_count; - int total_text_len; - - // Cursor - int cursor_run; // which run the cursor is in - int cursor_offset; // byte offset within that run - - // Selection (absolute positions, -1 = no selection) - int sel_anchor; // where selection started - int sel_end; // where selection extends to (cursor end) - bool has_selection; - bool mouse_selecting; // currently dragging - - // Current formatting for new text - uint8_t cur_font_id; - uint8_t cur_size; - uint8_t cur_flags; - - // Scrollbar - Scrollbar scrollbar; - int content_height; - - // Word-wrap cache - WrapLine* wrap_lines; - int wrap_line_count; - bool wrap_dirty; - int last_wrap_width; // detect resize - - // UI state - bool modified; - char filepath[256]; - char filename[64]; - DesktopState* desktop; - - bool show_pathbar; - bool pathbar_save_mode; // true = save, false = open - char pathbar_text[256]; - int pathbar_cursor; - int pathbar_len; - - // Dropdown state - bool font_dropdown_open; - bool size_dropdown_open; -}; - -// ============================================================================ -// Run management -// ============================================================================ - -static void wp_init_run(StyledRun* r, uint8_t font_id, uint8_t size, uint8_t flags) { - r->cap = 64; - r->text = (char*)montauk::malloc(r->cap); - r->text[0] = '\0'; - r->len = 0; - r->font_id = font_id; - r->size = size; - r->flags = flags; -} - -static void wp_free_run(StyledRun* r) { - if (r->text) montauk::mfree(r->text); - r->text = nullptr; - r->len = 0; - r->cap = 0; -} - -static void wp_ensure_run_cap(StyledRun* r, int needed) { - if (r->len + needed <= r->cap) return; - int new_cap = r->cap * 2; - if (new_cap < r->len + needed) new_cap = r->len + needed; - r->text = (char*)montauk::realloc(r->text, new_cap); - r->cap = new_cap; -} - -static bool wp_same_style(const StyledRun* a, uint8_t font_id, uint8_t size, uint8_t flags) { - return a->font_id == font_id && a->size == size && a->flags == flags; -} - -// Get absolute character position from run+offset -static int wp_abs_pos(WordProcessorState* wp, int run, int offset) { - int pos = 0; - for (int i = 0; i < run && i < wp->run_count; i++) - pos += wp->runs[i].len; - pos += offset; - return pos; -} - -// Convert absolute position to run+offset -static void wp_pos_to_run(WordProcessorState* wp, int abs_pos, int* out_run, int* out_offset) { - if (abs_pos <= 0) { *out_run = 0; *out_offset = 0; return; } - int pos = 0; - for (int i = 0; i < wp->run_count; i++) { - if (pos + wp->runs[i].len >= abs_pos || i == wp->run_count - 1) { - *out_run = i; - *out_offset = abs_pos - pos; - if (*out_offset > wp->runs[i].len) *out_offset = wp->runs[i].len; - return; - } - pos += wp->runs[i].len; - } - *out_run = 0; - *out_offset = 0; -} - -// Get char at absolute position -static char wp_char_at(WordProcessorState* wp, int abs_pos) { - int r, o; - wp_pos_to_run(wp, abs_pos, &r, &o); - if (r < wp->run_count && o < wp->runs[r].len) - return wp->runs[r].text[o]; - return '\0'; -} - -// Get font info for char at absolute position -static void wp_font_at(WordProcessorState* wp, int abs_pos, - TrueTypeFont** out_font, GlyphCache** out_gc) { - int r, o; - wp_pos_to_run(wp, abs_pos, &r, &o); - if (r >= wp->run_count) r = wp->run_count - 1; - StyledRun* run = &wp->runs[r]; - *out_font = wp_get_font(run->font_id, run->flags); - *out_gc = (*out_font && (*out_font)->valid) ? (*out_font)->get_cache(run->size) : nullptr; -} - -// ============================================================================ -// Text insertion / deletion -// ============================================================================ - -static void wp_insert_char(WordProcessorState* wp, char c) { - if (wp->total_text_len >= WP_MAX_TEXT - 1) return; - - StyledRun* cur = &wp->runs[wp->cursor_run]; - - // If cursor is at same style as current formatting, insert into current run - if (wp_same_style(cur, wp->cur_font_id, wp->cur_size, wp->cur_flags)) { - wp_ensure_run_cap(cur, 1); - // Shift chars after cursor offset - for (int i = cur->len; i > wp->cursor_offset; i--) - cur->text[i] = cur->text[i - 1]; - cur->text[wp->cursor_offset] = c; - cur->len++; - wp->cursor_offset++; - wp->total_text_len++; - wp->modified = true; - wp->wrap_dirty = true; - return; - } - - // Different style: need to split the run - if (wp->cursor_offset == 0) { - // Insert a new run before current - if (wp->run_count >= WP_MAX_RUNS) return; - for (int i = wp->run_count; i > wp->cursor_run; i--) - wp->runs[i] = wp->runs[i - 1]; - wp->run_count++; - StyledRun* nr = &wp->runs[wp->cursor_run]; - wp_init_run(nr, wp->cur_font_id, wp->cur_size, wp->cur_flags); - wp_ensure_run_cap(nr, 1); - nr->text[0] = c; - nr->len = 1; - wp->cursor_offset = 1; - wp->total_text_len++; - } else if (wp->cursor_offset == cur->len) { - // Append a new run after current - if (wp->run_count >= WP_MAX_RUNS) return; - int insert_idx = wp->cursor_run + 1; - for (int i = wp->run_count; i > insert_idx; i--) - wp->runs[i] = wp->runs[i - 1]; - wp->run_count++; - StyledRun* nr = &wp->runs[insert_idx]; - wp_init_run(nr, wp->cur_font_id, wp->cur_size, wp->cur_flags); - wp_ensure_run_cap(nr, 1); - nr->text[0] = c; - nr->len = 1; - wp->cursor_run = insert_idx; - wp->cursor_offset = 1; - wp->total_text_len++; - } else { - // Split current run into three: [before][new char][after] - if (wp->run_count + 2 > WP_MAX_RUNS) return; - int split_at = wp->cursor_offset; - int after_len = cur->len - split_at; - - // Create "after" run - StyledRun after; - wp_init_run(&after, cur->font_id, cur->size, cur->flags); - wp_ensure_run_cap(&after, after_len); - montauk::memcpy(after.text, cur->text + split_at, after_len); - after.len = after_len; - - // Truncate current run to "before" - cur->len = split_at; - - // Insert new style run and after run - int new_idx = wp->cursor_run + 1; - // Shift runs from new_idx onward by 2 to make room - for (int i = wp->run_count - 1; i >= new_idx; i--) - wp->runs[i + 2] = wp->runs[i]; - - StyledRun* nr = &wp->runs[new_idx]; - wp_init_run(nr, wp->cur_font_id, wp->cur_size, wp->cur_flags); - wp_ensure_run_cap(nr, 1); - nr->text[0] = c; - nr->len = 1; - - wp->runs[new_idx + 1] = after; - wp->run_count += 2; - wp->cursor_run = new_idx; - wp->cursor_offset = 1; - wp->total_text_len++; - } - - wp->modified = true; - wp->wrap_dirty = true; -} - -static void wp_merge_adjacent(WordProcessorState* wp) { - // Merge adjacent runs with the same style - int i = 0; - while (i < wp->run_count - 1) { - StyledRun* a = &wp->runs[i]; - StyledRun* b = &wp->runs[i + 1]; - if (a->font_id == b->font_id && a->size == b->size && a->flags == b->flags) { - int old_a_len = a->len; - // Merge b into a - wp_ensure_run_cap(a, b->len); - montauk::memcpy(a->text + a->len, b->text, b->len); - a->len += b->len; - - // Update cursor if it was in run b - if (wp->cursor_run == i + 1) { - wp->cursor_run = i; - wp->cursor_offset = old_a_len + wp->cursor_offset; - } else if (wp->cursor_run > i + 1) { - wp->cursor_run--; - } - - wp_free_run(b); - for (int j = i + 1; j < wp->run_count - 1; j++) - wp->runs[j] = wp->runs[j + 1]; - wp->run_count--; - } else { - i++; - } - } - - // Remove empty runs (except if it's the only one) - i = 0; - while (i < wp->run_count && wp->run_count > 1) { - if (wp->runs[i].len == 0) { - if (wp->cursor_run == i) { - if (i < wp->run_count - 1) { - wp->cursor_run = i; - wp->cursor_offset = 0; - } else { - wp->cursor_run = i - 1; - wp->cursor_offset = wp->runs[i - 1].len; - } - } else if (wp->cursor_run > i) { - wp->cursor_run--; - } - wp_free_run(&wp->runs[i]); - for (int j = i; j < wp->run_count - 1; j++) - wp->runs[j] = wp->runs[j + 1]; - wp->run_count--; - } else { - i++; - } - } -} - -static void wp_backspace(WordProcessorState* wp) { - if (wp->cursor_run == 0 && wp->cursor_offset == 0) return; - - if (wp->cursor_offset > 0) { - StyledRun* r = &wp->runs[wp->cursor_run]; - for (int i = wp->cursor_offset - 1; i < r->len - 1; i++) - r->text[i] = r->text[i + 1]; - r->len--; - wp->cursor_offset--; - } else { - wp->cursor_run--; - StyledRun* r = &wp->runs[wp->cursor_run]; - if (r->len > 0) { - r->len--; - } - wp->cursor_offset = r->len; - } - - wp->total_text_len--; - wp->modified = true; - wp->wrap_dirty = true; - wp_merge_adjacent(wp); -} - -static void wp_delete_char(WordProcessorState* wp) { - int abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); - if (abs >= wp->total_text_len) return; - - StyledRun* r = &wp->runs[wp->cursor_run]; - if (wp->cursor_offset < r->len) { - for (int i = wp->cursor_offset; i < r->len - 1; i++) - r->text[i] = r->text[i + 1]; - r->len--; - } else if (wp->cursor_run + 1 < wp->run_count) { - StyledRun* next = &wp->runs[wp->cursor_run + 1]; - if (next->len > 0) { - for (int i = 0; i < next->len - 1; i++) - next->text[i] = next->text[i + 1]; - next->len--; - } - } - - wp->total_text_len--; - wp->modified = true; - wp->wrap_dirty = true; - wp_merge_adjacent(wp); -} - -// ============================================================================ -// Cursor movement -// ============================================================================ - -static void wp_cursor_left(WordProcessorState* wp) { - if (wp->cursor_offset > 0) { - wp->cursor_offset--; - } else if (wp->cursor_run > 0) { - wp->cursor_run--; - wp->cursor_offset = wp->runs[wp->cursor_run].len; - } -} - -static void wp_cursor_right(WordProcessorState* wp) { - if (wp->cursor_offset < wp->runs[wp->cursor_run].len) { - wp->cursor_offset++; - } else if (wp->cursor_run + 1 < wp->run_count) { - wp->cursor_run++; - wp->cursor_offset = 0; - } -} - -// ============================================================================ -// Selection helpers -// ============================================================================ - -static void wp_clear_selection(WordProcessorState* wp) { - wp->has_selection = false; - wp->sel_anchor = 0; - wp->sel_end = 0; -} - -static void wp_sel_range(WordProcessorState* wp, int* out_start, int* out_end) { - if (wp->sel_anchor < wp->sel_end) { - *out_start = wp->sel_anchor; - *out_end = wp->sel_end; - } else { - *out_start = wp->sel_end; - *out_end = wp->sel_anchor; - } -} - -// Start or extend selection from current cursor position -static void wp_start_selection(WordProcessorState* wp) { - if (!wp->has_selection) { - wp->sel_anchor = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); - wp->sel_end = wp->sel_anchor; - wp->has_selection = true; - } -} - -static void wp_update_selection_to_cursor(WordProcessorState* wp) { - wp->sel_end = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); - // Collapse if anchor == end - if (wp->sel_anchor == wp->sel_end) wp->has_selection = false; -} - -// Split run at absolute position, returning the run index where `abs_pos` starts. -// If abs_pos falls at a run boundary, no split needed — just returns the index. -static int wp_split_at(WordProcessorState* wp, int abs_pos) { - int r, o; - wp_pos_to_run(wp, abs_pos, &r, &o); - if (o == 0) return r; - if (o >= wp->runs[r].len) return r + 1; - - // Need to split run r at offset o - if (wp->run_count >= WP_MAX_RUNS) return r; - - StyledRun* src = &wp->runs[r]; - int after_len = src->len - o; - - // Create the "after" portion - StyledRun after; - wp_init_run(&after, src->font_id, src->size, src->flags); - wp_ensure_run_cap(&after, after_len); - montauk::memcpy(after.text, src->text + o, after_len); - after.len = after_len; - - // Truncate original to "before" - src->len = o; - - // Shift runs to make room at r+1 - for (int i = wp->run_count - 1; i > r; i--) - wp->runs[i + 1] = wp->runs[i]; - wp->runs[r + 1] = after; - wp->run_count++; - - // Fix cursor if it was in the split run - if (wp->cursor_run == r && wp->cursor_offset > o) { - wp->cursor_run = r + 1; - wp->cursor_offset -= o; - } else if (wp->cursor_run > r) { - wp->cursor_run++; - } - - return r + 1; -} - -// Apply a style change to the selected range. -// `mode`: 0 = set font_id, 1 = set size, 2 = toggle bold, 3 = toggle italic -static void wp_apply_style_to_selection(WordProcessorState* wp, int mode, int value) { - if (!wp->has_selection) return; - - int sel_s, sel_e; - wp_sel_range(wp, &sel_s, &sel_e); - if (sel_s >= sel_e) return; - - // Split at selection boundaries to isolate affected runs - int end_ri = wp_split_at(wp, sel_e); - int start_ri = wp_split_at(wp, sel_s); - // Recompute end_ri after the start split may have shifted things - { - int pos = 0; - end_ri = wp->run_count; - for (int i = 0; i < wp->run_count; i++) { - if (pos >= sel_e) { end_ri = i; break; } - pos += wp->runs[i].len; - } - } - - // Apply style to runs [start_ri, end_ri) - for (int i = start_ri; i < end_ri && i < wp->run_count; i++) { - StyledRun* r = &wp->runs[i]; - switch (mode) { - case 0: r->font_id = (uint8_t)value; break; - case 1: r->size = (uint8_t)value; break; - case 2: r->flags ^= STYLE_BOLD; break; - case 3: r->flags ^= STYLE_ITALIC; break; - } - } - - wp->wrap_dirty = true; - wp->modified = true; - wp_merge_adjacent(wp); -} - -// Delete the selected text and place cursor at selection start -static void wp_delete_selection(WordProcessorState* wp) { - if (!wp->has_selection) return; - - int sel_s, sel_e; - wp_sel_range(wp, &sel_s, &sel_e); - int count = sel_e - sel_s; - if (count <= 0) { wp_clear_selection(wp); return; } - - // Place cursor at selection start - wp_pos_to_run(wp, sel_s, &wp->cursor_run, &wp->cursor_offset); - - // Delete chars one at a time from the cursor position - for (int i = 0; i < count; i++) - wp_delete_char(wp); - - wp_clear_selection(wp); -} - -// ============================================================================ -// Word-wrap layout (absolute-position based) -// ============================================================================ - -// Measure the advance width of a single character at absolute position -static int wp_char_width(WordProcessorState* wp, int abs_pos) { - TrueTypeFont* font; - GlyphCache* gc; - wp_font_at(wp, abs_pos, &font, &gc); - if (!font || !gc) return 8; - char ch = wp_char_at(wp, abs_pos); - if (ch == '\n') return 0; - CachedGlyph* g = font->get_glyph(gc, (unsigned char)ch); - return g ? g->advance : 8; -} - -static void wp_recompute_wrap(WordProcessorState* wp, int content_w) { - int wrap_width = content_w - WP_MARGIN * 2 - WP_SCROLLBAR_W; - if (wrap_width < 50) wrap_width = 50; - - // Invalidate if width changed - if (wrap_width != wp->last_wrap_width) { - wp->wrap_dirty = true; - wp->last_wrap_width = wrap_width; - } - - if (!wp->wrap_dirty && wp->wrap_lines) return; - - if (!wp->wrap_lines) { - wp->wrap_lines = (WrapLine*)montauk::malloc(WP_MAX_WRAP_LINES * sizeof(WrapLine)); - } - - wp->wrap_line_count = 0; - int total = wp->total_text_len; - int y = WP_MARGIN; - - int pos = 0; // absolute char position - - while (pos < total && wp->wrap_line_count < WP_MAX_WRAP_LINES) { - // Start a new line at position `pos` - WrapLine* line = &wp->wrap_lines[wp->wrap_line_count]; - wp_pos_to_run(wp, pos, &line->run_idx, &line->run_offset); - line->char_count = 0; - line->y = y; - - // First pass: find where this line ends (newline, word-wrap, or end of text) - int x = 0; - int last_space = -1; // absolute position of last space on this line - int line_start = pos; - int max_ascent = 0; - int max_height = 0; - - int scan = pos; - while (scan < total) { - char ch = wp_char_at(wp, scan); - - // Get font metrics for this char - TrueTypeFont* font; - GlyphCache* gc; - wp_font_at(wp, scan, &font, &gc); - if (gc) { - if (gc->ascent > max_ascent) max_ascent = gc->ascent; - if (gc->line_height > max_height) max_height = gc->line_height; - } - - if (ch == '\n') { - // Include the newline in this line's char_count - scan++; - break; - } - - int cw = 0; - if (font && gc) { - CachedGlyph* g = font->get_glyph(gc, (unsigned char)ch); - cw = g ? g->advance : 8; - } else { - cw = 8; - } - - if (x + cw > wrap_width && scan > line_start) { - // Need to wrap. Try breaking at last space. - if (last_space >= line_start) { - scan = last_space + 1; // wrap after the space - } - // else: no space found, break right here (hard break) - break; - } - - if (ch == ' ') last_space = scan; - x += cw; - scan++; - } - - line->char_count = scan - line_start; - - // If we didn't encounter any chars (empty doc), set defaults - if (max_height == 0) { - TrueTypeFont* df = wp_get_font(wp->cur_font_id, 0); - if (df && df->valid) { - GlyphCache* dgc = df->get_cache(wp->cur_size); - max_height = dgc->line_height; - max_ascent = dgc->ascent; - } else { - max_height = WP_DEFAULT_SIZE; - max_ascent = WP_DEFAULT_SIZE; - } - } - - // Recompute max_ascent/max_height precisely by walking the line's runs - // (the scan above may have overshot for word-wrap — recalculate for actual chars) - max_ascent = 0; - max_height = 0; - { - int ri, ro; - wp_pos_to_run(wp, line_start, &ri, &ro); - int left = line->char_count; - while (left > 0 && ri < wp->run_count) { - StyledRun* r = &wp->runs[ri]; - TrueTypeFont* font = wp_get_font(r->font_id, r->flags); - if (font && font->valid) { - GlyphCache* gc = font->get_cache(r->size); - if (gc->ascent > max_ascent) max_ascent = gc->ascent; - if (gc->line_height > max_height) max_height = gc->line_height; - } - int avail = r->len - ro; - int consume = avail < left ? avail : left; - left -= consume; - ro += consume; - if (ro >= r->len) { ri++; ro = 0; } - } - if (max_height == 0) { - TrueTypeFont* df = wp_get_font(wp->cur_font_id, 0); - if (df && df->valid) { - GlyphCache* dgc = df->get_cache(wp->cur_size); - max_height = dgc->line_height; - max_ascent = dgc->ascent; - } else { - max_height = WP_DEFAULT_SIZE; - max_ascent = WP_DEFAULT_SIZE; - } - } - } - - line->height = max_height; - line->baseline = max_ascent; - wp->wrap_line_count++; - - y += max_height; - pos = line_start + line->char_count; - - // If we consumed nothing (shouldn't happen), advance to avoid infinite loop - if (line->char_count == 0 && pos < total) { - pos++; - } - } - - // Add trailing line if document ends with newline (cursor needs somewhere to go) - if (total > 0 && wp_char_at(wp, total - 1) == '\n' && wp->wrap_line_count < WP_MAX_WRAP_LINES) { - WrapLine* line = &wp->wrap_lines[wp->wrap_line_count]; - wp_pos_to_run(wp, total, &line->run_idx, &line->run_offset); - line->char_count = 0; - line->y = y; - TrueTypeFont* df = wp_get_font(wp->cur_font_id, wp->cur_flags); - if (df && df->valid) { - GlyphCache* dgc = df->get_cache(wp->cur_size); - line->height = dgc->line_height; - line->baseline = dgc->ascent; - } else { - line->height = WP_DEFAULT_SIZE; - line->baseline = WP_DEFAULT_SIZE; - } - wp->wrap_line_count++; - y += line->height; - } - - // Ensure at least one line exists - if (wp->wrap_line_count == 0) { - WrapLine* line = &wp->wrap_lines[0]; - line->run_idx = 0; - line->run_offset = 0; - line->char_count = 0; - line->y = WP_MARGIN; - TrueTypeFont* df = wp_get_font(wp->cur_font_id, 0); - if (df && df->valid) { - GlyphCache* dgc = df->get_cache(wp->cur_size); - line->height = dgc->line_height; - line->baseline = dgc->ascent; - } else { - line->height = WP_DEFAULT_SIZE; - line->baseline = WP_DEFAULT_SIZE; - } - wp->wrap_line_count = 1; - y = WP_MARGIN + line->height; - } - - wp->content_height = y + WP_MARGIN; - wp->wrap_dirty = false; -} - -// ============================================================================ -// Cursor position helpers (using wrap lines) -// ============================================================================ - -static int wp_find_wrap_line(WordProcessorState* wp, int abs_pos) { - int pos = 0; - for (int i = 0; i < wp->wrap_line_count; i++) { - int next_pos = pos + wp->wrap_lines[i].char_count; - if (abs_pos < next_pos || i == wp->wrap_line_count - 1) - return i; - pos = next_pos; - } - return 0; -} - -static int wp_wrap_line_start(WordProcessorState* wp, int line_idx) { - int pos = 0; - for (int i = 0; i < line_idx && i < wp->wrap_line_count; i++) - pos += wp->wrap_lines[i].char_count; - return pos; -} - -static void wp_cursor_up(WordProcessorState* wp) { - int abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); - int line = wp_find_wrap_line(wp, abs); - if (line <= 0) return; - - int col = abs - wp_wrap_line_start(wp, line); - int prev_start = wp_wrap_line_start(wp, line - 1); - int prev_len = wp->wrap_lines[line - 1].char_count; - int new_col = col < prev_len ? col : prev_len; - // Don't land on the newline at end of line - if (new_col > 0 && new_col == prev_len) { - char ch = wp_char_at(wp, prev_start + prev_len - 1); - if (ch == '\n') new_col = prev_len - 1; - } - wp_pos_to_run(wp, prev_start + new_col, &wp->cursor_run, &wp->cursor_offset); -} - -static void wp_cursor_down(WordProcessorState* wp) { - int abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); - int line = wp_find_wrap_line(wp, abs); - if (line >= wp->wrap_line_count - 1) return; - - int col = abs - wp_wrap_line_start(wp, line); - int next_start = wp_wrap_line_start(wp, line + 1); - int next_len = wp->wrap_lines[line + 1].char_count; - int new_col = col < next_len ? col : next_len; - if (new_col > 0 && new_col == next_len && line + 1 < wp->wrap_line_count - 1) { - char ch = wp_char_at(wp, next_start + next_len - 1); - if (ch == '\n') new_col = next_len - 1; - } - wp_pos_to_run(wp, next_start + new_col, &wp->cursor_run, &wp->cursor_offset); -} - -static void wp_ensure_cursor_visible(WordProcessorState* wp, int view_h) { - int abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); - int line = wp_find_wrap_line(wp, abs); - if (line < 0 || line >= wp->wrap_line_count) return; - - int cy = wp->wrap_lines[line].y; - int ch = wp->wrap_lines[line].height; - - if (cy < wp->scrollbar.scroll_offset) { - wp->scrollbar.scroll_offset = cy - WP_MARGIN; - if (wp->scrollbar.scroll_offset < 0) wp->scrollbar.scroll_offset = 0; - } - if (cy + ch > wp->scrollbar.scroll_offset + view_h) { - wp->scrollbar.scroll_offset = cy + ch - view_h + WP_MARGIN; - } - - // Clamp scroll - int ms = wp->scrollbar.max_scroll(); - if (wp->scrollbar.scroll_offset > ms) wp->scrollbar.scroll_offset = ms; - if (wp->scrollbar.scroll_offset < 0) wp->scrollbar.scroll_offset = 0; -} - -// ============================================================================ -// File I/O - MWP binary format -// ============================================================================ - -static void wp_set_filepath(WordProcessorState* wp, const char* path) { - montauk::strncpy(wp->filepath, path, 255); - int last_slash = -1; - for (int i = 0; path[i]; i++) - if (path[i] == '/') last_slash = i; - if (last_slash >= 0) - montauk::strncpy(wp->filename, path + last_slash + 1, 63); - else - montauk::strncpy(wp->filename, path, 63); -} - -static void wp_open_save_pathbar(WordProcessorState* wp) { - wp->show_pathbar = true; - wp->pathbar_save_mode = true; - montauk::strncpy(wp->pathbar_text, wp->filepath, 255); - wp->pathbar_len = montauk::slen(wp->pathbar_text); - wp->pathbar_cursor = wp->pathbar_len; -} - -static void wp_save_file(WordProcessorState* wp) { - if (wp->filepath[0] == '\0') { - wp_open_save_pathbar(wp); - return; - } - - int size = 4 + 2 + 1 + 1 + 2; - for (int i = 0; i < wp->run_count; i++) - size += 2 + 1 + 1 + 1 + 1 + wp->runs[i].len; - - uint8_t* buf = (uint8_t*)montauk::malloc(size); - int off = 0; - - buf[off++] = 'M'; buf[off++] = 'W'; buf[off++] = 'P'; buf[off++] = '1'; - buf[off++] = 1; buf[off++] = 0; - buf[off++] = wp->cur_font_id; - buf[off++] = wp->cur_size; - buf[off++] = (uint8_t)(wp->run_count & 0xFF); - buf[off++] = (uint8_t)((wp->run_count >> 8) & 0xFF); - - for (int i = 0; i < wp->run_count; i++) { - StyledRun* r = &wp->runs[i]; - buf[off++] = (uint8_t)(r->len & 0xFF); - buf[off++] = (uint8_t)((r->len >> 8) & 0xFF); - buf[off++] = r->font_id; - buf[off++] = r->size; - buf[off++] = r->flags; - buf[off++] = 0; - montauk::memcpy(buf + off, r->text, r->len); - off += r->len; - } - - int fd = montauk::fcreate(wp->filepath); - if (fd >= 0) { - montauk::fwrite(fd, buf, 0, off); - montauk::close(fd); - wp->modified = false; - } - - montauk::mfree(buf); -} - -static void wp_load_file(WordProcessorState* wp, const char* path) { - int fd = montauk::open(path); - if (fd < 0) return; - - uint64_t fsize = montauk::getsize(fd); - if (fsize < 10 || fsize > WP_MAX_TEXT * 2) { - montauk::close(fd); - return; - } - - uint8_t* buf = (uint8_t*)montauk::malloc((int)fsize); - montauk::read(fd, buf, 0, fsize); - montauk::close(fd); - - if (buf[0] != 'M' || buf[1] != 'W' || buf[2] != 'P' || buf[3] != '1') { - montauk::mfree(buf); - return; - } - - for (int i = 0; i < wp->run_count; i++) - wp_free_run(&wp->runs[i]); - - int off = 4; - off += 2; - wp->cur_font_id = buf[off++]; - wp->cur_size = buf[off++]; - - int run_count = buf[off] | (buf[off + 1] << 8); - off += 2; - - wp->run_count = 0; - wp->total_text_len = 0; - - for (int i = 0; i < run_count && off < (int)fsize && i < WP_MAX_RUNS; i++) { - if (off + 6 > (int)fsize) break; - int text_len = buf[off] | (buf[off + 1] << 8); - off += 2; - uint8_t font_id = buf[off++]; - uint8_t size = buf[off++]; - uint8_t flags = buf[off++]; - off++; - - if (off + text_len > (int)fsize) break; - - StyledRun* r = &wp->runs[wp->run_count]; - wp_init_run(r, font_id, size, flags); - wp_ensure_run_cap(r, text_len); - montauk::memcpy(r->text, buf + off, text_len); - r->len = text_len; - off += text_len; - - wp->total_text_len += text_len; - wp->run_count++; - } - - if (wp->run_count == 0) { - wp_init_run(&wp->runs[0], wp->cur_font_id, wp->cur_size, 0); - wp->run_count = 1; - } - - wp->cursor_run = 0; - wp->cursor_offset = 0; - wp->scrollbar.scroll_offset = 0; - wp->modified = false; - wp->wrap_dirty = true; - - montauk::strncpy(wp->filepath, path, 255); - - int last_slash = -1; - for (int i = 0; path[i]; i++) - if (path[i] == '/') last_slash = i; - if (last_slash >= 0) - montauk::strncpy(wp->filename, path + last_slash + 1, 63); - else - montauk::strncpy(wp->filename, path, 63); - - montauk::mfree(buf); -} - -// ============================================================================ -// Drawing -// ============================================================================ - -static const char* font_names[] = { "Roboto", "NotoSerif", "C059" }; -static const int size_options[] = { 12, 14, 16, 18, 20, 24, 28, 36 }; -static constexpr int SIZE_OPTION_COUNT = 8; - -static void wp_on_draw(Window* win, Framebuffer& fb) { - WordProcessorState* wp = (WordProcessorState*)win->app_data; - if (!wp) return; - - Canvas c(win); - c.fill(colors::WINDOW_BG); - - int sfh = system_font_height(); - Color toolbar_bg = Color::from_rgb(0xF5, 0xF5, 0xF5); - Color btn_bg = Color::from_rgb(0xE8, 0xE8, 0xE8); - Color btn_active = Color::from_rgb(0xC0, 0xD0, 0xE8); - - // ---- Toolbar (36px) ---- - c.fill_rect(0, 0, c.w, WP_TOOLBAR_H, toolbar_bg); - - // Open button - c.fill_rounded_rect(4, 6, 24, 24, 3, btn_bg); - if (wp->desktop && wp->desktop->icon_folder.pixels) - c.icon(8, 10, wp->desktop->icon_folder); - - // Save button - c.fill_rounded_rect(32, 6, 24, 24, 3, btn_bg); - if (wp->desktop && wp->desktop->icon_save.pixels) - c.icon(36, 10, wp->desktop->icon_save); - - c.vline(60, 4, 28, colors::BORDER); - - // Bold button - Color bold_bg = (wp->cur_flags & STYLE_BOLD) ? btn_active : btn_bg; - c.fill_rounded_rect(66, 6, 24, 24, 3, bold_bg); - if (fonts::system_bold && fonts::system_bold->valid) { - fonts::system_bold->draw_to_buffer(c.pixels, c.w, c.h, 73, 8, "B", colors::TEXT_COLOR, fonts::UI_SIZE); - } else { - c.text(73, 10, "B", colors::TEXT_COLOR); - } - - // Italic button - Color italic_bg = (wp->cur_flags & STYLE_ITALIC) ? btn_active : btn_bg; - c.fill_rounded_rect(94, 6, 24, 24, 3, italic_bg); - { - TrueTypeFont* italic_font = wp_get_font(FONT_ROBOTO, STYLE_ITALIC); - if (italic_font && italic_font->valid) { - italic_font->draw_to_buffer(c.pixels, c.w, c.h, 103, 8, "I", colors::TEXT_COLOR, fonts::UI_SIZE); - } else { - c.text(103, 10, "I", colors::TEXT_COLOR); - } - } - - c.vline(122, 4, 28, colors::BORDER); - - // Font dropdown - c.fill_rounded_rect(128, 6, 90, 24, 3, btn_bg); - { - const char* fn = font_names[wp->cur_font_id < FONT_COUNT ? wp->cur_font_id : 0]; - c.text(134, (WP_TOOLBAR_H - sfh) / 2, fn, colors::TEXT_COLOR); - } - - // Size dropdown - c.fill_rounded_rect(224, 6, 44, 24, 3, btn_bg); - { - char sz[8]; - snprintf(sz, 8, "%d", (int)wp->cur_size); - c.text(232, (WP_TOOLBAR_H - sfh) / 2, sz, colors::TEXT_COLOR); - } - - c.vline(272, 4, 28, colors::BORDER); - - // Section sign button - c.fill_rounded_rect(278, 6, 24, 24, 3, btn_bg); - { - char section[2] = { (char)0xA7, '\0' }; - TrueTypeFont* sf = wp_get_font(FONT_ROBOTO, 0); - if (sf && sf->valid) - sf->draw_to_buffer(c.pixels, c.w, c.h, 284, 8, section, colors::TEXT_COLOR, fonts::UI_SIZE); - else - c.text(284, 10, "S", colors::TEXT_COLOR); - } - - // Filename + modified - { - char label[128]; - if (wp->filename[0]) - snprintf(label, 128, "%s%s", wp->filename, wp->modified ? " *" : ""); - else - snprintf(label, 128, "Untitled%s", wp->modified ? " *" : ""); - c.text(312, (WP_TOOLBAR_H - sfh) / 2, label, Color::from_rgb(0x88, 0x88, 0x88)); - } - - c.hline(0, WP_TOOLBAR_H - 1, c.w, colors::BORDER); - - // ---- Path bar (conditional) ---- - int edit_y = WP_TOOLBAR_H; - - if (wp->show_pathbar) { - int pb_y = WP_TOOLBAR_H; - c.fill_rect(0, pb_y, c.w, WP_PATHBAR_H, Color::from_rgb(0xF0, 0xF0, 0xF0)); - - int inp_x = 8; - int inp_y = pb_y + 4; - int btn_w = 56; - int inp_w = c.w - inp_x - btn_w - 12; - int inp_h = 24; - - c.fill_rect(inp_x, inp_y, inp_w, inp_h, colors::WHITE); - c.rect(inp_x, inp_y, inp_w, inp_h, colors::ACCENT); - int text_y = inp_y + (inp_h - sfh) / 2; - c.text(inp_x + 4, text_y, wp->pathbar_text, colors::TEXT_COLOR); - - char prefix[256]; - int plen = wp->pathbar_cursor; - if (plen > 255) plen = 255; - for (int i = 0; i < plen; i++) prefix[i] = wp->pathbar_text[i]; - prefix[plen] = '\0'; - int cx = inp_x + 4 + text_width(prefix); - c.fill_rect(cx, inp_y + 3, 2, inp_h - 6, colors::ACCENT); - - int ob_x = inp_x + inp_w + 6; - const char* pb_label = wp->pathbar_save_mode ? "Save" : "Open"; - c.button(ob_x, inp_y, btn_w, inp_h, pb_label, colors::ACCENT, colors::WHITE, 3); - - c.hline(0, pb_y + WP_PATHBAR_H - 1, c.w, colors::BORDER); - edit_y = WP_TOOLBAR_H + WP_PATHBAR_H; - } - - // ---- Text area ---- - int text_area_h = c.h - edit_y - WP_STATUS_H; - int text_area_w = c.w; - - wp_recompute_wrap(wp, text_area_w); - - // Update scrollbar - wp->scrollbar.bounds = {c.w - WP_SCROLLBAR_W, edit_y, WP_SCROLLBAR_W, text_area_h}; - wp->scrollbar.content_height = wp->content_height; - wp->scrollbar.view_height = text_area_h; - - // Clamp scroll offset - { - int ms = wp->scrollbar.max_scroll(); - if (wp->scrollbar.scroll_offset > ms) wp->scrollbar.scroll_offset = ms; - if (wp->scrollbar.scroll_offset < 0) wp->scrollbar.scroll_offset = 0; - } - - wp_ensure_cursor_visible(wp, text_area_h); - - int scroll_y = wp->scrollbar.scroll_offset; - - // Draw text content - int cursor_abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); - int sel_s = 0, sel_e = 0; - if (wp->has_selection) wp_sel_range(wp, &sel_s, &sel_e); - Color sel_bg = Color::from_rgb(0xB0, 0xD0, 0xF0); - - for (int li = 0; li < wp->wrap_line_count; li++) { - WrapLine* wl = &wp->wrap_lines[li]; - int py = edit_y + wl->y - scroll_y; - - // Clip - if (py + wl->height <= edit_y) continue; - if (py >= edit_y + text_area_h) break; - - // Walk through runs to render this line's chars - int chars_left = wl->char_count; - int ri = wl->run_idx; - int ro = wl->run_offset; - int x = WP_MARGIN; - int line_abs_start = wp_wrap_line_start(wp, li); - int char_idx = 0; - - while (chars_left > 0 && ri < wp->run_count) { - StyledRun* r = &wp->runs[ri]; - TrueTypeFont* font = wp_get_font(r->font_id, r->flags); - if (!font || !font->valid) { - ri++; ro = 0; - continue; - } - - GlyphCache* gc = font->get_cache(r->size); - int baseline = py + wl->baseline; - - int avail = r->len - ro; - int to_draw = avail < chars_left ? avail : chars_left; - - for (int ci = 0; ci < to_draw; ci++) { - char ch = r->text[ro + ci]; - int abs_ch = line_abs_start + char_idx; - - // Compute char advance for selection highlight - int char_adv = 0; - if (ch != '\n' && (ch >= 32 || ch < 0)) { - CachedGlyph* g = font->get_glyph(gc, (unsigned char)ch); - char_adv = g ? g->advance : 8; - } - - // Draw selection highlight behind text - if (wp->has_selection && abs_ch >= sel_s && abs_ch < sel_e) { - int sel_w = char_adv > 0 ? char_adv : 6; - c.fill_rect(x, py, sel_w, wl->height, sel_bg); - } - - // Draw cursor - if (abs_ch == cursor_abs) { - int cur_h = wl->height; - if (py >= edit_y && py + cur_h <= edit_y + text_area_h) - c.fill_rect(x, py, 2, cur_h, colors::ACCENT); - } - - if (ch != '\n' && (ch >= 32 || ch < 0)) { - if (x < c.w - WP_SCROLLBAR_W - WP_MARGIN) { - Color text_col = (wp->has_selection && abs_ch >= sel_s && abs_ch < sel_e) - ? Color::from_rgb(0x10, 0x10, 0x10) : colors::TEXT_COLOR; - int adv = font->draw_char_to_buffer( - c.pixels, c.w, c.h, x, baseline, (unsigned char)ch, - text_col, gc); - x += adv; - } - } - char_idx++; - } - - chars_left -= to_draw; - ro += to_draw; - if (ro >= r->len) { - ri++; - ro = 0; - } - } - - // Draw cursor at end of line only on the last wrap line - if (line_abs_start + char_idx == cursor_abs && li == wp->wrap_line_count - 1) { - int cur_h = wl->height; - if (py >= edit_y && py + cur_h <= edit_y + text_area_h) - c.fill_rect(x, py, 2, cur_h, colors::ACCENT); - } - } - - // ---- Scrollbar ---- - if (wp->scrollbar.content_height > wp->scrollbar.view_height) { - Color sb_fg = (wp->scrollbar.hovered || wp->scrollbar.dragging) - ? wp->scrollbar.hover_fg : wp->scrollbar.fg; - int sbx = wp->scrollbar.bounds.x; - int sby = wp->scrollbar.bounds.y; - int sbw = wp->scrollbar.bounds.w; - int sbh = wp->scrollbar.bounds.h; - c.fill_rect(sbx, sby, sbw, sbh, colors::SCROLLBAR_BG); - int th = wp->scrollbar.thumb_height(); - int tty = wp->scrollbar.thumb_y(); - c.fill_rect(sbx + 1, tty, sbw - 2, th, sb_fg); - } - - // ---- Status bar ---- - int status_y = c.h - WP_STATUS_H; - c.fill_rect(0, status_y, c.w, WP_STATUS_H, Color::from_rgb(0x2B, 0x3E, 0x50)); - int status_text_y = status_y + (WP_STATUS_H - sfh) / 2; - - char status_left[128]; - snprintf(status_left, 128, " %s %dpt %s%s", - font_names[wp->cur_font_id < FONT_COUNT ? wp->cur_font_id : 0], (int)wp->cur_size, - (wp->cur_flags & STYLE_BOLD) ? "Bold " : "", - (wp->cur_flags & STYLE_ITALIC) ? "Italic" : ""); - c.text(4, status_text_y, status_left, colors::PANEL_TEXT); - - char status_right[32]; - snprintf(status_right, 32, "%d chars ", wp->total_text_len); - int sr_w = text_width(status_right); - c.text(c.w - sr_w - 4, status_text_y, status_right, colors::PANEL_TEXT); - - // ---- Dropdown overlays ---- - if (wp->font_dropdown_open) { - int dx = 128, dy = WP_TOOLBAR_H; - int dw = 110, dh = FONT_COUNT * 26 + 4; - c.fill_rect(dx, dy, dw, dh, colors::MENU_BG); - c.rect(dx, dy, dw, dh, colors::BORDER); - for (int i = 0; i < FONT_COUNT; i++) { - int iy = dy + 2 + i * 26; - if (i == wp->cur_font_id) - c.fill_rect(dx + 2, iy, dw - 4, 24, colors::MENU_HOVER); - c.text(dx + 8, iy + (24 - sfh) / 2, font_names[i], colors::TEXT_COLOR); - } - } - - if (wp->size_dropdown_open) { - int dx = 224, dy = WP_TOOLBAR_H; - int dw = 56, dh = SIZE_OPTION_COUNT * 26 + 4; - c.fill_rect(dx, dy, dw, dh, colors::MENU_BG); - c.rect(dx, dy, dw, dh, colors::BORDER); - for (int i = 0; i < SIZE_OPTION_COUNT; i++) { - int iy = dy + 2 + i * 26; - if (size_options[i] == wp->cur_size) - c.fill_rect(dx + 2, iy, dw - 4, 24, colors::MENU_HOVER); - char sz[8]; - snprintf(sz, 8, "%d", size_options[i]); - c.text(dx + 8, iy + (24 - sfh) / 2, sz, colors::TEXT_COLOR); - } - } -} - -// ============================================================================ -// Mouse handling -// ============================================================================ - -static void wp_on_mouse(Window* win, MouseEvent& ev) { - WordProcessorState* wp = (WordProcessorState*)win->app_data; - if (!wp) return; - - Rect cr = win->content_rect(); - int local_x = ev.x - cr.x; - int local_y = ev.y - cr.y; - int edit_y = WP_TOOLBAR_H + (wp->show_pathbar ? WP_PATHBAR_H : 0); - int text_area_h = cr.h - edit_y - WP_STATUS_H; - - // Scrollbar - MouseEvent local_ev = ev; - local_ev.x = local_x; - local_ev.y = local_y; - wp->scrollbar.handle_mouse(local_ev); - - // ---- Font dropdown clicks ---- - if (wp->font_dropdown_open && ev.left_pressed()) { - int dx = 128, dy = WP_TOOLBAR_H; - int dh = FONT_COUNT * 26 + 4; - if (local_x >= dx && local_x < dx + 110 && local_y >= dy && local_y < dy + dh) { - int idx = (local_y - dy - 2) / 26; - if (idx >= 0 && idx < FONT_COUNT) { - if (wp->has_selection) wp_apply_style_to_selection(wp, 0, idx); - wp->cur_font_id = (uint8_t)idx; - } - } - wp->font_dropdown_open = false; - return; - } - - // ---- Size dropdown clicks ---- - if (wp->size_dropdown_open && ev.left_pressed()) { - int dx = 224, dy = WP_TOOLBAR_H; - int dh = SIZE_OPTION_COUNT * 26 + 4; - if (local_x >= dx && local_x < dx + 56 && local_y >= dy && local_y < dy + dh) { - int idx = (local_y - dy - 2) / 26; - if (idx >= 0 && idx < SIZE_OPTION_COUNT) { - if (wp->has_selection) wp_apply_style_to_selection(wp, 1, size_options[idx]); - wp->cur_size = (uint8_t)size_options[idx]; - wp->wrap_dirty = true; - } - } - wp->size_dropdown_open = false; - return; - } - - // ---- Toolbar clicks ---- - if (ev.left_pressed() && local_y < WP_TOOLBAR_H) { - if (local_x >= 4 && local_x < 28 && local_y >= 6 && local_y < 30) { - wp->show_pathbar = !wp->show_pathbar; - wp->pathbar_save_mode = false; - if (wp->show_pathbar) { - montauk::strncpy(wp->pathbar_text, wp->filepath, 255); - wp->pathbar_len = montauk::slen(wp->pathbar_text); - wp->pathbar_cursor = wp->pathbar_len; - } - return; - } - if (local_x >= 32 && local_x < 56 && local_y >= 6 && local_y < 30) { - wp_save_file(wp); - return; - } - if (local_x >= 66 && local_x < 90 && local_y >= 6 && local_y < 30) { - if (wp->has_selection) wp_apply_style_to_selection(wp, 2, 0); - wp->cur_flags ^= STYLE_BOLD; - return; - } - if (local_x >= 94 && local_x < 118 && local_y >= 6 && local_y < 30) { - if (wp->has_selection) wp_apply_style_to_selection(wp, 3, 0); - wp->cur_flags ^= STYLE_ITALIC; - return; - } - if (local_x >= 128 && local_x < 218 && local_y >= 6 && local_y < 30) { - wp->font_dropdown_open = !wp->font_dropdown_open; - wp->size_dropdown_open = false; - return; - } - if (local_x >= 224 && local_x < 268 && local_y >= 6 && local_y < 30) { - wp->size_dropdown_open = !wp->size_dropdown_open; - wp->font_dropdown_open = false; - return; - } - if (local_x >= 278 && local_x < 302 && local_y >= 6 && local_y < 30) { - wp_insert_char(wp, (char)0xA7); - return; - } - return; - } - - // ---- Path bar clicks ---- - if (wp->show_pathbar && local_y >= WP_TOOLBAR_H && local_y < WP_TOOLBAR_H + WP_PATHBAR_H) { - if (ev.left_pressed()) { - int btn_w = 56; - int inp_w = cr.w - 8 - btn_w - 12; - int ob_x = 8 + inp_w + 6; - if (local_x >= ob_x && local_x < ob_x + btn_w) { - if (wp->pathbar_text[0]) { - if (wp->pathbar_save_mode) { - wp_set_filepath(wp, wp->pathbar_text); - wp_save_file(wp); - } else { - wp_load_file(wp, wp->pathbar_text); - } - char title[64]; - snprintf(title, 64, "%s - Word Processor", wp->filename); - montauk::strncpy(win->title, title, 63); - wp->show_pathbar = false; - } - } - } - return; - } - - // ---- Text area: hit-test helper ---- - auto hit_test = [&](int lx, int ly) -> int { - int click_y = ly - edit_y + wp->scrollbar.scroll_offset; - int click_x = lx - WP_MARGIN; - - int target_line = wp->wrap_line_count - 1; - for (int i = 0; i < wp->wrap_line_count; i++) { - if (click_y >= wp->wrap_lines[i].y && - click_y < wp->wrap_lines[i].y + wp->wrap_lines[i].height) { - target_line = i; - break; - } - } - - WrapLine* wl = &wp->wrap_lines[target_line]; - int ri = wl->run_idx; - int ro = wl->run_offset; - int chars_left = wl->char_count; - int x = 0; - int best_abs = wp_wrap_line_start(wp, target_line); - - while (chars_left > 0 && ri < wp->run_count) { - StyledRun* r = &wp->runs[ri]; - TrueTypeFont* font = wp_get_font(r->font_id, r->flags); - if (!font || !font->valid) { ri++; ro = 0; continue; } - - GlyphCache* gc = font->get_cache(r->size); - int avail = r->len - ro; - int to_check = avail < chars_left ? avail : chars_left; - - for (int ci = 0; ci < to_check; ci++) { - char ch = r->text[ro + ci]; - CachedGlyph* g = (ch >= 32 || ch < 0) && ch != '\n' - ? font->get_glyph(gc, (unsigned char)ch) : nullptr; - int char_w = g ? g->advance : 0; - - if (x + char_w / 2 > click_x) return best_abs; - x += char_w; - best_abs++; - } - - chars_left -= to_check; - ro += to_check; - if (ro >= r->len) { ri++; ro = 0; } - } - return best_abs; - }; - - // ---- Text area: mouse press - start selection or place cursor ---- - if (ev.left_pressed() && local_y >= edit_y && local_y < edit_y + text_area_h) { - wp->font_dropdown_open = false; - wp->size_dropdown_open = false; - - int abs = hit_test(local_x, local_y); - wp_pos_to_run(wp, abs, &wp->cursor_run, &wp->cursor_offset); - wp->sel_anchor = abs; - wp->sel_end = abs; - wp->has_selection = false; - wp->mouse_selecting = true; - return; - } - - // ---- Text area: mouse drag - extend selection ---- - if (wp->mouse_selecting && ev.left_held() && local_y >= edit_y - 20) { - int abs = hit_test(local_x, local_y); - wp->sel_end = abs; - wp_pos_to_run(wp, abs, &wp->cursor_run, &wp->cursor_offset); - wp->has_selection = (wp->sel_anchor != wp->sel_end); - return; - } - - // ---- Mouse release: end selection ---- - if (wp->mouse_selecting && ev.left_released()) { - wp->mouse_selecting = false; - return; - } - - // ---- Scroll ---- - if (ev.scroll != 0 && local_y >= edit_y && local_y < edit_y + text_area_h) { - wp->scrollbar.scroll_offset -= ev.scroll * 40; - int ms = wp->scrollbar.max_scroll(); - if (wp->scrollbar.scroll_offset < 0) wp->scrollbar.scroll_offset = 0; - if (wp->scrollbar.scroll_offset > ms) wp->scrollbar.scroll_offset = ms; - } -} - -// ============================================================================ -// Keyboard handling -// ============================================================================ - -static void wp_on_key(Window* win, const Montauk::KeyEvent& key) { - WordProcessorState* wp = (WordProcessorState*)win->app_data; - if (!wp || !key.pressed) return; - - // ---- Path bar mode ---- - if (wp->show_pathbar) { - if (key.ascii == '\n' || key.ascii == '\r') { - if (wp->pathbar_text[0]) { - if (wp->pathbar_save_mode) { - wp_set_filepath(wp, wp->pathbar_text); - wp_save_file(wp); - } else { - wp_load_file(wp, wp->pathbar_text); - } - char title[64]; - snprintf(title, 64, "%s - Word Processor", wp->filename); - montauk::strncpy(win->title, title, 63); - wp->show_pathbar = false; - } - return; - } - if (key.scancode == 0x01) { wp->show_pathbar = false; return; } - if (key.ascii == '\b' || key.scancode == 0x0E) { - if (wp->pathbar_cursor > 0) { - for (int i = wp->pathbar_cursor - 1; i < wp->pathbar_len - 1; i++) - wp->pathbar_text[i] = wp->pathbar_text[i + 1]; - wp->pathbar_len--; - wp->pathbar_cursor--; - wp->pathbar_text[wp->pathbar_len] = '\0'; - } - return; - } - if (key.scancode == 0x4B) { if (wp->pathbar_cursor > 0) wp->pathbar_cursor--; return; } - if (key.scancode == 0x4D) { if (wp->pathbar_cursor < wp->pathbar_len) wp->pathbar_cursor++; return; } - if (key.ascii >= 32 && key.ascii < 127 && wp->pathbar_len < 254) { - for (int i = wp->pathbar_len; i > wp->pathbar_cursor; i--) - wp->pathbar_text[i] = wp->pathbar_text[i - 1]; - wp->pathbar_text[wp->pathbar_cursor] = key.ascii; - wp->pathbar_cursor++; - wp->pathbar_len++; - wp->pathbar_text[wp->pathbar_len] = '\0'; - return; - } - return; - } - - // Close dropdowns on any key - wp->font_dropdown_open = false; - wp->size_dropdown_open = false; - - // Ctrl+S - if (key.ctrl && (key.ascii == 's' || key.ascii == 'S')) { - wp_save_file(wp); - return; - } - // Ctrl+O - if (key.ctrl && (key.ascii == 'o' || key.ascii == 'O')) { - wp->show_pathbar = !wp->show_pathbar; - wp->pathbar_save_mode = false; - if (wp->show_pathbar) { - montauk::strncpy(wp->pathbar_text, wp->filepath, 255); - wp->pathbar_len = montauk::slen(wp->pathbar_text); - wp->pathbar_cursor = wp->pathbar_len; - } - return; - } - // Ctrl+B - if (key.ctrl && (key.ascii == 'b' || key.ascii == 'B')) { - if (wp->has_selection) wp_apply_style_to_selection(wp, 2, 0); - wp->cur_flags ^= STYLE_BOLD; - return; - } - // Ctrl+I - if (key.ctrl && (key.ascii == 'i' || key.ascii == 'I')) { - if (wp->has_selection) wp_apply_style_to_selection(wp, 3, 0); - wp->cur_flags ^= STYLE_ITALIC; - return; - } - - // Arrow keys (Shift extends selection) - if (key.scancode == 0x48) { // Up - if (key.shift) wp_start_selection(wp); - else wp_clear_selection(wp); - wp_cursor_up(wp); - if (key.shift) wp_update_selection_to_cursor(wp); - return; - } - if (key.scancode == 0x50) { // Down - if (key.shift) wp_start_selection(wp); - else wp_clear_selection(wp); - wp_cursor_down(wp); - if (key.shift) wp_update_selection_to_cursor(wp); - return; - } - if (key.scancode == 0x4B) { // Left - if (key.shift) wp_start_selection(wp); - else if (wp->has_selection) { - int s, e; wp_sel_range(wp, &s, &e); - wp_pos_to_run(wp, s, &wp->cursor_run, &wp->cursor_offset); - wp_clear_selection(wp); - return; - } - wp_cursor_left(wp); - if (key.shift) wp_update_selection_to_cursor(wp); - return; - } - if (key.scancode == 0x4D) { // Right - if (key.shift) wp_start_selection(wp); - else if (wp->has_selection) { - int s, e; wp_sel_range(wp, &s, &e); - wp_pos_to_run(wp, e, &wp->cursor_run, &wp->cursor_offset); - wp_clear_selection(wp); - return; - } - wp_cursor_right(wp); - if (key.shift) wp_update_selection_to_cursor(wp); - return; - } - - // Home - if (key.scancode == 0x47) { - if (key.shift) wp_start_selection(wp); - else wp_clear_selection(wp); - int abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); - int line = wp_find_wrap_line(wp, abs); - int start = wp_wrap_line_start(wp, line); - wp_pos_to_run(wp, start, &wp->cursor_run, &wp->cursor_offset); - if (key.shift) wp_update_selection_to_cursor(wp); - return; - } - // End - if (key.scancode == 0x4F) { - if (key.shift) wp_start_selection(wp); - else wp_clear_selection(wp); - int abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); - int line = wp_find_wrap_line(wp, abs); - int start = wp_wrap_line_start(wp, line); - int end = start + wp->wrap_lines[line].char_count; - if (end > start) { - char ch = wp_char_at(wp, end - 1); - if (ch == '\n') end--; - } - wp_pos_to_run(wp, end, &wp->cursor_run, &wp->cursor_offset); - if (key.shift) wp_update_selection_to_cursor(wp); - return; - } - - // Ctrl+A - select all - if (key.ctrl && (key.ascii == 'a' || key.ascii == 'A')) { - wp->sel_anchor = 0; - wp->sel_end = wp->total_text_len; - wp->has_selection = (wp->total_text_len > 0); - wp_pos_to_run(wp, wp->total_text_len, &wp->cursor_run, &wp->cursor_offset); - return; - } - - // Delete - if (key.scancode == 0x53) { - if (wp->has_selection) wp_delete_selection(wp); - else wp_delete_char(wp); - return; - } - - // Backspace - if (key.ascii == '\b' || key.scancode == 0x0E) { - if (wp->has_selection) wp_delete_selection(wp); - else wp_backspace(wp); - return; - } - - // Enter - if (key.ascii == '\n' || key.ascii == '\r') { - if (wp->has_selection) wp_delete_selection(wp); - wp_insert_char(wp, '\n'); - return; - } - - // Tab - if (key.ascii == '\t') { - if (wp->has_selection) wp_delete_selection(wp); - for (int i = 0; i < 4; i++) wp_insert_char(wp, ' '); - return; - } - - // Printable characters - if (key.ascii >= 32 && key.ascii < 127) { - if (wp->has_selection) wp_delete_selection(wp); - wp_insert_char(wp, key.ascii); - return; - } -} - -// ============================================================================ -// Close handler -// ============================================================================ - -static void wp_on_close(Window* win) { - WordProcessorState* wp = (WordProcessorState*)win->app_data; - if (wp) { - for (int i = 0; i < wp->run_count; i++) - wp_free_run(&wp->runs[i]); - if (wp->wrap_lines) montauk::mfree(wp->wrap_lines); - montauk::mfree(wp); - win->app_data = nullptr; - } -} - -// ============================================================================ -// Word Processor launcher -// ============================================================================ - void open_wordprocessor(DesktopState* ds) { - wp_load_fonts(); - - int idx = desktop_create_window(ds, "Word Processor", 140, 50, 640, 480); - if (idx < 0) return; - - Window* win = &ds->windows[idx]; - WordProcessorState* wp = (WordProcessorState*)montauk::malloc(sizeof(WordProcessorState)); - montauk::memset(wp, 0, sizeof(WordProcessorState)); - - wp_init_run(&wp->runs[0], FONT_ROBOTO, WP_DEFAULT_SIZE, 0); - wp->run_count = 1; - wp->total_text_len = 0; - wp->cursor_run = 0; - wp->cursor_offset = 0; - - wp->cur_font_id = FONT_ROBOTO; - wp->cur_size = WP_DEFAULT_SIZE; - wp->cur_flags = 0; - - wp->scrollbar.init(0, 0, WP_SCROLLBAR_W, 100); - - wp->modified = false; - wp->desktop = ds; - wp->show_pathbar = false; - wp->wrap_dirty = true; - wp->wrap_lines = nullptr; - wp->last_wrap_width = 0; - wp->font_dropdown_open = false; - wp->size_dropdown_open = false; - - win->app_data = wp; - win->on_draw = wp_on_draw; - win->on_mouse = wp_on_mouse; - win->on_key = wp_on_key; - win->on_close = wp_on_close; + (void)ds; + montauk::spawn("0:/apps/wordprocessor/wordprocessor.elf"); } diff --git a/programs/src/wordprocessor/Makefile b/programs/src/wordprocessor/Makefile new file mode 100644 index 0000000..f8278ac --- /dev/null +++ b/programs/src/wordprocessor/Makefile @@ -0,0 +1,74 @@ +# Makefile for wordprocessor (standalone Window Server app) on MontaukOS +# Copyright (c) 2026 Daniel Hammer + +MAKEFLAGS += -rR +.SUFFIXES: + +TOOLCHAIN_PREFIX := $(shell cd ../../.. && pwd)/toolchain/local/bin/x86_64-elf- +ifneq ($(wildcard $(TOOLCHAIN_PREFIX)gcc),) + CXX := $(TOOLCHAIN_PREFIX)g++ +else + CXX := g++ +endif + +PROG_INC := ../../include +LINK_LD := ../../link.ld +BINDIR := ../../bin +OBJDIR := obj +LIBDIR := ../../lib + +CXXFLAGS := \ + -std=gnu++20 \ + -g -O2 -pipe \ + -Wall \ + -Wextra \ + -Wno-unused-parameter \ + -Wno-unused-function \ + -nostdinc \ + -ffreestanding \ + -fno-stack-protector \ + -fno-stack-check \ + -fno-PIC \ + -fno-rtti \ + -fno-exceptions \ + -ffunction-sections \ + -fdata-sections \ + -m64 \ + -march=x86-64 \ + -msse \ + -msse2 \ + -mno-red-zone \ + -mcmodel=small \ + -I $(PROG_INC) \ + -isystem $(PROG_INC)/libc \ + -isystem ../../../kernel/freestnd-c-hdrs/x86_64/include \ + -isystem ../../../kernel/freestnd-cxx-hdrs/x86_64/include + +LDFLAGS := \ + -nostdlib \ + -static \ + -Wl,--build-id=none \ + -Wl,--gc-sections \ + -Wl,-m,elf_x86_64 \ + -z max-page-size=0x1000 \ + -T $(LINK_LD) + +SRCS := main.cpp document.cpp render.cpp input.cpp stb_truetype_impl.cpp +OBJS := $(addprefix $(OBJDIR)/,$(SRCS:.cpp=.o)) + +TARGET := $(BINDIR)/apps/wordprocessor/wordprocessor.elf + +.PHONY: all clean + +all: $(TARGET) + +$(TARGET): $(OBJS) $(LINK_LD) Makefile + mkdir -p $(BINDIR)/apps/wordprocessor + $(CXX) $(CXXFLAGS) $(LDFLAGS) $(OBJS) $(LIBDIR)/libc/liblibc.a -o $@ + +$(OBJDIR)/%.o: %.cpp Makefile + mkdir -p $(OBJDIR) + $(CXX) $(CXXFLAGS) -c $< -o $@ + +clean: + rm -rf $(OBJDIR) $(TARGET) diff --git a/programs/src/wordprocessor/document.cpp b/programs/src/wordprocessor/document.cpp new file mode 100644 index 0000000..46f12d5 --- /dev/null +++ b/programs/src/wordprocessor/document.cpp @@ -0,0 +1,872 @@ +/* + * document.cpp + * Document model, wrap layout, font loading, and file I/O + * Copyright (c) 2026 Daniel Hammer + */ + +#include "wordprocessor.hpp" + +static void wp_init_run(StyledRun* r, uint8_t font_id, uint8_t size, uint8_t flags) { + r->cap = 64; + r->text = (char*)montauk::malloc(r->cap); + r->text[0] = '\0'; + r->len = 0; + r->font_id = font_id; + r->size = size; + r->flags = flags; +} + +static void wp_free_run(StyledRun* r) { + if (r->text) montauk::mfree(r->text); + r->text = nullptr; + r->len = 0; + r->cap = 0; +} + +static void wp_ensure_run_cap(StyledRun* r, int needed) { + if (r->len + needed <= r->cap) return; + int new_cap = r->cap * 2; + if (new_cap < r->len + needed) new_cap = r->len + needed; + r->text = (char*)montauk::realloc(r->text, new_cap); + r->cap = new_cap; +} + +static bool wp_same_style(const StyledRun* a, uint8_t font_id, uint8_t size, uint8_t flags) { + return a->font_id == font_id && a->size == size && a->flags == flags; +} + +static void wp_font_at(WordProcessorState* wp, int abs_pos, + TrueTypeFont** out_font, GlyphCache** out_gc) { + int r, o; + wp_pos_to_run(wp, abs_pos, &r, &o); + if (r >= wp->run_count) r = wp->run_count - 1; + StyledRun* run = &wp->runs[r]; + *out_font = wp_get_font(run->font_id, run->flags); + *out_gc = (*out_font && (*out_font)->valid) ? (*out_font)->get_cache(run->size) : nullptr; +} + +static void wp_free_runs(WordProcessorState* wp) { + for (int i = 0; i < wp->run_count; i++) + wp_free_run(&wp->runs[i]); + wp->run_count = 0; + wp->total_text_len = 0; +} + +static void wp_merge_adjacent(WordProcessorState* wp) { + int i = 0; + while (i < wp->run_count - 1) { + StyledRun* a = &wp->runs[i]; + StyledRun* b = &wp->runs[i + 1]; + if (a->font_id == b->font_id && a->size == b->size && a->flags == b->flags) { + int old_a_len = a->len; + wp_ensure_run_cap(a, b->len); + montauk::memcpy(a->text + a->len, b->text, b->len); + a->len += b->len; + + if (wp->cursor_run == i + 1) { + wp->cursor_run = i; + wp->cursor_offset = old_a_len + wp->cursor_offset; + } else if (wp->cursor_run > i + 1) { + wp->cursor_run--; + } + + wp_free_run(b); + for (int j = i + 1; j < wp->run_count - 1; j++) + wp->runs[j] = wp->runs[j + 1]; + wp->run_count--; + } else { + i++; + } + } + + i = 0; + while (i < wp->run_count && wp->run_count > 1) { + if (wp->runs[i].len == 0) { + if (wp->cursor_run == i) { + if (i < wp->run_count - 1) { + wp->cursor_run = i; + wp->cursor_offset = 0; + } else { + wp->cursor_run = i - 1; + wp->cursor_offset = wp->runs[i - 1].len; + } + } else if (wp->cursor_run > i) { + wp->cursor_run--; + } + wp_free_run(&wp->runs[i]); + for (int j = i; j < wp->run_count - 1; j++) + wp->runs[j] = wp->runs[j + 1]; + wp->run_count--; + } else { + i++; + } + } +} + +static int wp_split_at(WordProcessorState* wp, int abs_pos) { + int r, o; + wp_pos_to_run(wp, abs_pos, &r, &o); + if (o == 0) return r; + if (o >= wp->runs[r].len) return r + 1; + if (wp->run_count >= WP_MAX_RUNS) return r; + + StyledRun* src = &wp->runs[r]; + int after_len = src->len - o; + + StyledRun after; + wp_init_run(&after, src->font_id, src->size, src->flags); + wp_ensure_run_cap(&after, after_len); + montauk::memcpy(after.text, src->text + o, after_len); + after.len = after_len; + + src->len = o; + + for (int i = wp->run_count - 1; i > r; i--) + wp->runs[i + 1] = wp->runs[i]; + wp->runs[r + 1] = after; + wp->run_count++; + + if (wp->cursor_run == r && wp->cursor_offset > o) { + wp->cursor_run = r + 1; + wp->cursor_offset -= o; + } else if (wp->cursor_run > r) { + wp->cursor_run++; + } + + return r + 1; +} + +void wp_load_fonts() { + if (g_wp_fonts.loaded) return; + + auto load = [](const char* path) -> TrueTypeFont* { + TrueTypeFont* f = (TrueTypeFont*)montauk::malloc(sizeof(TrueTypeFont)); + montauk::memset(f, 0, sizeof(TrueTypeFont)); + if (!f->init(path)) { + montauk::mfree(f); + return nullptr; + } + return f; + }; + + g_wp_fonts.fonts[FONT_ROBOTO][0] = fonts::system_font ? fonts::system_font : load("0:/fonts/Roboto-Medium.ttf"); + g_wp_fonts.fonts[FONT_ROBOTO][1] = fonts::system_bold ? fonts::system_bold : load("0:/fonts/Roboto-Bold.ttf"); + g_wp_fonts.fonts[FONT_ROBOTO][2] = load("0:/fonts/Roboto-Italic.ttf"); + g_wp_fonts.fonts[FONT_ROBOTO][3] = load("0:/fonts/Roboto-BoldItalic.ttf"); + + g_wp_fonts.fonts[FONT_NOTOSERIF][0] = load("0:/fonts/NotoSerif-Regular.ttf"); + g_wp_fonts.fonts[FONT_NOTOSERIF][1] = load("0:/fonts/NotoSerif-SemiBold.ttf"); + g_wp_fonts.fonts[FONT_NOTOSERIF][2] = load("0:/fonts/NotoSerif-Italic.ttf"); + g_wp_fonts.fonts[FONT_NOTOSERIF][3] = load("0:/fonts/NotoSerif-BoldItalic.ttf"); + + g_wp_fonts.fonts[FONT_C059][0] = load("0:/fonts/C059-Roman.ttf"); + g_wp_fonts.fonts[FONT_C059][1] = load("0:/fonts/C059-Bold.ttf"); + g_wp_fonts.fonts[FONT_C059][2] = load("0:/fonts/C059-Italic.ttf"); + g_wp_fonts.fonts[FONT_C059][3] = load("0:/fonts/C059-Bold.ttf"); + + g_ui_font = g_wp_fonts.fonts[FONT_ROBOTO][0]; + g_ui_bold = g_wp_fonts.fonts[FONT_ROBOTO][1] ? g_wp_fonts.fonts[FONT_ROBOTO][1] : g_ui_font; + g_wp_fonts.loaded = true; +} + +TrueTypeFont* wp_get_font(int font_id, uint8_t flags) { + if (font_id < 0 || font_id >= FONT_COUNT) font_id = 0; + + int variant = 0; + if ((flags & STYLE_BOLD) && (flags & STYLE_ITALIC)) variant = 3; + else if (flags & STYLE_BOLD) variant = 1; + else if (flags & STYLE_ITALIC) variant = 2; + + TrueTypeFont* f = g_wp_fonts.fonts[font_id][variant]; + if (f && f->valid) return f; + + f = g_wp_fonts.fonts[font_id][0]; + if (f && f->valid) return f; + + return g_ui_font ? g_ui_font : fonts::system_font; +} + +void wp_init_empty_document(WordProcessorState* wp) { + wp_init_run(&wp->runs[0], FONT_ROBOTO, WP_DEFAULT_SIZE, 0); + wp->run_count = 1; + wp->total_text_len = 0; + + wp->cursor_run = 0; + wp->cursor_offset = 0; + + wp->sel_anchor = 0; + wp->sel_end = 0; + wp->has_selection = false; + wp->mouse_selecting = false; + + wp->cur_font_id = FONT_ROBOTO; + wp->cur_size = WP_DEFAULT_SIZE; + wp->cur_flags = 0; + + wp->scrollbar.init(0, 0, WP_SCROLLBAR_W, 100); + wp->content_height = 0; + + wp->wrap_lines = nullptr; + wp->wrap_line_count = 0; + wp->wrap_dirty = true; + wp->last_wrap_width = 0; + + wp->modified = false; + wp->filepath[0] = '\0'; + wp->filename[0] = '\0'; + + wp->show_pathbar = false; + wp->pathbar_save_mode = false; + wp->pathbar_text[0] = '\0'; + wp->pathbar_cursor = 0; + wp->pathbar_len = 0; + + wp->font_dropdown_open = false; + wp->size_dropdown_open = false; +} + +void wp_free_document(WordProcessorState* wp) { + wp_free_runs(wp); + if (wp->wrap_lines) { + montauk::mfree(wp->wrap_lines); + wp->wrap_lines = nullptr; + } + wp->wrap_line_count = 0; +} + +int wp_abs_pos(WordProcessorState* wp, int run, int offset) { + int pos = 0; + for (int i = 0; i < run && i < wp->run_count; i++) + pos += wp->runs[i].len; + pos += offset; + return pos; +} + +void wp_pos_to_run(WordProcessorState* wp, int abs_pos, int* out_run, int* out_offset) { + if (abs_pos <= 0) { + *out_run = 0; + *out_offset = 0; + return; + } + + int pos = 0; + for (int i = 0; i < wp->run_count; i++) { + if (pos + wp->runs[i].len >= abs_pos || i == wp->run_count - 1) { + *out_run = i; + *out_offset = abs_pos - pos; + if (*out_offset > wp->runs[i].len) *out_offset = wp->runs[i].len; + return; + } + pos += wp->runs[i].len; + } + + *out_run = 0; + *out_offset = 0; +} + +char wp_char_at(WordProcessorState* wp, int abs_pos) { + int r, o; + wp_pos_to_run(wp, abs_pos, &r, &o); + if (r < wp->run_count && o < wp->runs[r].len) + return wp->runs[r].text[o]; + return '\0'; +} + +void wp_insert_char(WordProcessorState* wp, char c) { + if (wp->total_text_len >= WP_MAX_TEXT - 1) return; + + StyledRun* cur = &wp->runs[wp->cursor_run]; + if (wp_same_style(cur, wp->cur_font_id, wp->cur_size, wp->cur_flags)) { + wp_ensure_run_cap(cur, 1); + for (int i = cur->len; i > wp->cursor_offset; i--) + cur->text[i] = cur->text[i - 1]; + cur->text[wp->cursor_offset] = c; + cur->len++; + wp->cursor_offset++; + wp->total_text_len++; + wp->modified = true; + wp->wrap_dirty = true; + return; + } + + if (wp->cursor_offset == 0) { + if (wp->run_count >= WP_MAX_RUNS) return; + for (int i = wp->run_count; i > wp->cursor_run; i--) + wp->runs[i] = wp->runs[i - 1]; + wp->run_count++; + StyledRun* nr = &wp->runs[wp->cursor_run]; + wp_init_run(nr, wp->cur_font_id, wp->cur_size, wp->cur_flags); + wp_ensure_run_cap(nr, 1); + nr->text[0] = c; + nr->len = 1; + wp->cursor_offset = 1; + wp->total_text_len++; + } else if (wp->cursor_offset == cur->len) { + if (wp->run_count >= WP_MAX_RUNS) return; + int insert_idx = wp->cursor_run + 1; + for (int i = wp->run_count; i > insert_idx; i--) + wp->runs[i] = wp->runs[i - 1]; + wp->run_count++; + StyledRun* nr = &wp->runs[insert_idx]; + wp_init_run(nr, wp->cur_font_id, wp->cur_size, wp->cur_flags); + wp_ensure_run_cap(nr, 1); + nr->text[0] = c; + nr->len = 1; + wp->cursor_run = insert_idx; + wp->cursor_offset = 1; + wp->total_text_len++; + } else { + if (wp->run_count + 2 > WP_MAX_RUNS) return; + int split_at = wp->cursor_offset; + int after_len = cur->len - split_at; + + StyledRun after; + wp_init_run(&after, cur->font_id, cur->size, cur->flags); + wp_ensure_run_cap(&after, after_len); + montauk::memcpy(after.text, cur->text + split_at, after_len); + after.len = after_len; + + cur->len = split_at; + + int new_idx = wp->cursor_run + 1; + for (int i = wp->run_count - 1; i >= new_idx; i--) + wp->runs[i + 2] = wp->runs[i]; + + StyledRun* nr = &wp->runs[new_idx]; + wp_init_run(nr, wp->cur_font_id, wp->cur_size, wp->cur_flags); + wp_ensure_run_cap(nr, 1); + nr->text[0] = c; + nr->len = 1; + + wp->runs[new_idx + 1] = after; + wp->run_count += 2; + wp->cursor_run = new_idx; + wp->cursor_offset = 1; + wp->total_text_len++; + } + + wp->modified = true; + wp->wrap_dirty = true; +} + +void wp_backspace(WordProcessorState* wp) { + if (wp->cursor_run == 0 && wp->cursor_offset == 0) return; + + if (wp->cursor_offset > 0) { + StyledRun* r = &wp->runs[wp->cursor_run]; + for (int i = wp->cursor_offset - 1; i < r->len - 1; i++) + r->text[i] = r->text[i + 1]; + r->len--; + wp->cursor_offset--; + } else { + wp->cursor_run--; + StyledRun* r = &wp->runs[wp->cursor_run]; + if (r->len > 0) r->len--; + wp->cursor_offset = r->len; + } + + wp->total_text_len--; + wp->modified = true; + wp->wrap_dirty = true; + wp_merge_adjacent(wp); +} + +void wp_delete_char(WordProcessorState* wp) { + int abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); + if (abs >= wp->total_text_len) return; + + StyledRun* r = &wp->runs[wp->cursor_run]; + if (wp->cursor_offset < r->len) { + for (int i = wp->cursor_offset; i < r->len - 1; i++) + r->text[i] = r->text[i + 1]; + r->len--; + } else if (wp->cursor_run + 1 < wp->run_count) { + StyledRun* next = &wp->runs[wp->cursor_run + 1]; + if (next->len > 0) { + for (int i = 0; i < next->len - 1; i++) + next->text[i] = next->text[i + 1]; + next->len--; + } + } + + wp->total_text_len--; + wp->modified = true; + wp->wrap_dirty = true; + wp_merge_adjacent(wp); +} + +void wp_cursor_left(WordProcessorState* wp) { + if (wp->cursor_offset > 0) { + wp->cursor_offset--; + } else if (wp->cursor_run > 0) { + wp->cursor_run--; + wp->cursor_offset = wp->runs[wp->cursor_run].len; + } +} + +void wp_cursor_right(WordProcessorState* wp) { + if (wp->cursor_offset < wp->runs[wp->cursor_run].len) { + wp->cursor_offset++; + } else if (wp->cursor_run + 1 < wp->run_count) { + wp->cursor_run++; + wp->cursor_offset = 0; + } +} + +void wp_clear_selection(WordProcessorState* wp) { + wp->has_selection = false; + wp->sel_anchor = 0; + wp->sel_end = 0; +} + +void wp_sel_range(WordProcessorState* wp, int* out_start, int* out_end) { + if (wp->sel_anchor < wp->sel_end) { + *out_start = wp->sel_anchor; + *out_end = wp->sel_end; + } else { + *out_start = wp->sel_end; + *out_end = wp->sel_anchor; + } +} + +void wp_start_selection(WordProcessorState* wp) { + if (!wp->has_selection) { + wp->sel_anchor = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); + wp->sel_end = wp->sel_anchor; + wp->has_selection = true; + } +} + +void wp_update_selection_to_cursor(WordProcessorState* wp) { + wp->sel_end = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); + if (wp->sel_anchor == wp->sel_end) wp->has_selection = false; +} + +void wp_apply_style_to_selection(WordProcessorState* wp, int mode, int value) { + if (!wp->has_selection) return; + + int sel_s, sel_e; + wp_sel_range(wp, &sel_s, &sel_e); + if (sel_s >= sel_e) return; + + int end_ri = wp_split_at(wp, sel_e); + int start_ri = wp_split_at(wp, sel_s); + + { + int pos = 0; + end_ri = wp->run_count; + for (int i = 0; i < wp->run_count; i++) { + if (pos >= sel_e) { + end_ri = i; + break; + } + pos += wp->runs[i].len; + } + } + + for (int i = start_ri; i < end_ri && i < wp->run_count; i++) { + StyledRun* r = &wp->runs[i]; + switch (mode) { + case 0: r->font_id = (uint8_t)value; break; + case 1: r->size = (uint8_t)value; break; + case 2: r->flags ^= STYLE_BOLD; break; + case 3: r->flags ^= STYLE_ITALIC; break; + } + } + + wp->wrap_dirty = true; + wp->modified = true; + wp_merge_adjacent(wp); +} + +void wp_delete_selection(WordProcessorState* wp) { + if (!wp->has_selection) return; + + int sel_s, sel_e; + wp_sel_range(wp, &sel_s, &sel_e); + int count = sel_e - sel_s; + if (count <= 0) { + wp_clear_selection(wp); + return; + } + + wp_pos_to_run(wp, sel_s, &wp->cursor_run, &wp->cursor_offset); + for (int i = 0; i < count; i++) + wp_delete_char(wp); + + wp_clear_selection(wp); +} + +void wp_recompute_wrap(WordProcessorState* wp, int content_w) { + int wrap_width = content_w - WP_MARGIN * 2 - WP_SCROLLBAR_W; + if (wrap_width < 50) wrap_width = 50; + + if (wrap_width != wp->last_wrap_width) { + wp->wrap_dirty = true; + wp->last_wrap_width = wrap_width; + } + + if (!wp->wrap_dirty && wp->wrap_lines) return; + + if (!wp->wrap_lines) + wp->wrap_lines = (WrapLine*)montauk::malloc(WP_MAX_WRAP_LINES * sizeof(WrapLine)); + + wp->wrap_line_count = 0; + int total = wp->total_text_len; + int y = WP_MARGIN; + int pos = 0; + + while (pos < total && wp->wrap_line_count < WP_MAX_WRAP_LINES) { + WrapLine* line = &wp->wrap_lines[wp->wrap_line_count]; + wp_pos_to_run(wp, pos, &line->run_idx, &line->run_offset); + line->char_count = 0; + line->y = y; + + int x = 0; + int last_space = -1; + int line_start = pos; + int max_ascent = 0; + int max_height = 0; + + int scan = pos; + while (scan < total) { + char ch = wp_char_at(wp, scan); + + TrueTypeFont* font; + GlyphCache* gc; + wp_font_at(wp, scan, &font, &gc); + if (gc) { + if (gc->ascent > max_ascent) max_ascent = gc->ascent; + if (gc->line_height > max_height) max_height = gc->line_height; + } + + if (ch == '\n') { + scan++; + break; + } + + int cw = 8; + if (font && gc) { + CachedGlyph* g = font->get_glyph(gc, (unsigned char)ch); + cw = g ? g->advance : 8; + } + + if (x + cw > wrap_width && scan > line_start) { + if (last_space >= line_start) scan = last_space + 1; + break; + } + + if (ch == ' ') last_space = scan; + x += cw; + scan++; + } + + line->char_count = scan - line_start; + + if (max_height == 0) { + TrueTypeFont* df = wp_get_font(wp->cur_font_id, 0); + if (df && df->valid) { + GlyphCache* dgc = df->get_cache(wp->cur_size); + max_height = dgc->line_height; + max_ascent = dgc->ascent; + } else { + max_height = WP_DEFAULT_SIZE; + max_ascent = WP_DEFAULT_SIZE; + } + } + + max_ascent = 0; + max_height = 0; + { + int ri, ro; + wp_pos_to_run(wp, line_start, &ri, &ro); + int left = line->char_count; + while (left > 0 && ri < wp->run_count) { + StyledRun* r = &wp->runs[ri]; + TrueTypeFont* font = wp_get_font(r->font_id, r->flags); + if (font && font->valid) { + GlyphCache* gc = font->get_cache(r->size); + if (gc->ascent > max_ascent) max_ascent = gc->ascent; + if (gc->line_height > max_height) max_height = gc->line_height; + } + int avail = r->len - ro; + int consume = avail < left ? avail : left; + left -= consume; + ro += consume; + if (ro >= r->len) { + ri++; + ro = 0; + } + } + if (max_height == 0) { + TrueTypeFont* df = wp_get_font(wp->cur_font_id, 0); + if (df && df->valid) { + GlyphCache* dgc = df->get_cache(wp->cur_size); + max_height = dgc->line_height; + max_ascent = dgc->ascent; + } else { + max_height = WP_DEFAULT_SIZE; + max_ascent = WP_DEFAULT_SIZE; + } + } + } + + line->height = max_height; + line->baseline = max_ascent; + wp->wrap_line_count++; + + y += max_height; + pos = line_start + line->char_count; + + if (line->char_count == 0 && pos < total) + pos++; + } + + if (total > 0 && wp_char_at(wp, total - 1) == '\n' && wp->wrap_line_count < WP_MAX_WRAP_LINES) { + WrapLine* line = &wp->wrap_lines[wp->wrap_line_count]; + wp_pos_to_run(wp, total, &line->run_idx, &line->run_offset); + line->char_count = 0; + line->y = y; + + TrueTypeFont* df = wp_get_font(wp->cur_font_id, wp->cur_flags); + if (df && df->valid) { + GlyphCache* dgc = df->get_cache(wp->cur_size); + line->height = dgc->line_height; + line->baseline = dgc->ascent; + } else { + line->height = WP_DEFAULT_SIZE; + line->baseline = WP_DEFAULT_SIZE; + } + + wp->wrap_line_count++; + y += line->height; + } + + if (wp->wrap_line_count == 0) { + WrapLine* line = &wp->wrap_lines[0]; + line->run_idx = 0; + line->run_offset = 0; + line->char_count = 0; + line->y = WP_MARGIN; + + TrueTypeFont* df = wp_get_font(wp->cur_font_id, 0); + if (df && df->valid) { + GlyphCache* dgc = df->get_cache(wp->cur_size); + line->height = dgc->line_height; + line->baseline = dgc->ascent; + } else { + line->height = WP_DEFAULT_SIZE; + line->baseline = WP_DEFAULT_SIZE; + } + + wp->wrap_line_count = 1; + y = WP_MARGIN + line->height; + } + + wp->content_height = y + WP_MARGIN; + wp->wrap_dirty = false; +} + +int wp_find_wrap_line(WordProcessorState* wp, int abs_pos) { + int pos = 0; + for (int i = 0; i < wp->wrap_line_count; i++) { + int next_pos = pos + wp->wrap_lines[i].char_count; + if (abs_pos < next_pos || i == wp->wrap_line_count - 1) + return i; + pos = next_pos; + } + return 0; +} + +int wp_wrap_line_start(WordProcessorState* wp, int line_idx) { + int pos = 0; + for (int i = 0; i < line_idx && i < wp->wrap_line_count; i++) + pos += wp->wrap_lines[i].char_count; + return pos; +} + +void wp_cursor_up(WordProcessorState* wp) { + int abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); + int line = wp_find_wrap_line(wp, abs); + if (line <= 0) return; + + int col = abs - wp_wrap_line_start(wp, line); + int prev_start = wp_wrap_line_start(wp, line - 1); + int prev_len = wp->wrap_lines[line - 1].char_count; + int new_col = col < prev_len ? col : prev_len; + if (new_col > 0 && new_col == prev_len) { + char ch = wp_char_at(wp, prev_start + prev_len - 1); + if (ch == '\n') new_col = prev_len - 1; + } + wp_pos_to_run(wp, prev_start + new_col, &wp->cursor_run, &wp->cursor_offset); +} + +void wp_cursor_down(WordProcessorState* wp) { + int abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); + int line = wp_find_wrap_line(wp, abs); + if (line >= wp->wrap_line_count - 1) return; + + int col = abs - wp_wrap_line_start(wp, line); + int next_start = wp_wrap_line_start(wp, line + 1); + int next_len = wp->wrap_lines[line + 1].char_count; + int new_col = col < next_len ? col : next_len; + if (new_col > 0 && new_col == next_len && line + 1 < wp->wrap_line_count - 1) { + char ch = wp_char_at(wp, next_start + next_len - 1); + if (ch == '\n') new_col = next_len - 1; + } + wp_pos_to_run(wp, next_start + new_col, &wp->cursor_run, &wp->cursor_offset); +} + +void wp_ensure_cursor_visible(WordProcessorState* wp, int view_h) { + int abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); + int line = wp_find_wrap_line(wp, abs); + if (line < 0 || line >= wp->wrap_line_count) return; + + int cy = wp->wrap_lines[line].y; + int ch = wp->wrap_lines[line].height; + + if (cy < wp->scrollbar.scroll_offset) { + wp->scrollbar.scroll_offset = cy - WP_MARGIN; + if (wp->scrollbar.scroll_offset < 0) wp->scrollbar.scroll_offset = 0; + } + if (cy + ch > wp->scrollbar.scroll_offset + view_h) { + wp->scrollbar.scroll_offset = cy + ch - view_h + WP_MARGIN; + } + + int ms = wp->scrollbar.max_scroll(); + if (wp->scrollbar.scroll_offset > ms) wp->scrollbar.scroll_offset = ms; + if (wp->scrollbar.scroll_offset < 0) wp->scrollbar.scroll_offset = 0; +} + +void wp_set_filepath(WordProcessorState* wp, const char* path) { + montauk::strncpy(wp->filepath, path, 255); + int last_slash = -1; + for (int i = 0; path[i]; i++) + if (path[i] == '/') last_slash = i; + if (last_slash >= 0) + montauk::strncpy(wp->filename, path + last_slash + 1, 63); + else + montauk::strncpy(wp->filename, path, 63); +} + +void wp_open_save_pathbar(WordProcessorState* wp) { + wp->show_pathbar = true; + wp->pathbar_save_mode = true; + montauk::strncpy(wp->pathbar_text, wp->filepath, 255); + wp->pathbar_len = montauk::slen(wp->pathbar_text); + wp->pathbar_cursor = wp->pathbar_len; +} + +void wp_save_file(WordProcessorState* wp) { + if (wp->filepath[0] == '\0') { + wp_open_save_pathbar(wp); + return; + } + + int size = 4 + 2 + 1 + 1 + 2; + for (int i = 0; i < wp->run_count; i++) + size += 2 + 1 + 1 + 1 + 1 + wp->runs[i].len; + + uint8_t* buf = (uint8_t*)montauk::malloc(size); + int off = 0; + + buf[off++] = 'M'; buf[off++] = 'W'; buf[off++] = 'P'; buf[off++] = '1'; + buf[off++] = 1; buf[off++] = 0; + buf[off++] = wp->cur_font_id; + buf[off++] = wp->cur_size; + buf[off++] = (uint8_t)(wp->run_count & 0xFF); + buf[off++] = (uint8_t)((wp->run_count >> 8) & 0xFF); + + for (int i = 0; i < wp->run_count; i++) { + StyledRun* r = &wp->runs[i]; + buf[off++] = (uint8_t)(r->len & 0xFF); + buf[off++] = (uint8_t)((r->len >> 8) & 0xFF); + buf[off++] = r->font_id; + buf[off++] = r->size; + buf[off++] = r->flags; + buf[off++] = 0; + montauk::memcpy(buf + off, r->text, r->len); + off += r->len; + } + + int fd = montauk::fcreate(wp->filepath); + if (fd >= 0) { + montauk::fwrite(fd, buf, 0, off); + montauk::close(fd); + wp->modified = false; + } + + montauk::mfree(buf); +} + +void wp_load_file(WordProcessorState* wp, const char* path) { + int fd = montauk::open(path); + if (fd < 0) return; + + uint64_t fsize = montauk::getsize(fd); + if (fsize < 10 || fsize > WP_MAX_TEXT * 2) { + montauk::close(fd); + return; + } + + uint8_t* buf = (uint8_t*)montauk::malloc((int)fsize); + montauk::read(fd, buf, 0, fsize); + montauk::close(fd); + + if (buf[0] != 'M' || buf[1] != 'W' || buf[2] != 'P' || buf[3] != '1') { + montauk::mfree(buf); + return; + } + + wp_free_runs(wp); + + int off = 4; + off += 2; + wp->cur_font_id = buf[off++]; + wp->cur_size = buf[off++]; + + int run_count = buf[off] | (buf[off + 1] << 8); + off += 2; + + for (int i = 0; i < run_count && off < (int)fsize && i < WP_MAX_RUNS; i++) { + if (off + 6 > (int)fsize) break; + int text_len = buf[off] | (buf[off + 1] << 8); + off += 2; + uint8_t font_id = buf[off++]; + uint8_t size = buf[off++]; + uint8_t flags = buf[off++]; + off++; + + if (off + text_len > (int)fsize) break; + + StyledRun* r = &wp->runs[wp->run_count]; + wp_init_run(r, font_id, size, flags); + wp_ensure_run_cap(r, text_len); + montauk::memcpy(r->text, buf + off, text_len); + r->len = text_len; + off += text_len; + + wp->total_text_len += text_len; + wp->run_count++; + } + + if (wp->run_count == 0) { + wp_init_run(&wp->runs[0], wp->cur_font_id, wp->cur_size, 0); + wp->run_count = 1; + } + + wp->cursor_run = 0; + wp->cursor_offset = 0; + wp->scrollbar.scroll_offset = 0; + wp->modified = false; + wp->wrap_dirty = true; + wp->sel_anchor = 0; + wp->sel_end = 0; + wp->has_selection = false; + wp->mouse_selecting = false; + wp->font_dropdown_open = false; + wp->size_dropdown_open = false; + + wp_set_filepath(wp, path); + montauk::mfree(buf); +} diff --git a/programs/src/wordprocessor/input.cpp b/programs/src/wordprocessor/input.cpp new file mode 100644 index 0000000..e245491 --- /dev/null +++ b/programs/src/wordprocessor/input.cpp @@ -0,0 +1,378 @@ +/* + * input.cpp + * Input handling + * Copyright (c) 2026 Daniel Hammer + */ + +#include "wordprocessor.hpp" + +static void wp_open_pathbar_for_open(WordProcessorState* wp) { + wp->show_pathbar = !wp->show_pathbar; + wp->pathbar_save_mode = false; + if (wp->show_pathbar) { + montauk::strncpy(wp->pathbar_text, wp->filepath, 255); + wp->pathbar_len = montauk::slen(wp->pathbar_text); + wp->pathbar_cursor = wp->pathbar_len; + } +} + +static void wp_commit_pathbar(WordProcessorState* wp) { + if (!wp->pathbar_text[0]) return; + if (wp->pathbar_save_mode) { + wp_set_filepath(wp, wp->pathbar_text); + wp_save_file(wp); + } else { + wp_load_file(wp, wp->pathbar_text); + } + wp->show_pathbar = false; +} + +static int wp_hit_test_text(WordProcessorState* wp, int local_x, int local_y, int edit_y) { + int click_y = local_y - edit_y + wp->scrollbar.scroll_offset; + int click_x = local_x - WP_MARGIN; + + int target_line = wp->wrap_line_count - 1; + for (int i = 0; i < wp->wrap_line_count; i++) { + if (click_y >= wp->wrap_lines[i].y && + click_y < wp->wrap_lines[i].y + wp->wrap_lines[i].height) { + target_line = i; + break; + } + } + + WrapLine* wl = &wp->wrap_lines[target_line]; + int ri = wl->run_idx; + int ro = wl->run_offset; + int chars_left = wl->char_count; + int x = 0; + int best_abs = wp_wrap_line_start(wp, target_line); + + while (chars_left > 0 && ri < wp->run_count) { + StyledRun* r = &wp->runs[ri]; + TrueTypeFont* font = wp_get_font(r->font_id, r->flags); + if (!font || !font->valid) { + ri++; + ro = 0; + continue; + } + + GlyphCache* gc = font->get_cache(r->size); + int avail = r->len - ro; + int to_check = avail < chars_left ? avail : chars_left; + + for (int ci = 0; ci < to_check; ci++) { + char ch = r->text[ro + ci]; + CachedGlyph* g = (ch >= 32 || ch < 0) && ch != '\n' + ? font->get_glyph(gc, (unsigned char)ch) : nullptr; + int char_w = g ? g->advance : 0; + + if (x + char_w / 2 > click_x) return best_abs; + x += char_w; + best_abs++; + } + + chars_left -= to_check; + ro += to_check; + if (ro >= r->len) { + ri++; + ro = 0; + } + } + + return best_abs; +} + +void wp_handle_mouse(const Montauk::WinEvent& ev) { + WordProcessorState* wp = &g_wp; + int local_x = ev.mouse.x; + int local_y = ev.mouse.y; + int edit_y = WP_TOOLBAR_H + (wp->show_pathbar ? WP_PATHBAR_H : 0); + int text_area_h = g_win_h - edit_y - WP_STATUS_H; + + wp->scrollbar.handle_mouse(local_x, local_y, ev.mouse.buttons, ev.mouse.prev_buttons, ev.mouse.scroll); + + if (wp->font_dropdown_open && wp_left_pressed(ev.mouse.buttons, ev.mouse.prev_buttons)) { + int dx = 128; + int dy = WP_TOOLBAR_H; + int dh = FONT_COUNT * 26 + 4; + if (local_x >= dx && local_x < dx + 110 && local_y >= dy && local_y < dy + dh) { + int idx = (local_y - dy - 2) / 26; + if (idx >= 0 && idx < FONT_COUNT) { + if (wp->has_selection) wp_apply_style_to_selection(wp, 0, idx); + wp->cur_font_id = (uint8_t)idx; + } + } + wp->font_dropdown_open = false; + return; + } + + if (wp->size_dropdown_open && wp_left_pressed(ev.mouse.buttons, ev.mouse.prev_buttons)) { + int dx = 224; + int dy = WP_TOOLBAR_H; + int dh = WP_SIZE_OPTION_COUNT * 26 + 4; + if (local_x >= dx && local_x < dx + 56 && local_y >= dy && local_y < dy + dh) { + int idx = (local_y - dy - 2) / 26; + if (idx >= 0 && idx < WP_SIZE_OPTION_COUNT) { + if (wp->has_selection) wp_apply_style_to_selection(wp, 1, WP_SIZE_OPTIONS[idx]); + wp->cur_size = (uint8_t)WP_SIZE_OPTIONS[idx]; + wp->wrap_dirty = true; + } + } + wp->size_dropdown_open = false; + return; + } + + if (wp_left_pressed(ev.mouse.buttons, ev.mouse.prev_buttons) && local_y < WP_TOOLBAR_H) { + if (local_x >= 4 && local_x < 28 && local_y >= 6 && local_y < 30) { + wp_open_pathbar_for_open(wp); + return; + } + if (local_x >= 32 && local_x < 56 && local_y >= 6 && local_y < 30) { + wp_save_file(wp); + return; + } + if (local_x >= 66 && local_x < 90 && local_y >= 6 && local_y < 30) { + if (wp->has_selection) wp_apply_style_to_selection(wp, 2, 0); + wp->cur_flags ^= STYLE_BOLD; + return; + } + if (local_x >= 94 && local_x < 118 && local_y >= 6 && local_y < 30) { + if (wp->has_selection) wp_apply_style_to_selection(wp, 3, 0); + wp->cur_flags ^= STYLE_ITALIC; + return; + } + if (local_x >= 128 && local_x < 218 && local_y >= 6 && local_y < 30) { + wp->font_dropdown_open = !wp->font_dropdown_open; + wp->size_dropdown_open = false; + return; + } + if (local_x >= 224 && local_x < 268 && local_y >= 6 && local_y < 30) { + wp->size_dropdown_open = !wp->size_dropdown_open; + wp->font_dropdown_open = false; + return; + } + if (local_x >= 278 && local_x < 302 && local_y >= 6 && local_y < 30) { + wp_insert_char(wp, (char)0xA7); + return; + } + return; + } + + if (wp->show_pathbar && local_y >= WP_TOOLBAR_H && local_y < WP_TOOLBAR_H + WP_PATHBAR_H) { + if (wp_left_pressed(ev.mouse.buttons, ev.mouse.prev_buttons)) { + int btn_w = 56; + int inp_w = g_win_w - 8 - btn_w - 12; + int btn_x = 8 + inp_w + 6; + if (local_x >= btn_x && local_x < btn_x + btn_w) { + wp_commit_pathbar(wp); + } + } + return; + } + + wp_recompute_wrap(wp, g_win_w); + + if (wp_left_pressed(ev.mouse.buttons, ev.mouse.prev_buttons) && + local_y >= edit_y && local_y < edit_y + text_area_h) { + wp->font_dropdown_open = false; + wp->size_dropdown_open = false; + + int abs = wp_hit_test_text(wp, local_x, local_y, edit_y); + wp_pos_to_run(wp, abs, &wp->cursor_run, &wp->cursor_offset); + wp->sel_anchor = abs; + wp->sel_end = abs; + wp->has_selection = false; + wp->mouse_selecting = true; + return; + } + + if (wp->mouse_selecting && wp_left_held(ev.mouse.buttons) && local_y >= edit_y - 20) { + int abs = wp_hit_test_text(wp, local_x, local_y, edit_y); + wp->sel_end = abs; + wp_pos_to_run(wp, abs, &wp->cursor_run, &wp->cursor_offset); + wp->has_selection = (wp->sel_anchor != wp->sel_end); + return; + } + + if (wp->mouse_selecting && wp_left_released(ev.mouse.buttons, ev.mouse.prev_buttons)) { + wp->mouse_selecting = false; + return; + } + + if (ev.mouse.scroll != 0 && local_y >= edit_y && local_y < edit_y + text_area_h) { + wp->scrollbar.scroll_offset -= ev.mouse.scroll * 40; + int ms = wp->scrollbar.max_scroll(); + if (wp->scrollbar.scroll_offset < 0) wp->scrollbar.scroll_offset = 0; + if (wp->scrollbar.scroll_offset > ms) wp->scrollbar.scroll_offset = ms; + } +} + +void wp_handle_key(const Montauk::KeyEvent& key) { + WordProcessorState* wp = &g_wp; + if (!key.pressed) return; + + if (wp->show_pathbar) { + if (key.ascii == '\n' || key.ascii == '\r') { + wp_commit_pathbar(wp); + return; + } + if (key.scancode == 0x01) { + wp->show_pathbar = false; + return; + } + if (key.ascii == '\b' || key.scancode == 0x0E) { + if (wp->pathbar_cursor > 0) { + for (int i = wp->pathbar_cursor - 1; i < wp->pathbar_len - 1; i++) + wp->pathbar_text[i] = wp->pathbar_text[i + 1]; + wp->pathbar_len--; + wp->pathbar_cursor--; + wp->pathbar_text[wp->pathbar_len] = '\0'; + } + return; + } + if (key.scancode == 0x4B) { + if (wp->pathbar_cursor > 0) wp->pathbar_cursor--; + return; + } + if (key.scancode == 0x4D) { + if (wp->pathbar_cursor < wp->pathbar_len) wp->pathbar_cursor++; + return; + } + if (key.ascii >= 32 && key.ascii < 127 && wp->pathbar_len < 254) { + for (int i = wp->pathbar_len; i > wp->pathbar_cursor; i--) + wp->pathbar_text[i] = wp->pathbar_text[i - 1]; + wp->pathbar_text[wp->pathbar_cursor] = key.ascii; + wp->pathbar_cursor++; + wp->pathbar_len++; + wp->pathbar_text[wp->pathbar_len] = '\0'; + } + return; + } + + wp->font_dropdown_open = false; + wp->size_dropdown_open = false; + wp_recompute_wrap(wp, g_win_w); + + if (key.ctrl && (key.ascii == 's' || key.ascii == 'S')) { + wp_save_file(wp); + return; + } + if (key.ctrl && (key.ascii == 'o' || key.ascii == 'O')) { + wp_open_pathbar_for_open(wp); + return; + } + if (key.ctrl && (key.ascii == 'b' || key.ascii == 'B')) { + if (wp->has_selection) wp_apply_style_to_selection(wp, 2, 0); + wp->cur_flags ^= STYLE_BOLD; + return; + } + if (key.ctrl && (key.ascii == 'i' || key.ascii == 'I')) { + if (wp->has_selection) wp_apply_style_to_selection(wp, 3, 0); + wp->cur_flags ^= STYLE_ITALIC; + return; + } + + if (key.scancode == 0x48) { + if (key.shift) wp_start_selection(wp); + else wp_clear_selection(wp); + wp_cursor_up(wp); + if (key.shift) wp_update_selection_to_cursor(wp); + return; + } + if (key.scancode == 0x50) { + if (key.shift) wp_start_selection(wp); + else wp_clear_selection(wp); + wp_cursor_down(wp); + if (key.shift) wp_update_selection_to_cursor(wp); + return; + } + if (key.scancode == 0x4B) { + if (key.shift) wp_start_selection(wp); + else if (wp->has_selection) { + int s, e; + wp_sel_range(wp, &s, &e); + wp_pos_to_run(wp, s, &wp->cursor_run, &wp->cursor_offset); + wp_clear_selection(wp); + return; + } + wp_cursor_left(wp); + if (key.shift) wp_update_selection_to_cursor(wp); + return; + } + if (key.scancode == 0x4D) { + if (key.shift) wp_start_selection(wp); + else if (wp->has_selection) { + int s, e; + wp_sel_range(wp, &s, &e); + wp_pos_to_run(wp, e, &wp->cursor_run, &wp->cursor_offset); + wp_clear_selection(wp); + return; + } + wp_cursor_right(wp); + if (key.shift) wp_update_selection_to_cursor(wp); + return; + } + + if (key.scancode == 0x47) { + if (key.shift) wp_start_selection(wp); + else wp_clear_selection(wp); + int abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); + int line = wp_find_wrap_line(wp, abs); + int start = wp_wrap_line_start(wp, line); + wp_pos_to_run(wp, start, &wp->cursor_run, &wp->cursor_offset); + if (key.shift) wp_update_selection_to_cursor(wp); + return; + } + if (key.scancode == 0x4F) { + if (key.shift) wp_start_selection(wp); + else wp_clear_selection(wp); + int abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); + int line = wp_find_wrap_line(wp, abs); + int start = wp_wrap_line_start(wp, line); + int end = start + wp->wrap_lines[line].char_count; + if (end > start) { + char ch = wp_char_at(wp, end - 1); + if (ch == '\n') end--; + } + wp_pos_to_run(wp, end, &wp->cursor_run, &wp->cursor_offset); + if (key.shift) wp_update_selection_to_cursor(wp); + return; + } + + if (key.ctrl && (key.ascii == 'a' || key.ascii == 'A')) { + wp->sel_anchor = 0; + wp->sel_end = wp->total_text_len; + wp->has_selection = (wp->total_text_len > 0); + wp_pos_to_run(wp, wp->total_text_len, &wp->cursor_run, &wp->cursor_offset); + return; + } + + if (key.scancode == 0x53) { + if (wp->has_selection) wp_delete_selection(wp); + else wp_delete_char(wp); + return; + } + + if (key.ascii == '\b' || key.scancode == 0x0E) { + if (wp->has_selection) wp_delete_selection(wp); + else wp_backspace(wp); + return; + } + + if (key.ascii == '\n' || key.ascii == '\r') { + if (wp->has_selection) wp_delete_selection(wp); + wp_insert_char(wp, '\n'); + return; + } + + if (key.ascii == '\t') { + if (wp->has_selection) wp_delete_selection(wp); + for (int i = 0; i < 4; i++) wp_insert_char(wp, ' '); + return; + } + + if (key.ascii >= 32 && key.ascii < 127) { + if (wp->has_selection) wp_delete_selection(wp); + wp_insert_char(wp, key.ascii); + } +} diff --git a/programs/src/wordprocessor/main.cpp b/programs/src/wordprocessor/main.cpp new file mode 100644 index 0000000..1cacf2a --- /dev/null +++ b/programs/src/wordprocessor/main.cpp @@ -0,0 +1,99 @@ +/* + * main.cpp + * MontaukOS Word Processor + * Copyright (c) 2026 Daniel Hammer + */ + +#include "wordprocessor.hpp" + +int g_win_w = INIT_W; +int g_win_h = INIT_H; +WsWindow g_win; +WordProcessorState g_wp = {}; +WPFontTable g_wp_fonts = { {{nullptr}}, false }; +SvgIcon g_icon_folder = {}; +SvgIcon g_icon_save = {}; +TrueTypeFont* g_ui_font = nullptr; +TrueTypeFont* g_ui_bold = nullptr; + +void wp_load_icons() { + Color def_color = colors::ICON_COLOR; + if (!g_icon_folder.pixels) + g_icon_folder = svg_load("0:/icons/folder.svg", 16, 16, def_color); + if (!g_icon_save.pixels) + g_icon_save = svg_load("0:/icons/document-save-symbolic.svg", 16, 16, def_color); +} + +void wp_cleanup_state() { + wp_free_document(&g_wp); + if (g_icon_folder.pixels) svg_free(g_icon_folder); + if (g_icon_save.pixels) svg_free(g_icon_save); +} + +extern "C" void _start() { + if (!fonts::init()) + montauk::exit(1); + + wp_load_fonts(); + wp_load_icons(); + wp_init_empty_document(&g_wp); + + char args[512] = {}; + int arglen = montauk::getargs(args, sizeof(args)); + if (arglen > 0 && args[0]) { + wp_load_file(&g_wp, args); + } + + char title[64] = "Word Processor"; + if (g_wp.filename[0]) { + snprintf(title, sizeof(title), "%s - Word Processor", g_wp.filename); + } + + if (!g_win.create(title, INIT_W, INIT_H)) + montauk::exit(1); + + g_win_w = g_win.width; + g_win_h = g_win.height; + + wp_render(); + g_win.present(); + + while (true) { + Montauk::WinEvent ev; + int r = g_win.poll(&ev); + + if (r < 0) break; + + if (r == 0) { + montauk::sleep_ms(16); + continue; + } + + if (ev.type == 3) break; + + if (ev.type == 2 || ev.type == 4) { + g_win_w = g_win.width; + g_win_h = g_win.height; + wp_render(); + g_win.present(); + continue; + } + + if (ev.type == 0 && ev.key.pressed) { + wp_handle_key(ev.key); + wp_render(); + g_win.present(); + continue; + } + + if (ev.type == 1) { + wp_handle_mouse(ev); + wp_render(); + g_win.present(); + } + } + + wp_cleanup_state(); + g_win.destroy(); + montauk::exit(0); +} diff --git a/programs/src/wordprocessor/manifest.toml b/programs/src/wordprocessor/manifest.toml new file mode 100644 index 0000000..ddfebca --- /dev/null +++ b/programs/src/wordprocessor/manifest.toml @@ -0,0 +1,8 @@ +[app] +name = "Word Processor" +binary = "wordprocessor.elf" +icon = "wordprocessor.svg" + +[menu] +category = "Applications" +visible = false diff --git a/programs/src/wordprocessor/render.cpp b/programs/src/wordprocessor/render.cpp new file mode 100644 index 0000000..112b46c --- /dev/null +++ b/programs/src/wordprocessor/render.cpp @@ -0,0 +1,290 @@ +/* + * render.cpp + * Rendering for the standalone MontaukOS Word Processor + * Copyright (c) 2026 Daniel Hammer + */ + +#include "wordprocessor.hpp" + +static int wp_ui_line_height() { + if (g_ui_font && g_ui_font->valid) + return g_ui_font->get_line_height(fonts::UI_SIZE); + return fonts::UI_SIZE; +} + +static void wp_draw_ui_text(Canvas& c, int x, int y, const char* text, Color color) { + draw_text(c, g_ui_font, x, y, text, color, fonts::UI_SIZE); +} + +static void wp_draw_ui_button(Canvas& c, int x, int y, int w, int h, + const char* label, Color bg, Color fg, int radius = 3) { + c.fill_rounded_rect(x, y, w, h, radius, bg); + int tw = text_width(g_ui_font, label, fonts::UI_SIZE); + int th = wp_ui_line_height(); + draw_text(c, g_ui_font, x + (w - tw) / 2, y + (h - th) / 2, label, fg, fonts::UI_SIZE); +} + +void wp_render() { + if (!g_win.pixels) return; + + WordProcessorState* wp = &g_wp; + Canvas c = g_win.canvas(); + c.fill(colors::WINDOW_BG); + + int sfh = wp_ui_line_height(); + Color toolbar_bg = Color::from_rgb(0xF5, 0xF5, 0xF5); + Color btn_bg = Color::from_rgb(0xE8, 0xE8, 0xE8); + Color btn_active = Color::from_rgb(0xC0, 0xD0, 0xE8); + + c.fill_rect(0, 0, c.w, WP_TOOLBAR_H, toolbar_bg); + + c.fill_rounded_rect(4, 6, 24, 24, 3, btn_bg); + if (g_icon_folder.pixels) + c.icon(8, 10, g_icon_folder); + + c.fill_rounded_rect(32, 6, 24, 24, 3, btn_bg); + if (g_icon_save.pixels) + c.icon(36, 10, g_icon_save); + + c.vline(60, 4, 28, colors::BORDER); + + Color bold_bg = (wp->cur_flags & STYLE_BOLD) ? btn_active : btn_bg; + c.fill_rounded_rect(66, 6, 24, 24, 3, bold_bg); + draw_text(c, g_ui_bold ? g_ui_bold : g_ui_font, 73, 8, "B", colors::TEXT_COLOR, fonts::UI_SIZE); + + Color italic_bg = (wp->cur_flags & STYLE_ITALIC) ? btn_active : btn_bg; + c.fill_rounded_rect(94, 6, 24, 24, 3, italic_bg); + { + TrueTypeFont* italic_font = wp_get_font(FONT_ROBOTO, STYLE_ITALIC); + draw_text(c, italic_font ? italic_font : g_ui_font, 103, 8, "I", colors::TEXT_COLOR, fonts::UI_SIZE); + } + + c.vline(122, 4, 28, colors::BORDER); + + c.fill_rounded_rect(128, 6, 90, 24, 3, btn_bg); + wp_draw_ui_text(c, 134, (WP_TOOLBAR_H - sfh) / 2, + WP_FONT_NAMES[wp->cur_font_id < FONT_COUNT ? wp->cur_font_id : 0], + colors::TEXT_COLOR); + + c.fill_rounded_rect(224, 6, 44, 24, 3, btn_bg); + { + char sz[8]; + snprintf(sz, sizeof(sz), "%d", (int)wp->cur_size); + wp_draw_ui_text(c, 232, (WP_TOOLBAR_H - sfh) / 2, sz, colors::TEXT_COLOR); + } + + c.vline(272, 4, 28, colors::BORDER); + + c.fill_rounded_rect(278, 6, 24, 24, 3, btn_bg); + { + char section[2] = { (char)0xA7, '\0' }; + TrueTypeFont* font = wp_get_font(FONT_ROBOTO, 0); + draw_text(c, font ? font : g_ui_font, 284, 8, section, colors::TEXT_COLOR, fonts::UI_SIZE); + } + + { + char label[128]; + if (wp->filename[0]) + snprintf(label, sizeof(label), "%s%s", wp->filename, wp->modified ? " *" : ""); + else + snprintf(label, sizeof(label), "Untitled%s", wp->modified ? " *" : ""); + wp_draw_ui_text(c, 312, (WP_TOOLBAR_H - sfh) / 2, label, Color::from_rgb(0x88, 0x88, 0x88)); + } + + c.hline(0, WP_TOOLBAR_H - 1, c.w, colors::BORDER); + + int edit_y = WP_TOOLBAR_H; + if (wp->show_pathbar) { + int pb_y = WP_TOOLBAR_H; + c.fill_rect(0, pb_y, c.w, WP_PATHBAR_H, Color::from_rgb(0xF0, 0xF0, 0xF0)); + + int inp_x = 8; + int inp_y = pb_y + 4; + int btn_w = 56; + int inp_w = c.w - inp_x - btn_w - 12; + int inp_h = 24; + + c.fill_rect(inp_x, inp_y, inp_w, inp_h, colors::WHITE); + c.rect(inp_x, inp_y, inp_w, inp_h, colors::ACCENT); + + int text_y = inp_y + (inp_h - sfh) / 2; + wp_draw_ui_text(c, inp_x + 4, text_y, wp->pathbar_text, colors::TEXT_COLOR); + + char prefix[256]; + int plen = wp->pathbar_cursor; + if (plen > 255) plen = 255; + for (int i = 0; i < plen; i++) prefix[i] = wp->pathbar_text[i]; + prefix[plen] = '\0'; + int cx = inp_x + 4 + text_width(g_ui_font, prefix, fonts::UI_SIZE); + c.fill_rect(cx, inp_y + 3, 2, inp_h - 6, colors::ACCENT); + + int btn_x = inp_x + inp_w + 6; + wp_draw_ui_button(c, btn_x, inp_y, btn_w, inp_h, + wp->pathbar_save_mode ? "Save" : "Open", + colors::ACCENT, colors::WHITE); + + c.hline(0, pb_y + WP_PATHBAR_H - 1, c.w, colors::BORDER); + edit_y = WP_TOOLBAR_H + WP_PATHBAR_H; + } + + int text_area_h = c.h - edit_y - WP_STATUS_H; + wp_recompute_wrap(wp, c.w); + + wp->scrollbar.bounds = {c.w - WP_SCROLLBAR_W, edit_y, WP_SCROLLBAR_W, text_area_h}; + wp->scrollbar.content_height = wp->content_height; + wp->scrollbar.view_height = text_area_h; + + { + int ms = wp->scrollbar.max_scroll(); + if (wp->scrollbar.scroll_offset > ms) wp->scrollbar.scroll_offset = ms; + if (wp->scrollbar.scroll_offset < 0) wp->scrollbar.scroll_offset = 0; + } + + wp_ensure_cursor_visible(wp, text_area_h); + + int scroll_y = wp->scrollbar.scroll_offset; + int cursor_abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); + int sel_s = 0; + int sel_e = 0; + if (wp->has_selection) wp_sel_range(wp, &sel_s, &sel_e); + Color sel_bg = Color::from_rgb(0xB0, 0xD0, 0xF0); + + for (int li = 0; li < wp->wrap_line_count; li++) { + WrapLine* wl = &wp->wrap_lines[li]; + int py = edit_y + wl->y - scroll_y; + + if (py + wl->height <= edit_y) continue; + if (py >= edit_y + text_area_h) break; + + int chars_left = wl->char_count; + int ri = wl->run_idx; + int ro = wl->run_offset; + int x = WP_MARGIN; + int line_abs_start = wp_wrap_line_start(wp, li); + int char_idx = 0; + + while (chars_left > 0 && ri < wp->run_count) { + StyledRun* r = &wp->runs[ri]; + TrueTypeFont* font = wp_get_font(r->font_id, r->flags); + if (!font || !font->valid) { + ri++; + ro = 0; + continue; + } + + GlyphCache* gc = font->get_cache(r->size); + int baseline = py + wl->baseline; + + int avail = r->len - ro; + int to_draw = avail < chars_left ? avail : chars_left; + + for (int ci = 0; ci < to_draw; ci++) { + char ch = r->text[ro + ci]; + int abs_ch = line_abs_start + char_idx; + + int char_adv = 0; + if (ch != '\n' && (ch >= 32 || ch < 0)) { + CachedGlyph* g = font->get_glyph(gc, (unsigned char)ch); + char_adv = g ? g->advance : 8; + } + + if (wp->has_selection && abs_ch >= sel_s && abs_ch < sel_e) { + int sel_w = char_adv > 0 ? char_adv : 6; + c.fill_rect(x, py, sel_w, wl->height, sel_bg); + } + + if (abs_ch == cursor_abs) { + int cur_h = wl->height; + if (py >= edit_y && py + cur_h <= edit_y + text_area_h) + c.fill_rect(x, py, 2, cur_h, colors::ACCENT); + } + + if (ch != '\n' && (ch >= 32 || ch < 0)) { + if (x < c.w - WP_SCROLLBAR_W - WP_MARGIN) { + Color text_col = (wp->has_selection && abs_ch >= sel_s && abs_ch < sel_e) + ? Color::from_rgb(0x10, 0x10, 0x10) : colors::TEXT_COLOR; + int adv = font->draw_char_to_buffer( + c.pixels, c.w, c.h, x, baseline, (unsigned char)ch, text_col, gc); + x += adv; + } + } + + char_idx++; + } + + chars_left -= to_draw; + ro += to_draw; + if (ro >= r->len) { + ri++; + ro = 0; + } + } + + if (line_abs_start + char_idx == cursor_abs && li == wp->wrap_line_count - 1) { + int cur_h = wl->height; + if (py >= edit_y && py + cur_h <= edit_y + text_area_h) + c.fill_rect(x, py, 2, cur_h, colors::ACCENT); + } + } + + if (wp->scrollbar.content_height > wp->scrollbar.view_height) { + Color sb_fg = (wp->scrollbar.hovered || wp->scrollbar.dragging) + ? wp->scrollbar.hover_fg : wp->scrollbar.fg; + int sbx = wp->scrollbar.bounds.x; + int sby = wp->scrollbar.bounds.y; + int sbw = wp->scrollbar.bounds.w; + int sbh = wp->scrollbar.bounds.h; + c.fill_rect(sbx, sby, sbw, sbh, colors::SCROLLBAR_BG); + int th = wp->scrollbar.thumb_height(); + int tty = wp->scrollbar.thumb_y(); + c.fill_rect(sbx + 1, tty, sbw - 2, th, sb_fg); + } + + int status_y = c.h - WP_STATUS_H; + c.fill_rect(0, status_y, c.w, WP_STATUS_H, Color::from_rgb(0x2B, 0x3E, 0x50)); + int status_text_y = status_y + (WP_STATUS_H - sfh) / 2; + + char status_left[128]; + snprintf(status_left, sizeof(status_left), " %s %dpt %s%s", + WP_FONT_NAMES[wp->cur_font_id < FONT_COUNT ? wp->cur_font_id : 0], (int)wp->cur_size, + (wp->cur_flags & STYLE_BOLD) ? "Bold " : "", + (wp->cur_flags & STYLE_ITALIC) ? "Italic" : ""); + wp_draw_ui_text(c, 4, status_text_y, status_left, colors::PANEL_TEXT); + + char status_right[32]; + snprintf(status_right, sizeof(status_right), "%d chars ", wp->total_text_len); + int sr_w = text_width(g_ui_font, status_right, fonts::UI_SIZE); + wp_draw_ui_text(c, c.w - sr_w - 4, status_text_y, status_right, colors::PANEL_TEXT); + + if (wp->font_dropdown_open) { + int dx = 128; + int dy = WP_TOOLBAR_H; + int dw = 110; + int dh = FONT_COUNT * 26 + 4; + c.fill_rect(dx, dy, dw, dh, colors::MENU_BG); + c.rect(dx, dy, dw, dh, colors::BORDER); + for (int i = 0; i < FONT_COUNT; i++) { + int iy = dy + 2 + i * 26; + if (i == wp->cur_font_id) + c.fill_rect(dx + 2, iy, dw - 4, 24, colors::MENU_HOVER); + wp_draw_ui_text(c, dx + 8, iy + (24 - sfh) / 2, WP_FONT_NAMES[i], colors::TEXT_COLOR); + } + } + + if (wp->size_dropdown_open) { + int dx = 224; + int dy = WP_TOOLBAR_H; + int dw = 56; + int dh = WP_SIZE_OPTION_COUNT * 26 + 4; + c.fill_rect(dx, dy, dw, dh, colors::MENU_BG); + c.rect(dx, dy, dw, dh, colors::BORDER); + for (int i = 0; i < WP_SIZE_OPTION_COUNT; i++) { + int iy = dy + 2 + i * 26; + if (WP_SIZE_OPTIONS[i] == wp->cur_size) + c.fill_rect(dx + 2, iy, dw - 4, 24, colors::MENU_HOVER); + char sz[8]; + snprintf(sz, sizeof(sz), "%d", WP_SIZE_OPTIONS[i]); + wp_draw_ui_text(c, dx + 8, iy + (24 - sfh) / 2, sz, colors::TEXT_COLOR); + } + } +} diff --git a/programs/src/wordprocessor/stb_truetype_impl.cpp b/programs/src/wordprocessor/stb_truetype_impl.cpp new file mode 100644 index 0000000..dd91785 --- /dev/null +++ b/programs/src/wordprocessor/stb_truetype_impl.cpp @@ -0,0 +1,33 @@ +/* + * stb_truetype_impl.cpp + * Single compilation unit for stb_truetype in MontaukOS freestanding environment + * Copyright (c) 2026 Daniel Hammer + */ + +#include +#include +#include +#include +#include + +#define STBTT_ifloor(x) ((int) stb_floor(x)) +#define STBTT_iceil(x) ((int) stb_ceil(x)) +#define STBTT_sqrt(x) stb_sqrt(x) +#define STBTT_pow(x,y) stb_pow(x,y) +#define STBTT_fmod(x,y) stb_fmod(x,y) +#define STBTT_cos(x) stb_cos(x) +#define STBTT_acos(x) stb_acos(x) +#define STBTT_fabs(x) stb_fabs(x) + +#define STBTT_malloc(x,u) ((void)(u), montauk::malloc(x)) +#define STBTT_free(x,u) ((void)(u), montauk::mfree(x)) + +#define STBTT_memcpy(d,s,n) montauk::memcpy(d,s,n) +#define STBTT_memset(d,v,n) montauk::memset(d,v,n) + +#define STBTT_strlen(x) montauk::slen(x) + +#define STBTT_assert(x) ((void)(x)) + +#define STB_TRUETYPE_IMPLEMENTATION +#include diff --git a/programs/src/wordprocessor/wordprocessor.hpp b/programs/src/wordprocessor/wordprocessor.hpp new file mode 100644 index 0000000..40d599f --- /dev/null +++ b/programs/src/wordprocessor/wordprocessor.hpp @@ -0,0 +1,259 @@ +/* + * wordprocessor.hpp + * Shared declarations for the Word Processor app + * Copyright (c) 2026 Daniel Hammer + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +} + +using namespace gui; + +static constexpr int INIT_W = 640; +static constexpr int INIT_H = 480; +static constexpr int WP_TOOLBAR_H = 36; +static constexpr int WP_PATHBAR_H = 32; +static constexpr int WP_STATUS_H = 24; +static constexpr int WP_SCROLLBAR_W = 12; +static constexpr int WP_MARGIN = 16; +static constexpr int WP_MAX_RUNS = 1024; +static constexpr int WP_MAX_TEXT = 262144; +static constexpr int WP_MAX_WRAP_LINES = 4096; +static constexpr int WP_DEFAULT_SIZE = 18; + +static constexpr int FONT_ROBOTO = 0; +static constexpr int FONT_NOTOSERIF = 1; +static constexpr int FONT_C059 = 2; +static constexpr int FONT_COUNT = 3; + +static constexpr uint8_t STYLE_BOLD = 0x01; +static constexpr uint8_t STYLE_ITALIC = 0x02; + +inline constexpr const char* WP_FONT_NAMES[FONT_COUNT] = { + "Roboto", + "NotoSerif", + "C059", +}; + +inline constexpr int WP_SIZE_OPTIONS[] = { 12, 14, 16, 18, 20, 24, 28, 36 }; +static constexpr int WP_SIZE_OPTION_COUNT = 8; + +struct WPFontTable { + TrueTypeFont* fonts[FONT_COUNT][4]; + bool loaded; +}; + +struct StyledRun { + char* text; + int len; + int cap; + uint8_t font_id; + uint8_t size; + uint8_t flags; +}; + +struct WrapLine { + int run_idx; + int run_offset; + int char_count; + int y; + int height; + int baseline; +}; + +inline bool wp_left_held(uint8_t buttons) { + return (buttons & 0x01) != 0; +} + +inline bool wp_left_pressed(uint8_t buttons, uint8_t prev_buttons) { + return (buttons & 0x01) && !(prev_buttons & 0x01); +} + +inline bool wp_left_released(uint8_t buttons, uint8_t prev_buttons) { + return !(buttons & 0x01) && (prev_buttons & 0x01); +} + +struct WpScrollbar { + Rect bounds; + int content_height; + int view_height; + int scroll_offset; + bool dragging; + int drag_start_y; + int drag_start_offset; + Color bg; + Color fg; + Color hover_fg; + bool hovered; + + void init(int x, int y, int w, int h) { + bounds = {x, y, w, h}; + content_height = 0; + view_height = h; + scroll_offset = 0; + dragging = false; + drag_start_y = 0; + drag_start_offset = 0; + bg = colors::SCROLLBAR_BG; + fg = colors::SCROLLBAR_FG; + hover_fg = Color::from_rgb(0xA0, 0xA0, 0xA0); + hovered = false; + } + + int thumb_height() const { + if (content_height <= view_height) return bounds.h; + int th = (view_height * bounds.h) / content_height; + return th < 20 ? 20 : th; + } + + int thumb_y() const { + if (content_height <= view_height) return bounds.y; + int range = bounds.h - thumb_height(); + int max_scroll = content_height - view_height; + if (max_scroll <= 0) return bounds.y; + return bounds.y + (scroll_offset * range) / max_scroll; + } + + int max_scroll() const { + int ms = content_height - view_height; + return ms > 0 ? ms : 0; + } + + void handle_mouse(int x, int y, uint8_t buttons, uint8_t prev_buttons, int scroll) { + if (content_height <= view_height) return; + + Rect thumb_rect = {bounds.x, thumb_y(), bounds.w, thumb_height()}; + hovered = thumb_rect.contains(x, y); + + if (hovered && wp_left_pressed(buttons, prev_buttons)) { + dragging = true; + drag_start_y = y; + drag_start_offset = scroll_offset; + } + + if (dragging && wp_left_held(buttons)) { + int dy = y - drag_start_y; + int range = bounds.h - thumb_height(); + if (range > 0) { + int ms = max_scroll(); + scroll_offset = drag_start_offset + (dy * ms) / range; + if (scroll_offset < 0) scroll_offset = 0; + if (scroll_offset > ms) scroll_offset = ms; + } + } + + if (!wp_left_held(buttons)) { + dragging = false; + } + + if (bounds.contains(x, y) && scroll != 0) { + scroll_offset += scroll * 20; + int ms = max_scroll(); + if (scroll_offset < 0) scroll_offset = 0; + if (scroll_offset > ms) scroll_offset = ms; + } + } +}; + +struct WordProcessorState { + StyledRun runs[WP_MAX_RUNS]; + int run_count; + int total_text_len; + + int cursor_run; + int cursor_offset; + + int sel_anchor; + int sel_end; + bool has_selection; + bool mouse_selecting; + + uint8_t cur_font_id; + uint8_t cur_size; + uint8_t cur_flags; + + WpScrollbar scrollbar; + int content_height; + + WrapLine* wrap_lines; + int wrap_line_count; + bool wrap_dirty; + int last_wrap_width; + + bool modified; + char filepath[256]; + char filename[64]; + + bool show_pathbar; + bool pathbar_save_mode; + char pathbar_text[256]; + int pathbar_cursor; + int pathbar_len; + + bool font_dropdown_open; + bool size_dropdown_open; +}; + +extern int g_win_w; +extern int g_win_h; +extern WsWindow g_win; +extern WordProcessorState g_wp; +extern WPFontTable g_wp_fonts; +extern SvgIcon g_icon_folder; +extern SvgIcon g_icon_save; +extern TrueTypeFont* g_ui_font; +extern TrueTypeFont* g_ui_bold; + +void wp_load_fonts(); +void wp_load_icons(); +void wp_init_empty_document(WordProcessorState* wp); +void wp_free_document(WordProcessorState* wp); +void wp_cleanup_state(); + +TrueTypeFont* wp_get_font(int font_id, uint8_t flags); + +int wp_abs_pos(WordProcessorState* wp, int run, int offset); +void wp_pos_to_run(WordProcessorState* wp, int abs_pos, int* out_run, int* out_offset); +char wp_char_at(WordProcessorState* wp, int abs_pos); + +void wp_insert_char(WordProcessorState* wp, char c); +void wp_delete_char(WordProcessorState* wp); +void wp_backspace(WordProcessorState* wp); + +void wp_cursor_left(WordProcessorState* wp); +void wp_cursor_right(WordProcessorState* wp); +void wp_cursor_up(WordProcessorState* wp); +void wp_cursor_down(WordProcessorState* wp); + +void wp_clear_selection(WordProcessorState* wp); +void wp_sel_range(WordProcessorState* wp, int* out_start, int* out_end); +void wp_start_selection(WordProcessorState* wp); +void wp_update_selection_to_cursor(WordProcessorState* wp); +void wp_apply_style_to_selection(WordProcessorState* wp, int mode, int value); +void wp_delete_selection(WordProcessorState* wp); + +void wp_recompute_wrap(WordProcessorState* wp, int content_w); +int wp_find_wrap_line(WordProcessorState* wp, int abs_pos); +int wp_wrap_line_start(WordProcessorState* wp, int line_idx); +void wp_ensure_cursor_visible(WordProcessorState* wp, int view_h); + +void wp_set_filepath(WordProcessorState* wp, const char* path); +void wp_open_save_pathbar(WordProcessorState* wp); +void wp_save_file(WordProcessorState* wp); +void wp_load_file(WordProcessorState* wp, const char* path); + +void wp_render(); +void wp_handle_mouse(const Montauk::WinEvent& ev); +void wp_handle_key(const Montauk::KeyEvent& key); diff --git a/scripts/install_apps.sh b/scripts/install_apps.sh index 7cc1a1f..f43ec19 100755 --- a/scripts/install_apps.sh +++ b/scripts/install_apps.sh @@ -17,6 +17,7 @@ ICON_SRC="$PROJECT_ROOT/programs/gui/icons/Flat-Remix-Blue-Light-darkPanel" APPS=( "doom|apps/scalable/doom.svg" "spreadsheet|mimetypes/scalable/spreadsheet.svg" + "wordprocessor|apps/scalable/libreoffice6.0-writer.svg" "weather|apps/scalable/weather-widget.svg" "wikipedia|apps/scalable/web-browser.svg" "imageviewer|apps/scalable/utilities-terminal.svg"