diff --git a/ui/text_editor.v b/ui/text_editor.v index ea3b0d0..2efb2a3 100644 --- a/ui/text_editor.v +++ b/ui/text_editor.v @@ -1,7 +1,667 @@ 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, + 0x0897, + 0x089f, + 0x08ca, + 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, + 0x0c3c, + 0x0c3c, + 0x0c3e, + 0x0c44, + 0x0c46, + 0x0c48, + 0x0c4a, + 0x0c4d, + 0x0c55, + 0x0c56, + 0x0c62, + 0x0c63, + 0x0c81, + 0x0c83, + 0x0cbc, + 0x0cbc, + 0x0cbe, + 0x0cc4, + 0x0cc6, + 0x0cc8, + 0x0cca, + 0x0ccd, + 0x0cd5, + 0x0cd6, + 0x0ce2, + 0x0ce3, + 0x0cf3, + 0x0cf3, + 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, + 0x0ece, + 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, + 0x1715, + 0x1732, + 0x1734, + 0x1752, + 0x1753, + 0x1772, + 0x1773, + 0x17b4, + 0x17d3, + 0x17dd, + 0x17dd, + 0x180b, + 0x180d, + 0x180f, + 0x180f, + 0x1885, + 0x1886, + 0x18a9, + 0x18a9, + 0x1920, + 0x192b, + 0x1930, + 0x193b, + 0x1a17, + 0x1a1b, + 0x1a55, + 0x1a5e, + 0x1a60, + 0x1a7c, + 0x1a7f, + 0x1a7f, + 0x1ab0, + 0x1add, + 0x1ae0, + 0x1aeb, + 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, + 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, + 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, + 0x11134, + 0x11145, + 0x11146, + 0x11173, + 0x11173, + 0x11180, + 0x11182, + 0x111b3, + 0x111c0, + 0x111c9, + 0x111cc, + 0x111ce, + 0x111cf, + 0x1122c, + 0x11237, + 0x1123e, + 0x1123e, + 0x11241, + 0x11241, + 0x112df, + 0x112ea, + 0x11300, + 0x11303, + 0x1133b, + 0x1133c, + 0x1133e, + 0x11344, + 0x11347, + 0x11348, + 0x1134b, + 0x1134d, + 0x11357, + 0x11357, + 0x11362, + 0x11363, + 0x11366, + 0x1136c, + 0x11370, + 0x11374, + 0x113b8, + 0x113c0, + 0x113c2, + 0x113c2, + 0x113c5, + 0x113c5, + 0x113c7, + 0x113ca, + 0x113cc, + 0x113d0, + 0x113d2, + 0x113d2, + 0x113e1, + 0x113e2, + 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, + 0x11b60, + 0x11b67, + 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, + 0x11f00, + 0x11f01, + 0x11f03, + 0x11f03, + 0x11f34, + 0x11f3a, + 0x11f3e, + 0x11f42, + 0x11f5a, + 0x11f5a, + 0x13440, + 0x13440, + 0x13447, + 0x13455, + 0x1611e, + 0x1612f, + 0x16af0, + 0x16af4, + 0x16b30, + 0x16b36, + 0x16f4f, + 0x16f4f, + 0x16f51, + 0x16f87, + 0x16f8f, + 0x16f92, + 0x16fe4, + 0x16fe4, + 0x16ff0, + 0x16ff1, + 0x1bc9d, + 0x1bc9e, + 0x1cf00, + 0x1cf2d, + 0x1cf30, + 0x1cf46, + 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, + 0x1e08f, + 0x1e08f, + 0x1e130, + 0x1e136, + 0x1e2ae, + 0x1e2ae, + 0x1e2ec, + 0x1e2ef, + 0x1e4ec, + 0x1e4ef, + 0x1e5ee, + 0x1e5ef, + 0x1e6e3, + 0x1e6e3, + 0x1e6e6, + 0x1e6e6, + 0x1e6ee, + 0x1e6ef, + 0x1e6f5, + 0x1e6f5, + 0x1e8d0, + 0x1e8d6, + 0x1e944, + 0x1e94a, + 0xe0100, + 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 { @@ -68,6 +728,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 +744,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 +892,40 @@ 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 == `_` || is_unicode_mark(value) +} + +// 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 { + 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..ad150f0 100644 --- a/ui/text_editor_test.v +++ b/ui/text_editor_test.v @@ -84,6 +84,97 @@ 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_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_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)) + 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 + + 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() { runs := [ TextRun{ diff --git a/ui/ui_custom_test.v b/ui/ui_custom_test.v index a667b6a..87ac263 100644 --- a/ui/ui_custom_test.v +++ b/ui/ui_custom_test.v @@ -98,6 +98,23 @@ $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) + 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, 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() { horizontal := HitTarget{ slider: true @@ -481,3 +498,18 @@ $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' +} + +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 79b3ffc..648da1b 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,65 @@ $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)) + mut navigation_key := match key { + .left { 'left' } + .right { 'right' } + .up { 'up' } + .down { 'down' } + .home { 'home' } + .end { 'end' } + .page_up { 'page_up' } + .page_down { 'page_down' } + .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 } - replace_text_editor(g_focused_field, editor) + } + 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, + ) + 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' { + 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, navigation_modifier) { + g_text_editors[g_focused_field] = editor } if key == .enter || key == .kp_enter { for target in g_hit_targets { @@ -1073,6 +1121,44 @@ $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 + } + } + + // 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 + } +} + +// 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) { + 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 { @@ -1730,6 +1816,10 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 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 { @@ -1743,7 +1833,9 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 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) @@ -2483,6 +2575,56 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 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 + }) + 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 + } + 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_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 := 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 diff --git a/ui/ui_scroll_immediate.c.v b/ui/ui_scroll_immediate.c.v index 5cbf44f..60e8f70 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,118 @@ $if (android || linux || ((macos || windows) && ui2_custom_rendering ?)) && !ui2 return lines } + // 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, 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: source_offsets[cursor] + end: source_offsets[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: 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 @@ -243,6 +360,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 +382,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 f1a5540..8c7fdb9 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', ''] @@ -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'] @@ -271,3 +284,55 @@ $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 + }, + ] +} + +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 + } +}