From 8f118c7a0fac8c1e489a01164813f858ebb594bd Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Fri, 27 Mar 2026 07:55:46 +0100 Subject: [PATCH] feat: new word processor features --- programs/include/gui/truetype.hpp | 2 + programs/src/wordprocessor/document.cpp | 1101 ++++++++++++++---- programs/src/wordprocessor/input.cpp | 236 +++- programs/src/wordprocessor/main.cpp | 36 + programs/src/wordprocessor/render.cpp | 173 ++- programs/src/wordprocessor/wordprocessor.hpp | 102 ++ scripts/copy_icons.sh | 9 + 7 files changed, 1402 insertions(+), 257 deletions(-) diff --git a/programs/include/gui/truetype.hpp b/programs/include/gui/truetype.hpp index 7706763..15df10f 100644 --- a/programs/include/gui/truetype.hpp +++ b/programs/include/gui/truetype.hpp @@ -169,6 +169,8 @@ struct TrueTypeFont { if (g->width > 0 && g->height > 0) { g->bitmap = (uint8_t*)montauk::malloc(g->width * g->height); + if (!g->bitmap) + return g; stbtt_MakeCodepointBitmap(&info, g->bitmap, g->width, g->height, g->width, gc->scale, gc->scale, codepoint); } diff --git a/programs/src/wordprocessor/document.cpp b/programs/src/wordprocessor/document.cpp index 46f12d5..a1bd602 100644 --- a/programs/src/wordprocessor/document.cpp +++ b/programs/src/wordprocessor/document.cpp @@ -6,6 +6,10 @@ #include "wordprocessor.hpp" +static inline int wp_min_int(int a, int b) { + return a < b ? a : b; +} + 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); @@ -35,11 +39,29 @@ static bool wp_same_style(const StyledRun* a, uint8_t font_id, uint8_t size, uin return a->font_id == font_id && a->size == size && a->flags == flags; } +void wp_init_paragraph_style(ParagraphStyle* para) { + para->align = PARA_ALIGN_LEFT; + para->list_type = PARA_LIST_NONE; + para->line_spacing = 100; + para->_pad = 0; + para->left_indent = 0; + para->first_line_indent = 0; + para->space_before = 0; + para->space_after = 0; +} + static void wp_font_at(WordProcessorState* wp, int abs_pos, TrueTypeFont** out_font, GlyphCache** out_gc) { + if (wp->run_count <= 0) { + *out_font = wp_get_font(wp->cur_font_id, wp->cur_flags); + *out_gc = (*out_font && (*out_font)->valid) ? (*out_font)->get_cache(wp->cur_size) : nullptr; + return; + } + int r, o; wp_pos_to_run(wp, abs_pos, &r, &o); if (r >= wp->run_count) r = wp->run_count - 1; + if (r < 0) r = 0; 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; @@ -52,6 +74,184 @@ static void wp_free_runs(WordProcessorState* wp) { wp->total_text_len = 0; } +static void wp_free_snapshot(UndoSnapshot* snap) { + if (snap->data) montauk::mfree(snap->data); + montauk::memset(snap, 0, sizeof(*snap)); +} + +static void wp_clear_history(WordProcessorState* wp) { + for (int i = 0; i < wp->undo_count; i++) + wp_free_snapshot(&wp->undo[i]); + wp->undo_count = 0; + wp->undo_pos = 0; +} + +static void wp_insert_paragraph_style(WordProcessorState* wp, int at, const ParagraphStyle* style) { + if (wp->paragraph_count >= WP_MAX_PARAGRAPHS) return; + if (at < 0) at = 0; + if (at > wp->paragraph_count) at = wp->paragraph_count; + for (int i = wp->paragraph_count; i > at; i--) + wp->paragraphs[i] = wp->paragraphs[i - 1]; + wp->paragraphs[at] = *style; + wp->paragraph_count++; +} + +static void wp_remove_paragraph_style(WordProcessorState* wp, int at) { + if (wp->paragraph_count <= 1) { + wp->paragraph_count = 1; + wp_init_paragraph_style(&wp->paragraphs[0]); + return; + } + if (at < 0 || at >= wp->paragraph_count) return; + for (int i = at; i < wp->paragraph_count - 1; i++) + wp->paragraphs[i] = wp->paragraphs[i + 1]; + wp->paragraph_count--; +} + +static int wp_count_paragraphs_in_text(WordProcessorState* wp) { + int count = 1; + for (int i = 0; i < wp->total_text_len; i++) { + if (wp_char_at(wp, i) == '\n') { + count++; + if (count > WP_MAX_PARAGRAPHS) return WP_MAX_PARAGRAPHS + 1; + } + } + return count; +} + +static void wp_sync_paragraph_count(WordProcessorState* wp) { + int needed = wp_count_paragraphs_in_text(wp); + if (needed <= 0) needed = 1; + if (needed > WP_MAX_PARAGRAPHS) needed = WP_MAX_PARAGRAPHS; + + if (wp->paragraph_count <= 0) { + wp->paragraph_count = 1; + wp_init_paragraph_style(&wp->paragraphs[0]); + } + + if (wp->paragraph_count < needed) { + ParagraphStyle style = wp->paragraphs[wp->paragraph_count - 1]; + while (wp->paragraph_count < needed) + wp_insert_paragraph_style(wp, wp->paragraph_count, &style); + } else if (wp->paragraph_count > needed) { + wp->paragraph_count = needed; + } +} + +static void wp_on_insert_newline(WordProcessorState* wp, int abs_pos) { + if (wp->paragraph_count >= WP_MAX_PARAGRAPHS) return; + int para = wp_find_paragraph_at(wp, abs_pos); + if (para < 0) para = 0; + if (para >= wp->paragraph_count) para = wp->paragraph_count - 1; + ParagraphStyle style = wp->paragraphs[para]; + wp_insert_paragraph_style(wp, para + 1, &style); +} + +static void wp_on_delete_newline(WordProcessorState* wp, int abs_pos) { + int para = wp_find_paragraph_at(wp, abs_pos); + if (para + 1 < wp->paragraph_count) + wp_remove_paragraph_style(wp, para + 1); +} + +static void wp_default_line_metrics(WordProcessorState* wp, int abs_pos, int* out_height, int* out_ascent) { + TrueTypeFont* font = nullptr; + GlyphCache* gc = nullptr; + if (wp->total_text_len > 0) { + int pos = abs_pos; + if (pos >= wp->total_text_len) pos = wp->total_text_len - 1; + if (pos < 0) pos = 0; + wp_font_at(wp, pos, &font, &gc); + } else { + font = wp_get_font(wp->cur_font_id, wp->cur_flags); + gc = (font && font->valid) ? font->get_cache(wp->cur_size) : nullptr; + } + + if (gc) { + *out_height = gc->line_height; + *out_ascent = gc->ascent; + } else { + *out_height = WP_DEFAULT_SIZE; + *out_ascent = WP_DEFAULT_SIZE; + } +} + +static int wp_char_advance_at(WordProcessorState* wp, int abs_pos, char ch) { + if (ch == '\n') return 0; + if (!(ch >= 32 || ch < 0)) return 0; + + TrueTypeFont* font = nullptr; + GlyphCache* gc = nullptr; + wp_font_at(wp, abs_pos, &font, &gc); + if (!font || !gc) return 8; + + CachedGlyph* g = font->get_glyph(gc, (unsigned char)ch); + return g ? g->advance : 8; +} + +static void wp_measure_line_range(WordProcessorState* wp, int start, int count, + int* out_width, int* out_height, int* out_ascent) { + int width = 0; + int height = 0; + int ascent = 0; + + for (int i = 0; i < count; i++) { + char ch = wp_char_at(wp, start + i); + if (ch != '\n') + width += wp_char_advance_at(wp, start + i, ch); + + TrueTypeFont* font = nullptr; + GlyphCache* gc = nullptr; + wp_font_at(wp, start + i, &font, &gc); + if (gc) { + if (gc->line_height > height) height = gc->line_height; + if (gc->ascent > ascent) ascent = gc->ascent; + } + } + + if (height == 0) + wp_default_line_metrics(wp, start, &height, &ascent); + + *out_width = width; + *out_height = height; + *out_ascent = ascent; +} + +static int wp_line_spacing_advance(int height, int spacing_percent) { + int advance = (height * spacing_percent + 99) / 100; + return advance < height ? height : advance; +} + +static int wp_effective_text_indent(const ParagraphStyle* para, bool first_line) { + int indent = para->left_indent; + if (indent < 0) indent = 0; + if (first_line && para->list_type == PARA_LIST_NONE) + indent += para->first_line_indent; + if (indent < 0) indent = 0; + return indent; +} + +static bool wp_ensure_wrap_capacity(WordProcessorState* wp, int needed) { + if (needed <= wp->wrap_line_cap && wp->wrap_lines) + return true; + + int new_cap = wp->wrap_line_cap > 0 ? wp->wrap_line_cap : 128; + while (new_cap < needed && new_cap < WP_MAX_WRAP_LINES) + new_cap *= 2; + if (new_cap > WP_MAX_WRAP_LINES) new_cap = WP_MAX_WRAP_LINES; + if (new_cap < needed) return false; + + void* mem = nullptr; + if (wp->wrap_lines) + mem = montauk::realloc(wp->wrap_lines, new_cap * (int)sizeof(WrapLine)); + else + mem = montauk::malloc(new_cap * (int)sizeof(WrapLine)); + if (!mem) return false; + + wp->wrap_lines = (WrapLine*)mem; + wp->wrap_line_cap = new_cap; + return true; +} + static void wp_merge_adjacent(WordProcessorState* wp) { int i = 0; while (i < wp->run_count - 1) { @@ -208,9 +408,13 @@ void wp_init_empty_document(WordProcessorState* wp) { wp->wrap_lines = nullptr; wp->wrap_line_count = 0; + wp->wrap_line_cap = 0; wp->wrap_dirty = true; wp->last_wrap_width = 0; + wp->paragraph_count = 1; + wp_init_paragraph_style(&wp->paragraphs[0]); + wp->modified = false; wp->filepath[0] = '\0'; wp->filename[0] = '\0'; @@ -223,6 +427,13 @@ void wp_init_empty_document(WordProcessorState* wp) { wp->font_dropdown_open = false; wp->size_dropdown_open = false; + wp->line_spacing_dropdown_open = false; + + wp->undo_count = 0; + wp->undo_pos = 0; + montauk::memset(wp->undo, 0, sizeof(wp->undo)); + + wp_history_reset(wp); } void wp_free_document(WordProcessorState* wp) { @@ -232,6 +443,8 @@ void wp_free_document(WordProcessorState* wp) { wp->wrap_lines = nullptr; } wp->wrap_line_count = 0; + wp->wrap_line_cap = 0; + wp_clear_history(wp); } int wp_abs_pos(WordProcessorState* wp, int run, int offset) { @@ -272,8 +485,46 @@ char wp_char_at(WordProcessorState* wp, int abs_pos) { return '\0'; } +int wp_find_paragraph_at(WordProcessorState* wp, int abs_pos) { + if (wp->paragraph_count <= 1 || abs_pos <= 0) return 0; + if (abs_pos > wp->total_text_len) abs_pos = wp->total_text_len; + + int para = 0; + for (int i = 0; i < abs_pos && i < wp->total_text_len; i++) { + if (wp_char_at(wp, i) == '\n') { + para++; + if (para >= wp->paragraph_count - 1) + return wp->paragraph_count - 1; + } + } + return para; +} + +void wp_selected_paragraph_range(WordProcessorState* wp, int* out_start_para, int* out_end_para) { + if (!out_start_para || !out_end_para) return; + + if (wp->has_selection) { + int sel_s, sel_e; + wp_sel_range(wp, &sel_s, &sel_e); + if (sel_s != sel_e) { + *out_start_para = wp_find_paragraph_at(wp, sel_s); + int end_abs = sel_e > 0 ? sel_e - 1 : 0; + if (end_abs < sel_s) end_abs = sel_s; + *out_end_para = wp_find_paragraph_at(wp, end_abs); + return; + } + } + + int abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); + *out_start_para = wp_find_paragraph_at(wp, abs); + *out_end_para = *out_start_para; +} + void wp_insert_char(WordProcessorState* wp, char c) { if (wp->total_text_len >= WP_MAX_TEXT - 1) return; + if (c == '\n' && wp->paragraph_count >= WP_MAX_PARAGRAPHS) return; + + int insert_abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); StyledRun* cur = &wp->runs[wp->cursor_run]; if (wp_same_style(cur, wp->cur_font_id, wp->cur_size, wp->cur_flags)) { @@ -286,6 +537,7 @@ void wp_insert_char(WordProcessorState* wp, char c) { wp->total_text_len++; wp->modified = true; wp->wrap_dirty = true; + if (c == '\n') wp_on_insert_newline(wp, insert_abs); return; } @@ -347,11 +599,17 @@ void wp_insert_char(WordProcessorState* wp, char c) { wp->modified = true; wp->wrap_dirty = true; + if (c == '\n') wp_on_insert_newline(wp, insert_abs); } void wp_backspace(WordProcessorState* wp) { if (wp->cursor_run == 0 && wp->cursor_offset == 0) return; + int removed_abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset) - 1; + char removed = '\0'; + if (removed_abs >= 0) + removed = wp_char_at(wp, removed_abs); + if (wp->cursor_offset > 0) { StyledRun* r = &wp->runs[wp->cursor_run]; for (int i = wp->cursor_offset - 1; i < r->len - 1; i++) @@ -368,13 +626,17 @@ void wp_backspace(WordProcessorState* wp) { wp->total_text_len--; wp->modified = true; wp->wrap_dirty = true; + if (removed == '\n') wp_on_delete_newline(wp, removed_abs); wp_merge_adjacent(wp); + wp_sync_paragraph_count(wp); } void wp_delete_char(WordProcessorState* wp) { int abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); if (abs >= wp->total_text_len) return; + char removed = wp_char_at(wp, abs); + StyledRun* r = &wp->runs[wp->cursor_run]; if (wp->cursor_offset < r->len) { for (int i = wp->cursor_offset; i < r->len - 1; i++) @@ -392,7 +654,9 @@ void wp_delete_char(WordProcessorState* wp) { wp->total_text_len--; wp->modified = true; wp->wrap_dirty = true; + if (removed == '\n') wp_on_delete_newline(wp, abs); wp_merge_adjacent(wp); + wp_sync_paragraph_count(wp); } void wp_cursor_left(WordProcessorState* wp) { @@ -479,6 +743,191 @@ void wp_apply_style_to_selection(WordProcessorState* wp, int mode, int value) { wp_merge_adjacent(wp); } +void wp_apply_alignment(WordProcessorState* wp, uint8_t align) { + if (align > PARA_ALIGN_RIGHT) align = PARA_ALIGN_LEFT; + + int para_s, para_e; + wp_selected_paragraph_range(wp, ¶_s, ¶_e); + bool changed = false; + for (int i = para_s; i <= para_e && i < wp->paragraph_count; i++) { + if (wp->paragraphs[i].align != align) { + wp->paragraphs[i].align = align; + changed = true; + } + } + + if (changed) { + wp->modified = true; + wp->wrap_dirty = true; + } +} + +void wp_adjust_paragraph_indent(WordProcessorState* wp, int delta) { + int para_s, para_e; + wp_selected_paragraph_range(wp, ¶_s, ¶_e); + bool changed = false; + for (int i = para_s; i <= para_e && i < wp->paragraph_count; i++) { + int v = wp->paragraphs[i].left_indent + delta; + int16_t nv = (int16_t)gui_clamp(v, 0, 240); + if (wp->paragraphs[i].left_indent != nv) { + wp->paragraphs[i].left_indent = nv; + changed = true; + } + } + + if (changed) { + wp->modified = true; + wp->wrap_dirty = true; + } +} + +void wp_adjust_paragraph_first_line_indent(WordProcessorState* wp, int delta) { + int para_s, para_e; + wp_selected_paragraph_range(wp, ¶_s, ¶_e); + bool changed = false; + for (int i = para_s; i <= para_e && i < wp->paragraph_count; i++) { + int v = wp->paragraphs[i].first_line_indent + delta; + int16_t nv = (int16_t)gui_clamp(v, -120, 120); + if (wp->paragraphs[i].first_line_indent != nv) { + wp->paragraphs[i].first_line_indent = nv; + changed = true; + } + } + + if (changed) { + wp->modified = true; + wp->wrap_dirty = true; + } +} + +void wp_adjust_paragraph_spacing_before(WordProcessorState* wp, int delta) { + int para_s, para_e; + wp_selected_paragraph_range(wp, ¶_s, ¶_e); + bool changed = false; + for (int i = para_s; i <= para_e && i < wp->paragraph_count; i++) { + int v = wp->paragraphs[i].space_before + delta; + int16_t nv = (int16_t)gui_clamp(v, 0, 120); + if (wp->paragraphs[i].space_before != nv) { + wp->paragraphs[i].space_before = nv; + changed = true; + } + } + + if (changed) { + wp->modified = true; + wp->wrap_dirty = true; + } +} + +void wp_adjust_paragraph_spacing_after(WordProcessorState* wp, int delta) { + int para_s, para_e; + wp_selected_paragraph_range(wp, ¶_s, ¶_e); + bool changed = false; + for (int i = para_s; i <= para_e && i < wp->paragraph_count; i++) { + int v = wp->paragraphs[i].space_after + delta; + int16_t nv = (int16_t)gui_clamp(v, 0, 120); + if (wp->paragraphs[i].space_after != nv) { + wp->paragraphs[i].space_after = nv; + changed = true; + } + } + + if (changed) { + wp->modified = true; + wp->wrap_dirty = true; + } +} + +void wp_set_line_spacing(WordProcessorState* wp, int value) { + int selected = WP_LINE_SPACING_OPTIONS[0]; + for (int i = 0; i < WP_LINE_SPACING_OPTION_COUNT; i++) { + if (WP_LINE_SPACING_OPTIONS[i] == value) { + selected = value; + break; + } + } + + int para_s, para_e; + wp_selected_paragraph_range(wp, ¶_s, ¶_e); + bool changed = false; + for (int i = para_s; i <= para_e && i < wp->paragraph_count; i++) { + if (wp->paragraphs[i].line_spacing != selected) { + wp->paragraphs[i].line_spacing = (uint8_t)selected; + changed = true; + } + } + + if (changed) { + wp->modified = true; + wp->wrap_dirty = true; + } +} + +void wp_cycle_line_spacing(WordProcessorState* wp) { + int para_s, para_e; + wp_selected_paragraph_range(wp, ¶_s, ¶_e); + int current = wp->paragraphs[para_s].line_spacing; + int next = WP_LINE_SPACING_OPTIONS[0]; + + for (int i = 0; i < WP_LINE_SPACING_OPTION_COUNT; i++) { + if (WP_LINE_SPACING_OPTIONS[i] == current) { + next = WP_LINE_SPACING_OPTIONS[(i + 1) % WP_LINE_SPACING_OPTION_COUNT]; + break; + } + } + + wp_set_line_spacing(wp, next); +} + +void wp_toggle_list(WordProcessorState* wp, uint8_t list_type) { + if (list_type != PARA_LIST_BULLET && list_type != PARA_LIST_NUMBER) + return; + + int para_s, para_e; + wp_selected_paragraph_range(wp, ¶_s, ¶_e); + + bool turn_off = true; + for (int i = para_s; i <= para_e && i < wp->paragraph_count; i++) { + if (wp->paragraphs[i].list_type != list_type) { + turn_off = false; + break; + } + } + + bool changed = false; + for (int i = para_s; i <= para_e && i < wp->paragraph_count; i++) { + ParagraphStyle* para = &wp->paragraphs[i]; + if (turn_off) { + if (para->list_type != PARA_LIST_NONE) { + para->list_type = PARA_LIST_NONE; + changed = true; + } + if (para->left_indent == WP_LIST_LEFT && para->first_line_indent == WP_LIST_HANGING) { + para->left_indent = 0; + para->first_line_indent = 0; + changed = true; + } + continue; + } + + if (para->list_type == PARA_LIST_NONE && + para->left_indent == 0 && para->first_line_indent == 0) { + para->left_indent = WP_LIST_LEFT; + para->first_line_indent = WP_LIST_HANGING; + changed = true; + } + if (para->list_type != list_type) { + para->list_type = list_type; + changed = true; + } + } + + if (changed) { + wp->modified = true; + wp->wrap_dirty = true; + } +} + void wp_delete_selection(WordProcessorState* wp) { if (!wp->has_selection) return; @@ -508,156 +957,161 @@ void wp_recompute_wrap(WordProcessorState* wp, int content_w) { if (!wp->wrap_dirty && wp->wrap_lines) return; - if (!wp->wrap_lines) - wp->wrap_lines = (WrapLine*)montauk::malloc(WP_MAX_WRAP_LINES * sizeof(WrapLine)); - wp->wrap_line_count = 0; - int total = wp->total_text_len; - int y = WP_MARGIN; - int pos = 0; - - while (pos < total && wp->wrap_line_count < WP_MAX_WRAP_LINES) { - WrapLine* line = &wp->wrap_lines[wp->wrap_line_count]; - wp_pos_to_run(wp, pos, &line->run_idx, &line->run_offset); - line->char_count = 0; - line->y = y; - - int x = 0; - int last_space = -1; - int line_start = pos; - int max_ascent = 0; - int max_height = 0; - - int scan = pos; - while (scan < total) { - char ch = wp_char_at(wp, scan); - - TrueTypeFont* font; - GlyphCache* gc; - wp_font_at(wp, scan, &font, &gc); - if (gc) { - if (gc->ascent > max_ascent) max_ascent = gc->ascent; - if (gc->line_height > max_height) max_height = gc->line_height; - } - - if (ch == '\n') { - scan++; - break; - } - - int cw = 8; - if (font && gc) { - CachedGlyph* g = font->get_glyph(gc, (unsigned char)ch); - cw = g ? g->advance : 8; - } - - if (x + cw > wrap_width && scan > line_start) { - if (last_space >= line_start) scan = last_space + 1; - break; - } - - if (ch == ' ') last_space = scan; - x += cw; - scan++; - } - - line->char_count = scan - line_start; - - if (max_height == 0) { - TrueTypeFont* df = wp_get_font(wp->cur_font_id, 0); - if (df && df->valid) { - GlyphCache* dgc = df->get_cache(wp->cur_size); - max_height = dgc->line_height; - max_ascent = dgc->ascent; - } else { - max_height = WP_DEFAULT_SIZE; - max_ascent = WP_DEFAULT_SIZE; - } - } - - max_ascent = 0; - max_height = 0; - { - int ri, ro; - wp_pos_to_run(wp, line_start, &ri, &ro); - int left = line->char_count; - while (left > 0 && ri < wp->run_count) { - StyledRun* r = &wp->runs[ri]; - TrueTypeFont* font = wp_get_font(r->font_id, r->flags); - if (font && font->valid) { - GlyphCache* gc = font->get_cache(r->size); - if (gc->ascent > max_ascent) max_ascent = gc->ascent; - if (gc->line_height > max_height) max_height = gc->line_height; - } - int avail = r->len - ro; - int consume = avail < left ? avail : left; - left -= consume; - ro += consume; - if (ro >= r->len) { - ri++; - ro = 0; - } - } - if (max_height == 0) { - TrueTypeFont* df = wp_get_font(wp->cur_font_id, 0); - if (df && df->valid) { - GlyphCache* dgc = df->get_cache(wp->cur_size); - max_height = dgc->line_height; - max_ascent = dgc->ascent; - } else { - max_height = WP_DEFAULT_SIZE; - max_ascent = WP_DEFAULT_SIZE; - } - } - } - - line->height = max_height; - line->baseline = max_ascent; - wp->wrap_line_count++; - - y += max_height; - pos = line_start + line->char_count; - - if (line->char_count == 0 && pos < total) - pos++; + if (!wp_ensure_wrap_capacity(wp, 1)) { + wp->content_height = WP_MARGIN * 2; + return; } + int y = WP_MARGIN; + int total = wp->total_text_len; + int pos = 0; + int para_idx = 0; + int list_number = 1; - 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; + wp_sync_paragraph_count(wp); - 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; + while (para_idx < wp->paragraph_count && wp->wrap_line_count < WP_MAX_WRAP_LINES) { + ParagraphStyle* para = &wp->paragraphs[para_idx]; + int para_start = pos; + int para_content_end = pos; + bool has_newline = false; + + while (para_content_end < total) { + char ch = wp_char_at(wp, para_content_end); + if (ch == '\n') { + has_newline = true; + break; + } + para_content_end++; } - wp->wrap_line_count++; - y += line->height; + y += para->space_before; + + bool first_line = true; + int line_start = para_start; + while (line_start < para_content_end) { + if (wp->wrap_line_count >= WP_MAX_WRAP_LINES) break; + if (!wp_ensure_wrap_capacity(wp, wp->wrap_line_count + 1)) break; + + int text_indent = wp_effective_text_indent(para, first_line); + int avail_width = wrap_width - text_indent; + if (avail_width < 40) avail_width = 40; + + int scan = line_start; + int width = 0; + int last_space = -1; + int width_at_last_space = 0; + + while (scan < para_content_end) { + char ch = wp_char_at(wp, scan); + int cw = wp_char_advance_at(wp, scan, ch); + if (width + cw > avail_width && scan > line_start) { + if (last_space >= line_start) { + scan = last_space + 1; + width = width_at_last_space; + } + break; + } + + width += cw; + if (ch == ' ') { + last_space = scan; + width_at_last_space = width; + } + scan++; + } + + if (scan == line_start && scan < para_content_end) { + width += wp_char_advance_at(wp, scan, wp_char_at(wp, scan)); + scan++; + } + + int char_count = scan - line_start; + bool last_visual_line = (scan >= para_content_end); + if (last_visual_line && has_newline) char_count++; + + int line_width = 0; + int line_height = 0; + int line_ascent = 0; + wp_measure_line_range(wp, line_start, char_count, &line_width, &line_height, &line_ascent); + + WrapLine* line = &wp->wrap_lines[wp->wrap_line_count]; + wp_pos_to_run(wp, line_start, &line->run_idx, &line->run_offset); + line->char_count = char_count; + line->y = y; + line->height = line_height; + line->baseline = line_ascent; + line->width = line_width; + line->paragraph_idx = para_idx; + line->list_number = (first_line && para->list_type == PARA_LIST_NUMBER) ? list_number : 0; + line->first_in_paragraph = first_line; + + int extra = wrap_width - text_indent - line_width; + if (extra < 0) extra = 0; + int x = WP_MARGIN + text_indent; + if (para->align == PARA_ALIGN_CENTER) x += extra / 2; + else if (para->align == PARA_ALIGN_RIGHT) x += extra; + line->x = x; + + wp->wrap_line_count++; + + line_start = scan; + first_line = false; + if (!last_visual_line) + y += wp_line_spacing_advance(line_height, para->line_spacing); + else + y += line_height; + } + + if (para_start == para_content_end && wp->wrap_line_count < WP_MAX_WRAP_LINES) { + if (!wp_ensure_wrap_capacity(wp, wp->wrap_line_count + 1)) break; + WrapLine* line = &wp->wrap_lines[wp->wrap_line_count]; + wp_pos_to_run(wp, para_start, &line->run_idx, &line->run_offset); + line->char_count = has_newline ? 1 : 0; + line->y = y; + wp_default_line_metrics(wp, para_start, &line->height, &line->baseline); + line->width = 0; + line->paragraph_idx = para_idx; + line->list_number = (para->list_type == PARA_LIST_NUMBER) ? list_number : 0; + line->first_in_paragraph = true; + + int text_indent = wp_effective_text_indent(para, true); + int extra = wrap_width - text_indent; + if (extra < 0) extra = 0; + int x = WP_MARGIN + text_indent; + if (para->align == PARA_ALIGN_CENTER) x += extra / 2; + else if (para->align == PARA_ALIGN_RIGHT) x += extra; + line->x = x; + + wp->wrap_line_count++; + y = line->y + line->height; + } + + y += para->space_after; + + pos = para_content_end; + if (has_newline) pos++; + if (para->list_type == PARA_LIST_NUMBER) list_number++; + else list_number = 1; + para_idx++; } if (wp->wrap_line_count == 0) { + if (!wp_ensure_wrap_capacity(wp, 1)) { + wp->content_height = WP_MARGIN * 2; + return; + } 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_default_line_metrics(wp, 0, &line->height, &line->baseline); + line->x = WP_MARGIN; + line->width = 0; + line->paragraph_idx = 0; + line->list_number = 0; + line->first_in_paragraph = true; wp->wrap_line_count = 1; y = WP_MARGIN + line->height; @@ -757,30 +1211,46 @@ void wp_open_save_pathbar(WordProcessorState* wp) { wp->pathbar_cursor = wp->pathbar_len; } -void wp_save_file(WordProcessorState* wp) { - if (wp->filepath[0] == '\0') { - wp_open_save_pathbar(wp); - return; - } - - int size = 4 + 2 + 1 + 1 + 2; +static int wp_serialized_size(WordProcessorState* wp) { + int size = 4 + 2 + 1 + 1 + 1 + 2 + 2; for (int i = 0; i < wp->run_count; i++) size += 2 + 1 + 1 + 1 + 1 + wp->runs[i].len; + size += wp->paragraph_count * 12; + return size; +} +static void wp_write_u16(uint8_t* buf, int* off, int value) { + buf[(*off)++] = (uint8_t)(value & 0xFF); + buf[(*off)++] = (uint8_t)((value >> 8) & 0xFF); +} + +static int wp_read_u16(const uint8_t* buf, int* off) { + int value = buf[*off] | (buf[*off + 1] << 8); + *off += 2; + return value; +} + +static bool wp_serialize_document(WordProcessorState* wp, uint8_t** out_buf, int* out_size) { + int size = wp_serialized_size(wp); uint8_t* buf = (uint8_t*)montauk::malloc(size); - int off = 0; + if (!buf) return false; - buf[off++] = 'M'; buf[off++] = 'W'; buf[off++] = 'P'; buf[off++] = '1'; - buf[off++] = 1; buf[off++] = 0; + int off = 0; + buf[off++] = 'M'; + buf[off++] = 'W'; + buf[off++] = 'P'; + buf[off++] = '1'; + buf[off++] = 2; + 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); + buf[off++] = wp->cur_flags; + wp_write_u16(buf, &off, wp->run_count); + wp_write_u16(buf, &off, wp->paragraph_count); 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); + wp_write_u16(buf, &off, r->len); buf[off++] = r->font_id; buf[off++] = r->size; buf[off++] = r->flags; @@ -789,11 +1259,223 @@ void wp_save_file(WordProcessorState* wp) { off += r->len; } + for (int i = 0; i < wp->paragraph_count; i++) { + ParagraphStyle* para = &wp->paragraphs[i]; + buf[off++] = para->align; + buf[off++] = para->list_type; + buf[off++] = para->line_spacing; + buf[off++] = 0; + wp_write_u16(buf, &off, (uint16_t)para->left_indent); + wp_write_u16(buf, &off, (uint16_t)para->first_line_indent); + wp_write_u16(buf, &off, (uint16_t)para->space_before); + wp_write_u16(buf, &off, (uint16_t)para->space_after); + } + + *out_buf = buf; + *out_size = off; + return true; +} + +static int wp_count_newlines_in_buf(const uint8_t* text, int len) { + int count = 1; + for (int i = 0; i < len; i++) { + if (text[i] == '\n') { + count++; + if (count > WP_MAX_PARAGRAPHS) return WP_MAX_PARAGRAPHS + 1; + } + } + return count; +} + +static bool wp_deserialize_document(WordProcessorState* wp, const uint8_t* buf, int size) { + if (size < 10) return false; + if (buf[0] != 'M' || buf[1] != 'W' || buf[2] != 'P' || buf[3] != '1') + return false; + + uint8_t major = buf[4]; + if (major != 1 && major != 2) return false; + + if (major == 1) { + if (size < 10) return false; + } else { + if (size < 13) return false; + } + + int header_off = 6; + uint8_t cur_font = buf[header_off++]; + uint8_t cur_size = buf[header_off++]; + uint8_t cur_flags = 0; + if (major >= 2) cur_flags = buf[header_off++]; + + if (header_off + 2 > size) return false; + int run_count = buf[header_off] | (buf[header_off + 1] << 8); + header_off += 2; + int stored_para_count = 0; + if (major >= 2) { + if (header_off + 2 > size) return false; + stored_para_count = buf[header_off] | (buf[header_off + 1] << 8); + header_off += 2; + } + + if (run_count < 0 || run_count > WP_MAX_RUNS) return false; + + int off = header_off; + int actual_para_count = 1; + for (int i = 0; i < run_count; i++) { + if (off + 6 > size) return false; + int text_len = wp_read_u16(buf, &off); + off += 4; + if (text_len < 0 || off + text_len > size) return false; + int para_count = wp_count_newlines_in_buf(buf + off, text_len); + if (para_count > WP_MAX_PARAGRAPHS) return false; + actual_para_count += para_count - 1; + if (actual_para_count > WP_MAX_PARAGRAPHS) return false; + off += text_len; + } + + if (major >= 2) { + if (stored_para_count <= 0) stored_para_count = actual_para_count; + if (stored_para_count > WP_MAX_PARAGRAPHS) return false; + if (off + stored_para_count * 12 > size) return false; + } + + wp_free_runs(wp); + wp->paragraph_count = 1; + wp_init_paragraph_style(&wp->paragraphs[0]); + wp->total_text_len = 0; + + off = header_off; + for (int i = 0; i < run_count; i++) { + int text_len = wp_read_u16(buf, &off); + uint8_t font_id = buf[off++]; + uint8_t run_size = buf[off++]; + uint8_t flags = buf[off++]; + off++; + + StyledRun* r = &wp->runs[wp->run_count]; + wp_init_run(r, font_id, run_size, flags); + wp_ensure_run_cap(r, text_len); + montauk::memcpy(r->text, buf + off, text_len); + r->len = text_len; + wp->run_count++; + wp->total_text_len += text_len; + off += text_len; + } + + if (wp->run_count == 0) { + wp_init_run(&wp->runs[0], cur_font, cur_size, cur_flags); + wp->run_count = 1; + } + + wp->cur_font_id = cur_font; + wp->cur_size = cur_size; + wp->cur_flags = cur_flags; + + wp->paragraph_count = actual_para_count > 0 ? actual_para_count : 1; + for (int i = 0; i < wp->paragraph_count; i++) + wp_init_paragraph_style(&wp->paragraphs[i]); + + if (major >= 2) { + int limit = wp_min_int(stored_para_count, wp->paragraph_count); + for (int i = 0; i < limit; i++) { + ParagraphStyle* para = &wp->paragraphs[i]; + para->align = buf[off++]; + para->list_type = buf[off++]; + para->line_spacing = buf[off++]; + off++; + para->left_indent = (int16_t)wp_read_u16(buf, &off); + para->first_line_indent = (int16_t)wp_read_u16(buf, &off); + para->space_before = (int16_t)wp_read_u16(buf, &off); + para->space_after = (int16_t)wp_read_u16(buf, &off); + + if (para->align > PARA_ALIGN_RIGHT) para->align = PARA_ALIGN_LEFT; + if (para->list_type > PARA_LIST_NUMBER) para->list_type = PARA_LIST_NONE; + + bool known_spacing = false; + for (int j = 0; j < WP_LINE_SPACING_OPTION_COUNT; j++) { + if (para->line_spacing == WP_LINE_SPACING_OPTIONS[j]) { + known_spacing = true; + break; + } + } + if (!known_spacing) para->line_spacing = 100; + } + + for (int i = limit; i < stored_para_count; i++) + off += 12; + } + + wp_sync_paragraph_count(wp); + wp_merge_adjacent(wp); + wp->cursor_run = 0; + wp->cursor_offset = 0; + wp->scrollbar.scroll_offset = 0; + wp->modified = false; + wp->wrap_dirty = true; + wp->sel_anchor = 0; + wp->sel_end = 0; + wp->has_selection = false; + wp->mouse_selecting = false; + wp->font_dropdown_open = false; + wp->size_dropdown_open = false; + wp->line_spacing_dropdown_open = false; + wp->show_pathbar = false; + return true; +} + +static bool wp_capture_snapshot(WordProcessorState* wp, UndoSnapshot* snap) { + montauk::memset(snap, 0, sizeof(*snap)); + if (!wp_serialize_document(wp, &snap->data, &snap->size)) + return false; + + snap->cursor_abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); + snap->sel_anchor = wp->sel_anchor; + snap->sel_end = wp->sel_end; + snap->has_selection = wp->has_selection; + snap->modified = wp->modified; + return true; +} + +static bool wp_snapshots_match(const UndoSnapshot* a, const UndoSnapshot* b) { + if (a->size != b->size) return false; + if (a->cursor_abs != b->cursor_abs) return false; + if (a->sel_anchor != b->sel_anchor || a->sel_end != b->sel_end) return false; + if (a->has_selection != b->has_selection) return false; + if (a->modified != b->modified) return false; + if (a->size == 0) return true; + return memcmp(a->data, b->data, a->size) == 0; +} + +static bool wp_restore_snapshot(WordProcessorState* wp, const UndoSnapshot* snap) { + if (!wp_deserialize_document(wp, snap->data, snap->size)) + return false; + + int cursor_abs = gui_clamp(snap->cursor_abs, 0, wp->total_text_len); + wp_pos_to_run(wp, cursor_abs, &wp->cursor_run, &wp->cursor_offset); + wp->sel_anchor = gui_clamp(snap->sel_anchor, 0, wp->total_text_len); + wp->sel_end = gui_clamp(snap->sel_end, 0, wp->total_text_len); + wp->has_selection = snap->has_selection && (wp->sel_anchor != wp->sel_end); + wp->modified = snap->modified; + wp->scrollbar.scroll_offset = 0; + return true; +} + +void wp_save_file(WordProcessorState* wp) { + if (wp->filepath[0] == '\0') { + wp_open_save_pathbar(wp); + return; + } + + uint8_t* buf = nullptr; + int off = 0; + if (!wp_serialize_document(wp, &buf, &off)) + return; + int fd = montauk::fcreate(wp->filepath); if (fd >= 0) { montauk::fwrite(fd, buf, 0, off); montauk::close(fd); - wp->modified = false; + wp_history_mark_saved(wp); } montauk::mfree(buf); @@ -818,55 +1500,74 @@ void wp_load_file(WordProcessorState* wp, const char* path) { return; } - wp_free_runs(wp); - - int off = 4; - off += 2; - wp->cur_font_id = buf[off++]; - wp->cur_size = buf[off++]; - - int run_count = buf[off] | (buf[off + 1] << 8); - off += 2; - - for (int i = 0; i < run_count && off < (int)fsize && i < WP_MAX_RUNS; i++) { - if (off + 6 > (int)fsize) break; - int text_len = buf[off] | (buf[off + 1] << 8); - off += 2; - uint8_t font_id = buf[off++]; - uint8_t size = buf[off++]; - uint8_t flags = buf[off++]; - off++; - - if (off + text_len > (int)fsize) break; - - StyledRun* r = &wp->runs[wp->run_count]; - wp_init_run(r, font_id, size, flags); - wp_ensure_run_cap(r, text_len); - montauk::memcpy(r->text, buf + off, text_len); - r->len = text_len; - off += text_len; - - wp->total_text_len += text_len; - wp->run_count++; - } - - if (wp->run_count == 0) { - wp_init_run(&wp->runs[0], wp->cur_font_id, wp->cur_size, 0); - wp->run_count = 1; - } - - wp->cursor_run = 0; - wp->cursor_offset = 0; - wp->scrollbar.scroll_offset = 0; - wp->modified = false; - wp->wrap_dirty = true; - wp->sel_anchor = 0; - wp->sel_end = 0; - wp->has_selection = false; - wp->mouse_selecting = false; - wp->font_dropdown_open = false; - wp->size_dropdown_open = false; + bool ok = wp_deserialize_document(wp, buf, (int)fsize); + montauk::mfree(buf); + if (!ok) return; wp_set_filepath(wp, path); - montauk::mfree(buf); + wp_history_reset(wp); +} + +void wp_history_reset(WordProcessorState* wp) { + wp_clear_history(wp); + + UndoSnapshot snap = {}; + if (!wp_capture_snapshot(wp, &snap)) + return; + + wp->undo[0] = snap; + wp->undo_count = 1; + wp->undo_pos = 0; +} + +void wp_history_checkpoint(WordProcessorState* wp) { + UndoSnapshot snap = {}; + if (!wp_capture_snapshot(wp, &snap)) + return; + + if (wp->undo_count > 0 && wp_snapshots_match(&snap, &wp->undo[wp->undo_pos])) { + wp_free_snapshot(&snap); + return; + } + + for (int i = wp->undo_pos + 1; i < wp->undo_count; i++) + wp_free_snapshot(&wp->undo[i]); + wp->undo_count = wp->undo_pos + 1; + + if (wp->undo_count >= WP_UNDO_MAX) { + wp_free_snapshot(&wp->undo[0]); + for (int i = 1; i < wp->undo_count; i++) + wp->undo[i - 1] = wp->undo[i]; + montauk::memset(&wp->undo[wp->undo_count - 1], 0, sizeof(UndoSnapshot)); + wp->undo_count--; + if (wp->undo_pos > 0) wp->undo_pos--; + } + + wp->undo[wp->undo_count++] = snap; + wp->undo_pos = wp->undo_count - 1; +} + +void wp_history_mark_saved(WordProcessorState* wp) { + wp->modified = false; + if (wp->undo_count <= 0) { + wp_history_reset(wp); + return; + } + wp->undo[wp->undo_pos].modified = false; +} + +bool wp_undo(WordProcessorState* wp) { + if (wp->undo_count <= 0 || wp->undo_pos <= 0) + return false; + + wp->undo_pos--; + return wp_restore_snapshot(wp, &wp->undo[wp->undo_pos]); +} + +bool wp_redo(WordProcessorState* wp) { + if (wp->undo_count <= 0 || wp->undo_pos >= wp->undo_count - 1) + return false; + + wp->undo_pos++; + return wp_restore_snapshot(wp, &wp->undo[wp->undo_pos]); } diff --git a/programs/src/wordprocessor/input.cpp b/programs/src/wordprocessor/input.cpp index e245491..3b067e2 100644 --- a/programs/src/wordprocessor/input.cpp +++ b/programs/src/wordprocessor/input.cpp @@ -27,9 +27,14 @@ static void wp_commit_pathbar(WordProcessorState* wp) { wp->show_pathbar = false; } +static void wp_close_dropdowns(WordProcessorState* wp) { + wp->font_dropdown_open = false; + wp->size_dropdown_open = false; + wp->line_spacing_dropdown_open = false; +} + static int wp_hit_test_text(WordProcessorState* wp, int local_x, int local_y, int edit_y) { int click_y = local_y - edit_y + wp->scrollbar.scroll_offset; - int click_x = local_x - WP_MARGIN; int target_line = wp->wrap_line_count - 1; for (int i = 0; i < wp->wrap_line_count; i++) { @@ -41,12 +46,15 @@ static int wp_hit_test_text(WordProcessorState* wp, int local_x, int local_y, in } WrapLine* wl = &wp->wrap_lines[target_line]; + int click_x = local_x - wl->x; 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); + if (click_x <= 0) return best_abs; + while (chars_left > 0 && ri < wp->run_count) { StyledRun* r = &wp->runs[ri]; TrueTypeFont* font = wp_get_font(r->font_id, r->flags); @@ -92,7 +100,7 @@ void wp_handle_mouse(const Montauk::WinEvent& ev) { wp->scrollbar.handle_mouse(local_x, local_y, ev.mouse.buttons, ev.mouse.prev_buttons, ev.mouse.scroll); if (wp->font_dropdown_open && wp_left_pressed(ev.mouse.buttons, ev.mouse.prev_buttons)) { - int dx = 128; + int dx = WP_FONT_DD_X; int dy = WP_TOOLBAR_H; int dh = FONT_COUNT * 26 + 4; if (local_x >= dx && local_x < dx + 110 && local_y >= dy && local_y < dy + dh) { @@ -100,6 +108,7 @@ void wp_handle_mouse(const Montauk::WinEvent& ev) { 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; + if (wp->has_selection) wp_history_checkpoint(wp); } } wp->font_dropdown_open = false; @@ -107,7 +116,7 @@ void wp_handle_mouse(const Montauk::WinEvent& ev) { } if (wp->size_dropdown_open && wp_left_pressed(ev.mouse.buttons, ev.mouse.prev_buttons)) { - int dx = 224; + int dx = WP_SIZE_DD_X; int dy = WP_TOOLBAR_H; int dh = WP_SIZE_OPTION_COUNT * 26 + 4; if (local_x >= dx && local_x < dx + 56 && local_y >= dy && local_y < dy + dh) { @@ -116,43 +125,123 @@ void wp_handle_mouse(const Montauk::WinEvent& ev) { if (wp->has_selection) wp_apply_style_to_selection(wp, 1, WP_SIZE_OPTIONS[idx]); wp->cur_size = (uint8_t)WP_SIZE_OPTIONS[idx]; wp->wrap_dirty = true; + if (wp->has_selection) wp_history_checkpoint(wp); } } wp->size_dropdown_open = false; return; } + if (wp->line_spacing_dropdown_open && wp_left_pressed(ev.mouse.buttons, ev.mouse.prev_buttons)) { + int dx = WP_LINE_DD_X; + int dy = WP_TOOLBAR_H; + int dh = WP_LINE_SPACING_OPTION_COUNT * 26 + 4; + if (local_x >= dx && local_x < dx + 64 && local_y >= dy && local_y < dy + dh) { + int idx = (local_y - dy - 2) / 26; + if (idx >= 0 && idx < WP_LINE_SPACING_OPTION_COUNT) { + wp_set_line_spacing(wp, WP_LINE_SPACING_OPTIONS[idx]); + wp_history_checkpoint(wp); + } + } + wp->line_spacing_dropdown_open = false; + return; + } + if (wp_left_pressed(ev.mouse.buttons, ev.mouse.prev_buttons) && local_y < WP_TOOLBAR_H) { - if (local_x >= 4 && local_x < 28 && local_y >= 6 && local_y < 30) { + if (local_x >= WP_BTN_OPEN_X && local_x < WP_BTN_OPEN_X + 24 && local_y >= 6 && local_y < 30) { wp_open_pathbar_for_open(wp); return; } - if (local_x >= 32 && local_x < 56 && local_y >= 6 && local_y < 30) { + if (local_x >= WP_BTN_SAVE_X && local_x < WP_BTN_SAVE_X + 24 && 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 (local_x >= WP_BTN_UNDO_X && local_x < WP_BTN_UNDO_X + 24 && local_y >= 6 && local_y < 30) { + wp_close_dropdowns(wp); + wp_undo(wp); + return; + } + if (local_x >= WP_BTN_REDO_X && local_x < WP_BTN_REDO_X + 24 && local_y >= 6 && local_y < 30) { + wp_close_dropdowns(wp); + wp_redo(wp); + return; + } + if (local_x >= WP_BTN_BOLD_X && local_x < WP_BTN_BOLD_X + 24 && local_y >= 6 && local_y < 30) { if (wp->has_selection) wp_apply_style_to_selection(wp, 2, 0); wp->cur_flags ^= STYLE_BOLD; + if (wp->has_selection) wp_history_checkpoint(wp); return; } - if (local_x >= 94 && local_x < 118 && local_y >= 6 && local_y < 30) { + if (local_x >= WP_BTN_ITALIC_X && local_x < WP_BTN_ITALIC_X + 24 && local_y >= 6 && local_y < 30) { if (wp->has_selection) wp_apply_style_to_selection(wp, 3, 0); wp->cur_flags ^= STYLE_ITALIC; + if (wp->has_selection) wp_history_checkpoint(wp); return; } - if (local_x >= 128 && local_x < 218 && local_y >= 6 && local_y < 30) { + if (local_x >= WP_FONT_DD_X && local_x < WP_FONT_DD_X + WP_FONT_DD_W && local_y >= 6 && local_y < 30) { wp->font_dropdown_open = !wp->font_dropdown_open; wp->size_dropdown_open = false; + wp->line_spacing_dropdown_open = false; + return; + } + if (local_x >= WP_SIZE_DD_X && local_x < WP_SIZE_DD_X + WP_SIZE_DD_W && local_y >= 6 && local_y < 30) { + wp->size_dropdown_open = !wp->size_dropdown_open; + wp->font_dropdown_open = false; + wp->line_spacing_dropdown_open = false; + return; + } + if (local_x >= WP_BTN_ALIGN_L_X && local_x < WP_BTN_ALIGN_L_X + 24 && local_y >= 6 && local_y < 30) { + wp_close_dropdowns(wp); + wp_apply_alignment(wp, PARA_ALIGN_LEFT); + wp_history_checkpoint(wp); + return; + } + if (local_x >= WP_BTN_ALIGN_C_X && local_x < WP_BTN_ALIGN_C_X + 24 && local_y >= 6 && local_y < 30) { + wp_close_dropdowns(wp); + wp_apply_alignment(wp, PARA_ALIGN_CENTER); + wp_history_checkpoint(wp); + return; + } + if (local_x >= WP_BTN_ALIGN_R_X && local_x < WP_BTN_ALIGN_R_X + 24 && local_y >= 6 && local_y < 30) { + wp_close_dropdowns(wp); + wp_apply_alignment(wp, PARA_ALIGN_RIGHT); + wp_history_checkpoint(wp); + return; + } + if (local_x >= WP_BTN_BULLET_X && local_x < WP_BTN_BULLET_X + 24 && local_y >= 6 && local_y < 30) { + wp_close_dropdowns(wp); + wp_toggle_list(wp, PARA_LIST_BULLET); + wp_history_checkpoint(wp); + return; + } + if (local_x >= WP_BTN_NUMBER_X && local_x < WP_BTN_NUMBER_X + 24 && local_y >= 6 && local_y < 30) { + wp_close_dropdowns(wp); + wp_toggle_list(wp, PARA_LIST_NUMBER); + wp_history_checkpoint(wp); + return; + } + if (local_x >= WP_BTN_OUTDENT_X && local_x < WP_BTN_OUTDENT_X + 24 && local_y >= 6 && local_y < 30) { + wp_close_dropdowns(wp); + wp_adjust_paragraph_indent(wp, -WP_PARA_STEP); + wp_history_checkpoint(wp); + return; + } + if (local_x >= WP_BTN_INDENT_X && local_x < WP_BTN_INDENT_X + 24 && local_y >= 6 && local_y < 30) { + wp_close_dropdowns(wp); + wp_adjust_paragraph_indent(wp, WP_PARA_STEP); + wp_history_checkpoint(wp); + return; + } + if (local_x >= WP_LINE_DD_X && local_x < WP_LINE_DD_X + WP_LINE_DD_W && local_y >= 6 && local_y < 30) { + wp->line_spacing_dropdown_open = !wp->line_spacing_dropdown_open; + wp->font_dropdown_open = false; + 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) { + if (local_x >= WP_BTN_SECTION_X && local_x < WP_BTN_SECTION_X + 24 && local_y >= 6 && local_y < 30) { + wp_close_dropdowns(wp); wp_insert_char(wp, (char)0xA7); + wp_history_checkpoint(wp); return; } return; @@ -174,8 +263,7 @@ void wp_handle_mouse(const Montauk::WinEvent& ev) { if (wp_left_pressed(ev.mouse.buttons, ev.mouse.prev_buttons) && local_y >= edit_y && local_y < edit_y + text_area_h) { - wp->font_dropdown_open = false; - wp->size_dropdown_open = false; + wp_close_dropdowns(wp); int abs = wp_hit_test_text(wp, local_x, local_y, edit_y); wp_pos_to_run(wp, abs, &wp->cursor_run, &wp->cursor_offset); @@ -249,8 +337,7 @@ void wp_handle_key(const Montauk::KeyEvent& key) { return; } - wp->font_dropdown_open = false; - wp->size_dropdown_open = false; + wp_close_dropdowns(wp); wp_recompute_wrap(wp, g_win_w); if (key.ctrl && (key.ascii == 's' || key.ascii == 'S')) { @@ -261,14 +348,114 @@ void wp_handle_key(const Montauk::KeyEvent& key) { wp_open_pathbar_for_open(wp); return; } - if (key.ctrl && (key.ascii == 'b' || key.ascii == 'B')) { - if (wp->has_selection) wp_apply_style_to_selection(wp, 2, 0); - wp->cur_flags ^= STYLE_BOLD; + + if (key.ctrl && !key.alt && + (key.ascii == 'z' || key.ascii == 'Z')) { + if (key.shift) wp_redo(wp); + else wp_undo(wp); return; } - if (key.ctrl && (key.ascii == 'i' || key.ascii == 'I')) { + if (key.ctrl && !key.alt && (key.ascii == 'y' || key.ascii == 'Y')) { + wp_redo(wp); + return; + } + + if (key.ctrl && key.alt && (key.ascii == 'b' || key.ascii == 'B')) { + wp_toggle_list(wp, PARA_LIST_BULLET); + wp_history_checkpoint(wp); + return; + } + if (key.ctrl && key.alt && (key.ascii == 'n' || key.ascii == 'N')) { + wp_toggle_list(wp, PARA_LIST_NUMBER); + wp_history_checkpoint(wp); + return; + } + + if (key.ctrl && !key.alt && (key.ascii == 'l' || key.ascii == 'L')) { + wp_apply_alignment(wp, PARA_ALIGN_LEFT); + wp_history_checkpoint(wp); + return; + } + if (key.ctrl && !key.alt && (key.ascii == 'e' || key.ascii == 'E')) { + wp_apply_alignment(wp, PARA_ALIGN_CENTER); + wp_history_checkpoint(wp); + return; + } + if (key.ctrl && !key.alt && (key.ascii == 'r' || key.ascii == 'R')) { + wp_apply_alignment(wp, PARA_ALIGN_RIGHT); + wp_history_checkpoint(wp); + return; + } + + if (key.ctrl && !key.alt && (key.ascii == '1')) { + wp_set_line_spacing(wp, 100); + wp_history_checkpoint(wp); + return; + } + if (key.ctrl && !key.alt && (key.ascii == '2')) { + wp_set_line_spacing(wp, 125); + wp_history_checkpoint(wp); + return; + } + if (key.ctrl && !key.alt && (key.ascii == '3')) { + wp_set_line_spacing(wp, 150); + wp_history_checkpoint(wp); + return; + } + if (key.ctrl && !key.alt && (key.ascii == '4')) { + wp_set_line_spacing(wp, 200); + wp_history_checkpoint(wp); + return; + } + + if (key.ctrl && !key.alt && (key.ascii == '[' || key.ascii == '{')) { + if (key.shift || key.ascii == '{') + wp_adjust_paragraph_first_line_indent(wp, -WP_PARA_STEP); + else + wp_adjust_paragraph_indent(wp, -WP_PARA_STEP); + wp_history_checkpoint(wp); + return; + } + if (key.ctrl && !key.alt && (key.ascii == ']' || key.ascii == '}')) { + if (key.shift || key.ascii == '}') + wp_adjust_paragraph_first_line_indent(wp, WP_PARA_STEP); + else + wp_adjust_paragraph_indent(wp, WP_PARA_STEP); + wp_history_checkpoint(wp); + return; + } + + if (key.ctrl && key.alt && key.scancode == 0x48) { + wp_adjust_paragraph_spacing_before(wp, -WP_SPACE_STEP); + wp_history_checkpoint(wp); + return; + } + if (key.ctrl && key.alt && key.scancode == 0x50) { + wp_adjust_paragraph_spacing_before(wp, WP_SPACE_STEP); + wp_history_checkpoint(wp); + return; + } + if (key.ctrl && key.shift && key.scancode == 0x48) { + wp_adjust_paragraph_spacing_after(wp, -WP_SPACE_STEP); + wp_history_checkpoint(wp); + return; + } + if (key.ctrl && key.shift && key.scancode == 0x50) { + wp_adjust_paragraph_spacing_after(wp, WP_SPACE_STEP); + wp_history_checkpoint(wp); + return; + } + + if (key.ctrl && !key.alt && (key.ascii == 'b' || key.ascii == 'B')) { + if (wp->has_selection) wp_apply_style_to_selection(wp, 2, 0); + wp->cur_flags ^= STYLE_BOLD; + if (wp->has_selection) wp_history_checkpoint(wp); + return; + } + if (key.ctrl && !key.alt && (key.ascii == 'i' || key.ascii == 'I')) { if (wp->has_selection) wp_apply_style_to_selection(wp, 3, 0); wp->cur_flags ^= STYLE_ITALIC; + if (wp->has_selection) wp_history_checkpoint(wp); return; } @@ -350,29 +537,34 @@ void wp_handle_key(const Montauk::KeyEvent& key) { if (key.scancode == 0x53) { if (wp->has_selection) wp_delete_selection(wp); else wp_delete_char(wp); + wp_history_checkpoint(wp); return; } if (key.ascii == '\b' || key.scancode == 0x0E) { if (wp->has_selection) wp_delete_selection(wp); else wp_backspace(wp); + wp_history_checkpoint(wp); return; } if (key.ascii == '\n' || key.ascii == '\r') { if (wp->has_selection) wp_delete_selection(wp); wp_insert_char(wp, '\n'); + wp_history_checkpoint(wp); return; } if (key.ascii == '\t') { if (wp->has_selection) wp_delete_selection(wp); for (int i = 0; i < 4; i++) wp_insert_char(wp, ' '); + wp_history_checkpoint(wp); return; } if (key.ascii >= 32 && key.ascii < 127) { if (wp->has_selection) wp_delete_selection(wp); wp_insert_char(wp, key.ascii); + wp_history_checkpoint(wp); } } diff --git a/programs/src/wordprocessor/main.cpp b/programs/src/wordprocessor/main.cpp index 1cacf2a..91ef7d2 100644 --- a/programs/src/wordprocessor/main.cpp +++ b/programs/src/wordprocessor/main.cpp @@ -13,6 +13,15 @@ WordProcessorState g_wp = {}; WPFontTable g_wp_fonts = { {{nullptr}}, false }; SvgIcon g_icon_folder = {}; SvgIcon g_icon_save = {}; +SvgIcon g_icon_undo = {}; +SvgIcon g_icon_redo = {}; +SvgIcon g_icon_align_left = {}; +SvgIcon g_icon_align_center = {}; +SvgIcon g_icon_align_right = {}; +SvgIcon g_icon_list_bullet = {}; +SvgIcon g_icon_list_number = {}; +SvgIcon g_icon_indent_less = {}; +SvgIcon g_icon_indent_more = {}; TrueTypeFont* g_ui_font = nullptr; TrueTypeFont* g_ui_bold = nullptr; @@ -22,12 +31,39 @@ void wp_load_icons() { g_icon_folder = svg_load("0:/icons/folder.svg", 16, 16, def_color); if (!g_icon_save.pixels) g_icon_save = svg_load("0:/icons/document-save-symbolic.svg", 16, 16, def_color); + if (!g_icon_undo.pixels) + g_icon_undo = svg_load("0:/icons/edit-undo-symbolic.svg", 16, 16, def_color); + if (!g_icon_redo.pixels) + g_icon_redo = svg_load("0:/icons/edit-redo-symbolic.svg", 16, 16, def_color); + if (!g_icon_align_left.pixels) + g_icon_align_left = svg_load("0:/icons/format-justify-left-symbolic.svg", 16, 16, def_color); + if (!g_icon_align_center.pixels) + g_icon_align_center = svg_load("0:/icons/format-justify-center-symbolic.svg", 16, 16, def_color); + if (!g_icon_align_right.pixels) + g_icon_align_right = svg_load("0:/icons/format-justify-right-symbolic.svg", 16, 16, def_color); + if (!g_icon_list_bullet.pixels) + g_icon_list_bullet = svg_load("0:/icons/view-list-bullet-symbolic.svg", 16, 16, def_color); + if (!g_icon_list_number.pixels) + g_icon_list_number = svg_load("0:/icons/view-list-ordered-symbolic.svg", 16, 16, def_color); + if (!g_icon_indent_less.pixels) + g_icon_indent_less = svg_load("0:/icons/format-indent-less-symbolic.svg", 16, 16, def_color); + if (!g_icon_indent_more.pixels) + g_icon_indent_more = svg_load("0:/icons/format-indent-more-symbolic.svg", 16, 16, def_color); } void wp_cleanup_state() { wp_free_document(&g_wp); if (g_icon_folder.pixels) svg_free(g_icon_folder); if (g_icon_save.pixels) svg_free(g_icon_save); + if (g_icon_undo.pixels) svg_free(g_icon_undo); + if (g_icon_redo.pixels) svg_free(g_icon_redo); + if (g_icon_align_left.pixels) svg_free(g_icon_align_left); + if (g_icon_align_center.pixels) svg_free(g_icon_align_center); + if (g_icon_align_right.pixels) svg_free(g_icon_align_right); + if (g_icon_list_bullet.pixels) svg_free(g_icon_list_bullet); + if (g_icon_list_number.pixels) svg_free(g_icon_list_number); + if (g_icon_indent_less.pixels) svg_free(g_icon_indent_less); + if (g_icon_indent_more.pixels) svg_free(g_icon_indent_more); } extern "C" void _start() { diff --git a/programs/src/wordprocessor/render.cpp b/programs/src/wordprocessor/render.cpp index 112b46c..6affbc2 100644 --- a/programs/src/wordprocessor/render.cpp +++ b/programs/src/wordprocessor/render.cpp @@ -24,6 +24,68 @@ static void wp_draw_ui_button(Canvas& c, int x, int y, int w, int h, draw_text(c, g_ui_font, x + (w - tw) / 2, y + (h - th) / 2, label, fg, fonts::UI_SIZE); } +static void wp_draw_ui_icon_button(Canvas& c, int x, int y, int w, int h, + const SvgIcon& icon, const char* fallback, + Color bg, Color fg, int radius = 3) { + c.fill_rounded_rect(x, y, w, h, radius, bg); + if (icon.pixels) { + c.icon(x + (w - icon.width) / 2, y + (h - icon.height) / 2, icon); + return; + } + wp_draw_ui_button(c, x, y, w, h, fallback, bg, fg, radius); +} + +static ParagraphStyle* wp_current_paragraph_style(WordProcessorState* wp) { + if (wp->paragraph_count <= 0) { + static ParagraphStyle fallback = { PARA_ALIGN_LEFT, PARA_LIST_NONE, 100, 0, 0, 0, 0, 0 }; + return &fallback; + } + int para = wp_find_paragraph_at(wp, wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset)); + if (para < 0) para = 0; + if (para >= wp->paragraph_count) para = wp->paragraph_count - 1; + return &wp->paragraphs[para]; +} + +static const char* wp_align_label(uint8_t align) { + switch (align) { + case PARA_ALIGN_CENTER: return "Center"; + case PARA_ALIGN_RIGHT: return "Right"; + default: return "Left"; + } +} + +static const char* wp_list_label(uint8_t list_type) { + switch (list_type) { + case PARA_LIST_BULLET: return "Bullets"; + case PARA_LIST_NUMBER: return "Numbers"; + default: return "Plain"; + } +} + +static void wp_draw_list_marker(Canvas& c, WordProcessorState* wp, WrapLine* wl, int py) { + if (!wl->first_in_paragraph || wl->paragraph_idx < 0 || wl->paragraph_idx >= wp->paragraph_count) + return; + + ParagraphStyle* para = &wp->paragraphs[wl->paragraph_idx]; + if (para->list_type == PARA_LIST_NONE) return; + + int marker_x = WP_MARGIN + para->left_indent + para->first_line_indent; + if (marker_x < 4) marker_x = 4; + + if (para->list_type == PARA_LIST_BULLET) { + fill_circle(c, marker_x + 7, py + wl->height / 2, 3, colors::TEXT_COLOR); + return; + } + + char label[16]; + snprintf(label, sizeof(label), "%d.", wl->list_number > 0 ? wl->list_number : 1); + if (wp->run_count <= 0) return; + StyledRun* run = &wp->runs[wl->run_idx < wp->run_count ? wl->run_idx : 0]; + TrueTypeFont* font = wp_get_font(run->font_id, run->flags); + int top_y = py + (wl->height - run->size) / 2; + draw_text(c, font ? font : g_ui_font, marker_x, top_y, label, colors::TEXT_COLOR, run->size); +} + void wp_render() { if (!g_win.pixels) return; @@ -32,63 +94,84 @@ void wp_render() { c.fill(colors::WINDOW_BG); int sfh = wp_ui_line_height(); + int cursor_abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); + ParagraphStyle* cur_para = wp_current_paragraph_style(wp); Color toolbar_bg = Color::from_rgb(0xF5, 0xF5, 0xF5); Color btn_bg = Color::from_rgb(0xE8, 0xE8, 0xE8); Color btn_active = Color::from_rgb(0xC0, 0xD0, 0xE8); c.fill_rect(0, 0, c.w, WP_TOOLBAR_H, toolbar_bg); - c.fill_rounded_rect(4, 6, 24, 24, 3, btn_bg); + c.fill_rounded_rect(WP_BTN_OPEN_X, 6, 24, 24, 3, btn_bg); if (g_icon_folder.pixels) - c.icon(8, 10, g_icon_folder); + c.icon(WP_BTN_OPEN_X + 4, 10, g_icon_folder); - c.fill_rounded_rect(32, 6, 24, 24, 3, btn_bg); + c.fill_rounded_rect(WP_BTN_SAVE_X, 6, 24, 24, 3, btn_bg); if (g_icon_save.pixels) - c.icon(36, 10, g_icon_save); + c.icon(WP_BTN_SAVE_X + 4, 10, g_icon_save); - c.vline(60, 4, 28, colors::BORDER); + c.vline(WP_BTN_SAVE_X + 28, 4, 28, colors::BORDER); + + wp_draw_ui_icon_button(c, WP_BTN_UNDO_X, 6, 24, 24, g_icon_undo, "U", btn_bg, colors::TEXT_COLOR); + wp_draw_ui_icon_button(c, WP_BTN_REDO_X, 6, 24, 24, g_icon_redo, "R", btn_bg, colors::TEXT_COLOR); + + c.vline(WP_BTN_REDO_X + 28, 4, 28, colors::BORDER); Color bold_bg = (wp->cur_flags & STYLE_BOLD) ? btn_active : btn_bg; - c.fill_rounded_rect(66, 6, 24, 24, 3, bold_bg); - draw_text(c, g_ui_bold ? g_ui_bold : g_ui_font, 73, 8, "B", colors::TEXT_COLOR, fonts::UI_SIZE); + c.fill_rounded_rect(WP_BTN_BOLD_X, 6, 24, 24, 3, bold_bg); + draw_text(c, g_ui_bold ? g_ui_bold : g_ui_font, WP_BTN_BOLD_X + 7, 8, "B", colors::TEXT_COLOR, fonts::UI_SIZE); Color italic_bg = (wp->cur_flags & STYLE_ITALIC) ? btn_active : btn_bg; - c.fill_rounded_rect(94, 6, 24, 24, 3, italic_bg); + c.fill_rounded_rect(WP_BTN_ITALIC_X, 6, 24, 24, 3, italic_bg); { TrueTypeFont* italic_font = wp_get_font(FONT_ROBOTO, STYLE_ITALIC); - draw_text(c, italic_font ? italic_font : g_ui_font, 103, 8, "I", colors::TEXT_COLOR, fonts::UI_SIZE); + draw_text(c, italic_font ? italic_font : g_ui_font, WP_BTN_ITALIC_X + 9, 8, "I", colors::TEXT_COLOR, fonts::UI_SIZE); } - c.vline(122, 4, 28, colors::BORDER); - - c.fill_rounded_rect(128, 6, 90, 24, 3, btn_bg); - wp_draw_ui_text(c, 134, (WP_TOOLBAR_H - sfh) / 2, + c.fill_rounded_rect(WP_FONT_DD_X, 6, WP_FONT_DD_W, 24, 3, btn_bg); + wp_draw_ui_text(c, WP_FONT_DD_X + 6, (WP_TOOLBAR_H - sfh) / 2, WP_FONT_NAMES[wp->cur_font_id < FONT_COUNT ? wp->cur_font_id : 0], colors::TEXT_COLOR); - c.fill_rounded_rect(224, 6, 44, 24, 3, btn_bg); + c.fill_rounded_rect(WP_SIZE_DD_X, 6, WP_SIZE_DD_W, 24, 3, btn_bg); { char sz[8]; snprintf(sz, sizeof(sz), "%d", (int)wp->cur_size); - wp_draw_ui_text(c, 232, (WP_TOOLBAR_H - sfh) / 2, sz, colors::TEXT_COLOR); + wp_draw_ui_text(c, WP_SIZE_DD_X + 8, (WP_TOOLBAR_H - sfh) / 2, sz, colors::TEXT_COLOR); } - c.vline(272, 4, 28, colors::BORDER); + c.vline(WP_SIZE_DD_X + WP_SIZE_DD_W + 6, 4, 28, colors::BORDER); - c.fill_rounded_rect(278, 6, 24, 24, 3, btn_bg); + wp_draw_ui_icon_button(c, WP_BTN_ALIGN_L_X, 6, 24, 24, g_icon_align_left, "L", + cur_para->align == PARA_ALIGN_LEFT ? btn_active : btn_bg, colors::TEXT_COLOR); + wp_draw_ui_icon_button(c, WP_BTN_ALIGN_C_X, 6, 24, 24, g_icon_align_center, "C", + cur_para->align == PARA_ALIGN_CENTER ? btn_active : btn_bg, colors::TEXT_COLOR); + wp_draw_ui_icon_button(c, WP_BTN_ALIGN_R_X, 6, 24, 24, g_icon_align_right, "R", + cur_para->align == PARA_ALIGN_RIGHT ? btn_active : btn_bg, colors::TEXT_COLOR); + c.vline(WP_BTN_ALIGN_R_X + 28, 4, 28, colors::BORDER); + wp_draw_ui_icon_button(c, WP_BTN_BULLET_X, 6, 24, 24, g_icon_list_bullet, "*", + cur_para->list_type == PARA_LIST_BULLET ? btn_active : btn_bg, colors::TEXT_COLOR); + wp_draw_ui_icon_button(c, WP_BTN_NUMBER_X, 6, 24, 24, g_icon_list_number, "1.", + cur_para->list_type == PARA_LIST_NUMBER ? btn_active : btn_bg, colors::TEXT_COLOR); + c.vline(WP_BTN_NUMBER_X + 28, 4, 28, colors::BORDER); + wp_draw_ui_icon_button(c, WP_BTN_OUTDENT_X, 6, 24, 24, g_icon_indent_less, "<", btn_bg, colors::TEXT_COLOR); + wp_draw_ui_icon_button(c, WP_BTN_INDENT_X, 6, 24, 24, g_icon_indent_more, ">", btn_bg, colors::TEXT_COLOR); + c.vline(WP_BTN_INDENT_X + 28, 4, 28, colors::BORDER); + + { + char spacing[12]; + snprintf(spacing, sizeof(spacing), "%d%%", (int)cur_para->line_spacing); + c.fill_rounded_rect(WP_LINE_DD_X, 6, WP_LINE_DD_W, 24, 3, btn_bg); + wp_draw_ui_text(c, WP_LINE_DD_X + 8, (WP_TOOLBAR_H - sfh) / 2, spacing, colors::TEXT_COLOR); + } + + c.vline(WP_BTN_SECTION_X - 4, 4, 28, colors::BORDER); + + c.fill_rounded_rect(WP_BTN_SECTION_X, 6, 24, 24, 3, btn_bg); { char section[2] = { (char)0xA7, '\0' }; TrueTypeFont* font = wp_get_font(FONT_ROBOTO, 0); - draw_text(c, font ? font : g_ui_font, 284, 8, section, colors::TEXT_COLOR, fonts::UI_SIZE); - } - - { - char label[128]; - if (wp->filename[0]) - snprintf(label, sizeof(label), "%s%s", wp->filename, wp->modified ? " *" : ""); - else - snprintf(label, sizeof(label), "Untitled%s", wp->modified ? " *" : ""); - wp_draw_ui_text(c, 312, (WP_TOOLBAR_H - sfh) / 2, label, Color::from_rgb(0x88, 0x88, 0x88)); + draw_text(c, font ? font : g_ui_font, WP_BTN_SECTION_X + 6, 8, section, colors::TEXT_COLOR, fonts::UI_SIZE); } c.hline(0, WP_TOOLBAR_H - 1, c.w, colors::BORDER); @@ -143,7 +226,6 @@ void wp_render() { wp_ensure_cursor_visible(wp, text_area_h); int scroll_y = wp->scrollbar.scroll_offset; - int cursor_abs = wp_abs_pos(wp, wp->cursor_run, wp->cursor_offset); int sel_s = 0; int sel_e = 0; if (wp->has_selection) wp_sel_range(wp, &sel_s, &sel_e); @@ -156,10 +238,12 @@ void wp_render() { if (py + wl->height <= edit_y) continue; if (py >= edit_y + text_area_h) break; + wp_draw_list_marker(c, wp, wl, py); + int chars_left = wl->char_count; int ri = wl->run_idx; int ro = wl->run_offset; - int x = WP_MARGIN; + int x = wl->x; int line_abs_start = wp_wrap_line_start(wp, li); int char_idx = 0; @@ -244,11 +328,13 @@ void wp_render() { c.fill_rect(0, status_y, c.w, WP_STATUS_H, Color::from_rgb(0x2B, 0x3E, 0x50)); int status_text_y = status_y + (WP_STATUS_H - sfh) / 2; - char status_left[128]; - snprintf(status_left, sizeof(status_left), " %s %dpt %s%s", + char status_left[192]; + const char* name = wp->filename[0] ? wp->filename : "Untitled"; + snprintf(status_left, sizeof(status_left), " %s%s | %s %dpt | %s | %s | %d%%", + name, wp->modified ? " *" : "", WP_FONT_NAMES[wp->cur_font_id < FONT_COUNT ? wp->cur_font_id : 0], (int)wp->cur_size, - (wp->cur_flags & STYLE_BOLD) ? "Bold " : "", - (wp->cur_flags & STYLE_ITALIC) ? "Italic" : ""); + wp_align_label(cur_para->align), wp_list_label(cur_para->list_type), + (int)cur_para->line_spacing); wp_draw_ui_text(c, 4, status_text_y, status_left, colors::PANEL_TEXT); char status_right[32]; @@ -257,7 +343,7 @@ void wp_render() { wp_draw_ui_text(c, c.w - sr_w - 4, status_text_y, status_right, colors::PANEL_TEXT); if (wp->font_dropdown_open) { - int dx = 128; + int dx = WP_FONT_DD_X; int dy = WP_TOOLBAR_H; int dw = 110; int dh = FONT_COUNT * 26 + 4; @@ -272,7 +358,7 @@ void wp_render() { } if (wp->size_dropdown_open) { - int dx = 224; + int dx = WP_SIZE_DD_X; int dy = WP_TOOLBAR_H; int dw = 56; int dh = WP_SIZE_OPTION_COUNT * 26 + 4; @@ -287,4 +373,21 @@ void wp_render() { wp_draw_ui_text(c, dx + 8, iy + (24 - sfh) / 2, sz, colors::TEXT_COLOR); } } + + if (wp->line_spacing_dropdown_open) { + int dx = WP_LINE_DD_X; + int dy = WP_TOOLBAR_H; + int dw = 64; + int dh = WP_LINE_SPACING_OPTION_COUNT * 26 + 4; + c.fill_rect(dx, dy, dw, dh, colors::MENU_BG); + c.rect(dx, dy, dw, dh, colors::BORDER); + for (int i = 0; i < WP_LINE_SPACING_OPTION_COUNT; i++) { + int iy = dy + 2 + i * 26; + if (WP_LINE_SPACING_OPTIONS[i] == cur_para->line_spacing) + c.fill_rect(dx + 2, iy, dw - 4, 24, colors::MENU_HOVER); + char spacing[12]; + snprintf(spacing, sizeof(spacing), "%d%%", WP_LINE_SPACING_OPTIONS[i]); + wp_draw_ui_text(c, dx + 8, iy + (24 - sfh) / 2, spacing, colors::TEXT_COLOR); + } + } } diff --git a/programs/src/wordprocessor/wordprocessor.hpp b/programs/src/wordprocessor/wordprocessor.hpp index 40d599f..7c0384b 100644 --- a/programs/src/wordprocessor/wordprocessor.hpp +++ b/programs/src/wordprocessor/wordprocessor.hpp @@ -31,7 +31,34 @@ static constexpr int WP_MARGIN = 16; static constexpr int WP_MAX_RUNS = 1024; static constexpr int WP_MAX_TEXT = 262144; static constexpr int WP_MAX_WRAP_LINES = 4096; +static constexpr int WP_MAX_PARAGRAPHS = 4096; static constexpr int WP_DEFAULT_SIZE = 18; +static constexpr int WP_UNDO_MAX = 24; +static constexpr int WP_PARA_STEP = 12; +static constexpr int WP_SPACE_STEP = 6; +static constexpr int WP_LIST_LEFT = 28; +static constexpr int WP_LIST_HANGING = -18; +static constexpr int WP_LIST_MARKER_W = 20; +static constexpr int WP_BTN_OPEN_X = 4; +static constexpr int WP_BTN_SAVE_X = 32; +static constexpr int WP_BTN_UNDO_X = 66; +static constexpr int WP_BTN_REDO_X = 94; +static constexpr int WP_BTN_BOLD_X = 128; +static constexpr int WP_BTN_ITALIC_X = 156; +static constexpr int WP_FONT_DD_X = 188; +static constexpr int WP_FONT_DD_W = 84; +static constexpr int WP_SIZE_DD_X = 278; +static constexpr int WP_SIZE_DD_W = 40; +static constexpr int WP_BTN_ALIGN_L_X = 330; +static constexpr int WP_BTN_ALIGN_C_X = 358; +static constexpr int WP_BTN_ALIGN_R_X = 386; +static constexpr int WP_BTN_BULLET_X = 418; +static constexpr int WP_BTN_NUMBER_X = 446; +static constexpr int WP_BTN_OUTDENT_X = 482; +static constexpr int WP_BTN_INDENT_X = 510; +static constexpr int WP_LINE_DD_X = 544; +static constexpr int WP_LINE_DD_W = 56; +static constexpr int WP_BTN_SECTION_X = 608; static constexpr int FONT_ROBOTO = 0; static constexpr int FONT_NOTOSERIF = 1; @@ -49,6 +76,20 @@ inline constexpr const char* WP_FONT_NAMES[FONT_COUNT] = { inline constexpr int WP_SIZE_OPTIONS[] = { 12, 14, 16, 18, 20, 24, 28, 36 }; static constexpr int WP_SIZE_OPTION_COUNT = 8; +inline constexpr int WP_LINE_SPACING_OPTIONS[] = { 100, 125, 150, 200 }; +static constexpr int WP_LINE_SPACING_OPTION_COUNT = 4; + +enum ParagraphAlign : uint8_t { + PARA_ALIGN_LEFT = 0, + PARA_ALIGN_CENTER = 1, + PARA_ALIGN_RIGHT = 2, +}; + +enum ParagraphListType : uint8_t { + PARA_LIST_NONE = 0, + PARA_LIST_BULLET = 1, + PARA_LIST_NUMBER = 2, +}; struct WPFontTable { TrueTypeFont* fonts[FONT_COUNT][4]; @@ -71,6 +112,32 @@ struct WrapLine { int y; int height; int baseline; + int x; + int width; + int paragraph_idx; + int list_number; + bool first_in_paragraph; +}; + +struct ParagraphStyle { + uint8_t align; + uint8_t list_type; + uint8_t line_spacing; + uint8_t _pad; + int16_t left_indent; + int16_t first_line_indent; + int16_t space_before; + int16_t space_after; +}; + +struct UndoSnapshot { + uint8_t* data; + int size; + int cursor_abs; + int sel_anchor; + int sel_end; + bool has_selection; + bool modified; }; inline bool wp_left_held(uint8_t buttons) { @@ -189,9 +256,13 @@ struct WordProcessorState { WrapLine* wrap_lines; int wrap_line_count; + int wrap_line_cap; bool wrap_dirty; int last_wrap_width; + ParagraphStyle paragraphs[WP_MAX_PARAGRAPHS]; + int paragraph_count; + bool modified; char filepath[256]; char filename[64]; @@ -204,6 +275,11 @@ struct WordProcessorState { bool font_dropdown_open; bool size_dropdown_open; + bool line_spacing_dropdown_open; + + UndoSnapshot undo[WP_UNDO_MAX]; + int undo_count; + int undo_pos; }; extern int g_win_w; @@ -213,11 +289,21 @@ extern WordProcessorState g_wp; extern WPFontTable g_wp_fonts; extern SvgIcon g_icon_folder; extern SvgIcon g_icon_save; +extern SvgIcon g_icon_undo; +extern SvgIcon g_icon_redo; +extern SvgIcon g_icon_align_left; +extern SvgIcon g_icon_align_center; +extern SvgIcon g_icon_align_right; +extern SvgIcon g_icon_list_bullet; +extern SvgIcon g_icon_list_number; +extern SvgIcon g_icon_indent_less; +extern SvgIcon g_icon_indent_more; extern TrueTypeFont* g_ui_font; extern TrueTypeFont* g_ui_bold; void wp_load_fonts(); void wp_load_icons(); +void wp_init_paragraph_style(ParagraphStyle* para); void wp_init_empty_document(WordProcessorState* wp); void wp_free_document(WordProcessorState* wp); void wp_cleanup_state(); @@ -227,6 +313,8 @@ TrueTypeFont* wp_get_font(int font_id, uint8_t flags); int wp_abs_pos(WordProcessorState* wp, int run, int offset); void wp_pos_to_run(WordProcessorState* wp, int abs_pos, int* out_run, int* out_offset); char wp_char_at(WordProcessorState* wp, int abs_pos); +int wp_find_paragraph_at(WordProcessorState* wp, int abs_pos); +void wp_selected_paragraph_range(WordProcessorState* wp, int* out_start_para, int* out_end_para); void wp_insert_char(WordProcessorState* wp, char c); void wp_delete_char(WordProcessorState* wp); @@ -242,6 +330,14 @@ void wp_sel_range(WordProcessorState* wp, int* out_start, int* out_end); void wp_start_selection(WordProcessorState* wp); void wp_update_selection_to_cursor(WordProcessorState* wp); void wp_apply_style_to_selection(WordProcessorState* wp, int mode, int value); +void wp_apply_alignment(WordProcessorState* wp, uint8_t align); +void wp_adjust_paragraph_indent(WordProcessorState* wp, int delta); +void wp_adjust_paragraph_first_line_indent(WordProcessorState* wp, int delta); +void wp_adjust_paragraph_spacing_before(WordProcessorState* wp, int delta); +void wp_adjust_paragraph_spacing_after(WordProcessorState* wp, int delta); +void wp_cycle_line_spacing(WordProcessorState* wp); +void wp_set_line_spacing(WordProcessorState* wp, int value); +void wp_toggle_list(WordProcessorState* wp, uint8_t list_type); void wp_delete_selection(WordProcessorState* wp); void wp_recompute_wrap(WordProcessorState* wp, int content_w); @@ -254,6 +350,12 @@ void wp_open_save_pathbar(WordProcessorState* wp); void wp_save_file(WordProcessorState* wp); void wp_load_file(WordProcessorState* wp, const char* path); +void wp_history_reset(WordProcessorState* wp); +void wp_history_checkpoint(WordProcessorState* wp); +void wp_history_mark_saved(WordProcessorState* wp); +bool wp_undo(WordProcessorState* wp); +bool wp_redo(WordProcessorState* wp); + void wp_render(); void wp_handle_mouse(const Montauk::WinEvent& ev); void wp_handle_key(const Montauk::KeyEvent& key); diff --git a/scripts/copy_icons.sh b/scripts/copy_icons.sh index 71222f3..90468a6 100755 --- a/scripts/copy_icons.sh +++ b/scripts/copy_icons.sh @@ -24,6 +24,15 @@ ICONS=( "actions/symbolic/go-next-symbolic.svg" "actions/symbolic/go-home-symbolic.svg" "actions/symbolic/document-save-symbolic.svg" + "actions/symbolic/edit-undo-symbolic.svg" + "actions/symbolic/edit-redo-symbolic.svg" + "actions/symbolic/format-justify-left-symbolic.svg" + "actions/symbolic/format-justify-center-symbolic.svg" + "actions/symbolic/format-justify-right-symbolic.svg" + "actions/symbolic/view-list-bullet-symbolic.svg" + "actions/symbolic/view-list-ordered-symbolic.svg" + "actions/symbolic/format-indent-less-symbolic.svg" + "actions/symbolic/format-indent-more-symbolic.svg" "apps/symbolic/utilities-terminal-symbolic.svg" "apps/symbolic/system-file-manager-symbolic.svg" "apps/symbolic/preferences-desktop-apps-symbolic.svg"