diff --git a/kernel/src/Api/WinServer.cpp b/kernel/src/Api/WinServer.cpp index b71ea8d..60d1399 100644 --- a/kernel/src/Api/WinServer.cpp +++ b/kernel/src/Api/WinServer.cpp @@ -97,6 +97,19 @@ namespace WinServer { } } + // Unmap pixel pages from the owner's address space so that + // FreeUserHalf() won't double-free them when the process exits. + { + auto* ownerProc = Sched::GetProcessByPid(slot.ownerPid); + if (ownerProc) { + for (int p = 0; p < slot.pixelNumPages; p++) { + Memory::VMM::Paging::UnmapUserIn( + ownerProc->pml4Phys, + slot.ownerVa + (uint64_t)p * 0x1000); + } + } + } + // Free physical pixel pages for (int i = 0; i < slot.pixelNumPages; i++) { if (slot.pixelPhysPages[i] != 0) { @@ -198,9 +211,11 @@ namespace WinServer { int numPages = (int)((bufSize + 0xFFF) / 0x1000); if (numPages > MaxPixelPages) return -1; - // Free old pixel pages before allocating new ones + // Unmap old pixel pages from owner's address space, then free them int oldNumPages = slot.pixelNumPages; for (int i = 0; i < oldNumPages; i++) { + Memory::VMM::Paging::UnmapUserIn( + ownerPml4, slot.ownerVa + (uint64_t)i * 0x1000); if (slot.pixelPhysPages[i] != 0) { Memory::g_pfa->Free((void*)Memory::HHDM(slot.pixelPhysPages[i])); slot.pixelPhysPages[i] = 0; @@ -271,12 +286,10 @@ namespace WinServer { } } - // Free physical pixel pages - for (int p = 0; p < g_slots[i].pixelNumPages; p++) { - if (g_slots[i].pixelPhysPages[p] != 0) { - Memory::g_pfa->Free((void*)Memory::HHDM(g_slots[i].pixelPhysPages[p])); - } - } + // Do NOT free physical pixel pages here — they are still mapped + // in the owner's page tables and FreeUserHalf() (called right + // after CleanupProcess) will free them. Freeing here would + // cause a double-free, creating a cycle in the PFA free list. g_slots[i].used = false; } diff --git a/kernel/src/Memory/PageFrameAllocator.cpp b/kernel/src/Memory/PageFrameAllocator.cpp index 0082b8b..0782cf8 100644 --- a/kernel/src/Memory/PageFrameAllocator.cpp +++ b/kernel/src/Memory/PageFrameAllocator.cpp @@ -108,20 +108,84 @@ namespace Memory { return nullptr; } - void PageFrameAllocator::Free(void* ptr) { - Lock.Acquire(); - auto prev_next = head.next; - head.next = (Page*)ptr; + // Core free implementation: sorted-insert with coalescing. + // The free list is kept sorted by address so adjacent blocks can be merged, + // preventing fragmentation from accumulating over time. + void PageFrameAllocator::FreeRange(void* ptr, size_t size) { + if (ptr == nullptr || size == 0) return; + + Lock.Acquire(); + + uint64_t addr = (uint64_t)ptr; + + // Walk to find the sorted insertion point: prev < addr < current + Page* prev = &head; + Page* current = head.next; + + while (current != nullptr && (uint64_t)current < addr) { + // Double-free check: addr falls within an existing free block + if (addr < (uint64_t)current + current->size) { + Kt::KernelLogStream(Kt::WARNING, "PFA") + << "Double-free detected at " << addr << ", ignoring"; + Lock.Release(); + return; + } + prev = current; + current = current->next; + } + + // Double-free check: exact match with next block + if (current != nullptr && (uint64_t)current == addr) { + Kt::KernelLogStream(Kt::WARNING, "PFA") + << "Double-free detected at " << addr << ", ignoring"; + Lock.Release(); + return; + } + + // Try to coalesce with previous block (if prev ends where new block starts) + bool merged_prev = false; + if (prev != &head) { + uint64_t prev_end = (uint64_t)prev + prev->size; + if (prev_end == addr) { + prev->size += size; + merged_prev = true; + } + } + + // Try to coalesce with next block (if new block ends where next starts) + if (current != nullptr && addr + size == (uint64_t)current) { + if (merged_prev) { + // Three-way merge: prev absorbs new block and current + prev->size += current->size; + prev->next = current->next; + } else { + // Forward merge: new block absorbs current + Page* new_page = (Page*)addr; + new_page->size = size + current->size; + new_page->next = current->next; + prev->next = new_page; + } + } else if (!merged_prev) { + // No merging possible: insert new block between prev and current + Page* new_page = (Page*)addr; + new_page->size = size; + new_page->next = current; + prev->next = new_page; + } - head.next->next = prev_next; - head.next->size = 0x1000; Lock.Release(); } + void PageFrameAllocator::Free(void* ptr) { + FreeRange(ptr, 0x1000); + } + void PageFrameAllocator::Free(void* ptr, int n) { - for (int i = 0; i < n; i++) { - Free((void*)((uint64_t)ptr + 0x1000 * i)); - } + if (ptr == nullptr || n <= 0) return; + // Free the entire contiguous range as a single block. + // This is more efficient than freeing one page at a time and + // guarantees the range stays coalesced. + FreeRange(ptr, (size_t)n * 0x1000); } void PageFrameAllocator::GetStats(Montauk::MemStats* out) { @@ -140,4 +204,4 @@ namespace Memory { out->usedBytes = g_section.size > freeBytes ? g_section.size - freeBytes : 0; out->pageSize = 0x1000; } -}; \ No newline at end of file +}; diff --git a/kernel/src/Memory/PageFrameAllocator.hpp b/kernel/src/Memory/PageFrameAllocator.hpp index b72000a..d7bdbe6 100644 --- a/kernel/src/Memory/PageFrameAllocator.hpp +++ b/kernel/src/Memory/PageFrameAllocator.hpp @@ -20,6 +20,8 @@ namespace Memory { Page head{}; kcp::Spinlock Lock{}; LargestSection g_section; + + void FreeRange(void* ptr, std::size_t size); public: PageFrameAllocator(LargestSection section); diff --git a/programs/GNUmakefile b/programs/GNUmakefile index 4115b56..a8b40b1 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 desktop +CUSTOM_BUILDS := doom fetch wiki wikipedia weather imageviewer fontpreview spreadsheet desktop SYSTEM_PROGRAMS := $(filter-out $(CUSTOM_BUILDS),$(PROGRAMS)) # Build targets: system programs go to bin/os/, games are handled separately. @@ -84,9 +84,9 @@ CA_CERTS := $(BINDIR)/etc/ca-certificates.crt # Home directory placeholder. HOMEKEEP := $(BINDIR)/home/.keep -.PHONY: all clean doom fetch wiki wikipedia weather imageviewer fontpreview desktop icons fonts bearssl libc tls libjpeg +.PHONY: all clean doom fetch wiki wikipedia weather imageviewer fontpreview spreadsheet desktop icons fonts bearssl libc tls libjpeg -all: bearssl libc libjpeg tls $(TARGETS) fetch wiki wikipedia weather imageviewer fontpreview doom desktop icons fonts $(GAME_DATA) $(MANDST) $(WWWDST) $(CA_CERTS) $(HOMEKEEP) +all: bearssl libc libjpeg tls $(TARGETS) fetch wiki wikipedia weather imageviewer fontpreview spreadsheet doom desktop icons fonts $(GAME_DATA) $(MANDST) $(WWWDST) $(CA_CERTS) $(HOMEKEEP) # 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) @@ -135,6 +135,10 @@ imageviewer: libc libjpeg fontpreview: $(MAKE) -C src/fontpreview +# Build spreadsheet standalone GUI client (depends on libc). +spreadsheet: libc + $(MAKE) -C src/spreadsheet + # Build desktop via its own Makefile (depends on libc and libjpeg for wallpaper). desktop: libc libjpeg $(MAKE) -C src/desktop @@ -195,3 +199,4 @@ clean: $(MAKE) -C src/weather clean $(MAKE) -C src/imageviewer clean $(MAKE) -C src/fontpreview clean + $(MAKE) -C src/spreadsheet clean diff --git a/programs/gui/fonts/C059/C059-Bold.ttf b/programs/gui/fonts/C059/C059-Bold.ttf new file mode 100644 index 0000000..7411733 Binary files /dev/null and b/programs/gui/fonts/C059/C059-Bold.ttf differ diff --git a/programs/gui/fonts/C059/C059-Italic.ttf b/programs/gui/fonts/C059/C059-Italic.ttf new file mode 100644 index 0000000..2e1e861 Binary files /dev/null and b/programs/gui/fonts/C059/C059-Italic.ttf differ diff --git a/programs/gui/fonts/C059/C059-Roman.ttf b/programs/gui/fonts/C059/C059-Roman.ttf new file mode 100644 index 0000000..2d957d8 Binary files /dev/null and b/programs/gui/fonts/C059/C059-Roman.ttf differ diff --git a/programs/include/gui/desktop.hpp b/programs/include/gui/desktop.hpp index 9e211ff..b742bef 100644 --- a/programs/include/gui/desktop.hpp +++ b/programs/include/gui/desktop.hpp @@ -87,6 +87,7 @@ struct DesktopState { SvgIcon icon_procmgr; SvgIcon icon_mandelbrot; SvgIcon icon_devexplorer; + SvgIcon icon_spreadsheet; bool ctx_menu_open; int ctx_menu_x, ctx_menu_y; diff --git a/programs/include/gui/svg.hpp b/programs/include/gui/svg.hpp index f0ce5b4..a52176a 100644 --- a/programs/include/gui/svg.hpp +++ b/programs/include/gui/svg.hpp @@ -321,7 +321,7 @@ struct SvgEdgeList { int capacity; void init(int cap) { - edges = (SvgEdge*)montauk::alloc(cap * sizeof(SvgEdge)); + edges = (SvgEdge*)montauk::malloc(cap * sizeof(SvgEdge)); count = 0; capacity = cap; } @@ -800,7 +800,7 @@ inline void svg_rasterize(const SvgEdgeList& el, uint32_t* pixels, int w, int h, // Temporary array for x-intersections on each scanline // Allocate enough for all edges (each edge can intersect at most once per scanline) int maxIsect = el.count + 16; - fixed_t* isect = (fixed_t*)montauk::alloc(maxIsect * sizeof(fixed_t)); + fixed_t* isect = (fixed_t*)montauk::malloc(maxIsect * sizeof(fixed_t)); for (int y = 0; y < h; ++y) { // Scanline center in fixed-point @@ -858,7 +858,7 @@ inline void svg_rasterize(const SvgEdgeList& el, uint32_t* pixels, int w, int h, } } - montauk::free(isect); + montauk::mfree(isect); } // --------------------------------------------------------------------------- @@ -1018,7 +1018,7 @@ inline void svg_rasterize_blend(const SvgEdgeList& el, uint32_t* pixels, int w, if (el.count == 0) return; int maxIsect = el.count + 16; - fixed_t* isect = (fixed_t*)montauk::alloc(maxIsect * sizeof(fixed_t)); + fixed_t* isect = (fixed_t*)montauk::malloc(maxIsect * sizeof(fixed_t)); uint32_t fr = (fill >> 16) & 0xFF; uint32_t fg = (fill >> 8) & 0xFF; @@ -1081,7 +1081,7 @@ inline void svg_rasterize_blend(const SvgEdgeList& el, uint32_t* pixels, int w, } } - montauk::free(isect); + montauk::mfree(isect); } // --------------------------------------------------------------------------- @@ -1091,7 +1091,7 @@ inline SvgIcon svg_render(const char* svg_data, int svg_len, int target_w, int t SvgIcon icon; icon.width = target_w; icon.height = target_h; - icon.pixels = (uint32_t*)montauk::alloc(target_w * target_h * sizeof(uint32_t)); + icon.pixels = (uint32_t*)montauk::malloc(target_w * target_h * sizeof(uint32_t)); // Clear to transparent svg_memset(icon.pixels, 0, target_w * target_h * sizeof(uint32_t)); @@ -1204,6 +1204,9 @@ inline SvgIcon svg_render(const char* svg_data, int svg_len, int target_w, int t SvgEdgeList el; el.init(SVG_MAX_EDGES); + // Heap-allocated path data buffer (avoids 8 KiB on the stack) + char* d_buf = (char*)montauk::malloc(SVG_MAX_PATH_LEN); + // Scan for blocks (they define reusable items, not rendered directly) const char* p = svg_data; @@ -1249,7 +1252,6 @@ inline SvgIcon svg_render(const char* svg_data, int svg_len, int target_w, int t int alpha = svg_get_element_opacity(elem_start, elem_len); // Extract and rasterize path - char d_buf[SVG_MAX_PATH_LEN]; int d_len = svg_get_attr(elem_start, elem_len, " d", d_buf, SVG_MAX_PATH_LEN); if (d_len > 0) { el.clear(); @@ -1359,7 +1361,8 @@ inline SvgIcon svg_render(const char* svg_data, int svg_len, int target_w, int t ++p; } - montauk::free(el.edges); + montauk::mfree(d_buf); + montauk::mfree(el.edges); return icon; } @@ -1378,7 +1381,7 @@ inline SvgIcon svg_load(const char* vfs_path, int target_w, int target_h, Color return {nullptr, 0, 0}; } - char* buf = (char*)montauk::alloc(size + 1); + char* buf = (char*)montauk::malloc(size + 1); montauk::read(fd, (uint8_t*)buf, 0, size); montauk::close(fd); buf[size] = '\0'; @@ -1389,12 +1392,12 @@ inline SvgIcon svg_load(const char* vfs_path, int target_w, int target_h, Color int hi_h = target_h * SS; SvgIcon hi = svg_render(buf, (int)size, hi_w, hi_h, fill_color); - montauk::free(buf); + montauk::mfree(buf); if (!hi.pixels) return {nullptr, 0, 0}; // Allocate final icon at target resolution - uint32_t* out = (uint32_t*)montauk::alloc(target_w * target_h * 4); + uint32_t* out = (uint32_t*)montauk::malloc(target_w * target_h * 4); for (int i = 0; i < target_w * target_h; i++) out[i] = 0; // Downsample: average each SSxSS block using premultiplied alpha @@ -1432,7 +1435,7 @@ inline SvgIcon svg_load(const char* vfs_path, int target_w, int target_h, Color } } - montauk::free(hi.pixels); + montauk::mfree(hi.pixels); return {out, target_w, target_h}; } @@ -1440,7 +1443,7 @@ inline SvgIcon svg_load(const char* vfs_path, int target_w, int target_h, Color // Free icon pixel data // --------------------------------------------------------------------------- inline void svg_free(SvgIcon& icon) { - if (icon.pixels) montauk::free(icon.pixels); + if (icon.pixels) montauk::mfree(icon.pixels); icon.pixels = nullptr; icon.width = 0; icon.height = 0; diff --git a/programs/include/gui/terminal.hpp b/programs/include/gui/terminal.hpp index 09da368..65c1a54 100644 --- a/programs/include/gui/terminal.hpp +++ b/programs/include/gui/terminal.hpp @@ -130,6 +130,13 @@ static inline void terminal_init_cells(TerminalState* t, int cols, int rows, int int screen_cells = rows * cols; t->cells = (TermCell*)montauk::alloc(total_cells * sizeof(TermCell)); t->alt_cells = (TermCell*)montauk::alloc(screen_cells * sizeof(TermCell)); + if (!t->cells || !t->alt_cells) { + // Allocation failed — leave terminal in a safe but unusable state + if (t->cells) { montauk::free(t->cells); t->cells = nullptr; } + if (t->alt_cells) { montauk::free(t->alt_cells); t->alt_cells = nullptr; } + t->cols = 0; t->rows = 0; + return; + } for (int i = 0; i < total_cells; i++) { t->cells[i] = {' ', colors::TERM_FG, colors::TERM_BG}; } @@ -143,7 +150,8 @@ static inline void terminal_init(TerminalState* t, int cols, int rows) { t->cursor_visible = true; t->child_pid = montauk::spawn_redir("0:/os/shell.elf"); - montauk::childio_settermsz(t->child_pid, cols, rows); + if (t->child_pid > 0) + montauk::childio_settermsz(t->child_pid, cols, rows); } static inline void terminal_put_char(TerminalState* t, char ch) { @@ -467,7 +475,7 @@ static inline void terminal_feed(TerminalState* t, const char* data, int len) { } static inline void terminal_render(TerminalState* t, uint32_t* pixels, int pw, int ph) { - if (!t->dirty) return; + if (!t->dirty || !t->cells) return; t->dirty = false; int cell_w = mono_cell_width(); @@ -593,6 +601,11 @@ static inline void terminal_resize(TerminalState* t, int new_cols, int new_rows) int new_total = new_capacity * new_cols; TermCell* new_cells = (TermCell*)montauk::alloc(new_total * sizeof(TermCell)); TermCell* new_alt = (TermCell*)montauk::alloc(new_rows * new_cols * sizeof(TermCell)); + if (!new_cells || !new_alt) { + if (new_cells) montauk::free(new_cells); + if (new_alt) montauk::free(new_alt); + return; // keep existing buffers + } // Clear new buffers for (int i = 0; i < new_total; i++) diff --git a/programs/include/montauk/heap.h b/programs/include/montauk/heap.h index df4fdde..267c2b6 100644 --- a/programs/include/montauk/heap.h +++ b/programs/include/montauk/heap.h @@ -24,9 +24,10 @@ namespace heap_detail { FreeNode* next; }; - // Per-process heap state (single TU per program, so static is fine) - static FreeNode g_head{0, nullptr}; - static bool g_initialized = false; + // Per-process heap state — must be `inline` (not `static`) so that all + // translation units in a multi-TU program share a single heap. + inline FreeNode g_head{0, nullptr}; + inline bool g_initialized = false; static inline Header* get_header(void* block) { return (Header*)((uint8_t*)block - sizeof(Header)); diff --git a/programs/src/desktop/Makefile b/programs/src/desktop/Makefile index 1fe0a8e..1663a3f 100644 --- a/programs/src/desktop/Makefile +++ b/programs/src/desktop/Makefile @@ -63,7 +63,7 @@ LDFLAGS := \ # ---- C++ source files ---- -SRCS := main.cpp font_data.cpp stb_truetype_impl.cpp app_terminal.cpp app_filemanager.cpp app_sysinfo.cpp app_calculator.cpp app_texteditor.cpp app_klog.cpp app_wiki.cpp app_weather.cpp app_procmgr.cpp app_mandelbrot.cpp app_devexplorer.cpp app_settings.cpp app_doom.cpp +SRCS := main.cpp font_data.cpp stb_truetype_impl.cpp app_terminal.cpp app_filemanager.cpp app_sysinfo.cpp app_calculator.cpp app_texteditor.cpp app_wordprocessor.cpp app_spreadsheet.cpp app_klog.cpp app_wiki.cpp app_weather.cpp app_procmgr.cpp app_mandelbrot.cpp app_devexplorer.cpp app_settings.cpp app_doom.cpp OBJS := $(addprefix $(OBJDIR)/,$(SRCS:.cpp=.o)) # ---- Target ---- diff --git a/programs/src/desktop/app_spreadsheet.cpp b/programs/src/desktop/app_spreadsheet.cpp new file mode 100644 index 0000000..1100b7e --- /dev/null +++ b/programs/src/desktop/app_spreadsheet.cpp @@ -0,0 +1,12 @@ +/* + * app_spreadsheet.cpp + * MontaukOS Desktop - Spreadsheet launcher (spawns standalone spreadsheet.elf) + * Copyright (c) 2026 Daniel Hammer +*/ + +#include "apps_common.hpp" + +void open_spreadsheet(DesktopState* ds) { + (void)ds; + montauk::spawn("0:/os/spreadsheet.elf"); +} diff --git a/programs/src/desktop/app_terminal.cpp b/programs/src/desktop/app_terminal.cpp index f5b2aac..3cc0b0f 100644 --- a/programs/src/desktop/app_terminal.cpp +++ b/programs/src/desktop/app_terminal.cpp @@ -110,6 +110,8 @@ static void term_add_tab(TermTabState* tts, int cols, int rows) { static void terminal_on_draw(Window* win, Framebuffer& fb) { TermTabState* tts = (TermTabState*)win->app_data; if (!tts || tts->tab_count == 0) return; + if (tts->active_tab < 0 || tts->active_tab >= tts->tab_count) + tts->active_tab = 0; Rect cr = win->content_rect(); int term_h = cr.h - TERM_TAB_BAR_H; diff --git a/programs/src/desktop/app_wordprocessor.cpp b/programs/src/desktop/app_wordprocessor.cpp new file mode 100644 index 0000000..c3bf3d5 --- /dev/null +++ b/programs/src/desktop/app_wordprocessor.cpp @@ -0,0 +1,1779 @@ +/* + * app_wordprocessor.cpp + * Rich-text editor + * 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; +} diff --git a/programs/src/desktop/apps_common.hpp b/programs/src/desktop/apps_common.hpp index e3b2d50..27a8d0a 100644 --- a/programs/src/desktop/apps_common.hpp +++ b/programs/src/desktop/apps_common.hpp @@ -174,5 +174,7 @@ void open_devexplorer(DesktopState* ds); void open_settings(DesktopState* ds); void open_doom(DesktopState* ds); void open_reboot_dialog(DesktopState* ds); +void open_wordprocessor(DesktopState* ds); +void open_spreadsheet(DesktopState* ds); void open_shutdown_dialog(DesktopState* ds); void desktop_poll_external_windows(DesktopState* ds); diff --git a/programs/src/desktop/main.cpp b/programs/src/desktop/main.cpp index 4497cc1..b7411d3 100644 --- a/programs/src/desktop/main.cpp +++ b/programs/src/desktop/main.cpp @@ -69,6 +69,7 @@ void gui::desktop_init(DesktopState* ds) { ds->icon_procmgr = svg_load("0:/icons/system-monitor.svg", 20, 20, defColor); ds->icon_mandelbrot = svg_load("0:/icons/applications-science.svg", 20, 20, defColor); ds->icon_devexplorer = svg_load("0:/icons/hardware.svg", 20, 20, defColor); + ds->icon_spreadsheet = svg_load("0:/icons/spreadsheet.svg", 20, 20, defColor); // Settings defaults ds->settings.bg_gradient = true; @@ -428,7 +429,7 @@ void gui::desktop_draw_panel(DesktopState* ds) { static constexpr int MENU_W = 220; static constexpr int MENU_ITEM_H = 36; -static constexpr int MENU_CAT_H = 28; +static constexpr int MENU_CAT_H = 32; static constexpr int MENU_DIV_H = 10; struct MenuRow { @@ -437,30 +438,53 @@ struct MenuRow { int app_id; // -1 for category headers / dividers }; -static constexpr int MENU_ROW_COUNT = 20; +static constexpr int MENU_ROW_COUNT = 22; static const MenuRow menu_rows[MENU_ROW_COUNT] = { - { true, "Applications", -1 }, + { true, "Applications", -1 }, // cat 0 { false, "Terminal", 0 }, { false, "Files", 1 }, { false, "Text Editor", 4 }, + { false, "Word Processor", 15 }, + { false, "Spreadsheet", 16 }, { false, "Calculator", 3 }, - { true, "Internet", -1 }, + { true, "Internet", -1 }, // cat 1 { false, "Wikipedia", 9 }, { false, "Weather", 13 }, - { true, "System", -1 }, + { true, "System", -1 }, // cat 2 { false, "System Info", 2 }, { false, "Kernel Log", 5 }, { false, "Processes", 6 }, { false, "Devices", 8 }, - { true, "Games", -1 }, + { true, "Games", -1 }, // cat 3 { false, "Mandelbrot", 7 }, { false, "DOOM", 10 }, - { true, "", -1 }, // divider + { true, "", -1 }, // divider (always visible) { false, "Settings", 11 }, { false, "Reboot", 12 }, { false, "Shutdown", 14 }, }; +// Collapsible category state (categories 0-3 are toggleable; divider category always expanded) +static constexpr int MENU_NUM_CATS = 5; +static bool menu_cat_expanded[MENU_NUM_CATS] = { false, false, false, false, true }; + +// Map a menu_rows index to its category index (0-4), or -1 for items before any category +static int menu_get_cat(int row_idx) { + int cat = -1; + for (int i = 0; i <= row_idx; i++) + if (menu_rows[i].is_category) cat++; + return cat; +} + +// Check if a row should be visible given current expand/collapse state +static bool menu_row_visible(int row_idx) { + const MenuRow& row = menu_rows[row_idx]; + if (row.is_category) return true; // category headers always visible + int cat = menu_get_cat(row_idx); + if (cat < 0 || cat >= MENU_NUM_CATS) return true; + return menu_cat_expanded[cat]; +} + static int menu_row_height(const MenuRow& row) { if (!row.is_category) return MENU_ITEM_H; return row.label[0] ? MENU_CAT_H : MENU_DIV_H; @@ -469,7 +493,8 @@ static int menu_row_height(const MenuRow& row) { static int menu_total_height() { int h = 10; // top + bottom padding for (int i = 0; i < MENU_ROW_COUNT; i++) - h += menu_row_height(menu_rows[i]); + if (menu_row_visible(i)) + h += menu_row_height(menu_rows[i]); return h; } @@ -488,7 +513,7 @@ static void desktop_draw_app_menu(DesktopState* ds) { draw_rect(fb, menu_x, menu_y, MENU_W, menu_h, colors::BORDER); // Icon lookup by app_id - SvgIcon* icons[15] = { + SvgIcon* icons[17] = { &ds->icon_terminal, // 0 &ds->icon_filemanager, // 1 &ds->icon_sysinfo, // 2 @@ -504,29 +529,64 @@ static void desktop_draw_app_menu(DesktopState* ds) { &ds->icon_reboot, // 12 &ds->icon_weather, // 13 &ds->icon_shutdown, // 14 + &ds->icon_texteditor, // 15 + &ds->icon_spreadsheet, // 16 }; int mx = ds->mouse.x; int my = ds->mouse.y; int iy = menu_y + 5; + int cur_cat = -1; for (int i = 0; i < MENU_ROW_COUNT; i++) { const MenuRow& row = menu_rows[i]; + + if (row.is_category) cur_cat++; + if (!menu_row_visible(i)) continue; + int row_h = menu_row_height(row); if (row.is_category) { // Separator line above (except first category) if (i > 0) { for (int sx = menu_x + 8; sx < menu_x + MENU_W - 8; sx++) - fb.put_pixel(sx, iy + row_h / 2, colors::BORDER); + fb.put_pixel(sx, iy + 1, colors::BORDER); } - // Category label (dimmed), skip for divider-only rows + // Category header with hover + expand/collapse indicator if (row.label[0]) { - int tx = menu_x + 12; + Rect cat_rect = {menu_x + 4, iy, MENU_W - 8, row_h}; + bool hovered = cat_rect.contains(mx, my); + if (hovered) + fill_rounded_rect(fb, cat_rect.x, cat_rect.y, cat_rect.w, cat_rect.h, 4, + Color::from_rgb(0xE8, 0xE8, 0xE8)); + + // Arrow indicator: > for collapsed, v for expanded + bool expanded = (cur_cat >= 0 && cur_cat < MENU_NUM_CATS) && menu_cat_expanded[cur_cat]; + int ax = menu_x + 10; + int ay = iy + row_h / 2; + Color arrow_color = Color::from_rgb(0x88, 0x88, 0x88); + if (expanded) { + // Down arrow (v shape) + for (int d = 0; d < 4; d++) { + fb.put_pixel(ax + d, ay - 2 + d, arrow_color); + fb.put_pixel(ax + d + 1, ay - 2 + d, arrow_color); + fb.put_pixel(ax + 7 - d, ay - 2 + d, arrow_color); + fb.put_pixel(ax + 6 - d, ay - 2 + d, arrow_color); + } + } else { + // Right arrow (> shape) + for (int d = 0; d < 4; d++) { + fb.put_pixel(ax + d, ay - 3 + d, arrow_color); + fb.put_pixel(ax + d, ay - 3 + d + 1, arrow_color); + fb.put_pixel(ax + d, ay + 3 - d, arrow_color); + fb.put_pixel(ax + d, ay + 3 - d - 1, arrow_color); + } + } + + int tx = menu_x + 24; int ty = iy + (row_h - system_font_height()) / 2; - if (i > 0) ty += 2; - draw_text(fb, tx, ty, row.label, Color::from_rgb(0x88, 0x88, 0x88)); + draw_text(fb, tx, ty, row.label, Color::from_rgb(0x66, 0x66, 0x66)); } } else { Rect item_rect = {menu_x + 4, iy, MENU_W - 8, row_h}; @@ -539,7 +599,7 @@ static void desktop_draw_app_menu(DesktopState* ds) { // Icon int icon_x = item_rect.x + 8; int icon_y = item_rect.y + (row_h - 20) / 2; - if (row.app_id >= 0 && row.app_id < 15) { + if (row.app_id >= 0 && row.app_id < 17) { SvgIcon* icon = icons[row.app_id]; if (icon && icon->pixels) { fb.blit_alpha(icon_x, icon_y, icon->width, icon->height, icon->pixels); @@ -1217,13 +1277,19 @@ void gui::desktop_handle_mouse(DesktopState* ds) { Rect menu_rect = {menu_x, menu_y, MENU_W, menu_h}; if (menu_rect.contains(mx, my)) { - // Walk rows to find which one was clicked + // Walk visible rows to find which one was clicked int iy = menu_y + 5; + int cur_cat = -1; for (int i = 0; i < MENU_ROW_COUNT; i++) { const MenuRow& row = menu_rows[i]; + if (row.is_category) cur_cat++; + if (!menu_row_visible(i)) continue; int row_h = menu_row_height(row); if (my >= iy && my < iy + row_h) { - if (!row.is_category) { + if (row.is_category && row.label[0] && cur_cat >= 0 && cur_cat < MENU_NUM_CATS - 1) { + // Toggle category expand/collapse + menu_cat_expanded[cur_cat] = !menu_cat_expanded[cur_cat]; + } else if (!row.is_category) { switch (row.app_id) { case 0: open_terminal(ds); break; case 1: open_filemanager(ds); break; @@ -1240,6 +1306,8 @@ void gui::desktop_handle_mouse(DesktopState* ds) { case 12: open_reboot_dialog(ds); break; case 13: open_weather(ds); break; case 14: open_shutdown_dialog(ds); break; + case 15: open_wordprocessor(ds); break; + case 16: open_spreadsheet(ds); break; } ds->app_menu_open = false; } @@ -1509,6 +1577,10 @@ void gui::desktop_handle_keyboard(DesktopState* ds, const Montauk::KeyEvent& key open_texteditor(ds); return; } + if (key.ascii == 'w' || key.ascii == 'W') { + open_wordprocessor(ds); + return; + } if (key.ascii == 'k' || key.ascii == 'K') { open_klog(ds); return; diff --git a/programs/src/spreadsheet/Makefile b/programs/src/spreadsheet/Makefile new file mode 100644 index 0000000..cd15794 --- /dev/null +++ b/programs/src/spreadsheet/Makefile @@ -0,0 +1,86 @@ +# Makefile for spreadsheet (standalone Window Server app) on MontaukOS +# Copyright (c) 2026 Daniel Hammer + +MAKEFLAGS += -rR +.SUFFIXES: + +# ---- Toolchain ---- + +TOOLCHAIN_PREFIX := $(shell cd ../../.. && pwd)/toolchain/local/bin/x86_64-elf- +ifneq ($(wildcard $(TOOLCHAIN_PREFIX)gcc),) + CXX := $(TOOLCHAIN_PREFIX)g++ +else + CXX := g++ +endif + +# ---- Paths ---- + +PROG_INC := ../../include +LINK_LD := ../../link.ld +BINDIR := ../../bin +OBJDIR := obj +LIBDIR := ../../lib + +# ---- Compiler flags ---- + +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 + +# ---- Linker flags ---- + +LDFLAGS := \ + -nostdlib \ + -static \ + -Wl,--build-id=none \ + -Wl,--gc-sections \ + -Wl,-m,elf_x86_64 \ + -z max-page-size=0x1000 \ + -T $(LINK_LD) + +# ---- Source files ---- + +SRCS := main.cpp helpers.cpp formula.cpp fileio.cpp edit.cpp render.cpp stb_truetype_impl.cpp +OBJS := $(addprefix $(OBJDIR)/,$(SRCS:.cpp=.o)) + +# ---- Target ---- + +TARGET := $(BINDIR)/os/spreadsheet.elf + +.PHONY: all clean + +all: $(TARGET) + +$(TARGET): $(OBJS) $(LINK_LD) Makefile + mkdir -p $(BINDIR)/os + $(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/spreadsheet/edit.cpp b/programs/src/spreadsheet/edit.cpp new file mode 100644 index 0000000..4c843a1 --- /dev/null +++ b/programs/src/spreadsheet/edit.cpp @@ -0,0 +1,245 @@ +/* + * edit.cpp + * Editing, selection, clipboard, formatting, undo/redo, scroll + * Copyright (c) 2026 Daniel Hammer + */ + +#include "spreadsheet.h" + +// ============================================================================ +// Undo/redo +// ============================================================================ + +void undo_push() { + for (int i = g_undo_pos; i < g_undo_count; i++) { + if (g_undo[i]) { montauk::mfree(g_undo[i]); g_undo[i] = nullptr; } + } + if (g_undo[UNDO_MAX]) { montauk::mfree(g_undo[UNDO_MAX]); g_undo[UNDO_MAX] = nullptr; } + g_undo_count = g_undo_pos; + + if (g_undo_count >= UNDO_MAX) { + if (g_undo[0]) montauk::mfree(g_undo[0]); + for (int i = 0; i < UNDO_MAX - 1; i++) g_undo[i] = g_undo[i + 1]; + g_undo[UNDO_MAX - 1] = nullptr; + g_undo_count = UNDO_MAX - 1; + } + + UndoEntry* e = (UndoEntry*)montauk::malloc(sizeof(UndoEntry)); + if (!e) return; + for (int r = 0; r < MAX_ROWS; r++) + for (int c = 0; c < MAX_COLS; c++) { + str_cpy(e->cells[r][c].input, g_cells[r][c].input, CELL_TEXT_MAX); + e->cells[r][c].align = g_cells[r][c].align; + e->cells[r][c].fmt = g_cells[r][c].fmt; + e->cells[r][c].bold = g_cells[r][c].bold; + } + g_undo[g_undo_count] = e; + g_undo_count++; + g_undo_pos = g_undo_count; +} + +static void undo_restore(UndoEntry* e) { + for (int r = 0; r < MAX_ROWS; r++) + for (int c = 0; c < MAX_COLS; c++) { + str_cpy(g_cells[r][c].input, e->cells[r][c].input, CELL_TEXT_MAX); + g_cells[r][c].align = e->cells[r][c].align; + g_cells[r][c].fmt = e->cells[r][c].fmt; + g_cells[r][c].bold = e->cells[r][c].bold; + } + eval_all_cells(); +} + +void undo_do() { + if (g_undo_pos <= 0) return; + if (g_undo_pos == g_undo_count && !g_undo[g_undo_count]) { + UndoEntry* e = (UndoEntry*)montauk::malloc(sizeof(UndoEntry)); + if (e) { + for (int r = 0; r < MAX_ROWS; r++) + for (int c = 0; c < MAX_COLS; c++) { + str_cpy(e->cells[r][c].input, g_cells[r][c].input, CELL_TEXT_MAX); + e->cells[r][c].align = g_cells[r][c].align; + e->cells[r][c].fmt = g_cells[r][c].fmt; + e->cells[r][c].bold = g_cells[r][c].bold; + } + g_undo[g_undo_count] = e; + } + } + g_undo_pos--; + if (g_undo[g_undo_pos]) undo_restore(g_undo[g_undo_pos]); +} + +void redo_do() { + if (g_undo_pos >= g_undo_count) return; + g_undo_pos++; + UndoEntry* e = g_undo[g_undo_pos]; + if (e) undo_restore(e); +} + +// ============================================================================ +// Editing +// ============================================================================ + +void start_editing() { + if (g_editing) return; + g_editing = true; + Cell* c = &g_cells[g_sel_row][g_sel_col]; + str_cpy(g_edit_buf, c->input, CELL_TEXT_MAX); + g_edit_len = str_len(g_edit_buf); + g_edit_cursor = g_edit_len; +} + +void commit_edit() { + if (!g_editing) return; + g_editing = false; + undo_push(); + Cell* c = &g_cells[g_sel_row][g_sel_col]; + str_cpy(c->input, g_edit_buf, CELL_TEXT_MAX); + g_modified = true; + eval_all_cells(); +} + +void cancel_edit() { + g_editing = false; +} + +// ============================================================================ +// Selection helpers +// ============================================================================ + +void sel_range(int* c0, int* r0, int* c1, int* r1) { + if (!g_has_selection) { + *c0 = *c1 = g_sel_col; + *r0 = *r1 = g_sel_row; + return; + } + *c0 = g_sel_col < g_anchor_col ? g_sel_col : g_anchor_col; + *c1 = g_sel_col > g_anchor_col ? g_sel_col : g_anchor_col; + *r0 = g_sel_row < g_anchor_row ? g_sel_row : g_anchor_row; + *r1 = g_sel_row > g_anchor_row ? g_sel_row : g_anchor_row; +} + +void clear_selection() { + g_has_selection = false; + g_anchor_col = g_sel_col; + g_anchor_row = g_sel_row; +} + +void copy_selection() { + int c0, r0, c1, r1; + sel_range(&c0, &r0, &c1, &r1); + g_clip_count = 0; + g_clip_cols = c1 - c0 + 1; + g_clip_rows = r1 - r0 + 1; + for (int r = r0; r <= r1 && g_clip_count < CLIP_MAX_CELLS; r++) { + for (int c = c0; c <= c1 && g_clip_count < CLIP_MAX_CELLS; c++) { + ClipCell* cc = &g_clipboard[g_clip_count]; + str_cpy(cc->text, g_cells[r][c].input, CELL_TEXT_MAX); + cc->rel_col = c - c0; + cc->rel_row = r - r0; + cc->align = g_cells[r][c].align; + cc->fmt = g_cells[r][c].fmt; + cc->bold = g_cells[r][c].bold; + g_clip_count++; + } + } +} + +void cut_selection() { + copy_selection(); + undo_push(); + int c0, r0, c1, r1; + sel_range(&c0, &r0, &c1, &r1); + for (int r = r0; r <= r1; r++) + for (int c = c0; c <= c1; c++) + g_cells[r][c].input[0] = '\0'; + eval_all_cells(); + g_modified = true; +} + +void paste_at_cursor() { + if (g_clip_count == 0) return; + undo_push(); + for (int i = 0; i < g_clip_count; i++) { + int tc = g_sel_col + g_clipboard[i].rel_col; + int tr = g_sel_row + g_clipboard[i].rel_row; + if (tc >= 0 && tc < MAX_COLS && tr >= 0 && tr < MAX_ROWS) { + str_cpy(g_cells[tr][tc].input, g_clipboard[i].text, CELL_TEXT_MAX); + g_cells[tr][tc].align = g_clipboard[i].align; + g_cells[tr][tc].fmt = g_clipboard[i].fmt; + g_cells[tr][tc].bold = g_clipboard[i].bold; + } + } + eval_all_cells(); + g_modified = true; +} + +// ============================================================================ +// Formatting helpers (apply to selection) +// ============================================================================ + +void apply_bold_toggle() { + undo_push(); + int c0, r0, c1, r1; + sel_range(&c0, &r0, &c1, &r1); + bool new_bold = !g_cells[r0][c0].bold; + for (int r = r0; r <= r1; r++) + for (int c = c0; c <= c1; c++) + g_cells[r][c].bold = new_bold; + g_modified = true; +} + +void apply_align(CellAlign a) { + undo_push(); + int c0, r0, c1, r1; + sel_range(&c0, &r0, &c1, &r1); + for (int r = r0; r <= r1; r++) + for (int c = c0; c <= c1; c++) + g_cells[r][c].align = a; + g_modified = true; +} + +void apply_format(NumFormat f) { + undo_push(); + int c0, r0, c1, r1; + sel_range(&c0, &r0, &c1, &r1); + for (int r = r0; r <= r1; r++) + for (int c = c0; c <= c1; c++) + g_cells[r][c].fmt = f; + eval_all_cells(); + g_modified = true; +} + +// ============================================================================ +// Scroll clamping +// ============================================================================ + +void clamp_scroll() { + int pbh = g_pathbar_open ? PATHBAR_H : 0; + int max_x = content_width() - (g_win_w - ROW_HEADER_W); + int max_y = content_height() - (g_win_h - TOOLBAR_H - pbh - FORMULA_BAR_H - STATUS_BAR_H); + if (max_x < 0) max_x = 0; + if (max_y < 0) max_y = 0; + if (g_scroll_x < 0) g_scroll_x = 0; + if (g_scroll_x > max_x) g_scroll_x = max_x; + if (g_scroll_y < 0) g_scroll_y = 0; + if (g_scroll_y > max_y) g_scroll_y = max_y; +} + +void ensure_sel_visible() { + int pbh = g_pathbar_open ? PATHBAR_H : 0; + int area_w = g_win_w - ROW_HEADER_W; + int area_h = g_win_h - TOOLBAR_H - pbh - FORMULA_BAR_H - COL_HEADER_H - STATUS_BAR_H; + + int cx = col_x(g_sel_col) - ROW_HEADER_W; + int cy = g_sel_row * ROW_H; + + if (cx < g_scroll_x) g_scroll_x = cx; + if (cx + g_col_widths[g_sel_col] > g_scroll_x + area_w) + g_scroll_x = cx + g_col_widths[g_sel_col] - area_w; + + if (cy < g_scroll_y) g_scroll_y = cy; + if (cy + ROW_H > g_scroll_y + area_h) + g_scroll_y = cy + ROW_H - area_h; + + clamp_scroll(); +} diff --git a/programs/src/spreadsheet/fileio.cpp b/programs/src/spreadsheet/fileio.cpp new file mode 100644 index 0000000..9995cc8 --- /dev/null +++ b/programs/src/spreadsheet/fileio.cpp @@ -0,0 +1,125 @@ +/* + * fileio.cpp + * File I/O - MSS (Montauk SpreadSheet) format + * Copyright (c) 2026 Daniel Hammer + */ + +#include "spreadsheet.h" + +void save_file() { + if (g_filepath[0] == '\0') return; + + int count = 0; + for (int r = 0; r < MAX_ROWS; r++) + for (int c = 0; c < MAX_COLS; c++) + if (g_cells[r][c].input[0]) count++; + + int size = 4 + 2 + 2; + for (int r = 0; r < MAX_ROWS; r++) + for (int c = 0; c < MAX_COLS; c++) + if (g_cells[r][c].input[0]) + size += 1 + 1 + 2 + 1 + str_len(g_cells[r][c].input); + + uint8_t* buf = (uint8_t*)montauk::malloc(size); + int off = 0; + + buf[off++] = 'M'; buf[off++] = 'S'; buf[off++] = 'S'; buf[off++] = '2'; + buf[off++] = (uint8_t)(count & 0xFF); + buf[off++] = (uint8_t)((count >> 8) & 0xFF); + buf[off++] = MAX_COLS; + buf[off++] = MAX_ROWS; + + for (int r = 0; r < MAX_ROWS; r++) { + for (int c = 0; c < MAX_COLS; c++) { + if (!g_cells[r][c].input[0]) continue; + buf[off++] = (uint8_t)c; + buf[off++] = (uint8_t)r; + int len = str_len(g_cells[r][c].input); + buf[off++] = (uint8_t)(len & 0xFF); + buf[off++] = (uint8_t)((len >> 8) & 0xFF); + uint8_t flags = ((uint8_t)g_cells[r][c].align & 3) + | (((uint8_t)g_cells[r][c].fmt & 3) << 2) + | (g_cells[r][c].bold ? 0x10 : 0); + buf[off++] = flags; + montauk::memcpy(buf + off, g_cells[r][c].input, len); + off += len; + } + } + + int fd = montauk::fcreate(g_filepath); + if (fd >= 0) { + montauk::fwrite(fd, buf, 0, off); + montauk::close(fd); + g_modified = false; + } + + montauk::mfree(buf); +} + +void load_file(const char* path) { + int fd = montauk::open(path); + if (fd < 0) return; + + uint64_t fsize = montauk::getsize(fd); + if (fsize < 8 || fsize > 1024 * 1024) { + 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] != 'S' || buf[2] != 'S' || (buf[3] != '1' && buf[3] != '2')) { + montauk::mfree(buf); + return; + } + bool v2 = (buf[3] == '2'); + + for (int r = 0; r < MAX_ROWS; r++) + for (int c = 0; c < MAX_COLS; c++) { + g_cells[r][c].input[0] = '\0'; + g_cells[r][c].display[0] = '\0'; + g_cells[r][c].value = 0; + g_cells[r][c].type = CT_EMPTY; + g_cells[r][c].align = ALIGN_AUTO; + g_cells[r][c].fmt = FMT_AUTO; + g_cells[r][c].bold = false; + } + + int off = 4; + int count = buf[off] | (buf[off + 1] << 8); + off += 2; + off += 2; // skip cols/rows + + for (int i = 0; i < count && off < (int)fsize; i++) { + if (off + 4 > (int)fsize) break; + int c = buf[off++]; + int r = buf[off++]; + int len = buf[off] | (buf[off + 1] << 8); + off += 2; + + uint8_t flags = 0; + if (v2) { + if (off >= (int)fsize) break; + flags = buf[off++]; + } + + if (off + len > (int)fsize) break; + if (c < MAX_COLS && r < MAX_ROWS && len < CELL_TEXT_MAX) { + montauk::memcpy(g_cells[r][c].input, buf + off, len); + g_cells[r][c].input[len] = '\0'; + if (v2) { + g_cells[r][c].align = (CellAlign)(flags & 3); + g_cells[r][c].fmt = (NumFormat)((flags >> 2) & 3); + g_cells[r][c].bold = (flags & 0x10) != 0; + } + } + off += len; + } + + str_cpy(g_filepath, path, 256); + g_modified = false; + eval_all_cells(); + montauk::mfree(buf); +} diff --git a/programs/src/spreadsheet/formula.cpp b/programs/src/spreadsheet/formula.cpp new file mode 100644 index 0000000..4a27362 --- /dev/null +++ b/programs/src/spreadsheet/formula.cpp @@ -0,0 +1,308 @@ +/* + * formula.cpp + * Formula parser, cell evaluation, and geometry helpers + * Copyright (c) 2026 Daniel Hammer + */ + +#include "spreadsheet.h" + +// ============================================================================ +// Cell reference parsing +// ============================================================================ + +static bool parse_cell_ref(const char* s, int* col, int* row, int* consumed) { + int i = 0; + char c = to_upper(s[i]); + if (c < 'A' || c > 'Z') return false; + *col = c - 'A'; + i++; + + if (!is_digit(s[i])) return false; + int r = 0; + while (is_digit(s[i])) { + r = r * 10 + (s[i] - '0'); + i++; + } + if (r < 1 || r > MAX_ROWS) return false; + *row = r - 1; + *consumed = i; + return true; +} + +// ============================================================================ +// Formula evaluation (simple recursive-descent) +// ============================================================================ + +static double eval_expr(const char* s, int* pos, bool* ok); + +static double cell_value(int col, int row) { + if (col < 0 || col >= MAX_COLS || row < 0 || row >= MAX_ROWS) return 0; + return g_cells[row][col].value; +} + +static double eval_range_func(const char* func, const char* s, int* pos, bool* ok) { + if (s[*pos] != '(') { *ok = false; return 0; } + (*pos)++; + + int c1, r1, c2, r2, consumed; + if (!parse_cell_ref(s + *pos, &c1, &r1, &consumed)) { *ok = false; return 0; } + *pos += consumed; + + if (s[*pos] != ':') { *ok = false; return 0; } + (*pos)++; + + if (!parse_cell_ref(s + *pos, &c2, &r2, &consumed)) { *ok = false; return 0; } + *pos += consumed; + + if (s[*pos] != ')') { *ok = false; return 0; } + (*pos)++; + + if (c1 > c2) { int t = c1; c1 = c2; c2 = t; } + if (r1 > r2) { int t = r1; r1 = r2; r2 = t; } + + double sum = 0; + int count = 0; + for (int r = r1; r <= r2; r++) { + for (int c = c1; c <= c2; c++) { + sum += cell_value(c, r); + count++; + } + } + + bool is_sum = (func[0] == 'S' || func[0] == 's'); + bool is_avg = (func[0] == 'A' || func[0] == 'a'); + bool is_min = (func[0] == 'M' || func[0] == 'm') && (func[1] == 'I' || func[1] == 'i'); + bool is_max = (func[0] == 'M' || func[0] == 'm') && (func[1] == 'A' || func[1] == 'a'); + bool is_cnt = (func[0] == 'C' || func[0] == 'c'); + + if (is_sum) return sum; + if (is_avg) return count > 0 ? sum / count : 0; + if (is_cnt) return (double)count; + if (is_min || is_max) { + double result = cell_value(c1, r1); + for (int r = r1; r <= r2; r++) { + for (int c = c1; c <= c2; c++) { + double v = cell_value(c, r); + if (is_min && v < result) result = v; + if (is_max && v > result) result = v; + } + } + return result; + } + + *ok = false; + return 0; +} + +static void skip_spaces(const char* s, int* pos) { + while (s[*pos] == ' ') (*pos)++; +} + +static double eval_primary(const char* s, int* pos, bool* ok) { + skip_spaces(s, pos); + + if (s[*pos] == '-') { + (*pos)++; + return -eval_primary(s, pos, ok); + } + + if (s[*pos] == '(') { + (*pos)++; + double v = eval_expr(s, pos, ok); + skip_spaces(s, pos); + if (s[*pos] == ')') (*pos)++; + return v; + } + + if (is_alpha(s[*pos])) { + char fname[8] = {}; + int fi = 0; + int save_pos = *pos; + while (is_alpha(s[*pos]) && fi < 7) { + fname[fi++] = to_upper(s[*pos]); + (*pos)++; + } + fname[fi] = '\0'; + + if (s[*pos] == '(' && (strcmp(fname, "SUM") == 0 || + strcmp(fname, "AVG") == 0 || + strcmp(fname, "MIN") == 0 || + strcmp(fname, "MAX") == 0 || + strcmp(fname, "COUNT") == 0)) { + return eval_range_func(fname, s, pos, ok); + } + + *pos = save_pos; + int col, row, consumed; + if (parse_cell_ref(s + *pos, &col, &row, &consumed)) { + *pos += consumed; + return cell_value(col, row); + } + + *ok = false; + return 0; + } + + if (is_digit(s[*pos]) || s[*pos] == '.') { + double result = 0; + while (is_digit(s[*pos])) { + result = result * 10 + (s[*pos] - '0'); + (*pos)++; + } + if (s[*pos] == '.') { + (*pos)++; + double frac = 0.1; + while (is_digit(s[*pos])) { + result += (s[*pos] - '0') * frac; + frac *= 0.1; + (*pos)++; + } + } + return result; + } + + *ok = false; + return 0; +} + +static double eval_term(const char* s, int* pos, bool* ok) { + double left = eval_primary(s, pos, ok); + while (*ok) { + skip_spaces(s, pos); + char op = s[*pos]; + if (op != '*' && op != '/') break; + (*pos)++; + double right = eval_primary(s, pos, ok); + if (op == '*') left *= right; + else if (right != 0) left /= right; + else { *ok = false; return 0; } + } + return left; +} + +static double eval_expr(const char* s, int* pos, bool* ok) { + double left = eval_term(s, pos, ok); + while (*ok) { + skip_spaces(s, pos); + char op = s[*pos]; + if (op != '+' && op != '-') break; + (*pos)++; + double right = eval_term(s, pos, ok); + if (op == '+') left += right; + else left -= right; + } + return left; +} + +// ============================================================================ +// Cell evaluation +// ============================================================================ + +void format_value(char* buf, int max, double val, NumFormat fmt) { + switch (fmt) { + case FMT_CURRENCY: { + bool neg = val < 0; + double av = neg ? -val : val; + long long cents = (long long)(av * 100 + 0.5); + long long dollars = cents / 100; + int c = (int)(cents % 100); + if (neg) + snprintf(buf, max, "-$%lld.%02d", dollars, c); + else + snprintf(buf, max, "$%lld.%02d", dollars, c); + break; + } + case FMT_PERCENT: { + double av = val < 0 ? -val : val; + long long ip = (long long)av; + double frac = av - ip; + if (frac < 0.005) + snprintf(buf, max, "%s%lld%%", val < 0 ? "-" : "", ip); + else + snprintf(buf, max, "%s%lld.%02d%%", val < 0 ? "-" : "", ip, (int)(frac * 100 + 0.5)); + break; + } + case FMT_DECIMAL: { + long long rounded = (long long)((val < 0 ? -val : val) * 100 + 0.5); + long long ip = rounded / 100; + int dp = (int)(rounded % 100); + snprintf(buf, max, "%s%lld.%02d", val < 0 ? "-" : "", ip, dp); + break; + } + default: + double_to_str(buf, max, val); + break; + } +} + +void eval_cell(int col, int row) { + Cell* c = &g_cells[row][col]; + if (c->input[0] == '\0') { + c->type = CT_EMPTY; + c->display[0] = '\0'; + c->value = 0; + return; + } + + if (c->input[0] == '=') { + int pos = 1; + bool ok = true; + double val = eval_expr(c->input, &pos, &ok); + if (ok) { + c->type = CT_FORMULA; + c->value = val; + format_value(c->display, CELL_TEXT_MAX, val, c->fmt); + } else { + c->type = CT_ERROR; + c->value = 0; + str_cpy(c->display, "#ERR", CELL_TEXT_MAX); + } + return; + } + + bool ok; + double val = str_to_double(c->input, &ok); + if (ok) { + c->type = CT_NUMBER; + c->value = val; + format_value(c->display, CELL_TEXT_MAX, val, c->fmt); + } else { + c->type = CT_TEXT; + c->value = 0; + str_cpy(c->display, c->input, CELL_TEXT_MAX); + } +} + +void eval_all_cells() { + for (int r = 0; r < MAX_ROWS; r++) + for (int c = 0; c < MAX_COLS; c++) + eval_cell(c, r); +} + +// ============================================================================ +// Column/row geometry helpers +// ============================================================================ + +int col_x(int col) { + int x = ROW_HEADER_W; + for (int i = 0; i < col; i++) x += g_col_widths[i]; + return x; +} + +int content_width() { + int w = ROW_HEADER_W; + for (int i = 0; i < MAX_COLS; i++) w += g_col_widths[i]; + return w; +} + +int content_height() { + return COL_HEADER_H + MAX_ROWS * ROW_H; +} + +void cell_name(char* buf, int col, int row) { + buf[0] = 'A' + col; + int r = row + 1; + if (r >= 100) { buf[1] = '0' + r / 100; buf[2] = '0' + (r / 10) % 10; buf[3] = '0' + r % 10; buf[4] = '\0'; } + else if (r >= 10) { buf[1] = '0' + r / 10; buf[2] = '0' + r % 10; buf[3] = '\0'; } + else { buf[1] = '0' + r; buf[2] = '\0'; } +} diff --git a/programs/src/spreadsheet/helpers.cpp b/programs/src/spreadsheet/helpers.cpp new file mode 100644 index 0000000..9e745d8 --- /dev/null +++ b/programs/src/spreadsheet/helpers.cpp @@ -0,0 +1,194 @@ +/* + * helpers.cpp + * Pixel drawing and string/number helpers + * Copyright (c) 2026 Daniel Hammer + */ + +#include "spreadsheet.h" + +void px_fill(uint32_t* px, int bw, int bh, + int x, int y, int w, int h, Color c) { + uint32_t v = c.to_pixel(); + int x0 = x < 0 ? 0 : x, y0 = y < 0 ? 0 : y; + int x1 = x + w > bw ? bw : x + w; + int y1 = y + h > bh ? bh : y + h; + for (int row = y0; row < y1; row++) + for (int col = x0; col < x1; col++) + px[row * bw + col] = v; +} + +void px_hline(uint32_t* px, int bw, int bh, + int x, int y, int w, Color c) { + if (y < 0 || y >= bh) return; + uint32_t v = c.to_pixel(); + int x0 = x < 0 ? 0 : x; + int x1 = x + w > bw ? bw : x + w; + for (int col = x0; col < x1; col++) + px[y * bw + col] = v; +} + +void px_vline(uint32_t* px, int bw, int bh, + int x, int y, int h, Color c) { + if (x < 0 || x >= bw) return; + uint32_t v = c.to_pixel(); + int y0 = y < 0 ? 0 : y; + int y1 = y + h > bh ? bh : y + h; + for (int row = y0; row < y1; row++) + px[row * bw + x] = v; +} + +void px_rect(uint32_t* px, int bw, int bh, + int x, int y, int w, int h, Color c) { + px_hline(px, bw, bh, x, y, w, c); + px_hline(px, bw, bh, x, y + h - 1, w, c); + px_vline(px, bw, bh, x, y, h, c); + px_vline(px, bw, bh, x + w - 1, y, h, c); +} + +void px_fill_rounded(uint32_t* px, int bw, int bh, + int x, int y, int w, int h, int r, Color c) { + uint32_t v = c.to_pixel(); + for (int row = 0; row < h; row++) { + int py = y + row; + if (py < 0 || py >= bh) continue; + int inset = 0; + if (row < r) { + int dy = r - 1 - row; + if (r == 3) { + if (dy >= 2) inset = 2; + else if (dy >= 1) inset = 1; + } else { + for (int i = r; i > 0; i--) { + int dx = r - i; + if (dx * dx + dy * dy < r * r) { inset = i; break; } + } + } + } else if (row >= h - r) { + int dy = row - (h - r); + if (r == 3) { + if (dy >= 2) inset = 2; + else if (dy >= 1) inset = 1; + } else { + for (int i = r; i > 0; i--) { + int dx = r - i; + if (dx * dx + dy * dy < r * r) { inset = i; break; } + } + } + } + int x0 = x + inset; + int x1 = x + w - inset; + if (x0 < 0) x0 = 0; + if (x1 > bw) x1 = bw; + for (int col = x0; col < x1; col++) + px[py * bw + col] = v; + } +} + +int str_len(const char* s) { + int n = 0; + while (s[n]) n++; + return n; +} + +void str_cpy(char* dst, const char* src, int max) { + int i = 0; + while (src[i] && i < max - 1) { dst[i] = src[i]; i++; } + dst[i] = '\0'; +} + +bool is_digit(char c) { return c >= '0' && c <= '9'; } +bool is_alpha(char c) { return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); } +char to_upper(char c) { return (c >= 'a' && c <= 'z') ? c - 32 : c; } + +double str_to_double(const char* s, bool* ok) { + *ok = false; + if (!s || !s[0]) return 0; + + int i = 0; + bool neg = false; + if (s[i] == '-') { neg = true; i++; } + else if (s[i] == '+') i++; + + double result = 0; + bool has_digit = false; + while (is_digit(s[i])) { + result = result * 10 + (s[i] - '0'); + has_digit = true; + i++; + } + + if (s[i] == '.') { + i++; + double frac = 0.1; + while (is_digit(s[i])) { + result += (s[i] - '0') * frac; + frac *= 0.1; + has_digit = true; + i++; + } + } + + if (!has_digit) return 0; + while (s[i] == ' ') i++; + if (s[i] != '\0') return 0; + + *ok = true; + return neg ? -result : result; +} + +void double_to_str(char* buf, int max, double v) { + bool neg = false; + if (v < 0) { neg = true; v = -v; } + + long long integer_part = (long long)v; + double frac = v - (double)integer_part; + + if (frac < 0.005) { + int i = 0; + if (neg && integer_part != 0) buf[i++] = '-'; + if (integer_part == 0) { + buf[i++] = '0'; + } else { + char tmp[32]; + int t = 0; + long long n = integer_part; + while (n > 0 && t < 30) { tmp[t++] = '0' + (int)(n % 10); n /= 10; } + while (t > 0 && i < max - 1) buf[i++] = tmp[--t]; + } + buf[i] = '\0'; + return; + } + + long long rounded = (long long)(v * 100 + 0.5); + long long int_part = rounded / 100; + int dec_part = (int)(rounded % 100); + + int i = 0; + if (neg) buf[i++] = '-'; + if (int_part == 0) { + buf[i++] = '0'; + } else { + char tmp[32]; + int t = 0; + long long n = int_part; + while (n > 0 && t < 30) { tmp[t++] = '0' + (int)(n % 10); n /= 10; } + while (t > 0 && i < max - 1) buf[i++] = tmp[--t]; + } + if (i < max - 3) { + buf[i++] = '.'; + buf[i++] = '0' + dec_part / 10; + buf[i++] = '0' + dec_part % 10; + } + buf[i] = '\0'; + + int len = str_len(buf); + if (len > 0) { + bool has_dot = false; + for (int j = 0; j < len; j++) if (buf[j] == '.') has_dot = true; + if (has_dot) { + while (len > 1 && buf[len - 1] == '0') len--; + if (len > 1 && buf[len - 1] == '.') len--; + buf[len] = '\0'; + } + } +} diff --git a/programs/src/spreadsheet/main.cpp b/programs/src/spreadsheet/main.cpp new file mode 100644 index 0000000..10b06f8 --- /dev/null +++ b/programs/src/spreadsheet/main.cpp @@ -0,0 +1,538 @@ +/* + * main.cpp + * Spreadsheet app — global state, hit testing, entry point + * Copyright (c) 2026 Daniel Hammer + */ + +#include "spreadsheet.h" + +// ============================================================================ +// Global state definitions +// ============================================================================ + +int g_win_w = INIT_W; +int g_win_h = INIT_H; + +Cell g_cells[MAX_ROWS][MAX_COLS]; + +int g_sel_col = 0; +int g_sel_row = 0; + +int g_scroll_x = 0; +int g_scroll_y = 0; + +int g_col_widths[MAX_COLS]; + +bool g_editing = false; +char g_edit_buf[CELL_TEXT_MAX]; +int g_edit_len = 0; +int g_edit_cursor = 0; + +bool g_has_selection = false; +int g_anchor_col = 0; +int g_anchor_row = 0; + +ClipCell g_clipboard[CLIP_MAX_CELLS]; +int g_clip_count = 0; +int g_clip_cols = 0; +int g_clip_rows = 0; + +char g_filepath[256] = {}; +bool g_modified = false; + +bool g_pathbar_open = false; +bool g_pathbar_save = false; +char g_pathbar_text[256] = {}; +int g_pathbar_len = 0; +int g_pathbar_cursor = 0; + +TrueTypeFont* g_font = nullptr; +TrueTypeFont* g_font_bold = nullptr; + +bool g_fmt_dropdown_open = false; + +UndoEntry* g_undo[UNDO_MAX + 1]; +int g_undo_count = 0; +int g_undo_pos = 0; + +// ============================================================================ +// Hit testing +// ============================================================================ + +bool hit_cell(int mx, int my, int* out_col, int* out_row) { + int pbh = g_pathbar_open ? PATHBAR_H : 0; + int grid_y = TOOLBAR_H + pbh + FORMULA_BAR_H + COL_HEADER_H; + if (mx < ROW_HEADER_W || my < grid_y) return false; + + int content_x = mx + g_scroll_x; + int content_y = my - grid_y + g_scroll_y; + + *out_row = content_y / ROW_H; + if (*out_row < 0) *out_row = 0; + if (*out_row >= MAX_ROWS) *out_row = MAX_ROWS - 1; + + int x = ROW_HEADER_W; + for (int c = 0; c < MAX_COLS; c++) { + if (content_x >= x - ROW_HEADER_W && content_x < x - ROW_HEADER_W + g_col_widths[c]) { + *out_col = c; + return true; + } + x += g_col_widths[c]; + } + + *out_col = MAX_COLS - 1; + return true; +} + +bool handle_toolbar_click(int mx, int my) { + if (my >= TOOLBAR_H || my < TB_BTN_Y || my >= TB_BTN_Y + TB_BTN_SIZE) return false; + + if (g_fmt_dropdown_open && !(mx >= TB_FMT_X0 && mx < TB_FMT_X1)) { + g_fmt_dropdown_open = false; + } + + if (mx >= TB_OPEN_X0 && mx < TB_OPEN_X1) { + if (g_editing) commit_edit(); + g_pathbar_open = true; + g_pathbar_save = false; + g_pathbar_text[0] = '\0'; + g_pathbar_len = 0; + g_pathbar_cursor = 0; + } else if (mx >= TB_SAVE_X0 && mx < TB_SAVE_X1) { + if (g_editing) commit_edit(); + if (g_filepath[0]) { + save_file(); + } else { + g_pathbar_open = true; + g_pathbar_save = true; + str_cpy(g_pathbar_text, "0:/", 256); + g_pathbar_len = str_len(g_pathbar_text); + g_pathbar_cursor = g_pathbar_len; + } + } else if (mx >= TB_CUT_X0 && mx < TB_CUT_X1) { + if (!g_editing) cut_selection(); + } else if (mx >= TB_COPY_X0 && mx < TB_COPY_X1) { + if (!g_editing) copy_selection(); + } else if (mx >= TB_PASTE_X0 && mx < TB_PASTE_X1) { + if (!g_editing) paste_at_cursor(); + } else if (mx >= TB_BOLD_X0 && mx < TB_BOLD_X1) { + if (!g_editing) apply_bold_toggle(); + } else if (mx >= TB_AL_X0 && mx < TB_AL_X1) { + if (!g_editing) apply_align(ALIGN_LEFT); + } else if (mx >= TB_AC_X0 && mx < TB_AC_X1) { + if (!g_editing) apply_align(ALIGN_CENTER); + } else if (mx >= TB_AR_X0 && mx < TB_AR_X1) { + if (!g_editing) apply_align(ALIGN_RIGHT); + } else if (mx >= TB_FMT_X0 && mx < TB_FMT_X1) { + g_fmt_dropdown_open = !g_fmt_dropdown_open; + } else if (mx >= TB_UNDO_X0 && mx < TB_UNDO_X1) { + undo_do(); + } else if (mx >= TB_REDO_X0 && mx < TB_REDO_X1) { + redo_do(); + } else { + return false; + } + return true; +} + +bool handle_fmt_dropdown_click(int mx, int my) { + if (!g_fmt_dropdown_open) return false; + int dx = TB_FMT_X0; + int dy = TOOLBAR_H; + int dw = 80; + int item_h = 26; + int item_count = 4; + int dh = item_count * item_h + 4; + + if (mx >= dx && mx < dx + dw && my >= dy && my < dy + dh) { + int idx = (my - dy - 2) / item_h; + if (idx >= 0 && idx < item_count) { + apply_format((NumFormat)idx); + } + g_fmt_dropdown_open = false; + return true; + } + g_fmt_dropdown_open = false; + return true; +} + +// ============================================================================ +// Entry point +// ============================================================================ + +extern "C" void _start() { + // Initialize column widths + for (int i = 0; i < MAX_COLS; i++) + g_col_widths[i] = DEF_COL_W; + + // Initialize cells + for (int r = 0; r < MAX_ROWS; r++) + for (int c = 0; c < MAX_COLS; c++) { + g_cells[r][c].input[0] = '\0'; + g_cells[r][c].display[0] = '\0'; + g_cells[r][c].value = 0; + g_cells[r][c].type = CT_EMPTY; + g_cells[r][c].align = ALIGN_AUTO; + g_cells[r][c].fmt = FMT_AUTO; + g_cells[r][c].bold = false; + } + + // Initialize undo pointers + for (int i = 0; i <= UNDO_MAX; i++) g_undo[i] = nullptr; + g_undo_count = 0; + g_undo_pos = 0; + + // Load font + auto load_font = [](const char* path) -> TrueTypeFont* { + TrueTypeFont* f = (TrueTypeFont*)montauk::malloc(sizeof(TrueTypeFont)); + if (!f) return nullptr; + montauk::memset(f, 0, sizeof(TrueTypeFont)); + if (!f->init(path)) { montauk::mfree(f); return nullptr; } + return f; + }; + g_font = load_font("0:/fonts/Roboto-Medium.ttf"); + g_font_bold = load_font("0:/fonts/Roboto-Bold.ttf"); + + // Check for file argument + char args[512] = {}; + int arglen = montauk::getargs(args, sizeof(args)); + if (arglen > 0 && args[0]) { + str_cpy(g_filepath, args, 256); + load_file(g_filepath); + } + + // Build window title + char title[64] = "Spreadsheet"; + if (g_filepath[0]) { + const char* fname = g_filepath; + for (int i = 0; g_filepath[i]; i++) + if (g_filepath[i] == '/') fname = g_filepath + i + 1; + snprintf(title, 64, "%s - Spreadsheet", fname); + } + + // Create window + Montauk::WinCreateResult wres; + if (montauk::win_create(title, INIT_W, INIT_H, &wres) < 0 || wres.id < 0) + montauk::exit(1); + + int win_id = wres.id; + uint32_t* pixels = (uint32_t*)(uintptr_t)wres.pixelVa; + + render(pixels); + montauk::win_present(win_id); + + while (true) { + Montauk::WinEvent ev; + int r = montauk::win_poll(win_id, &ev); + + if (r < 0) break; + + if (r == 0) { + montauk::sleep_ms(16); + continue; + } + + // Close + if (ev.type == 3) break; + + // Resize + if (ev.type == 2) { + g_win_w = ev.resize.w; + g_win_h = ev.resize.h; + pixels = (uint32_t*)(uintptr_t)montauk::win_resize(win_id, g_win_w, g_win_h); + clamp_scroll(); + render(pixels); + montauk::win_present(win_id); + continue; + } + + bool redraw = false; + + // Keyboard + if (ev.type == 0 && ev.key.pressed) { + auto& key = ev.key; + + // Path bar input (intercepts all keys when open) + if (g_pathbar_open) { + if (key.ascii == '\n' || key.ascii == '\r') { + if (g_pathbar_text[0]) { + if (g_pathbar_save) { + str_cpy(g_filepath, g_pathbar_text, 256); + save_file(); + } else { + load_file(g_pathbar_text); + } + } + g_pathbar_open = false; + } else if (key.scancode == 0x01) { + g_pathbar_open = false; + } else if (key.ascii == '\b' || key.scancode == 0x0E) { + if (g_pathbar_cursor > 0) { + for (int i = g_pathbar_cursor - 1; i < g_pathbar_len - 1; i++) + g_pathbar_text[i] = g_pathbar_text[i + 1]; + g_pathbar_len--; + g_pathbar_cursor--; + g_pathbar_text[g_pathbar_len] = '\0'; + } + } else if (key.scancode == 0x4B) { + if (g_pathbar_cursor > 0) g_pathbar_cursor--; + } else if (key.scancode == 0x4D) { + if (g_pathbar_cursor < g_pathbar_len) g_pathbar_cursor++; + } else if (key.ascii >= 32 && key.ascii < 127 && g_pathbar_len < 254) { + for (int i = g_pathbar_len; i > g_pathbar_cursor; i--) + g_pathbar_text[i] = g_pathbar_text[i - 1]; + g_pathbar_text[g_pathbar_cursor] = key.ascii; + g_pathbar_cursor++; + g_pathbar_len++; + g_pathbar_text[g_pathbar_len] = '\0'; + } + redraw = true; + goto done_keys; + } + + // Escape: cancel edit or quit + if (key.scancode == 0x01) { + if (g_editing) { cancel_edit(); redraw = true; } + else break; + } + // Enter: commit edit and move down + else if (key.ascii == '\n' || key.ascii == '\r') { + if (g_editing) commit_edit(); + if (g_sel_row < MAX_ROWS - 1) g_sel_row++; + ensure_sel_visible(); + redraw = true; + } + // Tab: commit and move right + else if (key.ascii == '\t') { + if (g_editing) commit_edit(); + if (key.shift) { + if (g_sel_col > 0) g_sel_col--; + } else { + if (g_sel_col < MAX_COLS - 1) g_sel_col++; + } + ensure_sel_visible(); + redraw = true; + } + // Arrow keys (with Shift = extend selection) + else if (key.scancode == 0x48 && !g_editing) { // Up + if (key.shift) { + if (!g_has_selection) { g_anchor_col = g_sel_col; g_anchor_row = g_sel_row; g_has_selection = true; } + if (g_sel_row > 0) g_sel_row--; + } else { + if (g_sel_row > 0) g_sel_row--; + clear_selection(); + } + ensure_sel_visible(); + redraw = true; + } + else if (key.scancode == 0x50 && !g_editing) { // Down + if (key.shift) { + if (!g_has_selection) { g_anchor_col = g_sel_col; g_anchor_row = g_sel_row; g_has_selection = true; } + if (g_sel_row < MAX_ROWS - 1) g_sel_row++; + } else { + if (g_sel_row < MAX_ROWS - 1) g_sel_row++; + clear_selection(); + } + ensure_sel_visible(); + redraw = true; + } + else if (key.scancode == 0x4B && !g_editing) { // Left + if (key.shift) { + if (!g_has_selection) { g_anchor_col = g_sel_col; g_anchor_row = g_sel_row; g_has_selection = true; } + if (g_sel_col > 0) g_sel_col--; + } else { + if (g_sel_col > 0) g_sel_col--; + clear_selection(); + } + ensure_sel_visible(); + redraw = true; + } + else if (key.scancode == 0x4D && !g_editing) { // Right + if (key.shift) { + if (!g_has_selection) { g_anchor_col = g_sel_col; g_anchor_row = g_sel_row; g_has_selection = true; } + if (g_sel_col < MAX_COLS - 1) g_sel_col++; + } else { + if (g_sel_col < MAX_COLS - 1) g_sel_col++; + clear_selection(); + } + ensure_sel_visible(); + redraw = true; + } + // Backspace + else if (key.ascii == '\b' || key.scancode == 0x0E) { + if (g_editing) { + if (g_edit_cursor > 0) { + for (int i = g_edit_cursor - 1; i < g_edit_len - 1; i++) + g_edit_buf[i] = g_edit_buf[i + 1]; + g_edit_len--; + g_edit_cursor--; + g_edit_buf[g_edit_len] = '\0'; + } + redraw = true; + } else { + undo_push(); + int c0, r0, c1, r1; + sel_range(&c0, &r0, &c1, &r1); + for (int r = r0; r <= r1; r++) + for (int c = c0; c <= c1; c++) + g_cells[r][c].input[0] = '\0'; + eval_all_cells(); + g_modified = true; + redraw = true; + } + } + // Delete key + else if (key.scancode == 0x53) { + if (g_editing) { + if (g_edit_cursor < g_edit_len) { + for (int i = g_edit_cursor; i < g_edit_len - 1; i++) + g_edit_buf[i] = g_edit_buf[i + 1]; + g_edit_len--; + g_edit_buf[g_edit_len] = '\0'; + } + redraw = true; + } else { + undo_push(); + int c0, r0, c1, r1; + sel_range(&c0, &r0, &c1, &r1); + for (int r = r0; r <= r1; r++) + for (int c = c0; c <= c1; c++) + g_cells[r][c].input[0] = '\0'; + eval_all_cells(); + g_modified = true; + redraw = true; + } + } + // Edit mode arrow keys (cursor movement within formula bar) + else if (key.scancode == 0x4B && g_editing) { + if (g_edit_cursor > 0) g_edit_cursor--; + redraw = true; + } + else if (key.scancode == 0x4D && g_editing) { + if (g_edit_cursor < g_edit_len) g_edit_cursor++; + redraw = true; + } + // Ctrl+B: bold toggle + else if (key.ctrl && (key.ascii == 'b' || key.ascii == 'B' || key.ascii == 2) && !g_editing) { + apply_bold_toggle(); + redraw = true; + } + // Ctrl+Z: undo + else if (key.ctrl && (key.ascii == 'z' || key.ascii == 'Z' || key.ascii == 26) && !g_editing) { + undo_do(); + redraw = true; + } + // Ctrl+Y: redo + else if (key.ctrl && (key.ascii == 'y' || key.ascii == 'Y' || key.ascii == 25) && !g_editing) { + redo_do(); + redraw = true; + } + // Ctrl+C: copy + else if (key.ctrl && (key.ascii == 'c' || key.ascii == 'C' || key.ascii == 3) && !g_editing) { + copy_selection(); + redraw = true; + } + // Ctrl+X: cut + else if (key.ctrl && (key.ascii == 'x' || key.ascii == 'X' || key.ascii == 24) && !g_editing) { + cut_selection(); + redraw = true; + } + // Ctrl+V: paste + else if (key.ctrl && (key.ascii == 'v' || key.ascii == 'V' || key.ascii == 22) && !g_editing) { + paste_at_cursor(); + redraw = true; + } + // Ctrl+S: save (or Save As if no path) + else if (key.ctrl && (key.ascii == 's' || key.ascii == 'S' || key.ascii == 19)) { + if (g_editing) commit_edit(); + if (g_filepath[0]) { + save_file(); + } else { + g_pathbar_open = true; + g_pathbar_save = true; + str_cpy(g_pathbar_text, "0:/", 256); + g_pathbar_len = str_len(g_pathbar_text); + g_pathbar_cursor = g_pathbar_len; + } + redraw = true; + } + // Ctrl+O: open + else if (key.ctrl && (key.ascii == 'o' || key.ascii == 'O' || key.ascii == 15) && !g_editing) { + g_pathbar_open = true; + g_pathbar_save = false; + g_pathbar_text[0] = '\0'; + g_pathbar_len = 0; + g_pathbar_cursor = 0; + redraw = true; + } + // Printable character: start editing or insert + else if (key.ascii >= 32 && key.ascii < 127 && !key.ctrl) { + if (!g_editing) { + clear_selection(); + g_editing = true; + g_edit_buf[0] = key.ascii; + g_edit_buf[1] = '\0'; + g_edit_len = 1; + g_edit_cursor = 1; + } else if (g_edit_len < CELL_TEXT_MAX - 1) { + for (int i = g_edit_len; i > g_edit_cursor; i--) + g_edit_buf[i] = g_edit_buf[i - 1]; + g_edit_buf[g_edit_cursor] = key.ascii; + g_edit_len++; + g_edit_cursor++; + g_edit_buf[g_edit_len] = '\0'; + } + redraw = true; + } + // F2: edit current cell content (append mode) + else if (key.scancode == 0x3C) { + if (!g_editing) start_editing(); + redraw = true; + } + } + done_keys: + + // Mouse + if (ev.type == 1) { + int mx = ev.mouse.x; + int my = ev.mouse.y; + uint8_t btns = ev.mouse.buttons; + uint8_t prev = ev.mouse.prev_buttons; + bool clicked = (btns & 1) && !(prev & 1); + + if (clicked) { + if (handle_fmt_dropdown_click(mx, my)) { + redraw = true; + } + else if (handle_toolbar_click(mx, my)) { + redraw = true; + } + else { + int col, row; + if (hit_cell(mx, my, &col, &row)) { + if (g_editing) commit_edit(); + g_sel_col = col; + g_sel_row = row; + clear_selection(); + g_fmt_dropdown_open = false; + redraw = true; + } + } + } + + if (ev.mouse.scroll != 0) { + g_scroll_y -= ev.mouse.scroll * SCROLL_STEP; + clamp_scroll(); + redraw = true; + } + } + + if (redraw) { + render(pixels); + montauk::win_present(win_id); + } + } + + montauk::win_destroy(win_id); + montauk::exit(0); +} diff --git a/programs/src/spreadsheet/render.cpp b/programs/src/spreadsheet/render.cpp new file mode 100644 index 0000000..8c1dc4b --- /dev/null +++ b/programs/src/spreadsheet/render.cpp @@ -0,0 +1,342 @@ +/* + * render.cpp + * Rendering — toolbar, grid, status bar, overlays + * Copyright (c) 2026 Daniel Hammer + */ + +#include "spreadsheet.h" + +void render(uint32_t* pixels) { + px_fill(pixels, g_win_w, g_win_h, 0, 0, g_win_w, g_win_h, BG_COLOR); + + // ---- Toolbar ---- + px_fill(pixels, g_win_w, g_win_h, 0, 0, g_win_w, TOOLBAR_H, TOOLBAR_BG); + px_hline(pixels, g_win_w, g_win_h, 0, TOOLBAR_H - 1, g_win_w, GRID_COLOR); + + Cell* cur_cell = &g_cells[g_sel_row][g_sel_col]; + + int bx = 4; + auto tb_btn = [&](int w, bool active, const char* label) { + Color bg = active ? TB_BTN_ACTIVE : TB_BTN_BG; + px_fill_rounded(pixels, g_win_w, g_win_h, bx, TB_BTN_Y, w, TB_BTN_SIZE, TB_BTN_RAD, bg); + if (g_font && label[0]) { + TrueTypeFont* f = (active && g_font_bold) ? g_font_bold : g_font; + int tw = f->measure_text(label, HEADER_FONT); + f->draw_to_buffer(pixels, g_win_w, g_win_h, + bx + (w - tw) / 2, TB_BTN_Y + (TB_BTN_SIZE - HEADER_FONT) / 2, + label, HEADER_TEXT, HEADER_FONT); + } + bx += w + 4; + }; + auto tb_sep = [&]() { + px_vline(pixels, g_win_w, g_win_h, bx, 6, TOOLBAR_H - 12, TB_SEP_COLOR); + bx += 8; + }; + + // Open / Save + tb_btn(36, false, "Open"); + tb_btn(36, false, "Save"); + tb_sep(); + + // Cut/Copy/Paste + tb_btn(28, false, "Cut"); + tb_btn(36, false, "Copy"); + tb_btn(36, false, "Paste"); + tb_sep(); + + // Bold + tb_btn(24, cur_cell->bold, "B"); + tb_sep(); + + // Alignment + tb_btn(24, cur_cell->align == ALIGN_LEFT || (cur_cell->align == ALIGN_AUTO && cur_cell->type == CT_TEXT), "L"); + tb_btn(24, cur_cell->align == ALIGN_CENTER, "C"); + tb_btn(24, cur_cell->align == ALIGN_RIGHT || (cur_cell->align == ALIGN_AUTO && (cur_cell->type == CT_NUMBER || cur_cell->type == CT_FORMULA)), "R"); + tb_sep(); + + // Format dropdown + { + const char* fmt_labels[] = { "Auto", ".00", "$", "%" }; + int fi = (int)cur_cell->fmt; + if (fi < 0 || fi > 3) fi = 0; + char fmt_label[16]; + snprintf(fmt_label, 16, "%s v", fmt_labels[fi]); + int fmt_w = 64; + Color bg = g_fmt_dropdown_open ? TB_BTN_ACTIVE : TB_BTN_BG; + px_fill_rounded(pixels, g_win_w, g_win_h, bx, TB_BTN_Y, fmt_w, TB_BTN_SIZE, TB_BTN_RAD, bg); + if (g_font) { + int tw = g_font->measure_text(fmt_label, HEADER_FONT); + g_font->draw_to_buffer(pixels, g_win_w, g_win_h, + bx + (fmt_w - tw) / 2, TB_BTN_Y + (TB_BTN_SIZE - HEADER_FONT) / 2, + fmt_label, HEADER_TEXT, HEADER_FONT); + } + bx += fmt_w + 4; + tb_sep(); + + // Undo/Redo + tb_btn(36, false, "Undo"); + tb_btn(36, false, "Redo"); + } + + // ---- Path bar (Open/Save As) ---- + int pathbar_h = g_pathbar_open ? PATHBAR_H : 0; + if (g_pathbar_open) { + int pby = TOOLBAR_H; + px_fill(pixels, g_win_w, g_win_h, 0, pby, g_win_w, PATHBAR_H, HEADER_BG); + px_hline(pixels, g_win_w, g_win_h, 0, pby + PATHBAR_H - 1, g_win_w, GRID_COLOR); + + const char* label = g_pathbar_save ? "Save as:" : "Open:"; + if (g_font) { + int lw = g_font->measure_text(label, HEADER_FONT); + g_font->draw_to_buffer(pixels, g_win_w, g_win_h, + 8, pby + (PATHBAR_H - HEADER_FONT) / 2, label, HEADER_TEXT, HEADER_FONT); + + int inp_x = 16 + lw; + int inp_w = g_win_w - inp_x - 8; + px_fill(pixels, g_win_w, g_win_h, inp_x, pby + 4, inp_w, PATHBAR_H - 8, BG_COLOR); + px_rect(pixels, g_win_w, g_win_h, inp_x, pby + 4, inp_w, PATHBAR_H - 8, GRID_COLOR); + + if (g_pathbar_text[0]) + g_font->draw_to_buffer(pixels, g_win_w, g_win_h, + inp_x + 4, pby + (PATHBAR_H - FONT_SIZE) / 2, + g_pathbar_text, CELL_TEXT, FONT_SIZE); + + char prefix[256]; + int plen = g_pathbar_cursor < 255 ? g_pathbar_cursor : 255; + for (int i = 0; i < plen; i++) prefix[i] = g_pathbar_text[i]; + prefix[plen] = '\0'; + int cx = inp_x + 4 + g_font->measure_text(prefix, FONT_SIZE); + px_fill(pixels, g_win_w, g_win_h, cx, pby + 6, 2, PATHBAR_H - 12, SELECT_BORDER); + } + } + + // ---- Formula bar ---- + int fbar_y = TOOLBAR_H + pathbar_h; + px_fill(pixels, g_win_w, g_win_h, 0, fbar_y, g_win_w, FORMULA_BAR_H, HEADER_BG); + px_hline(pixels, g_win_w, g_win_h, 0, fbar_y + FORMULA_BAR_H - 1, g_win_w, GRID_COLOR); + + char name_buf[8]; + cell_name(name_buf, g_sel_col, g_sel_row); + int fbar_ty = fbar_y + (FORMULA_BAR_H - HEADER_FONT) / 2; + if (g_font) + g_font->draw_to_buffer(pixels, g_win_w, g_win_h, 8, fbar_ty, name_buf, HEADER_TEXT, HEADER_FONT); + + int sep_x = ROW_HEADER_W + 12; + px_vline(pixels, g_win_w, g_win_h, sep_x, fbar_y + 4, FORMULA_BAR_H - 8, GRID_COLOR); + + { + int fx = sep_x + 10; + const char* display; + if (g_editing) { + display = g_edit_buf; + } else { + display = cur_cell->input; + } + if (g_font && display[0]) + g_font->draw_to_buffer(pixels, g_win_w, g_win_h, fx, fbar_ty, display, CELL_TEXT, FONT_SIZE); + + if (g_editing && g_font) { + char prefix[CELL_TEXT_MAX]; + int plen = g_edit_cursor < CELL_TEXT_MAX - 1 ? g_edit_cursor : CELL_TEXT_MAX - 1; + for (int i = 0; i < plen; i++) prefix[i] = g_edit_buf[i]; + prefix[plen] = '\0'; + int cx = fx + g_font->measure_text(prefix, FONT_SIZE); + px_fill(pixels, g_win_w, g_win_h, cx, fbar_y + 6, 2, FORMULA_BAR_H - 12, SELECT_BORDER); + } + } + + int area_y = TOOLBAR_H + pathbar_h + FORMULA_BAR_H; + + // ---- Column headers ---- + px_fill(pixels, g_win_w, g_win_h, 0, area_y, g_win_w, COL_HEADER_H, HEADER_BG); + px_hline(pixels, g_win_w, g_win_h, 0, area_y + COL_HEADER_H - 1, g_win_w, GRID_COLOR); + + px_fill(pixels, g_win_w, g_win_h, 0, area_y, ROW_HEADER_W, COL_HEADER_H, HEADER_BG); + px_vline(pixels, g_win_w, g_win_h, ROW_HEADER_W - 1, area_y, COL_HEADER_H, GRID_COLOR); + + for (int c = 0; c < MAX_COLS; c++) { + int x = col_x(c) - g_scroll_x; + if (x + g_col_widths[c] <= ROW_HEADER_W) continue; + if (x >= g_win_w) break; + + { + int sc0, sr0, sc1, sr1; + sel_range(&sc0, &sr0, &sc1, &sr1); + if (c >= sc0 && c <= sc1) + px_fill(pixels, g_win_w, g_win_h, x, area_y, g_col_widths[c], COL_HEADER_H, SELECT_FILL); + } + + char label[2] = { (char)('A' + c), '\0' }; + if (g_font) { + int tw = g_font->measure_text(label, HEADER_FONT); + int lx = x + (g_col_widths[c] - tw) / 2; + if (lx >= ROW_HEADER_W) + g_font->draw_to_buffer(pixels, g_win_w, g_win_h, lx, area_y + (COL_HEADER_H - HEADER_FONT) / 2, label, HEADER_TEXT, HEADER_FONT); + } + + px_vline(pixels, g_win_w, g_win_h, x + g_col_widths[c] - 1, area_y, COL_HEADER_H, GRID_COLOR); + } + + // ---- Row headers + grid ---- + int grid_y = area_y + COL_HEADER_H; + int grid_h = g_win_h - TOOLBAR_H - pathbar_h - FORMULA_BAR_H - COL_HEADER_H - STATUS_BAR_H; + + for (int r = 0; r < MAX_ROWS; r++) { + int y = grid_y + r * ROW_H - g_scroll_y; + if (y + ROW_H <= grid_y) continue; + if (y >= grid_y + grid_h) break; + + // Row header + { + int sc0, sr0, sc1, sr1; + sel_range(&sc0, &sr0, &sc1, &sr1); + if (r >= sr0 && r <= sr1) + px_fill(pixels, g_win_w, g_win_h, 0, y, ROW_HEADER_W, ROW_H, SELECT_FILL); + else + px_fill(pixels, g_win_w, g_win_h, 0, y, ROW_HEADER_W, ROW_H, HEADER_BG); + } + + char row_label[8]; + int rv = r + 1; + if (rv >= 100) { row_label[0] = '0' + rv / 100; row_label[1] = '0' + (rv / 10) % 10; row_label[2] = '0' + rv % 10; row_label[3] = '\0'; } + else if (rv >= 10) { row_label[0] = '0' + rv / 10; row_label[1] = '0' + rv % 10; row_label[2] = '\0'; } + else { row_label[0] = '0' + rv; row_label[1] = '\0'; } + + if (g_font) { + int tw = g_font->measure_text(row_label, HEADER_FONT); + int lx = (ROW_HEADER_W - tw) / 2; + g_font->draw_to_buffer(pixels, g_win_w, g_win_h, lx, y + (ROW_H - HEADER_FONT) / 2, row_label, HEADER_TEXT, HEADER_FONT); + } + + px_vline(pixels, g_win_w, g_win_h, ROW_HEADER_W - 1, y, ROW_H, GRID_COLOR); + + // Cells in this row + for (int c = 0; c < MAX_COLS; c++) { + int x = col_x(c) - g_scroll_x; + if (x + g_col_widths[c] <= ROW_HEADER_W) continue; + if (x >= g_win_w) break; + + Cell* cell = &g_cells[r][c]; + + // Selection fill (drawn before text so text is visible) + if (g_has_selection) { + int sc0, sr0, sc1, sr1; + sel_range(&sc0, &sr0, &sc1, &sr1); + if (c >= sc0 && c <= sc1 && r >= sr0 && r <= sr1 && + !(c == g_sel_col && r == g_sel_row)) + px_fill(pixels, g_win_w, g_win_h, x + 1, y + 1, g_col_widths[c] - 2, ROW_H - 2, SELECT_FILL); + } + + // Cell text + if (cell->display[0] && g_font) { + Color col = CELL_TEXT; + if (cell->type == CT_NUMBER || cell->type == CT_FORMULA) col = NUM_COLOR; + if (cell->type == CT_ERROR) col = ERR_COLOR; + + TrueTypeFont* cf = (cell->bold && g_font_bold) ? g_font_bold : g_font; + + CellAlign eff_align = cell->align; + if (eff_align == ALIGN_AUTO) { + eff_align = (cell->type == CT_NUMBER || cell->type == CT_FORMULA) ? ALIGN_RIGHT : ALIGN_LEFT; + } + + int tw = cf->measure_text(cell->display, FONT_SIZE); + int text_x; + if (eff_align == ALIGN_RIGHT) + text_x = x + g_col_widths[c] - tw - 6; + else if (eff_align == ALIGN_CENTER) + text_x = x + (g_col_widths[c] - tw) / 2; + else + text_x = x + 6; + + if (text_x >= ROW_HEADER_W - tw && text_x < g_win_w) + cf->draw_to_buffer(pixels, g_win_w, g_win_h, text_x, y + (ROW_H - FONT_SIZE) / 2, cell->display, col, FONT_SIZE); + } + + // Cell right border + px_vline(pixels, g_win_w, g_win_h, x + g_col_widths[c] - 1, y, ROW_H, GRID_COLOR); + } + + // Row bottom border + px_hline(pixels, g_win_w, g_win_h, ROW_HEADER_W, y + ROW_H - 1, g_win_w - ROW_HEADER_W, GRID_COLOR); + } + + // ---- Selection border ---- + { + int c0, r0, c1, r1; + sel_range(&c0, &r0, &c1, &r1); + + int sx = col_x(c0) - g_scroll_x; + int sy_sel = grid_y + r0 * ROW_H - g_scroll_y; + int sw = 0; + for (int c = c0; c <= c1; c++) sw += g_col_widths[c]; + int sh = (r1 - r0 + 1) * ROW_H; + + px_rect(pixels, g_win_w, g_win_h, sx, sy_sel, sw, sh, SELECT_BORDER); + px_rect(pixels, g_win_w, g_win_h, sx + 1, sy_sel + 1, sw - 2, sh - 2, SELECT_BORDER); + } + + // ---- Status bar ---- + int sy = g_win_h - STATUS_BAR_H; + px_fill(pixels, g_win_w, g_win_h, 0, sy, g_win_w, STATUS_BAR_H, STATUS_BG); + + if (g_font) { + char status[128]; + if (g_filepath[0]) { + const char* fname = g_filepath; + for (int i = 0; g_filepath[i]; i++) + if (g_filepath[i] == '/') fname = g_filepath + i + 1; + snprintf(status, 128, " %s%s", fname, g_modified ? " *" : ""); + } else { + snprintf(status, 128, " Untitled%s", g_modified ? " *" : ""); + } + int sty = sy + (STATUS_BAR_H - HEADER_FONT) / 2; + g_font->draw_to_buffer(pixels, g_win_w, g_win_h, 6, sty, status, STATUS_TEXT, HEADER_FONT); + + char right[64]; + if (g_has_selection) { + int c0, r0, c1, r1; + sel_range(&c0, &r0, &c1, &r1); + char n0[8], n1[8]; + cell_name(n0, c0, r0); + cell_name(n1, c1, r1); + int ncells = (c1 - c0 + 1) * (r1 - r0 + 1); + snprintf(right, 64, "%s:%s (%d cells) ", n0, n1, ncells); + } else { + Cell* sel = &g_cells[g_sel_row][g_sel_col]; + char cname[8]; + cell_name(cname, g_sel_col, g_sel_row); + if (sel->type == CT_FORMULA || sel->type == CT_NUMBER) { + char vbuf[32]; + double_to_str(vbuf, 32, sel->value); + snprintf(right, 64, "%s = %s ", cname, vbuf); + } else { + snprintf(right, 64, "%s ", cname); + } + } + int rw = g_font->measure_text(right, HEADER_FONT); + g_font->draw_to_buffer(pixels, g_win_w, g_win_h, g_win_w - rw - 6, sty, right, STATUS_TEXT, HEADER_FONT); + } + + // ---- Format dropdown overlay ---- + if (g_fmt_dropdown_open && g_font) { + int dx = TB_FMT_X0; + int dy = TOOLBAR_H; + int dw = 80; + const char* items[] = { "Auto", "Decimal", "Currency $", "Percent %" }; + int item_count = 4; + int item_h = 26; + int dh = item_count * item_h + 4; + + px_fill(pixels, g_win_w, g_win_h, dx, dy, dw, dh, BG_COLOR); + px_rect(pixels, g_win_w, g_win_h, dx, dy, dw, dh, GRID_COLOR); + + for (int i = 0; i < item_count; i++) { + int iy = dy + 2 + i * item_h; + if ((int)cur_cell->fmt == i) + px_fill(pixels, g_win_w, g_win_h, dx + 2, iy, dw - 4, item_h - 2, SELECT_FILL); + g_font->draw_to_buffer(pixels, g_win_w, g_win_h, + dx + 8, iy + (item_h - HEADER_FONT) / 2, items[i], CELL_TEXT, HEADER_FONT); + } + } +} diff --git a/programs/src/spreadsheet/spreadsheet.h b/programs/src/spreadsheet/spreadsheet.h new file mode 100644 index 0000000..c4edd5f --- /dev/null +++ b/programs/src/spreadsheet/spreadsheet.h @@ -0,0 +1,240 @@ +/* + * spreadsheet.h + * Shared header for the MontaukOS spreadsheet app + * Copyright (c) 2026 Daniel Hammer + */ + +#pragma once + +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +} + +using namespace gui; + +// ============================================================================ +// Constants +// ============================================================================ + +static constexpr int INIT_W = 900; +static constexpr int INIT_H = 600; +static constexpr int TOOLBAR_H = 36; +static constexpr int FORMULA_BAR_H = 36; +static constexpr int COL_HEADER_H = 28; +static constexpr int ROW_HEADER_W = 48; +static constexpr int STATUS_BAR_H = 24; +static constexpr int SCROLL_STEP = 50; +static constexpr int TB_BTN_SIZE = 24; +static constexpr int TB_BTN_Y = 6; +static constexpr int TB_BTN_RAD = 3; + +static constexpr int MAX_COLS = 26; // A-Z +static constexpr int MAX_ROWS = 100; +static constexpr int DEF_COL_W = 100; +static constexpr int ROW_H = 26; + +static constexpr int CELL_TEXT_MAX = 128; +static constexpr int FONT_SIZE = 18; +static constexpr int HEADER_FONT = 16; + +static constexpr Color BG_COLOR = Color::from_rgb(0xFF, 0xFF, 0xFF); +static constexpr Color GRID_COLOR = Color::from_rgb(0xD0, 0xD0, 0xD0); +static constexpr Color HEADER_BG = Color::from_rgb(0xF0, 0xF0, 0xF0); +static constexpr Color HEADER_TEXT = Color::from_rgb(0x55, 0x55, 0x55); +static constexpr Color CELL_TEXT = Color::from_rgb(0x22, 0x22, 0x22); +static constexpr Color SELECT_BORDER = Color::from_rgb(0x36, 0x7B, 0xF0); +static constexpr Color SELECT_FILL = Color::from_rgb(0xD8, 0xE8, 0xFD); +static constexpr Color FORMULA_BG = Color::from_rgb(0xFF, 0xFF, 0xFF); +static constexpr Color FORMULA_BORDER = Color::from_rgb(0xCC, 0xCC, 0xCC); +static constexpr Color STATUS_BG = Color::from_rgb(0x2B, 0x3E, 0x50); +static constexpr Color STATUS_TEXT = Color::from_rgb(0xFF, 0xFF, 0xFF); +static constexpr Color NUM_COLOR = Color::from_rgb(0x10, 0x50, 0x90); +static constexpr Color ERR_COLOR = Color::from_rgb(0xCC, 0x22, 0x22); +static constexpr Color TOOLBAR_BG = Color::from_rgb(0xF5, 0xF5, 0xF5); +static constexpr Color TB_BTN_BG = Color::from_rgb(0xE8, 0xE8, 0xE8); +static constexpr Color TB_BTN_ACTIVE = Color::from_rgb(0xC0, 0xD0, 0xE8); +static constexpr Color TB_SEP_COLOR = Color::from_rgb(0xCC, 0xCC, 0xCC); + +// ============================================================================ +// Types +// ============================================================================ + +enum CellAlign : uint8_t { ALIGN_AUTO = 0, ALIGN_LEFT = 1, ALIGN_CENTER = 2, ALIGN_RIGHT = 3 }; +enum NumFormat : uint8_t { FMT_AUTO = 0, FMT_DECIMAL = 1, FMT_CURRENCY = 2, FMT_PERCENT = 3 }; + +enum CellType : uint8_t { + CT_EMPTY = 0, + CT_TEXT = 1, + CT_NUMBER = 2, + CT_FORMULA = 3, + CT_ERROR = 4, +}; + +struct Cell { + char input[CELL_TEXT_MAX]; // raw user input + char display[CELL_TEXT_MAX]; // computed display string + double value; // numeric value (for formulas/numbers) + CellType type; + CellAlign align; + NumFormat fmt; + bool bold; +}; + +static constexpr int CLIP_MAX_CELLS = 256; +struct ClipCell { + char text[CELL_TEXT_MAX]; + int rel_col; + int rel_row; + CellAlign align; + NumFormat fmt; + bool bold; +}; + +static constexpr int UNDO_MAX = 6; +struct UndoCellData { + char input[CELL_TEXT_MAX]; + CellAlign align; + NumFormat fmt; + bool bold; +}; +struct UndoEntry { + UndoCellData cells[MAX_ROWS][MAX_COLS]; +}; + +static constexpr int PATHBAR_H = 32; + +// ============================================================================ +// Toolbar button positions +// ============================================================================ + +static constexpr int TB_OPEN_X0 = 4, TB_OPEN_X1 = 40; +static constexpr int TB_SAVE_X0 = 44, TB_SAVE_X1 = 80; +static constexpr int TB_CUT_X0 = 92, TB_CUT_X1 = 120; +static constexpr int TB_COPY_X0 = 124, TB_COPY_X1 = 160; +static constexpr int TB_PASTE_X0 = 164, TB_PASTE_X1 = 200; +static constexpr int TB_BOLD_X0 = 212, TB_BOLD_X1 = 236; +static constexpr int TB_AL_X0 = 248, TB_AL_X1 = 272; +static constexpr int TB_AC_X0 = 276, TB_AC_X1 = 300; +static constexpr int TB_AR_X0 = 304, TB_AR_X1 = 328; +static constexpr int TB_FMT_X0 = 340, TB_FMT_X1 = 404; +static constexpr int TB_UNDO_X0 = 416, TB_UNDO_X1 = 452; +static constexpr int TB_REDO_X0 = 456, TB_REDO_X1 = 492; + +// ============================================================================ +// Global state (extern — defined in main.cpp) +// ============================================================================ + +extern int g_win_w, g_win_h; +extern Cell g_cells[MAX_ROWS][MAX_COLS]; +extern int g_sel_col, g_sel_row; +extern int g_scroll_x, g_scroll_y; +extern int g_col_widths[MAX_COLS]; + +extern bool g_editing; +extern char g_edit_buf[CELL_TEXT_MAX]; +extern int g_edit_len; +extern int g_edit_cursor; + +extern bool g_has_selection; +extern int g_anchor_col, g_anchor_row; + +extern ClipCell g_clipboard[CLIP_MAX_CELLS]; +extern int g_clip_count; +extern int g_clip_cols, g_clip_rows; + +extern char g_filepath[256]; +extern bool g_modified; + +extern bool g_pathbar_open; +extern bool g_pathbar_save; +extern char g_pathbar_text[256]; +extern int g_pathbar_len; +extern int g_pathbar_cursor; + +extern TrueTypeFont* g_font; +extern TrueTypeFont* g_font_bold; + +extern bool g_fmt_dropdown_open; + +extern UndoEntry* g_undo[UNDO_MAX + 1]; +extern int g_undo_count; +extern int g_undo_pos; + +// ============================================================================ +// Function declarations — helpers.cpp +// ============================================================================ + +void px_fill(uint32_t* px, int bw, int bh, int x, int y, int w, int h, Color c); +void px_hline(uint32_t* px, int bw, int bh, int x, int y, int w, Color c); +void px_vline(uint32_t* px, int bw, int bh, int x, int y, int h, Color c); +void px_rect(uint32_t* px, int bw, int bh, int x, int y, int w, int h, Color c); +void px_fill_rounded(uint32_t* px, int bw, int bh, int x, int y, int w, int h, int r, Color c); + +int str_len(const char* s); +void str_cpy(char* dst, const char* src, int max); +bool is_digit(char c); +bool is_alpha(char c); +char to_upper(char c); +double str_to_double(const char* s, bool* ok); +void double_to_str(char* buf, int max, double v); + +// ============================================================================ +// Function declarations — formula.cpp +// ============================================================================ + +void eval_cell(int col, int row); +void eval_all_cells(); +int col_x(int col); +int content_width(); +int content_height(); +void cell_name(char* buf, int col, int row); +void format_value(char* buf, int max, double val, NumFormat fmt); + +// ============================================================================ +// Function declarations — fileio.cpp +// ============================================================================ + +void save_file(); +void load_file(const char* path); + +// ============================================================================ +// Function declarations — edit.cpp +// ============================================================================ + +void undo_push(); +void undo_do(); +void redo_do(); +void start_editing(); +void commit_edit(); +void cancel_edit(); +void sel_range(int* c0, int* r0, int* c1, int* r1); +void clear_selection(); +void copy_selection(); +void cut_selection(); +void paste_at_cursor(); +void apply_bold_toggle(); +void apply_align(CellAlign a); +void apply_format(NumFormat f); +void clamp_scroll(); +void ensure_sel_visible(); + +// ============================================================================ +// Function declarations — render.cpp +// ============================================================================ + +void render(uint32_t* pixels); + +// ============================================================================ +// Function declarations — main.cpp +// ============================================================================ + +bool hit_cell(int mx, int my, int* out_col, int* out_row); +bool handle_toolbar_click(int mx, int my); +bool handle_fmt_dropdown_click(int mx, int my); diff --git a/programs/src/spreadsheet/stb_truetype_impl.cpp b/programs/src/spreadsheet/stb_truetype_impl.cpp new file mode 100644 index 0000000..9ce2266 --- /dev/null +++ b/programs/src/spreadsheet/stb_truetype_impl.cpp @@ -0,0 +1,35 @@ +/* + * stb_truetype_impl.cpp + * Single compilation unit for stb_truetype in MontaukOS freestanding environment + * Copyright (c) 2026 Daniel Hammer + */ + +#include +#include +#include +#include +#include + +// Override all stb_truetype dependencies before including the implementation + +#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/scripts/copy_fonts.sh b/scripts/copy_fonts.sh index 2ac178c..810caf7 100755 --- a/scripts/copy_fonts.sh +++ b/scripts/copy_fonts.sh @@ -16,10 +16,17 @@ FONTS=( "programs/gui/fonts/Roboto/static/Roboto-Regular.ttf" "programs/gui/fonts/Roboto/static/Roboto-Bold.ttf" "programs/gui/fonts/Roboto/static/Roboto-Medium.ttf" + "programs/gui/fonts/Roboto/static/Roboto-Italic.ttf" + "programs/gui/fonts/Roboto/static/Roboto-BoldItalic.ttf" "programs/gui/fonts/JetBrains_Mono/static/JetBrainsMono-Regular.ttf" "programs/gui/fonts/JetBrains_Mono/static/JetBrainsMono-Bold.ttf" "programs/gui/fonts/Noto_Serif/static/NotoSerif-Regular.ttf" "programs/gui/fonts/Noto_Serif/static/NotoSerif-SemiBold.ttf" + "programs/gui/fonts/Noto_Serif/static/NotoSerif-Italic.ttf" + "programs/gui/fonts/Noto_Serif/static/NotoSerif-BoldItalic.ttf" + "programs/gui/fonts/C059/C059-Roman.ttf" + "programs/gui/fonts/C059/C059-Bold.ttf" + "programs/gui/fonts/C059/C059-Italic.ttf" ) copied=0 diff --git a/scripts/copy_icons.sh b/scripts/copy_icons.sh index 03c8357..18a1067 100755 --- a/scripts/copy_icons.sh +++ b/scripts/copy_icons.sh @@ -84,6 +84,7 @@ ICONS=( "panel/weather-none-available.svg" "apps/symbolic/utilities-system-monitor-symbolic.svg" # system monitor "apps/scalable/utilities-system-monitor.svg" # system monitor + "mimetypes/scalable/spreadsheet.svg" ) copied=0