From 26405d200b88d2ee8a3eda8c1e65329a9977a716 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Thu, 10 Sep 2026 05:20:08 +0300 Subject: [PATCH 1/8] Fix Linux text input navigation --- ui/text_editor.v | 97 +++++++++++++++++++++++++++++++++++++++++++ ui/text_editor_test.v | 62 +++++++++++++++++++++++++++ ui/ui_immediate.c.v | 28 +++++++------ 3 files changed, 174 insertions(+), 13 deletions(-) diff --git a/ui/text_editor.v b/ui/text_editor.v index ea3b0d0..a4a95b3 100644 --- a/ui/text_editor.v +++ b/ui/text_editor.v @@ -1,6 +1,7 @@ module ui2 import strings +import encoding.utf8 // TextSelection stores positions as rune offsets, so cursor movement is stable // for UTF-8 text. The selection is collapsed when anchor == caret. @@ -68,6 +69,11 @@ pub fn (mut e TextEditor) select_all() { pub fn (mut e TextEditor) move_caret(delta int, extend bool) { length := rune_len(e.text) + if !extend && !e.selection.collapsed() { + start, end := e.selection.ordered() + e.set_caret(if delta < 0 { start } else { end }) + return + } next := clamp_int(e.selection.caret + delta, 0, length) if extend { e.selection.caret = next @@ -79,6 +85,71 @@ pub fn (mut e TextEditor) move_caret(delta int, extend bool) { } } +// move_word_caret moves to the beginning of the previous or next word. Word +// positions are rune offsets, so Ctrl+Arrow remains safe for Unicode input. +pub fn (mut e TextEditor) move_word_caret(direction int, extend bool) { + if !extend && !e.selection.collapsed() { + start, end := e.selection.ordered() + e.set_caret(if direction < 0 { start } else { end }) + return + } + runes := e.text.runes() + next := if direction < 0 { + previous_word_boundary(runes, e.selection.caret) + } else { + next_word_boundary(runes, e.selection.caret) + } + e.move_caret_to(next, extend) +} + +// move_caret_to moves or extends the selection to an absolute rune offset. +pub fn (mut e TextEditor) move_caret_to(pos int, extend bool) { + next := clamp_int(pos, 0, rune_len(e.text)) + if extend { + e.selection.caret = next + return + } + e.set_caret(next) +} + +// apply_text_editor_navigation is shared by the custom renderer and tests. +// A single-line field treats Page Up/Down like Home/End, matching the useful +// boundary navigation users expect when there is no vertical viewport. +fn apply_text_editor_navigation(mut editor TextEditor, key string, extend bool, ctrl bool) bool { + match key { + 'left' { + if ctrl { + editor.move_word_caret(-1, extend) + } else { + editor.move_caret(-1, extend) + } + } + 'right' { + if ctrl { + editor.move_word_caret(1, extend) + } else { + editor.move_caret(1, extend) + } + } + 'home', 'page_up' { + editor.move_caret_to(0, extend) + } + 'end', 'page_down' { + editor.move_caret_to(rune_len(editor.text), extend) + } + 'a' { + if !ctrl { + return false + } + editor.select_all() + } + else { + return false + } + } + return true +} + pub fn (mut e TextEditor) insert_text(value string) { e.replace_selection(value) } @@ -162,3 +233,29 @@ fn clamp_int(value int, min int, max int) int { } return value } + +fn is_word_rune(value rune) bool { + return utf8.is_letter(value) || utf8.is_number(value) || value == `_` +} + +fn previous_word_boundary(runes []rune, caret int) int { + mut position := clamp_int(caret, 0, runes.len) + for position > 0 && !is_word_rune(runes[position - 1]) { + position-- + } + for position > 0 && is_word_rune(runes[position - 1]) { + position-- + } + return position +} + +fn next_word_boundary(runes []rune, caret int) int { + mut position := clamp_int(caret, 0, runes.len) + for position < runes.len && is_word_rune(runes[position]) { + position++ + } + for position < runes.len && !is_word_rune(runes[position]) { + position++ + } + return position +} diff --git a/ui/text_editor_test.v b/ui/text_editor_test.v index 31869c0..504c1cb 100644 --- a/ui/text_editor_test.v +++ b/ui/text_editor_test.v @@ -84,6 +84,68 @@ fn test_text_editor_move_and_select_all() { assert end2 == 3 } +fn test_text_editor_navigation_extends_and_collapses_selection() { + mut editor := text_editor('abcdef') + editor.set_selection(1, 5) + assert apply_text_editor_navigation(mut editor, 'left', false, false) + assert editor.selection == TextSelection{ + anchor: 1 + caret: 1 + } + + editor.set_selection(1, 5) + assert apply_text_editor_navigation(mut editor, 'right', false, false) + assert editor.selection == TextSelection{ + anchor: 5 + caret: 5 + } + + editor.set_caret(3) + assert apply_text_editor_navigation(mut editor, 'left', true, false) + assert editor.selection == TextSelection{ + anchor: 3 + caret: 2 + } + assert apply_text_editor_navigation(mut editor, 'end', true, false) + assert editor.selection == TextSelection{ + anchor: 3 + caret: 6 + } + assert apply_text_editor_navigation(mut editor, 'home', false, false) + assert editor.selection == TextSelection{} +} + +fn test_text_editor_navigation_moves_by_unicode_words_and_page_boundaries() { + mut editor := text_editor('one, two_2 世界') + editor.set_caret(rune_len(editor.text)) + assert apply_text_editor_navigation(mut editor, 'left', false, true) + assert editor.selection.caret == 12 + assert apply_text_editor_navigation(mut editor, 'left', false, true) + assert editor.selection.caret == 5 + assert apply_text_editor_navigation(mut editor, 'left', false, true) + assert editor.selection.caret == 0 + assert apply_text_editor_navigation(mut editor, 'right', false, true) + assert editor.selection.caret == 5 + assert apply_text_editor_navigation(mut editor, 'right', true, true) + assert editor.selection == TextSelection{ + anchor: 5 + caret: 12 + } + + assert apply_text_editor_navigation(mut editor, 'page_down', false, false) + assert editor.selection.caret == rune_len(editor.text) + assert apply_text_editor_navigation(mut editor, 'page_up', true, false) + assert editor.selection == TextSelection{ + anchor: rune_len(editor.text) + caret: 0 + } + assert apply_text_editor_navigation(mut editor, 'a', false, true) + start, end := editor.selection.ordered() + assert start == 0 + assert end == rune_len(editor.text) + assert !apply_text_editor_navigation(mut editor, 'a', false, false) +} + fn test_rich_text_area_keeps_runs() { runs := [ TextRun{ diff --git a/ui/ui_immediate.c.v b/ui/ui_immediate.c.v index 79b3ffc..5654504 100644 --- a/ui/ui_immediate.c.v +++ b/ui/ui_immediate.c.v @@ -637,7 +637,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 } } if !dispatch_key_event(e) { - handle_key_down(e.key_code) + handle_key_down(e.key_code, e.modifiers) } } .files_dropped { @@ -1019,7 +1019,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 fire_field_change(g_focused_field) } - fn handle_key_down(key gg.KeyCode) { + fn handle_key_down(key gg.KeyCode, modifiers u32) { if g_focused_field.len == 0 { return } @@ -1040,17 +1040,19 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 fire_field_change(g_focused_field) } } - if key == .left || key == .right || key == .home || key == .end { - if key == .left { - editor.move_caret(-1, false) - } else if key == .right { - editor.move_caret(1, false) - } else if key == .home { - editor.set_caret(0) - } else { - editor.set_caret(rune_len(editor.text)) - } - replace_text_editor(g_focused_field, editor) + navigation_key := match key { + .left { 'left' } + .right { 'right' } + .home { 'home' } + .end { 'end' } + .page_up { 'page_up' } + .page_down { 'page_down' } + .a { 'a' } + else { '' } + } + if navigation_key.len > 0 && apply_text_editor_navigation(mut editor, navigation_key, + modifiers & u32(gg.Modifier.shift) != 0, modifiers & u32(gg.Modifier.ctrl) != 0) { + g_text_editors[g_focused_field] = editor } if key == .enter || key == .kp_enter { for target in g_hit_targets { From f1429c4da25361fb9934e56bb25d88165285f383 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Thu, 10 Sep 2026 05:43:33 +0300 Subject: [PATCH 2/8] fix: respect primary text modifier --- ui/ui_custom_test.v | 11 +++++++++++ ui/ui_immediate.c.v | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/ui/ui_custom_test.v b/ui/ui_custom_test.v index a667b6a..21f91c4 100644 --- a/ui/ui_custom_test.v +++ b/ui/ui_custom_test.v @@ -98,6 +98,17 @@ $if ui2_custom_rendering ? { assert system_symbol_fallback('future.symbol') == '' } + fn test_custom_text_navigation_uses_the_platform_primary_modifier() { + assert !text_navigation_primary_modifier(false, false, false) + $if macos { + assert !text_navigation_primary_modifier(true, false, false) + assert text_navigation_primary_modifier(false, false, true) + } $else { + assert text_navigation_primary_modifier(true, false, false) + assert !text_navigation_primary_modifier(true, true, false) + } + } + fn test_custom_slider_pointer_value_uses_range_step_and_orientation() { horizontal := HitTarget{ slider: true diff --git a/ui/ui_immediate.c.v b/ui/ui_immediate.c.v index 5654504..0835a35 100644 --- a/ui/ui_immediate.c.v +++ b/ui/ui_immediate.c.v @@ -1050,8 +1050,13 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 .a { 'a' } else { '' } } + primary_modifier := text_navigation_primary_modifier( + modifiers & u32(gg.Modifier.ctrl) != 0, + modifiers & u32(gg.Modifier.alt) != 0, + modifiers & u32(gg.Modifier.super) != 0, + ) if navigation_key.len > 0 && apply_text_editor_navigation(mut editor, navigation_key, - modifiers & u32(gg.Modifier.shift) != 0, modifiers & u32(gg.Modifier.ctrl) != 0) { + modifiers & u32(gg.Modifier.shift) != 0, primary_modifier) { g_text_editors[g_focused_field] = editor } if key == .enter || key == .kp_enter { @@ -1075,6 +1080,17 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 } } + // text_navigation_primary_modifier keeps the native text-editing shortcuts + // available without treating AltGr (reported as Ctrl+Alt on Windows) as + // Control. Command is the primary modifier on macOS. + fn text_navigation_primary_modifier(ctrl bool, alt bool, super_ bool) bool { + $if macos { + return super_ + } $else { + return ctrl && !alt + } + } + fn fire_field_change(id string) { for target in g_hit_targets { if target.id == id && (target.text_field || target.text_area) && target.emit_change { From 21f5bfe0fc1d3466e3028b16f22526fa48eb947f Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Thu, 10 Sep 2026 06:22:49 +0300 Subject: [PATCH 3/8] fix: align custom text navigation with platform shortcuts --- ui/ui_custom_test.v | 6 ++++- ui/ui_immediate.c.v | 42 +++++++++++++++++++++++++++++++++-- ui/ui_scroll_immediate_test.v | 13 +++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/ui/ui_custom_test.v b/ui/ui_custom_test.v index 21f91c4..511212a 100644 --- a/ui/ui_custom_test.v +++ b/ui/ui_custom_test.v @@ -103,9 +103,13 @@ $if ui2_custom_rendering ? { $if macos { assert !text_navigation_primary_modifier(true, false, false) assert text_navigation_primary_modifier(false, false, true) + assert text_navigation_word_modifier(false, true) + assert !text_navigation_word_modifier(true, false) } $else { assert text_navigation_primary_modifier(true, false, false) - assert !text_navigation_primary_modifier(true, true, false) + assert !text_navigation_primary_modifier(true, true, false) + assert text_navigation_word_modifier(true, false) + assert !text_navigation_word_modifier(true, true) } } diff --git a/ui/ui_immediate.c.v b/ui/ui_immediate.c.v index 0835a35..8d298fc 100644 --- a/ui/ui_immediate.c.v +++ b/ui/ui_immediate.c.v @@ -1040,7 +1040,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 fire_field_change(g_focused_field) } } - navigation_key := match key { + mut navigation_key := match key { .left { 'left' } .right { 'right' } .home { 'home' } @@ -1050,13 +1050,34 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 .a { 'a' } else { '' } } + mut focused_text_area := false + for target in g_hit_targets { + if target.id == g_focused_field { + focused_text_area = target.text_area + break + } + } + if focused_text_area && (navigation_key == 'page_up' || navigation_key == 'page_down') { + page_focused_text_area(if navigation_key == 'page_up' { -1 } else { 1 }) + return + } primary_modifier := text_navigation_primary_modifier( modifiers & u32(gg.Modifier.ctrl) != 0, modifiers & u32(gg.Modifier.alt) != 0, modifiers & u32(gg.Modifier.super) != 0, ) + word_modifier := text_navigation_word_modifier( + modifiers & u32(gg.Modifier.ctrl) != 0, + modifiers & u32(gg.Modifier.alt) != 0, + ) + if primary_modifier && navigation_key == 'left' { + navigation_key = 'home' + } else if primary_modifier && navigation_key == 'right' { + navigation_key = 'end' + } + navigation_modifier := if navigation_key == 'a' { primary_modifier } else { word_modifier } if navigation_key.len > 0 && apply_text_editor_navigation(mut editor, navigation_key, - modifiers & u32(gg.Modifier.shift) != 0, primary_modifier) { + modifiers & u32(gg.Modifier.shift) != 0, navigation_modifier) { g_text_editors[g_focused_field] = editor } if key == .enter || key == .kp_enter { @@ -1091,6 +1112,23 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 } } + // text_navigation_word_modifier follows native word movement: Option on + // macOS, and Control everywhere else. Excluding Alt on non-macOS systems + // keeps AltGr from being interpreted as a Control shortcut. + fn text_navigation_word_modifier(ctrl bool, alt bool) bool { + $if macos { + return alt + } $else { + return ctrl && !alt + } + } + + fn page_focused_text_area(direction int) { + viewport := g_scroll_viewports[g_focused_field] or { return } + set_scroll_offset(g_focused_field, scroll_offset(g_focused_field) + f64(direction) * viewport.height, + scroll_maximum(g_focused_field)) + } + fn fire_field_change(id string) { for target in g_hit_targets { if target.id == id && (target.text_field || target.text_area) && target.emit_change { diff --git a/ui/ui_scroll_immediate_test.v b/ui/ui_scroll_immediate_test.v index f1a5540..8048fe0 100644 --- a/ui/ui_scroll_immediate_test.v +++ b/ui/ui_scroll_immediate_test.v @@ -44,6 +44,19 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 assert wrap_text_area_lines('one\ttwo', 30, scroll_test_width) == ['one', 'two'] } + fn test_page_navigation_scrolls_the_focused_text_area() { + reset_scroll_test_state() + frame := rect(0, 0, 100, 80) + register_scroll_view('notes', frame, frame, 400, true, true, false) + g_focused_field = 'notes' + page_focused_text_area(1) + assert scroll_offset('notes') == 80 + page_focused_text_area(10) + assert scroll_offset('notes') == 320 + page_focused_text_area(-1) + assert scroll_offset('notes') == 240 + } + fn test_text_area_wraps_long_words_without_splitting_utf8_bytes() { reset_scroll_test_state() assert wrap_text_area_lines('é界🙂abcd', 30, scroll_test_width) == ['é界🙂', 'abc', 'd'] From 6def7251de7e29d13d9062d9922389af539202af Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Thu, 10 Sep 2026 06:37:19 +0300 Subject: [PATCH 4/8] fix: preserve Ctrl word navigation --- ui/ui_custom_test.v | 4 +++- ui/ui_immediate.c.v | 21 +++++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/ui/ui_custom_test.v b/ui/ui_custom_test.v index 511212a..2b26d75 100644 --- a/ui/ui_custom_test.v +++ b/ui/ui_custom_test.v @@ -105,11 +105,13 @@ $if ui2_custom_rendering ? { assert text_navigation_primary_modifier(false, false, true) assert text_navigation_word_modifier(false, true) assert !text_navigation_word_modifier(true, false) + assert text_navigation_boundary_modifier(true) } $else { - assert text_navigation_primary_modifier(true, false, false) + assert text_navigation_primary_modifier(true, false, false) assert !text_navigation_primary_modifier(true, true, false) assert text_navigation_word_modifier(true, false) assert !text_navigation_word_modifier(true, true) + assert !text_navigation_boundary_modifier(true) } } diff --git a/ui/ui_immediate.c.v b/ui/ui_immediate.c.v index 8d298fc..89f0d19 100644 --- a/ui/ui_immediate.c.v +++ b/ui/ui_immediate.c.v @@ -1070,9 +1070,12 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 modifiers & u32(gg.Modifier.ctrl) != 0, modifiers & u32(gg.Modifier.alt) != 0, ) - if primary_modifier && navigation_key == 'left' { + boundary_modifier := text_navigation_boundary_modifier( + modifiers & u32(gg.Modifier.super) != 0, + ) + if boundary_modifier && navigation_key == 'left' { navigation_key = 'home' - } else if primary_modifier && navigation_key == 'right' { + } else if boundary_modifier && navigation_key == 'right' { navigation_key = 'end' } navigation_modifier := if navigation_key == 'a' { primary_modifier } else { word_modifier } @@ -1115,15 +1118,25 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 // text_navigation_word_modifier follows native word movement: Option on // macOS, and Control everywhere else. Excluding Alt on non-macOS systems // keeps AltGr from being interpreted as a Control shortcut. - fn text_navigation_word_modifier(ctrl bool, alt bool) bool { +fn text_navigation_word_modifier(ctrl bool, alt bool) bool { $if macos { return alt } $else { return ctrl && !alt } +} + +// text_navigation_boundary_modifier maps Command+Arrow to line boundaries on +// macOS. On other platforms Ctrl+Arrow must remain word navigation. +fn text_navigation_boundary_modifier(super_ bool) bool { + $if macos { + return super_ + } $else { + return false } +} - fn page_focused_text_area(direction int) { +fn page_focused_text_area(direction int) { viewport := g_scroll_viewports[g_focused_field] or { return } set_scroll_offset(g_focused_field, scroll_offset(g_focused_field) + f64(direction) * viewport.height, scroll_maximum(g_focused_field)) From 789927badaacab7717c24a19fd72b024cb949896 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Thu, 10 Sep 2026 07:15:29 +0300 Subject: [PATCH 5/8] fix: render text field selections --- ui/text_editor.v | 10 +++++++++- ui/text_editor_test.v | 11 ++++++++++- ui/ui_custom_test.v | 9 +++++++++ ui/ui_immediate.c.v | 44 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/ui/text_editor.v b/ui/text_editor.v index a4a95b3..9322ce5 100644 --- a/ui/text_editor.v +++ b/ui/text_editor.v @@ -235,7 +235,15 @@ fn clamp_int(value int, min int, max int) int { } fn is_word_rune(value rune) bool { - return utf8.is_letter(value) || utf8.is_number(value) || value == `_` + return utf8.is_letter(value) || utf8.is_number(value) || value == `_` || is_combining_mark(value) +} + +// is_combining_mark keeps decomposed characters, such as e followed by a +// combining acute accent, in the same word during keyboard navigation. +fn is_combining_mark(value rune) bool { + return (value >= 0x0300 && value <= 0x036f) || (value >= 0x1ab0 && value <= 0x1aff) + || (value >= 0x1dc0 && value <= 0x1dff) || (value >= 0x20d0 && value <= 0x20ff) + || (value >= 0xfe00 && value <= 0xfe0f) || (value >= 0xfe20 && value <= 0xfe2f) } fn previous_word_boundary(runes []rune, caret int) int { diff --git a/ui/text_editor_test.v b/ui/text_editor_test.v index 504c1cb..16c9ab4 100644 --- a/ui/text_editor_test.v +++ b/ui/text_editor_test.v @@ -5,7 +5,7 @@ fn test_text_editor_insert_and_replace_selection() { editor.set_selection(6, 11) editor.insert_text('V') assert editor.text == 'Hello V' - assert editor.selection.caret == 7 + assert editor.selection.caret == 8 assert editor.selection.collapsed() } @@ -146,6 +146,15 @@ fn test_text_editor_navigation_moves_by_unicode_words_and_page_boundaries() { assert !apply_text_editor_navigation(mut editor, 'a', false, false) } +fn test_text_editor_word_navigation_keeps_combining_marks_in_words() { + mut editor := text_editor('éclair cafe') + editor.set_caret(rune_len(editor.text)) + assert apply_text_editor_navigation(mut editor, 'left', false, true) + assert editor.selection.caret == 7 + assert apply_text_editor_navigation(mut editor, 'left', false, true) + assert editor.selection.caret == 0 +} + fn test_rich_text_area_keeps_runs() { runs := [ TextRun{ diff --git a/ui/ui_custom_test.v b/ui/ui_custom_test.v index 2b26d75..921780c 100644 --- a/ui/ui_custom_test.v +++ b/ui/ui_custom_test.v @@ -498,3 +498,12 @@ $if ui2_custom_rendering ? { g_touch = TouchState{} } } + +fn test_text_field_selection_text_uses_rune_offsets() { + before, selected := text_field_selection_text('a🙂bc', TextSelection{ + anchor: 4 + caret: 1 + }) + assert before == 'a' + assert selected == '🙂bc' +} diff --git a/ui/ui_immediate.c.v b/ui/ui_immediate.c.v index 89f0d19..231873f 100644 --- a/ui/ui_immediate.c.v +++ b/ui/ui_immediate.c.v @@ -1799,6 +1799,10 @@ fn page_focused_text_area(direction int) { is_focused := g_focused_field == el.id draw_control_surface(ctx, x, y, el.frame.width, el.frame.height, el.box, is_focused, el.enabled) + if is_focused && !editor.selection.collapsed() { + draw_text_field_selection(ctx, display_text, editor.selection, x + padding_left, + y, content_width, el.frame.height, el.text_style) + } if current_text.len > 0 { draw_editable_text(ctx, display_text, x + padding_left, y, content_width, el.frame.height, el.text_style) } else if el.placeholder.len > 0 { @@ -2552,6 +2556,46 @@ fn page_focused_text_area(direction int) { draw_text_in_box(ctx, t, x, y, w, h, style, false) } + // draw_text_field_selection paints the selected rune range before its text. + // The range is derived from the rendered string so secure fields highlight + // their bullet characters instead of leaking the underlying value. + fn draw_text_field_selection(ctx &gg.Context, display_text string, selection TextSelection, x f64, y f64, w f64, h f64, style TextStyle) { + before, selected := text_field_selection_text(display_text, selection) + if selected.len == 0 || w <= 0 { + return + } + family := text_font_file(style.font_family, style.bold, style.italic) + ensure_family_fallbacks(ctx, family) + ctx.set_text_cfg(gg.TextCfg{ + color: hex_color(style.color) + size: int(font_render_size(style.size, text_font_metrics(family)) + 0.5) + bold: style.bold + italic: style.italic + family: family + align: text_align(style.align) + vertical_align: .middle + }) + mut left := x + f64(ctx.text_width(before)) + mut right := left + f64(ctx.text_width(selected)) + if left < x { + left = x + } + if right > x + w { + right = x + w + } + if right > left { + draw_rect(ctx, left, y + h * 0.2, right - left, h * 0.6, 0xb8d7ff, 0) + } + } + + fn text_field_selection_text(display_text string, selection TextSelection) (string, string) { + runes := display_text.runes() + start, end := selection.ordered() + from := clamp_int(start, 0, runes.len) + to := clamp_int(end, from, runes.len) + return runes[..from].string(), runes[from..to].string() + } + fn draw_text_in_box(ctx &gg.Context, t string, x f64, y f64, w f64, h f64, style TextStyle, fit bool) { if t.len == 0 { return From 7d2c4c18243157574fbf688a0afc3ed902dd4a8b Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Thu, 10 Sep 2026 07:41:16 +0300 Subject: [PATCH 6/8] fix: align text navigation rendering --- ui/text_editor.v | 600 +++++++++++++++++++++++++++++++++++++++++- ui/text_editor_test.v | 14 +- ui/ui_custom_test.v | 6 + ui/ui_immediate.c.v | 16 +- 4 files changed, 626 insertions(+), 10 deletions(-) diff --git a/ui/text_editor.v b/ui/text_editor.v index 9322ce5..9c4410b 100644 --- a/ui/text_editor.v +++ b/ui/text_editor.v @@ -3,6 +3,589 @@ module ui2 import strings import encoding.utf8 +const unicode_mark_ranges = [ + u32(0x0300), + 0x036f, + 0x0483, + 0x0489, + 0x0591, + 0x05bd, + 0x05bf, + 0x05bf, + 0x05c1, + 0x05c2, + 0x05c4, + 0x05c5, + 0x05c7, + 0x05c7, + 0x0610, + 0x061a, + 0x064b, + 0x065f, + 0x0670, + 0x0670, + 0x06d6, + 0x06dc, + 0x06df, + 0x06e4, + 0x06e7, + 0x06e8, + 0x06ea, + 0x06ed, + 0x0711, + 0x0711, + 0x0730, + 0x074a, + 0x07a6, + 0x07b0, + 0x07eb, + 0x07f3, + 0x07fd, + 0x07fd, + 0x0816, + 0x0819, + 0x081b, + 0x0823, + 0x0825, + 0x0827, + 0x0829, + 0x082d, + 0x0859, + 0x085b, + 0x08d3, + 0x08e1, + 0x08e3, + 0x0903, + 0x093a, + 0x093c, + 0x093e, + 0x094f, + 0x0951, + 0x0957, + 0x0962, + 0x0963, + 0x0981, + 0x0983, + 0x09bc, + 0x09bc, + 0x09be, + 0x09c4, + 0x09c7, + 0x09c8, + 0x09cb, + 0x09cd, + 0x09d7, + 0x09d7, + 0x09e2, + 0x09e3, + 0x09fe, + 0x09fe, + 0x0a01, + 0x0a03, + 0x0a3c, + 0x0a3c, + 0x0a3e, + 0x0a42, + 0x0a47, + 0x0a48, + 0x0a4b, + 0x0a4d, + 0x0a51, + 0x0a51, + 0x0a70, + 0x0a71, + 0x0a75, + 0x0a75, + 0x0a81, + 0x0a83, + 0x0abc, + 0x0abc, + 0x0abe, + 0x0ac5, + 0x0ac7, + 0x0ac9, + 0x0acb, + 0x0acd, + 0x0ae2, + 0x0ae3, + 0x0afa, + 0x0aff, + 0x0b01, + 0x0b03, + 0x0b3c, + 0x0b3c, + 0x0b3e, + 0x0b44, + 0x0b47, + 0x0b48, + 0x0b4b, + 0x0b4d, + 0x0b55, + 0x0b57, + 0x0b62, + 0x0b63, + 0x0b82, + 0x0b82, + 0x0bbe, + 0x0bc2, + 0x0bc6, + 0x0bc8, + 0x0bca, + 0x0bcd, + 0x0bd7, + 0x0bd7, + 0x0c00, + 0x0c04, + 0x0c3e, + 0x0c44, + 0x0c46, + 0x0c48, + 0x0c4a, + 0x0c4d, + 0x0c55, + 0x0c56, + 0x0c62, + 0x0c63, + 0x0c81, + 0x0c83, + 0x0cbc, + 0x0cbc, + 0x0cbe, + 0x0cc4, + 0x0cc6, + 0x0cc8, + 0x0cca, + 0x0ccd, + 0x0cd5, + 0x0cd6, + 0x0ce2, + 0x0ce3, + 0x0d00, + 0x0d03, + 0x0d3b, + 0x0d3c, + 0x0d3e, + 0x0d44, + 0x0d46, + 0x0d48, + 0x0d4a, + 0x0d4d, + 0x0d57, + 0x0d57, + 0x0d62, + 0x0d63, + 0x0d81, + 0x0d83, + 0x0dca, + 0x0dca, + 0x0dcf, + 0x0dd4, + 0x0dd6, + 0x0dd6, + 0x0dd8, + 0x0ddf, + 0x0df2, + 0x0df3, + 0x0e31, + 0x0e31, + 0x0e34, + 0x0e3a, + 0x0e47, + 0x0e4e, + 0x0eb1, + 0x0eb1, + 0x0eb4, + 0x0ebc, + 0x0ec8, + 0x0ecd, + 0x0f18, + 0x0f19, + 0x0f35, + 0x0f35, + 0x0f37, + 0x0f37, + 0x0f39, + 0x0f39, + 0x0f3e, + 0x0f3f, + 0x0f71, + 0x0f84, + 0x0f86, + 0x0f87, + 0x0f8d, + 0x0f97, + 0x0f99, + 0x0fbc, + 0x0fc6, + 0x0fc6, + 0x102b, + 0x103e, + 0x1056, + 0x1059, + 0x105e, + 0x1060, + 0x1062, + 0x1064, + 0x1067, + 0x106d, + 0x1071, + 0x1074, + 0x1082, + 0x108d, + 0x108f, + 0x108f, + 0x109a, + 0x109d, + 0x135d, + 0x135f, + 0x1712, + 0x1714, + 0x1732, + 0x1734, + 0x1752, + 0x1753, + 0x1772, + 0x1773, + 0x17b4, + 0x17d3, + 0x17dd, + 0x17dd, + 0x180b, + 0x180d, + 0x1885, + 0x1886, + 0x18a9, + 0x18a9, + 0x1920, + 0x192b, + 0x1930, + 0x193b, + 0x1a17, + 0x1a1b, + 0x1a55, + 0x1a5e, + 0x1a60, + 0x1a7c, + 0x1a7f, + 0x1a7f, + 0x1ab0, + 0x1ac0, + 0x1b00, + 0x1b04, + 0x1b34, + 0x1b44, + 0x1b6b, + 0x1b73, + 0x1b80, + 0x1b82, + 0x1ba1, + 0x1bad, + 0x1be6, + 0x1bf3, + 0x1c24, + 0x1c37, + 0x1cd0, + 0x1cd2, + 0x1cd4, + 0x1ce8, + 0x1ced, + 0x1ced, + 0x1cf4, + 0x1cf4, + 0x1cf7, + 0x1cf9, + 0x1dc0, + 0x1df9, + 0x1dfb, + 0x1dff, + 0x20d0, + 0x20f0, + 0x2cef, + 0x2cf1, + 0x2d7f, + 0x2d7f, + 0x2de0, + 0x2dff, + 0x302a, + 0x302f, + 0x3099, + 0x309a, + 0xa66f, + 0xa672, + 0xa674, + 0xa67d, + 0xa69e, + 0xa69f, + 0xa6f0, + 0xa6f1, + 0xa802, + 0xa802, + 0xa806, + 0xa806, + 0xa80b, + 0xa80b, + 0xa823, + 0xa827, + 0xa82c, + 0xa82c, + 0xa880, + 0xa881, + 0xa8b4, + 0xa8c5, + 0xa8e0, + 0xa8f1, + 0xa8ff, + 0xa8ff, + 0xa926, + 0xa92d, + 0xa947, + 0xa953, + 0xa980, + 0xa983, + 0xa9b3, + 0xa9c0, + 0xa9e5, + 0xa9e5, + 0xaa29, + 0xaa36, + 0xaa43, + 0xaa43, + 0xaa4c, + 0xaa4d, + 0xaa7b, + 0xaa7d, + 0xaab0, + 0xaab0, + 0xaab2, + 0xaab4, + 0xaab7, + 0xaab8, + 0xaabe, + 0xaabf, + 0xaac1, + 0xaac1, + 0xaaeb, + 0xaaef, + 0xaaf5, + 0xaaf6, + 0xabe3, + 0xabea, + 0xabec, + 0xabed, + 0xfb1e, + 0xfb1e, + 0xfe00, + 0xfe0f, + 0xfe20, + 0xfe2f, + 0x101fd, + 0x101fd, + 0x102e0, + 0x102e0, + 0x10376, + 0x1037a, + 0x10a01, + 0x10a03, + 0x10a05, + 0x10a06, + 0x10a0c, + 0x10a0f, + 0x10a38, + 0x10a3a, + 0x10a3f, + 0x10a3f, + 0x10ae5, + 0x10ae6, + 0x10d24, + 0x10d27, + 0x10eab, + 0x10eac, + 0x10f46, + 0x10f50, + 0x11000, + 0x11002, + 0x11038, + 0x11046, + 0x1107f, + 0x11082, + 0x110b0, + 0x110ba, + 0x11100, + 0x11102, + 0x11127, + 0x11134, + 0x11145, + 0x11146, + 0x11173, + 0x11173, + 0x11180, + 0x11182, + 0x111b3, + 0x111c0, + 0x111c9, + 0x111cc, + 0x111ce, + 0x111cf, + 0x1122c, + 0x11237, + 0x1123e, + 0x1123e, + 0x112df, + 0x112ea, + 0x11300, + 0x11303, + 0x1133b, + 0x1133c, + 0x1133e, + 0x11344, + 0x11347, + 0x11348, + 0x1134b, + 0x1134d, + 0x11357, + 0x11357, + 0x11362, + 0x11363, + 0x11366, + 0x1136c, + 0x11370, + 0x11374, + 0x11435, + 0x11446, + 0x1145e, + 0x1145e, + 0x114b0, + 0x114c3, + 0x115af, + 0x115b5, + 0x115b8, + 0x115c0, + 0x115dc, + 0x115dd, + 0x11630, + 0x11640, + 0x116ab, + 0x116b7, + 0x1171d, + 0x1172b, + 0x1182c, + 0x1183a, + 0x11930, + 0x11935, + 0x11937, + 0x11938, + 0x1193b, + 0x1193e, + 0x11940, + 0x11940, + 0x11942, + 0x11943, + 0x119d1, + 0x119d7, + 0x119da, + 0x119e0, + 0x119e4, + 0x119e4, + 0x11a01, + 0x11a0a, + 0x11a33, + 0x11a39, + 0x11a3b, + 0x11a3e, + 0x11a47, + 0x11a47, + 0x11a51, + 0x11a5b, + 0x11a8a, + 0x11a99, + 0x11c2f, + 0x11c36, + 0x11c38, + 0x11c3f, + 0x11c92, + 0x11ca7, + 0x11ca9, + 0x11cb6, + 0x11d31, + 0x11d36, + 0x11d3a, + 0x11d3a, + 0x11d3c, + 0x11d3d, + 0x11d3f, + 0x11d45, + 0x11d47, + 0x11d47, + 0x11d8a, + 0x11d8e, + 0x11d90, + 0x11d91, + 0x11d93, + 0x11d97, + 0x11ef3, + 0x11ef6, + 0x16af0, + 0x16af4, + 0x16b30, + 0x16b36, + 0x16f4f, + 0x16f4f, + 0x16f51, + 0x16f87, + 0x16f8f, + 0x16f92, + 0x16fe4, + 0x16fe4, + 0x16ff0, + 0x16ff1, + 0x1bc9d, + 0x1bc9e, + 0x1d165, + 0x1d169, + 0x1d16d, + 0x1d172, + 0x1d17b, + 0x1d182, + 0x1d185, + 0x1d18b, + 0x1d1aa, + 0x1d1ad, + 0x1d242, + 0x1d244, + 0x1da00, + 0x1da36, + 0x1da3b, + 0x1da6c, + 0x1da75, + 0x1da75, + 0x1da84, + 0x1da84, + 0x1da9b, + 0x1da9f, + 0x1daa1, + 0x1daaf, + 0x1e000, + 0x1e006, + 0x1e008, + 0x1e018, + 0x1e01b, + 0x1e021, + 0x1e023, + 0x1e024, + 0x1e026, + 0x1e02a, + 0x1e130, + 0x1e136, + 0x1e2ec, + 0x1e2ef, + 0x1e8d0, + 0x1e8d6, + 0x1e944, + 0x1e94a, + 0xe0100, + 0xe01ef, +] + // TextSelection stores positions as rune offsets, so cursor movement is stable // for UTF-8 text. The selection is collapsed when anchor == caret. pub struct TextSelection { @@ -235,15 +818,18 @@ fn clamp_int(value int, min int, max int) int { } fn is_word_rune(value rune) bool { - return utf8.is_letter(value) || utf8.is_number(value) || value == `_` || is_combining_mark(value) + return utf8.is_letter(value) || utf8.is_number(value) || value == `_` || is_unicode_mark(value) } -// is_combining_mark keeps decomposed characters, such as e followed by a -// combining acute accent, in the same word during keyboard navigation. -fn is_combining_mark(value rune) bool { - return (value >= 0x0300 && value <= 0x036f) || (value >= 0x1ab0 && value <= 0x1aff) - || (value >= 0x1dc0 && value <= 0x1dff) || (value >= 0x20d0 && value <= 0x20ff) - || (value >= 0xfe00 && value <= 0xfe0f) || (value >= 0xfe20 && value <= 0xfe2f) +// is_unicode_mark reports whether value belongs to a Unicode Mark category +// (Mn, Mc, or Me). The table is generated from Unicode general categories. +fn is_unicode_mark(value rune) bool { + for index := 0; index < unicode_mark_ranges.len; index += 2 { + if u32(value) >= unicode_mark_ranges[index] && u32(value) <= unicode_mark_ranges[index + 1] { + return true + } + } + return false } fn previous_word_boundary(runes []rune, caret int) int { diff --git a/ui/text_editor_test.v b/ui/text_editor_test.v index 16c9ab4..1be9278 100644 --- a/ui/text_editor_test.v +++ b/ui/text_editor_test.v @@ -5,7 +5,7 @@ fn test_text_editor_insert_and_replace_selection() { editor.set_selection(6, 11) editor.insert_text('V') assert editor.text == 'Hello V' - assert editor.selection.caret == 8 + assert editor.selection.caret == 7 assert editor.selection.collapsed() } @@ -155,6 +155,18 @@ fn test_text_editor_word_navigation_keeps_combining_marks_in_words() { assert editor.selection.caret == 0 } +fn test_text_editor_word_navigation_keeps_indic_and_arabic_marks_in_words() { + assert is_unicode_mark(`ि`) + assert is_unicode_mark(`َ`) + + mut editor := text_editor('किरण test') + editor.set_caret(rune_len(editor.text)) + assert apply_text_editor_navigation(mut editor, 'left', false, true) + assert editor.selection.caret == 5 + assert apply_text_editor_navigation(mut editor, 'left', false, true) + assert editor.selection.caret == 0 +} + fn test_rich_text_area_keeps_runs() { runs := [ TextRun{ diff --git a/ui/ui_custom_test.v b/ui/ui_custom_test.v index 921780c..2ecf774 100644 --- a/ui/ui_custom_test.v +++ b/ui/ui_custom_test.v @@ -507,3 +507,9 @@ fn test_text_field_selection_text_uses_rune_offsets() { assert before == 'a' assert selected == '🙂bc' } + +fn test_text_field_selection_origin_respects_text_alignment() { + assert text_field_aligned_text_origin(10, 100, 40, .left) == 10 + assert text_field_aligned_text_origin(10, 100, 40, .center) == 40 + assert text_field_aligned_text_origin(10, 100, 40, .right) == 70 +} diff --git a/ui/ui_immediate.c.v b/ui/ui_immediate.c.v index 231873f..f4c8c29 100644 --- a/ui/ui_immediate.c.v +++ b/ui/ui_immediate.c.v @@ -1816,7 +1816,9 @@ fn page_focused_text_area(direction int) { before := editor.text.runes()[..editor.selection.caret].string() caret_text := text_field_display_text(before, el.secure) text_w := f64(ctx.text_width(caret_text)) - cursor_x := x + padding_left + text_w + text_origin := text_field_aligned_text_origin(x + padding_left, content_width, + f64(ctx.text_width(display_text)), el.text_style.align) + cursor_x := text_origin + text_w cursor_y := y + el.frame.height * 0.2 cursor_h := el.frame.height * 0.6 draw_rect(ctx, cursor_x, cursor_y, 2, cursor_h, el.text_style.color, 0) @@ -2575,7 +2577,9 @@ fn page_focused_text_area(direction int) { align: text_align(style.align) vertical_align: .middle }) - mut left := x + f64(ctx.text_width(before)) + text_width := f64(ctx.text_width(display_text)) + text_origin := text_field_aligned_text_origin(x, w, text_width, style.align) + mut left := text_origin + f64(ctx.text_width(before)) mut right := left + f64(ctx.text_width(selected)) if left < x { left = x @@ -2588,6 +2592,14 @@ fn page_focused_text_area(direction int) { } } + fn text_field_aligned_text_origin(x f64, w f64, text_width f64, align Align) f64 { + return match align { + .left { x } + .center { x + (w - text_width) / 2 } + .right { x + w - text_width } + } + } + fn text_field_selection_text(display_text string, selection TextSelection) (string, string) { runes := display_text.runes() start, end := selection.ordered() From dae2f18c76470da38bc9d826bbc9b62b5d555c9c Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Thu, 10 Sep 2026 07:57:13 +0300 Subject: [PATCH 7/8] fix: render text area selections --- ui/text_editor.v | 88 ++++++++++++++++++++++++++++++++--- ui/text_editor_test.v | 8 ++++ ui/ui_scroll_immediate.c.v | 64 +++++++++++++++++++++++++ ui/ui_scroll_immediate_test.v | 16 ++++++- 4 files changed, 169 insertions(+), 7 deletions(-) diff --git a/ui/text_editor.v b/ui/text_editor.v index 9c4410b..2efb2a3 100644 --- a/ui/text_editor.v +++ b/ui/text_editor.v @@ -52,7 +52,9 @@ const unicode_mark_ranges = [ 0x082d, 0x0859, 0x085b, - 0x08d3, + 0x0897, + 0x089f, + 0x08ca, 0x08e1, 0x08e3, 0x0903, @@ -136,6 +138,8 @@ const unicode_mark_ranges = [ 0x0bd7, 0x0c00, 0x0c04, + 0x0c3c, + 0x0c3c, 0x0c3e, 0x0c44, 0x0c46, @@ -160,6 +164,8 @@ const unicode_mark_ranges = [ 0x0cd6, 0x0ce2, 0x0ce3, + 0x0cf3, + 0x0cf3, 0x0d00, 0x0d03, 0x0d3b, @@ -197,7 +203,7 @@ const unicode_mark_ranges = [ 0x0eb4, 0x0ebc, 0x0ec8, - 0x0ecd, + 0x0ece, 0x0f18, 0x0f19, 0x0f35, @@ -239,7 +245,7 @@ const unicode_mark_ranges = [ 0x135d, 0x135f, 0x1712, - 0x1714, + 0x1715, 0x1732, 0x1734, 0x1752, @@ -252,6 +258,8 @@ const unicode_mark_ranges = [ 0x17dd, 0x180b, 0x180d, + 0x180f, + 0x180f, 0x1885, 0x1886, 0x18a9, @@ -269,7 +277,9 @@ const unicode_mark_ranges = [ 0x1a7f, 0x1a7f, 0x1ab0, - 0x1ac0, + 0x1add, + 0x1ae0, + 0x1aeb, 0x1b00, 0x1b04, 0x1b34, @@ -295,8 +305,6 @@ const unicode_mark_ranges = [ 0x1cf7, 0x1cf9, 0x1dc0, - 0x1df9, - 0x1dfb, 0x1dff, 0x20d0, 0x20f0, @@ -398,18 +406,30 @@ const unicode_mark_ranges = [ 0x10ae6, 0x10d24, 0x10d27, + 0x10d69, + 0x10d6d, 0x10eab, 0x10eac, + 0x10efa, + 0x10eff, 0x10f46, 0x10f50, + 0x10f82, + 0x10f85, 0x11000, 0x11002, 0x11038, 0x11046, + 0x11070, + 0x11070, + 0x11073, + 0x11074, 0x1107f, 0x11082, 0x110b0, 0x110ba, + 0x110c2, + 0x110c2, 0x11100, 0x11102, 0x11127, @@ -430,6 +450,8 @@ const unicode_mark_ranges = [ 0x11237, 0x1123e, 0x1123e, + 0x11241, + 0x11241, 0x112df, 0x112ea, 0x11300, @@ -450,6 +472,20 @@ const unicode_mark_ranges = [ 0x1136c, 0x11370, 0x11374, + 0x113b8, + 0x113c0, + 0x113c2, + 0x113c2, + 0x113c5, + 0x113c5, + 0x113c7, + 0x113ca, + 0x113cc, + 0x113d0, + 0x113d2, + 0x113d2, + 0x113e1, + 0x113e2, 0x11435, 0x11446, 0x1145e, @@ -498,6 +534,8 @@ const unicode_mark_ranges = [ 0x11a5b, 0x11a8a, 0x11a99, + 0x11b60, + 0x11b67, 0x11c2f, 0x11c36, 0x11c38, @@ -524,6 +562,22 @@ const unicode_mark_ranges = [ 0x11d97, 0x11ef3, 0x11ef6, + 0x11f00, + 0x11f01, + 0x11f03, + 0x11f03, + 0x11f34, + 0x11f3a, + 0x11f3e, + 0x11f42, + 0x11f5a, + 0x11f5a, + 0x13440, + 0x13440, + 0x13447, + 0x13455, + 0x1611e, + 0x1612f, 0x16af0, 0x16af4, 0x16b30, @@ -540,6 +594,10 @@ const unicode_mark_ranges = [ 0x16ff1, 0x1bc9d, 0x1bc9e, + 0x1cf00, + 0x1cf2d, + 0x1cf30, + 0x1cf46, 0x1d165, 0x1d169, 0x1d16d, @@ -574,10 +632,26 @@ const unicode_mark_ranges = [ 0x1e024, 0x1e026, 0x1e02a, + 0x1e08f, + 0x1e08f, 0x1e130, 0x1e136, + 0x1e2ae, + 0x1e2ae, 0x1e2ec, 0x1e2ef, + 0x1e4ec, + 0x1e4ef, + 0x1e5ee, + 0x1e5ef, + 0x1e6e3, + 0x1e6e3, + 0x1e6e6, + 0x1e6e6, + 0x1e6ee, + 0x1e6ef, + 0x1e6f5, + 0x1e6f5, 0x1e8d0, 0x1e8d6, 0x1e944, @@ -586,6 +660,8 @@ const unicode_mark_ranges = [ 0xe01ef, ] +// Unicode 17.0.0 Mark ranges: 327 +// Unicode 17.0.0 Mark ranges: 561 // TextSelection stores positions as rune offsets, so cursor movement is stable // for UTF-8 text. The selection is collapsed when anchor == caret. pub struct TextSelection { diff --git a/ui/text_editor_test.v b/ui/text_editor_test.v index 1be9278..ad150f0 100644 --- a/ui/text_editor_test.v +++ b/ui/text_editor_test.v @@ -158,6 +158,7 @@ fn test_text_editor_word_navigation_keeps_combining_marks_in_words() { fn test_text_editor_word_navigation_keeps_indic_and_arabic_marks_in_words() { assert is_unicode_mark(`ि`) assert is_unicode_mark(`َ`) + assert is_unicode_mark(`ࢗ`) mut editor := text_editor('किरण test') editor.set_caret(rune_len(editor.text)) @@ -165,6 +166,13 @@ fn test_text_editor_word_navigation_keeps_indic_and_arabic_marks_in_words() { assert editor.selection.caret == 5 assert apply_text_editor_navigation(mut editor, 'left', false, true) assert editor.selection.caret == 0 + + mut newer_arabic := text_editor('بࢗت test') + newer_arabic.set_caret(rune_len(newer_arabic.text)) + assert apply_text_editor_navigation(mut newer_arabic, 'left', false, true) + assert newer_arabic.selection.caret == 4 + assert apply_text_editor_navigation(mut newer_arabic, 'left', false, true) + assert newer_arabic.selection.caret == 0 } fn test_rich_text_area_keeps_runs() { diff --git a/ui/ui_scroll_immediate.c.v b/ui/ui_scroll_immediate.c.v index 5cbf44f..dac4e81 100644 --- a/ui/ui_scroll_immediate.c.v +++ b/ui/ui_scroll_immediate.c.v @@ -10,6 +10,11 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 const text_area_vertical_padding = 8.0 const anonymous_text_area_scroll_prefix = '@text-area-key:' + struct TextAreaLineRange { + start int + end int + } + fn reset_scroll_frame() { g_scroll_areas = map[string]Rect{} g_scroll_viewports = map[string]Rect{} @@ -215,6 +220,46 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 return lines } + // text_area_line_rune_ranges maps rendered wrapped lines back to their rune + // offsets in the normalized source text. Whitespace discarded at wrap points + // is intentionally outside every line range. + fn text_area_line_rune_ranges(value string, lines []string) []TextAreaLineRange { + runes := value.replace('\r\n', '\n').replace('\r', '\n').runes() + mut ranges := []TextAreaLineRange{cap: lines.len} + mut cursor := 0 + for line in lines { + line_runes := line.runes() + if line_runes.len == 0 { + ranges << TextAreaLineRange{ + start: cursor + end: cursor + } + continue + } + mut start := cursor + for candidate := cursor; candidate + line_runes.len <= runes.len; candidate++ { + mut matches := true + for index in 0 .. line_runes.len { + if runes[candidate + index] != line_runes[index] { + matches = false + break + } + } + if matches { + start = candidate + break + } + } + end := start + line_runes.len + ranges << TextAreaLineRange{ + start: start + end: end + } + cursor = end + } + return ranges + } + fn visible_text_area_rows(count int, top f64, line_height f64, offset f64, clip Rect) (int, int) { if count <= 0 || line_height <= 0 || clip.width <= 0 || clip.height <= 0 { return 0, 0 @@ -243,6 +288,10 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 lines := text_area_lines(el.id, value, content.width, style, cfg.size, fn [ctx] (line string) f64 { return f64(ctx.text_width_f(line)) }) + line_ranges := text_area_line_rune_ranges(value, lines) + editor := g_text_editors[el.id] or { text_editor(value.clone()) } + selection_start, selection_end := editor.selection.ordered() + show_selection := g_focused_field == el.id && selection_start != selection_end line_height := math.max(1.0, font_line_height(style.size)) content_height := f64(lines.len) * line_height + text_area_vertical_padding * 2 // Read-only means not editable, not unscrollable. disable_scroll only @@ -261,6 +310,21 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 first, last := visible_text_area_rows(lines.len, content.y, line_height, offset, text_clip) for index in first .. last { text_y := content.y + (f64(index) + 0.5) * line_height - offset + if show_selection && index < line_ranges.len { + line_range := line_ranges[index] + from := if selection_start > line_range.start { selection_start } else { line_range.start } + to := if selection_end < line_range.end { selection_end } else { line_range.end } + if to > from { + line_runes := lines[index].runes() + prefix := line_runes[..from - line_range.start].string() + selected := line_runes[from - line_range.start..to - line_range.start].string() + line_width := f64(ctx.text_width_f(lines[index])) + line_origin := text_field_aligned_text_origin(content.x, content.width, line_width, + style.align) + draw_rect(ctx, line_origin + f64(ctx.text_width_f(prefix)), text_y - line_height / 2, + f64(ctx.text_width_f(selected)), line_height, 0xb8d7ff, 0) + } + } ctx.draw_text(int(text_x), int(text_y), lines[index], cfg) } } diff --git a/ui/ui_scroll_immediate_test.v b/ui/ui_scroll_immediate_test.v index 8048fe0..3282d12 100644 --- a/ui/ui_scroll_immediate_test.v +++ b/ui/ui_scroll_immediate_test.v @@ -36,7 +36,7 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 scroll_test_events << id } - fn test_text_area_wraps_words_and_preserves_explicit_blank_lines() { +fn test_text_area_wraps_words_and_preserves_explicit_blank_lines() { reset_scroll_test_state() assert wrap_text_area_lines('one two three', 70, scroll_test_width) == ['one two', 'three'] assert wrap_text_area_lines('one\r\n\r\ntwo\n', 100, scroll_test_width) == ['one', '', 'two', ''] @@ -284,3 +284,17 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 assert scroll_hit_test(150, 50) == '' } } + +fn test_text_area_line_ranges_follow_wrapped_source_runes() { + lines := ['one two', 'three'] + assert text_area_line_rune_ranges('one two three', lines) == [ + TextAreaLineRange{ + start: 0 + end: 7 + }, + TextAreaLineRange{ + start: 8 + end: 13 + }, + ] +} From 22dc0d58fc3c18add14db8aeecc70df32d771a57 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Thu, 10 Sep 2026 11:56:13 +0300 Subject: [PATCH 8/8] fix: complete text area navigation --- ui/ui_custom_test.v | 12 ++--- ui/ui_immediate.c.v | 17 +++++++ ui/ui_scroll_immediate.c.v | 88 +++++++++++++++++++++++++++++++---- ui/ui_scroll_immediate_test.v | 38 +++++++++++++++ 4 files changed, 141 insertions(+), 14 deletions(-) diff --git a/ui/ui_custom_test.v b/ui/ui_custom_test.v index 2ecf774..87ac263 100644 --- a/ui/ui_custom_test.v +++ b/ui/ui_custom_test.v @@ -107,12 +107,12 @@ $if ui2_custom_rendering ? { assert !text_navigation_word_modifier(true, false) assert text_navigation_boundary_modifier(true) } $else { - assert text_navigation_primary_modifier(true, false, false) - assert !text_navigation_primary_modifier(true, true, false) - assert text_navigation_word_modifier(true, false) - assert !text_navigation_word_modifier(true, true) - assert !text_navigation_boundary_modifier(true) - } + assert text_navigation_primary_modifier(true, false, false) + assert !text_navigation_primary_modifier(true, true, false) + assert text_navigation_word_modifier(true, false) + assert !text_navigation_word_modifier(true, true) + assert !text_navigation_boundary_modifier(true) + } } fn test_custom_slider_pointer_value_uses_range_step_and_orientation() { diff --git a/ui/ui_immediate.c.v b/ui/ui_immediate.c.v index f4c8c29..648da1b 100644 --- a/ui/ui_immediate.c.v +++ b/ui/ui_immediate.c.v @@ -1043,6 +1043,8 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 mut navigation_key := match key { .left { 'left' } .right { 'right' } + .up { 'up' } + .down { 'down' } .home { 'home' } .end { 'end' } .page_up { 'page_up' } @@ -1073,6 +1075,21 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 boundary_modifier := text_navigation_boundary_modifier( modifiers & u32(gg.Modifier.super) != 0, ) + if focused_text_area && (navigation_key == 'up' || navigation_key == 'down') { + if move_focused_text_area_caret(mut editor, if navigation_key == 'up' { -1 } else { 1 }, + modifiers & u32(gg.Modifier.shift) != 0) { + g_text_editors[g_focused_field] = editor + } + return + } + if focused_text_area && (navigation_key == 'home' || navigation_key == 'end') + && !primary_modifier { + if move_focused_text_area_line_boundary(mut editor, navigation_key == 'end', + modifiers & u32(gg.Modifier.shift) != 0) { + g_text_editors[g_focused_field] = editor + } + return + } if boundary_modifier && navigation_key == 'left' { navigation_key = 'home' } else if boundary_modifier && navigation_key == 'right' { diff --git a/ui/ui_scroll_immediate.c.v b/ui/ui_scroll_immediate.c.v index dac4e81..60e8f70 100644 --- a/ui/ui_scroll_immediate.c.v +++ b/ui/ui_scroll_immediate.c.v @@ -220,19 +220,45 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 return lines } - // text_area_line_rune_ranges maps rendered wrapped lines back to their rune - // offsets in the normalized source text. Whitespace discarded at wrap points - // is intentionally outside every line range. + // normalized_text_area_runes returns the renderer's newline-normalized runes + // and the corresponding original source offset for every rune boundary. + // Keeping this map makes selection offsets correct for CRLF input. + fn normalized_text_area_runes(value string) ([]rune, []int) { + source := value.runes() + mut normalized := []rune{cap: source.len} + mut source_offsets := []int{cap: source.len + 1} + mut source_index := 0 + for source_index < source.len { + source_offsets << source_index + if source[source_index] == `\r` { + normalized << `\n` + if source_index + 1 < source.len && source[source_index + 1] == `\n` { + source_index += 2 + } else { + source_index++ + } + } else { + normalized << source[source_index] + source_index++ + } + } + source_offsets << source.len + return normalized, source_offsets + } + + // text_area_line_rune_ranges maps rendered wrapped lines back to their + // original source rune offsets. Whitespace discarded at wrap points is + // intentionally outside every line range. fn text_area_line_rune_ranges(value string, lines []string) []TextAreaLineRange { - runes := value.replace('\r\n', '\n').replace('\r', '\n').runes() + runes, source_offsets := normalized_text_area_runes(value) mut ranges := []TextAreaLineRange{cap: lines.len} mut cursor := 0 for line in lines { line_runes := line.runes() if line_runes.len == 0 { ranges << TextAreaLineRange{ - start: cursor - end: cursor + start: source_offsets[cursor] + end: source_offsets[cursor] } continue } @@ -252,14 +278,60 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 } end := start + line_runes.len ranges << TextAreaLineRange{ - start: start - end: end + start: source_offsets[start] + end: source_offsets[end] } cursor = end } return ranges } + fn focused_text_area_line_index(ranges []TextAreaLineRange, caret int) int { + for index, line in ranges { + if caret <= line.end { + return index + } + } + return ranges.len - 1 + } + + fn move_focused_text_area_caret(mut editor TextEditor, direction int, extend bool) bool { + layout := g_text_area_layouts[g_focused_field] or { return false } + if layout.text != editor.text || layout.lines.len == 0 { + return false + } + if !extend && !editor.selection.collapsed() { + start, end := editor.selection.ordered() + editor.set_caret(if direction < 0 { start } else { end }) + return true + } + ranges := text_area_line_rune_ranges(editor.text, layout.lines) + if ranges.len == 0 { + return false + } + current := focused_text_area_line_index(ranges, editor.selection.caret) + next := clamp_int(current + direction, 0, ranges.len - 1) + column := clamp_int(editor.selection.caret - ranges[current].start, 0, + ranges[current].end - ranges[current].start) + editor.move_caret_to(ranges[next].start + clamp_int(column, 0, + ranges[next].end - ranges[next].start), extend) + return true + } + + fn move_focused_text_area_line_boundary(mut editor TextEditor, end bool, extend bool) bool { + layout := g_text_area_layouts[g_focused_field] or { return false } + if layout.text != editor.text || layout.lines.len == 0 { + return false + } + ranges := text_area_line_rune_ranges(editor.text, layout.lines) + if ranges.len == 0 { + return false + } + line := ranges[focused_text_area_line_index(ranges, editor.selection.caret)] + editor.move_caret_to(if end { line.end } else { line.start }, extend) + return true + } + fn visible_text_area_rows(count int, top f64, line_height f64, offset f64, clip Rect) (int, int) { if count <= 0 || line_height <= 0 || clip.width <= 0 || clip.height <= 0 { return 0, 0 diff --git a/ui/ui_scroll_immediate_test.v b/ui/ui_scroll_immediate_test.v index 3282d12..8c7fdb9 100644 --- a/ui/ui_scroll_immediate_test.v +++ b/ui/ui_scroll_immediate_test.v @@ -298,3 +298,41 @@ fn test_text_area_line_ranges_follow_wrapped_source_runes() { }, ] } + +fn test_text_area_line_ranges_keep_original_crlf_offsets() { + assert text_area_line_rune_ranges('a\r\nbc', ['a', 'bc']) == [ + TextAreaLineRange{ + start: 0 + end: 1 + }, + TextAreaLineRange{ + start: 3 + end: 5 + }, + ] +} + +fn test_text_area_vertical_and_line_boundary_navigation() { + reset_scroll_test_state() + g_focused_field = 'notes' + g_text_area_layouts['notes'] = TextAreaLayout{ + text: 'one\ntwo\nthree' + lines: ['one', 'two', 'three'] + } + mut editor := text_editor('one\ntwo\nthree') + editor.set_caret(6) + assert move_focused_text_area_caret(mut editor, 1, false) + assert editor.selection.caret == 10 + assert move_focused_text_area_caret(mut editor, -1, true) + assert editor.selection == TextSelection{ + anchor: 10 + caret: 6 + } + assert move_focused_text_area_line_boundary(mut editor, false, false) + assert editor.selection.caret == 4 + assert move_focused_text_area_line_boundary(mut editor, true, true) + assert editor.selection == TextSelection{ + anchor: 4 + caret: 7 + } +}