diff --git a/_input_alignment_test.v b/_input_alignment_test.v new file mode 100644 index 0000000..9f31781 --- /dev/null +++ b/_input_alignment_test.v @@ -0,0 +1,1095 @@ +module gui + +import gg +import vglyph + +struct InputAlignmentTestState { +mut: + changed_text string +} + +fn first_text_shape_from_view(mut view View) Shape { + mut w := Window{} + layout := generate_layout(mut view, mut w) + shape := layout.find_shape(fn (ly Layout) bool { + return ly.shape.shape_type == .text + }) or { + assert false, 'expected generated view to contain a text shape' + return Shape{} + } + return shape +} + +fn arranged_view_layout(mut view View, mut w Window) Layout { + mut layout := generate_layout(mut view, mut w) + layouts := layout_arrange(mut layout, mut w) + assert layouts.len > 0 + return layouts[0] +} + +fn generated_text_layout(root &Layout, id_focus u32) Layout { + return root.find_layout(fn [id_focus] (ly Layout) bool { + return ly.shape.shape_type == .text && ly.shape.id_focus == id_focus + }) or { + assert false, 'expected generated input text layout' + return Layout{} + } +} + +fn generated_text_viewport(text_layout Layout) Layout { + if text_layout.parent == unsafe { nil } { + assert false, 'expected generated input text viewport' + return Layout{} + } + return *text_layout.parent +} + +fn generated_text_shape_by_text(root &Layout, text string) Shape { + return root.find_shape(fn [text] (ly Layout) bool { + return ly.shape.shape_type == .text && ly.shape.tc != unsafe { nil } + && ly.shape.tc.text == text + }) or { + assert false, 'expected generated text shape ${text}' + return Shape{} + } +} + +fn generated_text_layout_by_text(root &Layout, text string) Layout { + return root.find_layout(fn [text] (ly Layout) bool { + return ly.shape.shape_type == .text && ly.shape.tc != unsafe { nil } + && ly.shape.tc.text == text + }) or { + assert false, 'expected generated text layout ${text}' + return Layout{} + } +} + +fn synthetic_single_line_layout(text string, line_x f32, char_width f32) &vglyph.Layout { + mut char_rects := []vglyph.CharRect{cap: text.len} + mut char_rect_by_index := map[int]int{} + for idx in 0 .. text.len { + char_rect_by_index[idx] = idx + char_rects << vglyph.CharRect{ + index: idx + rect: gg.Rect{ + x: line_x + (char_width * f32(idx)) + y: 0 + width: char_width + height: 10 + } + } + } + mut log_attrs := []vglyph.LogAttr{cap: text.len + 1} + mut log_attr_by_index := map[int]int{} + for idx in 0 .. text.len + 1 { + log_attr_by_index[idx] = idx + log_attrs << vglyph.LogAttr{ + is_cursor_position: true + } + } + return &vglyph.Layout{ + lines: [ + vglyph.Line{ + start_index: 0 + length: text.len + rect: gg.Rect{ + x: line_x + y: 0 + width: char_width * f32(text.len) + height: 10 + } + }, + ] + char_rects: char_rects + char_rect_by_index: char_rect_by_index + log_attrs: log_attrs + log_attr_by_index: log_attr_by_index + } +} + +fn attach_generated_text_geometry(mut layout Layout, id_focus u32, shape_width f32, line_x f32, char_width f32, text string) bool { + if layout.shape.shape_type == .text && layout.shape.id_focus == id_focus { + layout.shape.width = shape_width + layout.shape.height = 10 + layout.shape.tc.text = text + layout.shape.tc.text_mode = .single_line + layout.shape.tc.vglyph_layout = synthetic_single_line_layout(text, line_x, char_width) + return true + } + for mut child in layout.children { + if attach_generated_text_geometry(mut child, id_focus, shape_width, line_x, char_width, + text) + { + return true + } + } + return false +} + +fn attach_generated_text_geometry_by_text(mut layout Layout, expected_text string, shape_width f32, line_x f32, char_width f32, text string) bool { + if layout.shape.shape_type == .text && layout.shape.tc != unsafe { nil } + && layout.shape.tc.text == expected_text { + layout.shape.width = shape_width + layout.shape.height = 10 + layout.shape.tc.text = text + layout.shape.tc.text_mode = .single_line + layout.shape.tc.vglyph_layout = synthetic_single_line_layout(text, line_x, char_width) + return true + } + for mut child in layout.children { + if attach_generated_text_geometry_by_text(mut child, expected_text, shape_width, line_x, + char_width, text) + { + return true + } + } + return false +} + +fn attach_generated_text_geometry_by_text_index(mut layout Layout, expected_text string, target_index int, shape_width f32, line_x f32, char_width f32, text string) bool { + mut seen := [0] + return attach_generated_text_geometry_by_text_index_rec(mut layout, expected_text, + target_index, shape_width, line_x, char_width, text, mut seen) +} + +fn attach_generated_text_geometry_by_text_index_rec(mut layout Layout, expected_text string, target_index int, shape_width f32, line_x f32, char_width f32, text string, mut seen []int) bool { + if layout.shape.shape_type == .text && layout.shape.tc != unsafe { nil } + && layout.shape.tc.text == expected_text { + if seen[0] == target_index { + layout.shape.width = shape_width + layout.shape.height = 10 + layout.shape.tc.text = text + layout.shape.tc.text_mode = .single_line + layout.shape.tc.vglyph_layout = synthetic_single_line_layout(text, line_x, char_width) + return true + } + seen[0]++ + } + for mut child in layout.children { + if attach_generated_text_geometry_by_text_index_rec(mut child, expected_text, target_index, + shape_width, line_x, char_width, text, mut seen) + { + return true + } + } + return false +} + +fn collect_text_scrolls_by_text(layout &Layout, text string, mut scrolls []f32) { + if layout.shape.shape_type == .text && layout.shape.tc != unsafe { nil } + && layout.shape.tc.text == text { + scrolls << layout.shape.tc.text_scroll_x + } + for child in layout.children { + collect_text_scrolls_by_text(&child, text, mut scrolls) + } +} + +fn set_generated_text_y(mut layout Layout, id_focus u32, y f32) bool { + if layout.shape.shape_type == .text && layout.shape.id_focus == id_focus { + layout.shape.y = y + return true + } + for mut child in layout.children { + if set_generated_text_y(mut child, id_focus, y) { + return true + } + } + return false +} + +fn generated_input_private_scroll_x(cfg InputCfg, mut w Window) f32 { + return state_map[string, f32](mut w, ns_input_private_scroll_x, cap_scroll).get(input_private_scroll_key(cfg)) or { + f32(0) + } +} + +fn test_input_default_alignment_remains_left() { + cfg := InputCfg{} + assert cfg.text_style.align == .left + assert cfg.placeholder_style.align == .left +} + +fn test_single_line_default_input_has_no_public_scroll_id() { + cfg := InputCfg{ + id: 'amount' + id_focus: 9301 + mode: .single_line + } + assert input_text_scroll_id(cfg) == 0 + assert input_text_scroll_id(InputCfg{ + id_focus: 9301 + id_scroll: 77 + mode: .single_line + }) == 77 +} + +fn test_input_private_scroll_key_prefers_stable_input_identity() { + id_cfg := InputCfg{ + id: 'amount' + id_focus: 9301 + text: '12' + mode: .single_line + } + assert input_private_scroll_key(id_cfg) == input_private_scroll_key(InputCfg{ + ...id_cfg + text: '123456' + }) + assert input_private_scroll_key(id_cfg) != input_private_scroll_key(InputCfg{ + id: 'other' + id_focus: 9301 + mode: .single_line + }) + field_cfg := InputCfg{ + field_id: 'amount' + id_focus: 9302 + text: '12' + mode: .single_line + } + assert input_private_scroll_key(field_cfg) == input_private_scroll_key(InputCfg{ + ...field_cfg + text: '123456' + }) + focus_cfg := InputCfg{ + id_focus: 9303 + text: '12' + mode: .single_line + } + assert input_private_scroll_key(focus_cfg) == input_private_scroll_key(InputCfg{ + ...focus_cfg + text: '123456' + }) + readonly_cfg := InputCfg{ + text: '12' + mode: .single_line + } + assert input_private_scroll_key(readonly_cfg) == '' + assert input_private_scroll_key(InputCfg{ + ...readonly_cfg + text: '123' + }) == '' +} + +fn test_input_text_style_alignment_passes_to_text_shape() { + mut view := input( + text: '42' + placeholder: 'placeholder' + text_style: TextStyle{ + align: .right + } + placeholder_style: TextStyle{ + align: .center + } + ) + shape := first_text_shape_from_view(mut view) + assert shape.tc.text == '42' + assert shape.tc.text_style.align == .right + assert shape.sizing.width == .fill +} + +fn test_input_placeholder_style_alignment_passes_to_text_shape() { + mut view := input( + text: '' + placeholder: 'placeholder' + text_style: TextStyle{ + align: .right + } + placeholder_style: TextStyle{ + align: .center + } + ) + shape := first_text_shape_from_view(mut view) + assert shape.tc.text == 'placeholder' + assert shape.tc.text_is_placeholder + assert shape.tc.text_style.align == .center +} + +fn test_generated_input_text_alignment_uses_text_viewport() { + cfg := InputCfg{ + id: 'value' + id_focus: 9302 + text: '42' + sizing: fixed_fixed + width: 120 + height: 24 + text_style: TextStyle{ + align: .right + } + } + mut view := input(cfg) + mut w := Window{} + root := arranged_view_layout(mut view, mut w) + text_layout := generated_text_layout(&root, cfg.id_focus) + viewport := generated_text_viewport(text_layout) + assert text_layout.shape.tc.text == '42' + assert text_layout.shape.tc.text_style.align == .right + assert text_layout.shape.id_scroll_container == 0 + assert viewport.shape.id_scroll == 0 + assert viewport.shape.scroll_mode == .horizontal_only + assert viewport.shape.clip + assert viewport.children.len == 1 + assert viewport.children[0].shape.shape_type == .text + if found_scroll := root.find_layout(fn (ly Layout) bool { + return ly.shape.id_scroll > 0 + }) + { + _ = found_scroll + assert false, 'default single-line input should not generate public scroll ids' + } + if found_scrollbar := root.find_layout(fn (ly Layout) bool { + return ly.shape.scrollbar_orientation != .none + }) + { + _ = found_scrollbar + assert false, 'single-line input viewport should hide internal scrollbars' + } +} + +fn test_generated_input_placeholder_alignment_uses_placeholder_style() { + cfg := InputCfg{ + id: 'placeholder' + id_focus: 9303 + text: '' + placeholder: 'amount' + sizing: fixed_fixed + width: 120 + height: 24 + text_style: TextStyle{ + align: .right + } + placeholder_style: TextStyle{ + align: .center + } + } + mut view := input(cfg) + mut w := Window{} + root := arranged_view_layout(mut view, mut w) + text_layout := generated_text_layout(&root, cfg.id_focus) + assert text_layout.shape.tc.text == 'amount' + assert text_layout.shape.tc.text_is_placeholder + assert text_layout.shape.tc.text_style.align == .center + assert text_layout.shape.id_scroll_container == 0 +} + +fn test_numeric_input_alignment_defaults_and_passes_styles_through() { + default_cfg := NumericInputCfg{} + assert default_cfg.text_style.align == .left + assert default_cfg.placeholder_style.align == .left + + mut text_view := numeric_input( + text: '12.30' + step_cfg: NumericStepCfg{ + show_buttons: false + } + text_style: TextStyle{ + align: .right + } + ) + text_shape := first_text_shape_from_view(mut text_view) + assert text_shape.tc.text == '12.30' + assert text_shape.tc.text_style.align == .right + + mut placeholder_view := numeric_input( + text: '' + placeholder: 'amount' + step_cfg: NumericStepCfg{ + show_buttons: false + } + text_style: TextStyle{ + align: .right + } + placeholder_style: TextStyle{ + align: .center + } + ) + placeholder_shape := first_text_shape_from_view(mut placeholder_view) + assert placeholder_shape.tc.text == 'amount' + assert placeholder_shape.tc.text_is_placeholder + assert placeholder_shape.tc.text_style.align == .center +} + +fn test_explicit_single_line_input_scroll_id_scrolls_text_only_not_icon() { + cfg := InputCfg{ + id: 'with-icon' + id_focus: 9304 + id_scroll: 123 + text: '123' + icon: 'i' + sizing: fixed_fixed + width: 120 + height: 24 + scrollbar_cfg_x: input_hidden_scrollbar_cfg() + scrollbar_cfg_y: input_hidden_scrollbar_cfg() + } + mut view := input(cfg) + mut w := Window{} + root := arranged_view_layout(mut view, mut w) + text_layout := generated_text_layout(&root, cfg.id_focus) + icon_shape := generated_text_shape_by_text(&root, 'i') + assert root.shape.id_scroll == 0 + assert text_layout.shape.id_scroll_container == 123 + assert icon_shape.id_scroll_container == 0 + scroll_layout := find_layout_by_id_scroll(root, 123) or { + assert false, 'expected explicit text viewport' + return + } + assert scroll_layout.children.len == 1 + assert scroll_layout.children[0].shape.shape_type == .text +} + +fn test_explicit_single_line_input_preserves_text_viewport_scrollbar_cfg() { + scroll_id := u32(125) + cfg := InputCfg{ + id: 'with-visible-scrollbar-and-icon' + id_focus: 9317 + id_scroll: scroll_id + text: '123' + icon: 'i' + sizing: fixed_fixed + width: 120 + height: 24 + scrollbar_cfg_x: &ScrollbarCfg{ + id: 'explicit-x-scrollbar' + overflow: .visible + size: 13 + } + scrollbar_cfg_y: input_hidden_scrollbar_cfg() + } + mut view := input(cfg) + mut w := Window{} + root := arranged_view_layout(mut view, mut w) + text_layout := generated_text_layout(&root, cfg.id_focus) + icon_shape := generated_text_shape_by_text(&root, 'i') + scroll_layout := find_layout_by_id_scroll(root, scroll_id) or { + assert false, 'expected explicit text viewport' + return + } + horizontal_scrollbar := scroll_layout.find_layout(fn (ly Layout) bool { + return ly.shape.scrollbar_orientation == .horizontal + && ly.shape.id == 'explicit-x-scrollbar' + }) or { + assert false, 'expected caller horizontal scrollbar config on text viewport' + return + } + _ = horizontal_scrollbar + if vertical_scrollbar := scroll_layout.find_layout(fn (ly Layout) bool { + return ly.shape.scrollbar_orientation == .vertical + }) + { + _ = vertical_scrollbar + assert false, 'caller hidden vertical scrollbar config should be preserved' + } + assert root.shape.id_scroll == 0 + assert text_layout.shape.id_scroll_container == scroll_id + assert icon_shape.id_scroll_container == 0 +} + +fn test_explicit_single_line_input_scroll_shift_skips_scrollbar_overlay() { + scroll_id := u32(126) + mut root := Layout{ + shape: &Shape{} + children: [ + Layout{ + shape: &Shape{ + id_scroll: scroll_id + } + children: [ + Layout{ + shape: &Shape{ + shape_type: .text + x: 10 + } + }, + Layout{ + shape: &Shape{ + id: 'explicit-shift-x-scrollbar' + scrollbar_orientation: .horizontal + over_draw: true + x: 20 + } + children: [ + Layout{ + shape: &Shape{ + id: 'thumb' + x: 25 + } + }, + ] + }, + ] + }, + ] + } + assert input_shift_explicit_scroll_contents(mut root, scroll_id, -30, 0) + assert f32_are_close(root.children[0].children[0].shape.x, -20) + assert f32_are_close(root.children[0].children[1].shape.x, 20) + assert f32_are_close(root.children[0].children[1].children[0].shape.x, 25) +} + +fn test_explicit_center_aligned_overflow_rests_without_trailing_scroll() { + id_focus := u32(9315) + scroll_id := u32(124) + mut w := Window{} + cfg := InputCfg{ + id_focus: id_focus + id_scroll: scroll_id + text: '123456' + mode: .single_line + sizing: fixed_fixed + width: 100 + height: 24 + scrollbar_cfg_x: input_hidden_scrollbar_cfg() + scrollbar_cfg_y: input_hidden_scrollbar_cfg() + text_style: TextStyle{ + align: .center + } + } + mut view := input(cfg) + mut root := arranged_view_layout(mut view, mut w) + assert attach_generated_text_geometry(mut root, id_focus, 240, 0, 40, cfg.text) + layout_amend(mut root, mut w) + scroll_x := state_map[u32, f32](mut w, ns_scroll_x, cap_scroll).get(scroll_id) or { f32(0) } + assert f32_are_close(scroll_x, 0) +} + +fn test_generated_single_line_default_scroll_reveals_cursor_after_paste() { + id_focus := u32(9305) + mut w := Window{} + mut imap := state_map[u32, InputState](mut w, ns_input, cap_many) + imap.set(id_focus, InputState{ + cursor_pos: 2 + }) + cfg_before := InputCfg{ + id_focus: id_focus + text: '12' + mode: .single_line + sizing: fixed_fixed + width: 100 + height: 24 + } + got := cfg_before.paste('3456', mut w) or { + assert false + return + } + assert got == '123456' + assert input_state_or_default(id_focus, mut w).reveal_cursor + + cfg_after := InputCfg{ + ...cfg_before + text: got + } + mut view := input(cfg_after) + mut root := arranged_view_layout(mut view, mut w) + assert attach_generated_text_geometry(mut root, id_focus, 240, 0, 40, got) + before_layout := generated_text_layout(&root, id_focus) + layout_amend(mut root, mut w) + scroll_x := generated_input_private_scroll_x(cfg_after, mut w) + after_layout := generated_text_layout(&root, id_focus) + assert after_layout.shape.id_scroll_container == 0 + assert scroll_x < 0 + assert f32_are_close(after_layout.shape.x, before_layout.shape.x) + assert f32_are_close(after_layout.shape.tc.text_scroll_x, scroll_x) + assert !input_state_or_default(id_focus, mut w).reveal_cursor + assert w.refresh_layout +} + +fn test_generated_right_aligned_overflow_rests_on_trailing_side() { + id_focus := u32(9306) + mut w := Window{} + cfg := InputCfg{ + id_focus: id_focus + text: '123456' + mode: .single_line + sizing: fixed_fixed + width: 100 + height: 24 + text_style: TextStyle{ + align: .right + } + } + mut view := input(cfg) + mut root := arranged_view_layout(mut view, mut w) + assert attach_generated_text_geometry(mut root, id_focus, 240, 0, 40, cfg.text) + layout_amend(mut root, mut w) + scroll_x := generated_input_private_scroll_x(cfg, mut w) + text_layout := generated_text_layout(&root, id_focus) + assert scroll_x < 0 + assert f32_are_close(text_layout.shape.tc.text_scroll_x, scroll_x) +} + +fn test_generated_center_aligned_overflow_rests_without_trailing_scroll() { + id_focus := u32(9312) + mut w := Window{} + cfg := InputCfg{ + id_focus: id_focus + text: '123456' + mode: .single_line + sizing: fixed_fixed + width: 100 + height: 24 + text_style: TextStyle{ + align: .center + } + } + mut view := input(cfg) + mut root := arranged_view_layout(mut view, mut w) + assert attach_generated_text_geometry(mut root, id_focus, 240, 0, 40, cfg.text) + layout_amend(mut root, mut w) + text_layout := generated_text_layout(&root, id_focus) + scroll_x := generated_input_private_scroll_x(cfg, mut w) + assert f32_are_close(scroll_x, 0) + assert f32_are_close(text_layout.shape.tc.text_scroll_x, 0) +} + +fn test_generated_keyboard_cursor_reveal_uses_private_text_scroll_state() { + id_focus := u32(9313) + mut w := Window{} + w.set_id_focus(id_focus) + mut imap := state_map[u32, InputState](mut w, ns_input, cap_many) + imap.set(id_focus, InputState{ + cursor_pos: 5 + }) + cfg := InputCfg{ + id_focus: id_focus + text: '123456' + mode: .single_line + sizing: fixed_fixed + width: 100 + height: 24 + } + mut view := input(cfg) + mut root := arranged_view_layout(mut view, mut w) + assert attach_generated_text_geometry(mut root, id_focus, 240, 0, 40, cfg.text) + w.layout = root + text_layout := generated_text_layout(&w.layout, id_focus) + mut event := Event{ + key_code: .right + } + text_layout.shape.events.on_keydown(&text_layout, mut event, mut w) + private_x := generated_input_private_scroll_x(cfg, mut w) + public_zero_x := state_map[u32, f32](mut w, ns_scroll_x, cap_scroll).get(0) or { f32(9999) } + state := input_state_or_default(id_focus, mut w) + assert event.is_handled + assert state.cursor_pos == 6 + assert private_x < 0 + assert public_zero_x == 9999 +} + +fn test_generated_private_scroll_clamp_includes_nonzero_line_origin() { + id_focus := u32(9316) + mut w := Window{} + mut imap := state_map[u32, InputState](mut w, ns_input, cap_many) + imap.set(id_focus, InputState{ + cursor_pos: 6 + reveal_cursor: true + }) + cfg := InputCfg{ + id_focus: id_focus + text: '123456' + mode: .single_line + sizing: fixed_fixed + width: 100 + height: 24 + } + mut view := input(cfg) + mut root := arranged_view_layout(mut view, mut w) + assert attach_generated_text_geometry(mut root, id_focus, 270, 30, 40, cfg.text) + layout_amend(mut root, mut w) + text_layout := generated_text_layout(&root, id_focus) + viewport := generated_text_viewport(text_layout) + view_width := viewport.shape.width - viewport.shape.padding_width() + expected_scroll_x := f32_min(0, view_width - 270) + private_x := generated_input_private_scroll_x(cfg, mut w) + assert f32_are_close(private_x, expected_scroll_x) + assert f32_are_close(text_layout.shape.tc.text_scroll_x, expected_scroll_x) +} + +fn test_generated_private_scroll_clamp_uses_negative_line_origin_right_edge() { + id_focus := u32(9318) + mut w := Window{} + mut imap := state_map[u32, InputState](mut w, ns_input, cap_many) + imap.set(id_focus, InputState{ + cursor_pos: 6 + reveal_cursor: true + }) + cfg := InputCfg{ + id_focus: id_focus + text: '123456' + mode: .single_line + sizing: fixed_fixed + width: 100 + height: 24 + } + mut view := input(cfg) + mut root := arranged_view_layout(mut view, mut w) + assert attach_generated_text_geometry(mut root, id_focus, 200, -40, 40, cfg.text) + layout_amend(mut root, mut w) + text_layout := generated_text_layout(&root, id_focus) + viewport := generated_text_viewport(text_layout) + view_width := viewport.shape.width - viewport.shape.padding_width() + expected_scroll_x := f32_min(0, view_width - 200) + overestimated_scroll_x := f32_min(0, view_width - 240) + private_x := generated_input_private_scroll_x(cfg, mut w) + assert !f32_are_close(private_x, overestimated_scroll_x) + assert f32_are_close(private_x, expected_scroll_x) + assert f32_are_close(text_layout.shape.tc.text_scroll_x, expected_scroll_x) +} + +fn test_generated_selection_geometry_uses_private_text_scroll_offset() { + id_focus := u32(9310) + mut w := Window{} + mut imap := state_map[u32, InputState](mut w, ns_input, cap_many) + imap.set(id_focus, InputState{ + cursor_pos: 6 + select_beg: 1 + select_end: 6 + reveal_cursor: true + }) + cfg := InputCfg{ + id_focus: id_focus + text: '123456' + mode: .single_line + sizing: fixed_fixed + width: 100 + height: 24 + } + mut view := input(cfg) + mut root := arranged_view_layout(mut view, mut w) + assert attach_generated_text_geometry(mut root, id_focus, 240, 0, 40, cfg.text) + before_layout := generated_text_layout(&root, id_focus) + assert before_layout.shape.tc.text_sel_beg == 1 + assert before_layout.shape.tc.text_sel_end == 6 + layout_amend(mut root, mut w) + after_layout := generated_text_layout(&root, id_focus) + scroll_x := generated_input_private_scroll_x(cfg, mut w) + assert scroll_x < 0 + assert f32_are_close(after_layout.shape.x, before_layout.shape.x) + assert f32_are_close(after_layout.shape.tc.text_scroll_x, scroll_x) + assert after_layout.shape.tc.text_sel_beg == 1 + assert after_layout.shape.tc.text_sel_end == 6 +} + +fn test_generated_drag_selection_auto_scroll_uses_private_text_scroll_state() { + id_focus := u32(9314) + mut w := Window{ + ui: &gg.Context{ + mouse_pos_x: 250 + mouse_pos_y: 5 + } + } + mut imap := state_map[u32, InputState](mut w, ns_input, cap_many) + imap.set(id_focus, InputState{ + cursor_pos: 0 + }) + cfg := InputCfg{ + id_focus: id_focus + text: '12' + mode: .single_line + sizing: fixed_fixed + width: 100 + height: 24 + } + mut view := input(cfg) + mut root := arranged_view_layout(mut view, mut w) + assert attach_generated_text_geometry(mut root, id_focus, 200, 0, 100, cfg.text) + w.layout = root + text_layout := generated_text_layout(&w.layout, id_focus) + w.ui.mouse_buttons = .left + w.view_state.mouse_lock = MouseLockCfg{ + cursor_pos: 0 + } + mut event := Event{ + mouse_x: text_layout.shape.x + 250 + mouse_y: text_layout.shape.y + 5 + } + text_mouse_move_locked(&text_layout, mut event, mut w, false) + assert w.has_animation(id_auto_scroll_animation) + + mut an := Animate{ + id: id_auto_scroll_animation + callback: fn (mut _ Animate, mut _ Window) {} + } + text_auto_scroll_cursor(id_focus, 0, mut an, mut w, false) + state := input_state_or_default(id_focus, mut w) + private_x := generated_input_private_scroll_x(cfg, mut w) + public_zero_x := state_map[u32, f32](mut w, ns_scroll_x, cap_scroll).get(0) or { f32(9999) } + assert state.cursor_pos == 1 + assert state.select_beg == 0 + assert state.select_end == 1 + assert private_x < 0 + assert public_zero_x == 9999 +} + +fn test_default_single_line_input_inside_outer_scroll_reveals_vertically_and_keeps_private_text_scroll() { + id_focus := u32(9311) + outer_scroll_id := u32(8877) + mut w := Window{} + cfg := InputCfg{ + id_focus: id_focus + text: '123456' + mode: .single_line + sizing: fixed_fixed + width: 100 + height: 24 + } + mut view := column( + id_scroll: outer_scroll_id + sizing: fixed_fixed + width: 120 + height: 40 + scrollbar_cfg_x: input_hidden_scrollbar_cfg() + scrollbar_cfg_y: input_hidden_scrollbar_cfg() + padding: padding_none + content: [ + column( + sizing: fixed_fixed + width: 100 + height: 160 + padding: padding_none + ), + input(cfg), + ] + ) + mut root := arranged_view_layout(mut view, mut w) + assert root.shape.id_scroll == outer_scroll_id + assert attach_generated_text_geometry(mut root, id_focus, 240, 0, 40, cfg.text) + assert set_generated_text_y(mut root, id_focus, 180) + mut imap := state_map[u32, InputState](mut w, ns_input, cap_many) + imap.set(id_focus, InputState{ + cursor_pos: 6 + reveal_cursor: true + }) + layout_amend(mut root, mut w) + text_layout := generated_text_layout(&root, id_focus) + private_x := generated_input_private_scroll_x(cfg, mut w) + outer_x := state_map[u32, f32](mut w, ns_scroll_x, cap_scroll).get(outer_scroll_id) or { + f32(0) + } + outer_y := state_map[u32, f32](mut w, ns_scroll_y, cap_scroll).get(outer_scroll_id) or { + f32(0) + } + assert text_layout.shape.id_scroll_container == outer_scroll_id + assert private_x < 0 + assert f32_are_close(text_layout.shape.tc.text_scroll_x, private_x) + assert f32_are_close(outer_x, 0) + assert outer_y < 0 + assert !input_state_or_default(id_focus, mut w).reveal_cursor +} + +fn test_readonly_generated_right_aligned_overflow_gets_passive_rest_scroll() { + cfg := InputCfg{ + text: '123456' + mode: .single_line + sizing: fixed_fixed + width: 100 + height: 24 + text_style: TextStyle{ + align: .right + } + } + mut view := input(cfg) + mut w := Window{} + mut root := arranged_view_layout(mut view, mut w) + text_layout := generated_text_layout_by_text(&root, cfg.text) + assert text_layout.shape.id_scroll_container == 0 + assert attach_generated_text_geometry_by_text(mut root, cfg.text, 240, 0, 40, cfg.text) + layout_amend(mut root, mut w) + after_text_layout := generated_text_layout_by_text(&root, cfg.text) + assert input_private_scroll_key(cfg) == '' + assert after_text_layout.shape.tc.text_scroll_x < 0 + assert f32_are_close(generated_input_private_scroll_x(cfg, mut w), 0) + assert w.view_state.registry.entry_count(ns_input_private_scroll_x) == 0 + assert !w.refresh_layout +} + +fn test_readonly_anonymous_private_scroll_is_layout_local_for_same_text_different_widths() { + text_value := '123456' + narrow_cfg := InputCfg{ + text: text_value + mode: .single_line + sizing: fixed_fixed + width: 100 + height: 24 + text_style: TextStyle{ + align: .right + } + } + wide_cfg := InputCfg{ + ...narrow_cfg + width: 180 + } + mut view := column( + padding: padding_none + content: [ + input(narrow_cfg), + input(wide_cfg), + ] + ) + mut w := Window{} + mut root := arranged_view_layout(mut view, mut w) + assert input_private_scroll_key(narrow_cfg) == '' + assert input_private_scroll_key(wide_cfg) == '' + assert attach_generated_text_geometry_by_text_index(mut root, text_value, 0, 240, 0, 40, + text_value) + assert attach_generated_text_geometry_by_text_index(mut root, text_value, 1, 240, 0, 40, + text_value) + layout_amend(mut root, mut w) + + mut scrolls := []f32{} + collect_text_scrolls_by_text(&root, text_value, mut scrolls) + assert scrolls.len == 2 + assert scrolls[0] < scrolls[1] + assert scrolls[0] < 0 + assert scrolls[1] < 0 + assert w.view_state.registry.entry_count(ns_input_private_scroll_x) == 0 + assert !w.refresh_layout +} + +fn test_generated_input_edit_reveals_cursor_on_next_layout_amend() { + id_focus := u32(9309) + mut state := &InputAlignmentTestState{} + mut w := Window{ + state: state + } + w.set_id_focus(id_focus) + mut imap := state_map[u32, InputState](mut w, ns_input, cap_many) + imap.set(id_focus, InputState{ + cursor_pos: 2 + }) + cfg_before := InputCfg{ + id: 'two-frame-edit' + id_focus: id_focus + text: '12' + mode: .single_line + sizing: fixed_fixed + width: 100 + height: 24 + on_text_changed: fn (_ &Layout, text string, mut w Window) { + mut state := w.state[InputAlignmentTestState]() + state.changed_text = text + } + } + mut before_view := input(cfg_before) + before_root := arranged_view_layout(mut before_view, mut w) + mut event := Event{ + char_code: u32(`3`) + } + before_root.shape.events.on_char(&before_root, mut event, mut w) + assert event.is_handled + assert state.changed_text == '123' + assert input_state_or_default(id_focus, mut w).reveal_cursor + + cfg_after := InputCfg{ + ...cfg_before + text: state.changed_text + } + mut after_view := input(cfg_after) + mut after_root := arranged_view_layout(mut after_view, mut w) + assert input_state_or_default(id_focus, mut w).reveal_cursor + assert attach_generated_text_geometry(mut after_root, id_focus, 120, 0, 40, state.changed_text) + layout_amend(mut after_root, mut w) + scroll_x := generated_input_private_scroll_x(cfg_after, mut w) + text_layout := generated_text_layout(&after_root, id_focus) + assert scroll_x < 0 + assert f32_are_close(text_layout.shape.tc.text_scroll_x, scroll_x) + assert !input_state_or_default(id_focus, mut w).reveal_cursor +} + +fn test_generated_password_overflow_reveals_masked_caret() { + id_focus := u32(9307) + text_value := 'secret' + mut w := Window{} + mut imap := state_map[u32, InputState](mut w, ns_input, cap_many) + imap.set(id_focus, InputState{ + cursor_pos: text_value.len + reveal_cursor: true + }) + cfg := InputCfg{ + id_focus: id_focus + text: text_value + mode: .single_line + is_password: true + sizing: fixed_fixed + width: 100 + height: 24 + text_style: TextStyle{ + align: .right + } + } + mut view := input(cfg) + mut root := arranged_view_layout(mut view, mut w) + assert attach_generated_text_geometry(mut root, id_focus, 240, 0, 40, text_value) + layout_amend(mut root, mut w) + scroll_x := generated_input_private_scroll_x(cfg, mut w) + text_layout := generated_text_layout(&root, id_focus) + assert scroll_x < 0 + assert f32_are_close(text_layout.shape.tc.text_scroll_x, scroll_x) + assert !input_state_or_default(id_focus, mut w).reveal_cursor +} + +fn test_multiline_input_preserves_outer_scroll_behavior() { + cfg := InputCfg{ + id_focus: 9308 + id_scroll: 456 + text: 'line 1\nline 2' + mode: .multiline + sizing: fixed_fixed + width: 120 + height: 60 + } + mut view := input(cfg) + mut w := Window{} + root := arranged_view_layout(mut view, mut w) + text_layout := generated_text_layout(&root, cfg.id_focus) + assert root.shape.id_scroll == 456 + assert text_layout.shape.id_scroll_container == 456 +} + +fn test_fixed_width_single_line_insert_allows_overflow_for_scroll_clip() { + id_focus := u32(9201) + mut w := Window{} + mut imap := state_map[u32, InputState](mut w, ns_input, cap_many) + imap.set(id_focus, InputState{ + cursor_pos: 2 + }) + cfg := InputCfg{ + id_focus: id_focus + text: '12' + mode: .single_line + sizing: fixed_fixed + width: 10 + } + + got := cfg.insert('3456', mut w) or { + assert false + return + } + assert got == '123456' + state := input_state_or_default(id_focus, mut w) + assert state.cursor_pos == 6 +} + +fn test_fixed_width_masked_insert_allows_overflow_for_scroll_clip() { + id_focus := u32(9202) + mut w := Window{} + mut imap := state_map[u32, InputState](mut w, ns_input, cap_many) + imap.set(id_focus, InputState{ + cursor_pos: 2 + }) + cfg := InputCfg{ + id_focus: id_focus + text: '12' + mask: '999999' + mode: .single_line + sizing: fixed_fixed + width: 10 + } + + got := cfg.insert('3456', mut w) or { + assert false + return + } + assert got == '123456' + state := input_state_or_default(id_focus, mut w) + assert state.cursor_pos == 6 +} diff --git a/_markdown_test.v b/_markdown_test.v index 03331c6..4ebd46a 100644 --- a/_markdown_test.v +++ b/_markdown_test.v @@ -1109,19 +1109,27 @@ fn test_markdown_heading_anchor_set() { // Superscript/subscript tests fn test_markdown_superscript() { - rt := markdown_to_rich_text('E=mc^2^', MarkdownStyle{}) + style := MarkdownStyle{} + rt := markdown_to_rich_text('E=mc^2^', style) sup_runs := rt.runs.filter(it.text == '2') assert sup_runs.len >= 1 - // OpenType 'sups' feature handles sizing + assert sup_runs[0].style.size < style.text.size + assert sup_runs[0].style.rise > 0 assert sup_runs[0].style.features != unsafe { nil } + assert sup_runs[0].style.features.opentype_features.len == 1 + assert sup_runs[0].style.features.opentype_features[0].tag == 'sups' } fn test_markdown_subscript() { - rt := markdown_to_rich_text('H~2~O', MarkdownStyle{}) + style := MarkdownStyle{} + rt := markdown_to_rich_text('H~2~O', style) sub_runs := rt.runs.filter(it.text == '2') assert sub_runs.len >= 1 - // OpenType 'subs' feature handles sizing + assert sub_runs[0].style.size < style.text.size + assert sub_runs[0].style.rise < 0 assert sub_runs[0].style.features != unsafe { nil } + assert sub_runs[0].style.features.opentype_features.len == 1 + assert sub_runs[0].style.features.opentype_features[0].tag == 'subs' } fn test_markdown_subscript_vs_strikethrough() { diff --git a/_styles_test.v b/_styles_test.v index 54a9d75..24c7684 100644 --- a/_styles_test.v +++ b/_styles_test.v @@ -6,6 +6,7 @@ fn test_text_style_to_vglyph_cfg_mapping() { ts := TextStyle{ family: 'Roboto' size: 16.0 + rise: 3.5 underline: true strikethrough: true } @@ -14,6 +15,7 @@ fn test_text_style_to_vglyph_cfg_mapping() { assert cfg.style.font_name == 'Roboto' assert cfg.style.size == 16.0 + assert cfg.style.rise == 3.5 assert cfg.style.underline == true assert cfg.style.strikethrough == true } diff --git a/_text_test.v b/_text_test.v index 3f5fec8..c6340e1 100644 --- a/_text_test.v +++ b/_text_test.v @@ -1,7 +1,719 @@ module gui +import gg import vglyph +fn aligned_single_line_shape(align TextAlignment, shape_width f32, line_x f32, char_width f32, text string) &Shape { + mut char_rects := []vglyph.CharRect{cap: text.len} + mut char_rect_by_index := map[int]int{} + for idx in 0 .. text.len { + char_rect_by_index[idx] = idx + char_rects << vglyph.CharRect{ + index: idx + rect: gg.Rect{ + x: line_x + (char_width * f32(idx)) + y: 0 + width: char_width + height: 10 + } + } + } + mut log_attrs := []vglyph.LogAttr{cap: text.len + 1} + mut log_attr_by_index := map[int]int{} + for idx in 0 .. text.len + 1 { + log_attr_by_index[idx] = idx + log_attrs << vglyph.LogAttr{ + is_cursor_position: true + } + } + return &Shape{ + shape_type: .text + width: shape_width + height: 10 + tc: &ShapeTextConfig{ + text: text + text_mode: .single_line + text_style: TextStyle{ + align: align + } + vglyph_layout: &vglyph.Layout{ + lines: [ + vglyph.Line{ + start_index: 0 + length: text.len + rect: gg.Rect{ + x: line_x + y: 0 + width: char_width * f32(text.len) + height: 10 + } + }, + ] + char_rects: char_rects + char_rect_by_index: char_rect_by_index + log_attrs: log_attrs + log_attr_by_index: log_attr_by_index + } + } + } +} + +fn password_aligned_single_line_shape(align TextAlignment, shape_width f32, line_x f32, char_width f32, text string) &Shape { + mut shape := aligned_single_line_shape(align, shape_width, line_x, char_width, text) + shape.tc.text_is_password = true + return shape +} + +fn password_multiline_shape_with_second_line_origin() &Shape { + text := 'aa\nbb' + return &Shape{ + shape_type: .text + width: 100 + height: 24 + tc: &ShapeTextConfig{ + text: text + text_is_password: true + text_mode: .multiline + text_style: TextStyle{} + vglyph_layout: &vglyph.Layout{ + lines: [ + vglyph.Line{ + start_index: 0 + length: 2 + rect: gg.Rect{ + x: 0 + y: 0 + width: 20 + height: 10 + } + }, + vglyph.Line{ + start_index: 3 + length: 2 + rect: gg.Rect{ + x: 40 + y: 12 + width: 20 + height: 10 + } + }, + ] + char_rects: [ + vglyph.CharRect{ + index: 0 + rect: gg.Rect{ + x: 0 + y: 0 + width: 10 + height: 10 + } + }, + vglyph.CharRect{ + index: 1 + rect: gg.Rect{ + x: 10 + y: 0 + width: 10 + height: 10 + } + }, + vglyph.CharRect{ + index: 3 + rect: gg.Rect{ + x: 0 + y: 12 + width: 10 + height: 10 + } + }, + vglyph.CharRect{ + index: 4 + rect: gg.Rect{ + x: 10 + y: 12 + width: 10 + height: 10 + } + }, + ] + char_rect_by_index: { + 0: 0 + 1: 1 + 3: 2 + 4: 3 + } + log_attrs: []vglyph.LogAttr{} + log_attr_by_index: map[int]int{} + } + } + } +} + +fn test_single_line_text_alignment_offset_uses_shape_width_without_wrapping() { + mut w := Window{} + left_shape := aligned_single_line_shape(.left, 100, 0, 10, 'abcd') + center_shape := aligned_single_line_shape(.center, 100, 0, 10, 'abcd') + right_shape := aligned_single_line_shape(.right, 100, 0, 10, 'abcd') + overflow_shape := aligned_single_line_shape(.right, 30, 0, 10, 'abcd') + + assert f32_are_close(text_layout_align_offset_x(left_shape, mut w), 0) + assert f32_are_close(text_layout_align_offset_x(center_shape, mut w), 30) + assert f32_are_close(text_layout_align_offset_x(right_shape, mut w), 60) + assert f32_are_close(text_layout_align_offset_x(overflow_shape, mut w), 0) +} + +fn test_right_aligned_single_line_hit_test_subtracts_alignment_offset() { + mut w := Window{} + shape := aligned_single_line_shape(.right, 100, 0, 10, 'ab') + + start_event := Event{ + mouse_x: 81 + mouse_y: 5 + } + assert text_mouse_cursor_pos(shape, &start_event, mut w, false) == 0 + + end_event := Event{ + mouse_x: 101 + mouse_y: 5 + } + assert text_mouse_cursor_pos(shape, &end_event, mut w, false) == 2 +} + +fn test_right_aligned_single_line_caret_uses_alignment_offset() { + mut w := Window{} + shape := aligned_single_line_shape(.right, 100, 0, 10, 'ab') + rect := text_cursor_rect_for_position(shape, 2, mut w) + caret_x := text_layout_align_offset_x(shape, mut w) + rect.x + assert f32_are_close(caret_x, 100) +} + +fn test_single_line_text_scroll_offset_participates_in_render_geometry() { + mut w := Window{} + mut shape := aligned_single_line_shape(.left, 20, 0, 10, 'abcdef') + shape.tc.text_scroll_x = -40 + rect := text_cursor_rect_for_position(shape, 6, mut w) + caret_x := text_layout_render_offset_x(shape, mut w) + rect.x + assert f32_are_close(text_layout_align_offset_x(shape, mut w), 0) + assert f32_are_close(caret_x, 20) +} + +fn test_single_line_text_scroll_offset_participates_in_hit_testing() { + mut w := Window{} + mut shape := aligned_single_line_shape(.left, 20, 0, 10, 'abcdef') + shape.tc.text_scroll_x = -40 + event := Event{ + mouse_x: 20 + mouse_y: 5 + } + assert text_mouse_cursor_pos(shape, &event, mut w, false) == 6 +} + +fn test_selection_rect_uses_layout_origin_once_for_aligned_line_geometry() { + mut w := Window{} + shape := aligned_single_line_shape(.left, 100, 60, 10, 'ab') + line := shape.tc.vglyph_layout.lines[0] + + draw_text_selection(mut w, DrawTextSelectionParams{ + shape: shape + line: line + layout_x: 10 + draw_y: 5 + byte_beg: 0 + byte_end: 1 + text_cfg: TextStyle{ + color: black + size: 16 + }.to_vglyph_cfg() + }) + + assert w.renderers.len == 1 + r := w.renderers[0] + if r is DrawRect { + assert f32_are_close(r.x, 70) + assert f32_are_close(r.w, 10) + } else { + assert false, 'expected selection to emit DrawRect' + } +} + +fn test_single_line_cursor_scroll_x_keeps_end_visible() { + mut text_shape := aligned_single_line_shape(.left, 200, 0, 100, 'ab') + text_shape.id_scroll_container = 77 + scroll_container := Layout{ + shape: &Shape{ + id_scroll: 77 + width: 100 + height: 20 + } + children: [ + Layout{ + shape: text_shape + }, + ] + } + mut w := Window{ + layout: Layout{ + shape: &Shape{} + children: [scroll_container] + } + } + + assert f32_are_close(cursor_pos_to_scroll_x(2, text_shape, mut w), -100) + + mut sx := state_map[u32, f32](mut w, ns_scroll_x, cap_scroll) + sx.set(77, -100) + text_shape.x = -100 + assert f32_are_close(cursor_pos_to_scroll_x(0, text_shape, mut w), 0) +} + +fn test_horizontal_drag_selection_auto_scroll_is_scheduled() { + id_focus := u32(9301) + mut text_shape := aligned_single_line_shape(.left, 200, 0, 100, 'ab') + text_shape.id_focus = id_focus + text_shape.id_scroll_container = 77 + text_layout := Layout{ + shape: text_shape + } + scroll_container := Layout{ + shape: &Shape{ + id_scroll: 77 + width: 100 + height: 20 + } + children: [text_layout] + } + mut w := Window{ + ui: &gg.Context{ + mouse_buttons: .left + } + layout: Layout{ + shape: &Shape{} + children: [scroll_container] + } + } + w.view_state.mouse_lock = MouseLockCfg{ + cursor_pos: 0 + } + mut e := Event{ + mouse_x: 250 + mouse_y: 5 + } + + text_mouse_move_locked(&text_layout, mut e, mut w, false) + assert w.has_animation(id_auto_scroll_animation) +} + +fn test_horizontal_drag_selection_auto_scroll_timer_advances_cursor() { + id_focus := u32(9302) + mut text_shape := aligned_single_line_shape(.left, 200, 0, 100, 'ab') + text_shape.id_focus = id_focus + text_shape.id_scroll_container = 77 + text_layout := Layout{ + shape: text_shape + } + scroll_container := Layout{ + shape: &Shape{ + id_scroll: 77 + width: 100 + height: 20 + } + children: [text_layout] + } + mut w := Window{ + ui: &gg.Context{ + mouse_pos_x: 250 + mouse_pos_y: 5 + } + layout: Layout{ + shape: &Shape{} + children: [scroll_container] + } + } + mut imap := state_map[u32, InputState](mut w, ns_input, cap_many) + imap.set(id_focus, InputState{ + cursor_pos: 0 + }) + w.view_state.mouse_lock = MouseLockCfg{ + cursor_pos: 0 + } + mut an := Animate{ + id: id_auto_scroll_animation + callback: fn (mut _ Animate, mut _ Window) {} + } + + assert isnil(w.text_system) + text_auto_scroll_cursor(id_focus, 77, mut an, mut w, false) + + state := input_state_or_default(id_focus, mut w) + assert state.cursor_pos == 1 + assert state.select_beg == 0 + assert state.select_end == 1 + scroll_x := state_map[u32, f32](mut w, ns_scroll_x, cap_scroll).get(77) or { f32(0) } + assert f32_are_close(scroll_x, -100) +} + +fn test_horizontal_drag_selection_auto_scroll_left_uses_pointer_edge() { + id_focus := u32(9305) + mut text_shape := aligned_single_line_shape(.left, 200, 0, 50, 'abcd') + text_shape.id_focus = id_focus + text_shape.id_scroll_container = 77 + text_shape.x = -100 + text_layout := Layout{ + shape: text_shape + } + scroll_container := Layout{ + shape: &Shape{ + id_scroll: 77 + width: 100 + height: 20 + } + children: [text_layout] + } + mut w := Window{ + ui: &gg.Context{ + mouse_buttons: .left + mouse_pos_x: -10 + mouse_pos_y: 5 + } + layout: Layout{ + shape: &Shape{} + children: [scroll_container] + } + } + mut sx := state_map[u32, f32](mut w, ns_scroll_x, cap_scroll) + sx.set(77, -100) + mut imap := state_map[u32, InputState](mut w, ns_input, cap_many) + imap.set(id_focus, InputState{ + cursor_pos: 2 + }) + w.view_state.mouse_lock = MouseLockCfg{ + cursor_pos: 2 + } + mut e := Event{ + mouse_x: -10 + mouse_y: 5 + } + + text_mouse_move_locked(&text_layout, mut e, mut w, false) + assert w.has_animation(id_auto_scroll_animation) + + mut an := Animate{ + id: id_auto_scroll_animation + callback: fn (mut _ Animate, mut _ Window) {} + } + text_auto_scroll_cursor(id_focus, 77, mut an, mut w, false) + + state := input_state_or_default(id_focus, mut w) + scroll_x := state_map[u32, f32](mut w, ns_scroll_x, cap_scroll).get(77) or { f32(0) } + assert state.cursor_pos == 1 + assert state.select_beg == 1 + assert state.select_end == 2 + assert f32_are_close(scroll_x, -50) +} + +fn test_private_leftward_drag_selection_auto_scroll_uses_pointer_edge() { + id_focus := u32(9303) + scroll_key := 'private-leftward-drag' + mut text_shape := aligned_single_line_shape(.left, 200, 0, 50, 'abcd') + text_shape.id_focus = id_focus + text_shape.tc.text_scroll_key = scroll_key + text_shape.tc.text_scroll_x = -100 + mut root := Layout{ + shape: &Shape{} + children: [ + Layout{ + shape: &Shape{ + width: 100 + height: 20 + } + children: [ + Layout{ + shape: text_shape + }, + ] + }, + ] + } + layout_parents(mut root, unsafe { nil }) + mut w := Window{ + ui: &gg.Context{ + mouse_buttons: .left + } + layout: root + } + mut sx := state_map[string, f32](mut w, ns_input_private_scroll_x, cap_scroll) + sx.set(scroll_key, -100) + mut imap := state_map[u32, InputState](mut w, ns_input, cap_many) + imap.set(id_focus, InputState{ + cursor_pos: 2 + }) + w.view_state.mouse_lock = MouseLockCfg{ + cursor_pos: 2 + } + text_layout := w.layout.find_layout(fn [id_focus] (ly Layout) bool { + return ly.shape.id_focus == id_focus + }) or { panic('missing private text layout') } + mut e := Event{ + mouse_x: -10 + mouse_y: 5 + } + + text_mouse_move_locked(&text_layout, mut e, mut w, false) + assert w.has_animation(id_auto_scroll_animation) + + w.ui.mouse_pos_x = -10 + w.ui.mouse_pos_y = 5 + mut an := Animate{ + id: id_auto_scroll_animation + callback: fn (mut _ Animate, mut _ Window) {} + } + text_auto_scroll_cursor(id_focus, 0, mut an, mut w, false) + + state := input_state_or_default(id_focus, mut w) + private_x := state_map[string, f32](mut w, ns_input_private_scroll_x, cap_scroll).get(scroll_key) or { + f32(0) + } + public_zero_x := state_map[u32, f32](mut w, ns_scroll_x, cap_scroll).get(0) or { f32(9999) } + assert state.cursor_pos == 1 + assert state.select_beg == 1 + assert state.select_end == 2 + assert f32_are_close(private_x, -50) + assert public_zero_x == 9999 +} + +fn test_private_scroll_cursor_reveal_preserves_outer_vertical_scroll_only() { + id_focus := u32(9304) + outer_scroll_id := u32(8811) + scroll_key := 'private-vertical-reveal' + mut text_shape := aligned_single_line_shape(.left, 100, 0, 10, 'abcd') + text_shape.id_focus = id_focus + text_shape.id_scroll_container = outer_scroll_id + text_shape.y = 40 + text_shape.tc.text_scroll_key = scroll_key + mut root := Layout{ + shape: &Shape{} + children: [ + Layout{ + shape: &Shape{ + id_scroll: outer_scroll_id + width: 100 + height: 20 + } + children: [ + Layout{ + shape: text_shape + }, + ] + }, + ] + } + layout_parents(mut root, unsafe { nil }) + mut w := Window{ + layout: root + } + text_layout := w.layout.find_layout(fn [id_focus] (ly Layout) bool { + return ly.shape.id_focus == id_focus + }) or { panic('missing private text layout') } + + scroll_cursor_into_view(0, &text_layout, mut w) + + outer_y := state_map[u32, f32](mut w, ns_scroll_y, cap_scroll).get(outer_scroll_id) or { + f32(0) + } + outer_x := state_map[u32, f32](mut w, ns_scroll_x, cap_scroll).get(outer_scroll_id) or { + f32(9999) + } + assert f32_are_close(outer_y, -30) + assert outer_x == 9999 +} + +fn test_password_right_aligned_geometry_uses_mask_for_caret_and_hit_test() { + mut w := Window{} + shape := password_aligned_single_line_shape(.right, 100, 0, 10, 'ab') + + rect := text_cursor_rect_for_position(shape, 2, mut w) + caret_x := text_layout_align_offset_x(shape, mut w) + rect.x + assert f32_are_close(caret_x, 100) + + start_event := Event{ + mouse_x: 81 + mouse_y: 5 + } + assert text_mouse_cursor_pos(shape, &start_event, mut w, false) == 0 + + end_event := Event{ + mouse_x: 101 + mouse_y: 5 + } + assert text_mouse_cursor_pos(shape, &end_event, mut w, false) == 2 +} + +fn test_password_mask_width_path_keeps_nonzero_line_origin() { + shape := password_aligned_single_line_shape(.left, 100, 30, 10, 'ab') + + assert f32_are_close(text_password_cursor_x_from_mask_width(shape, 20), 50) + assert f32_are_close(text_password_mask_offset_x(shape, 35), 5) +} + +fn test_password_right_aligned_geometry_keeps_nonzero_line_origin() { + mut w := Window{} + shape := password_aligned_single_line_shape(.right, 100, 30, 10, 'ab') + + rect := text_cursor_rect_for_position(shape, 2, mut w) + assert f32_are_close(rect.x, 50) + caret_x := text_layout_align_offset_x(shape, mut w) + rect.x + assert f32_are_close(caret_x, 130) + + start_event := Event{ + mouse_x: 111 + mouse_y: 5 + } + assert text_mouse_cursor_pos(shape, &start_event, mut w, false) == 0 + + end_event := Event{ + mouse_x: 131 + mouse_y: 5 + } + assert text_mouse_cursor_pos(shape, &end_event, mut w, false) == 2 +} + +fn test_password_selection_uses_mask_geometry_with_alignment() { + mut w := Window{} + shape := password_aligned_single_line_shape(.right, 100, 0, 10, 'ab') + line := shape.tc.vglyph_layout.lines[0] + + draw_text_selection(mut w, DrawTextSelectionParams{ + shape: shape + line: line + layout_x: 80 + draw_y: 5 + byte_beg: 0 + byte_end: 1 + password_mask: '**' + text_cfg: TextStyle{ + color: black + size: 16 + }.to_vglyph_cfg() + }) + + assert w.renderers.len == 1 + r := w.renderers[0] + if r is DrawRect { + assert f32_are_close(r.x, 80) + assert f32_are_close(r.w, 10) + } else { + assert false, 'expected password selection to emit DrawRect' + } +} + +fn test_password_selection_uses_nonzero_line_origin_once() { + mut w := Window{} + shape := password_aligned_single_line_shape(.right, 100, 30, 10, 'ab') + line := shape.tc.vglyph_layout.lines[0] + + draw_text_selection(mut w, DrawTextSelectionParams{ + shape: shape + line: line + layout_x: 80 + draw_y: 5 + byte_beg: 0 + byte_end: 1 + password_mask: '**' + text_cfg: TextStyle{ + color: black + size: 16 + }.to_vglyph_cfg() + }) + + assert w.renderers.len == 1 + r := w.renderers[0] + if r is DrawRect { + assert f32_are_close(r.x, 110) + assert f32_are_close(r.w, 10) + } else { + assert false, 'expected password selection to emit DrawRect' + } +} + +fn test_multiline_password_selection_uses_line_relative_mask_geometry() { + mut w := Window{} + shape := password_multiline_shape_with_second_line_origin() + line := shape.tc.vglyph_layout.lines[1] + + draw_text_selection(mut w, DrawTextSelectionParams{ + shape: shape + line: line + layout_x: 80 + draw_y: 12 + byte_beg: 4 + byte_end: 5 + password_mask: '**\n**' + text_cfg: TextStyle{ + color: black + size: 16 + }.to_vglyph_cfg() + }) + + assert w.renderers.len == 1 + r := w.renderers[0] + if r is DrawRect { + assert f32_are_close(r.x, 130) + assert f32_are_close(r.w, 10) + } else { + assert false, 'expected password selection to emit DrawRect' + } +} + +fn test_password_cursor_scroll_x_keeps_end_visible() { + mut text_shape := password_aligned_single_line_shape(.left, 200, 0, 100, 'ab') + text_shape.id_scroll_container = 77 + scroll_container := Layout{ + shape: &Shape{ + id_scroll: 77 + width: 100 + height: 20 + } + children: [ + Layout{ + shape: text_shape + }, + ] + } + mut w := Window{ + layout: Layout{ + shape: &Shape{} + children: [scroll_container] + } + } + + assert f32_are_close(cursor_pos_to_scroll_x(2, text_shape, mut w), -100) +} + +fn test_password_cursor_scroll_x_includes_nonzero_line_origin() { + mut text_shape := password_aligned_single_line_shape(.left, 230, 30, 100, 'ab') + text_shape.id_scroll_container = 77 + scroll_container := Layout{ + shape: &Shape{ + id_scroll: 77 + width: 100 + height: 20 + } + children: [ + Layout{ + shape: text_shape + }, + ] + } + mut w := Window{ + layout: Layout{ + shape: &Shape{} + children: [scroll_container] + } + } + + assert f32_are_close(cursor_pos_to_scroll_x(2, text_shape, mut w), -130) +} + // ------------------------------------ // ## 1. Test split_text (Core Utility) // ------------------------------------ diff --git a/_view_rtf_test.v b/_view_rtf_test.v index a746157..f5959ba 100644 --- a/_view_rtf_test.v +++ b/_view_rtf_test.v @@ -19,6 +19,44 @@ fn test_rtf_cfg_validation() { assert cfg.rich_text.runs.len == 1 } +fn test_text_style_to_vglyph_style_forwards_rise() { + style := TextStyle{ + family: 'Sans' + size: 16 + rise: 5 + } + vg_style := style.to_vglyph_style() + assert vg_style.rise == 5 +} + +fn test_markdown_superscript_survives_vglyph_bridge_with_rise() { + style := MarkdownStyle{} + vg_rt := markdown_to_rich_text('E=mc^2^', style).to_vglyph_rich_text() + sup_runs := vg_rt.runs.filter(it.text == '2') + assert sup_runs.len == 1 + + sup_style := sup_runs[0].style + assert sup_style.size < style.text.size + assert sup_style.rise > 0 + assert sup_style.features != unsafe { nil } + assert sup_style.features.opentype_features.len == 1 + assert sup_style.features.opentype_features[0].tag == 'sups' +} + +fn test_markdown_subscript_survives_vglyph_bridge_with_rise() { + style := MarkdownStyle{} + vg_rt := markdown_to_rich_text('H~2~O', style).to_vglyph_rich_text() + sub_runs := vg_rt.runs.filter(it.text == '2') + assert sub_runs.len == 1 + + sub_style := sub_runs[0].style + assert sub_style.size < style.text.size + assert sub_style.rise < 0 + assert sub_style.features != unsafe { nil } + assert sub_style.features.opentype_features.len == 1 + assert sub_style.features.opentype_features[0].tag == 'subs' +} + fn test_is_safe_url() { // Valid URLs assert is_safe_url('https://google.com') diff --git a/ime.v b/ime.v index a2e82ae..bb28eee 100644 --- a/ime.v +++ b/ime.v @@ -151,9 +151,10 @@ fn ime_get_offset(data voidptr) (f32, f32) { if data == unsafe { nil } { return 0, 0 } - w := unsafe { &Window(data) } + mut w := unsafe { &Window(data) } shape := ime_focused_text_shape(w) or { return 0, 0 } - return shape.x + shape.padding_left(), shape.y + shape.padding_top() + return shape.x + shape.padding_left() + text_layout_render_offset_x(&shape, mut w), shape.y + + shape.padding_top() } // ime_get_cursor_index returns the byte index of the cursor diff --git a/markdown_style.v b/markdown_style.v index ae4d39e..7ceb008 100644 --- a/markdown_style.v +++ b/markdown_style.v @@ -14,6 +14,10 @@ const md_subscript_features = &vglyph.FontFeatures{ opentype_features: [vglyph.FontFeature{'subs', 1}] } +const md_script_size_scale = f32(0.7) +const md_superscript_rise_em = f32(0.35) +const md_subscript_rise_em = f32(-0.2) + // markdown_to_blocks parses markdown source and returns styled blocks. fn markdown_to_blocks(source string, style MarkdownStyle) []MarkdownBlock { ast := markdown.parse_with_options(source, markdown.ParseOptions{ @@ -130,18 +134,22 @@ fn style_md_run(run markdown.MdRun, base_style TextStyle, style MarkdownStyle) R // Apply superscript if run.superscript { + base_size := s.size s = TextStyle{ ...s - size: s.size * 1.2 + size: base_size * md_script_size_scale + rise: s.rise + base_size * md_superscript_rise_em features: md_superscript_features } } // Apply subscript if run.subscript { + base_size := s.size s = TextStyle{ ...s - size: s.size * 1.2 + size: base_size * md_script_size_scale + rise: s.rise + base_size * md_subscript_rise_em features: md_subscript_features } } diff --git a/render_text.v b/render_text.v index 1788d6a..bc85875 100644 --- a/render_text.v +++ b/render_text.v @@ -210,7 +210,8 @@ fn render_text_try_transformed(mut shape Shape, style_state RenderTextStyleState } emit_renderer(DrawLayoutTransformed{ layout: layout_to_draw - x: shape.x + shape.padding_left() + x: shape.x + shape.padding_left() + + text_layout_render_offset_x(&shape, mut window) y: shape.y + shape.padding_top() transform: transform gradient: shape.tc.text_style.gradient @@ -222,7 +223,9 @@ fn render_text_try_transformed(mut shape Shape, style_state RenderTextStyleState fn render_text_layout_lines(mut shape Shape, clip DrawClip, style_state RenderTextStyleState, selection_state RenderTextSelectionState, mut window Window) { for line in shape.tc.vglyph_layout.lines { - draw_x := shape.x + shape.padding_left() + line.rect.x + layout_x := shape.x + shape.padding_left() + + text_line_render_offset_x(&shape, line, mut window) + draw_x := layout_x + line.rect.x draw_y := shape.y + shape.padding_top() + line.rect.y // Extract text for this line @@ -273,7 +276,7 @@ fn render_text_layout_lines(mut shape Shape, clip DrawClip, style_state RenderTe draw_text_selection(mut window, DrawTextSelectionParams{ shape: shape line: line - draw_x: draw_x + layout_x: layout_x draw_y: draw_y byte_beg: selection_state.byte_beg byte_end: selection_state.byte_end @@ -312,7 +315,8 @@ fn render_text(mut shape Shape, clip DrawClip, mut window Window) { && (style_state.color != color_transparent || style_state.has_stroke) { emit_renderer(DrawLayout{ layout: shape.tc.vglyph_layout - x: shape.x + shape.padding_left() + x: shape.x + shape.padding_left() + + text_layout_render_offset_x(&shape, mut window) y: shape.y + shape.padding_top() gradient: shape.tc.text_style.gradient }, mut window) @@ -326,7 +330,7 @@ fn render_text(mut shape Shape, clip DrawClip, mut window Window) { fn draw_text_selection(mut window Window, params DrawTextSelectionParams) { shape := params.shape line := params.line - draw_x := params.draw_x + layout_x := params.layout_x draw_y := params.draw_y byte_beg := params.byte_beg byte_end := params.byte_end @@ -339,16 +343,57 @@ fn draw_text_selection(mut window Window, params DrawTextSelectionParams) { if i_start < i_end { if shape.tc.text_is_password && password_mask.len > 0 { - // Password fields still need measurement because the rendered text (*) - // is different from the logical text. - pw_pre := password_mask_slice(password_mask, shape.tc.text, line.start_index, i_start) - start_x_offset := window.text_system.text_width(pw_pre, text_cfg) or { 0 } - - pw_sel := password_mask_slice(password_mask, shape.tc.text, i_start, i_end) - sel_width := window.text_system.text_width(pw_sel, text_cfg) or { 0 } + mut selection_x := layout_x + mut sel_width := f32(0) + if shape.tc.text_mode == .single_line { + // Single-line password fields use cursor helpers so alignment, + // masking, and nonzero line origins match caret geometry. + start_cursor := byte_to_rune_index(shape.tc.text, i_start) + end_cursor := byte_to_rune_index(shape.tc.text, i_end) + selection_x += text_password_cursor_x(shape, start_cursor, mut window) + sel_width = text_password_range_width(shape, start_cursor, end_cursor, mut window) + } else { + prefix_mask := password_mask_slice(password_mask, shape.tc.text, line.start_index, + i_start) + selected_mask := password_mask_slice(password_mask, shape.tc.text, i_start, i_end) + mut prefix_width := text_width(prefix_mask, shape.tc.text_style, mut window) + mut selected_width := text_width(selected_mask, shape.tc.text_style, mut window) + r_start := shape.tc.vglyph_layout.get_char_rect(i_start) or { + gg.Rect{ + x: line.rect.x + } + } + mut start_x := r_start.x + if start_x >= line.rect.x { + start_x -= line.rect.x + } + if prefix_width <= 0 { + prefix_width = start_x + } + if selected_width <= 0 { + x_end := if i_end < (line.start_index + line.length) + && shape.tc.text[i_end] != `\n` { + r_end := shape.tc.vglyph_layout.get_char_rect(i_end) or { + gg.Rect{ + x: line.rect.x + line.rect.width + } + } + mut end_x := r_end.x + if end_x >= line.rect.x { + end_x -= line.rect.x + } + end_x + } else { + line.rect.width + } + selected_width = f32_max(0, x_end - start_x) + } + selection_x += line.rect.x + prefix_width + sel_width = selected_width + } emit_renderer(DrawRect{ - x: draw_x + start_x_offset + x: selection_x y: draw_y w: sel_width h: line.rect.height @@ -366,19 +411,19 @@ fn draw_text_selection(mut window Window, params DrawTextSelectionParams) { x_end := if i_end < (line.start_index + line.length) && shape.tc.text[i_end] != `\n` { r_end := shape.tc.vglyph_layout.get_char_rect(i_end) or { gg.Rect{ - x: line.rect.width + x: line.rect.x + line.rect.width } } r_end.x } else { // End of selection is end of line (or newline) - line.rect.width + line.rect.x + line.rect.width } sel_width := x_end - r_start.x emit_renderer(DrawRect{ - x: draw_x + r_start.x + x: layout_x + r_start.x y: draw_y w: sel_width h: line.rect.height @@ -408,32 +453,9 @@ fn render_cursor(shape &Shape, _ DrawClip, mut window Window) { } if cursor_pos >= 0 { - byte_idx := rune_to_byte_index(shape.tc.text, cursor_pos) - - // Use vglyph to get the rect - rect := if shape.has_text_layout() { - shape.tc.vglyph_layout.get_char_rect(byte_idx) or { - // If not found, check if it's at the very end - if byte_idx >= shape.tc.text.len && shape.tc.vglyph_layout.lines.len > 0 { - last_line := shape.tc.vglyph_layout.lines.last() - // Correction: use layout logic relative to shape - gg.Rect{ - x: last_line.rect.x + last_line.rect.width - y: last_line.rect.y - height: last_line.rect.height - } - } else { - gg.Rect{ - height: line_height(shape, mut window) - } // Fallback - } - } - } else { - gg.Rect{ - height: line_height(shape, mut window) - } - } - cx := shape.x + shape.padding_left() + rect.x + rect := text_cursor_rect_for_position(shape, cursor_pos, mut window) + cx := shape.x + shape.padding_left() + text_layout_render_offset_x(shape, mut window) + + rect.x cy := shape.y + shape.padding_top() + rect.y ch := rect.height @@ -479,7 +501,7 @@ fn render_composition(shape &Shape, mut window Window) { a: 178 // ~70% opacity } - ox := shape.x + shape.padding_left() + ox := shape.x + shape.padding_left() + text_layout_render_offset_x(shape, mut window) oy := shape.y + shape.padding_top() for cr in clause_rects { diff --git a/render_types.v b/render_types.v index fee2be1..2ad3e6a 100644 --- a/render_types.v +++ b/render_types.v @@ -189,7 +189,7 @@ type Renderer = DrawCircle struct DrawTextSelectionParams { shape &Shape line vglyph.Line - draw_x f32 + layout_x f32 draw_y f32 byte_beg int byte_end int diff --git a/shape.v b/shape.v index f3c1b67..c612b6a 100644 --- a/shape.v +++ b/shape.v @@ -84,6 +84,8 @@ pub mut: text_sel_beg u32 text_sel_end u32 text_tab_size u32 = 4 + text_scroll_x f32 + text_scroll_key string text_is_password bool text_is_placeholder bool hanging_indent f32 diff --git a/styles.v b/styles.v index 6fadbba..f6635c6 100644 --- a/styles.v +++ b/styles.v @@ -508,6 +508,7 @@ pub: typeface vglyph.Typeface // .regular, .bold, .italic, .bold_italic line_spacing f32 letter_spacing f32 + rise f32 align TextAlignment = .left underline bool strikethrough bool @@ -539,6 +540,7 @@ pub fn (ts TextStyle) to_vglyph_cfg() vglyph.TextConfig { strikethrough: ts.strikethrough typeface: ts.typeface letter_spacing: ts.letter_spacing + rise: ts.rise stroke_width: ts.stroke_width stroke_color: ts.stroke_color.to_gx_color() } diff --git a/view_input.v b/view_input.v index 923a4cb..25febcb 100644 --- a/view_input.v +++ b/view_input.v @@ -14,6 +14,7 @@ import arrays import vglyph const input_max_insert_runes = 65_536 +const ns_input_private_scroll_x = 'gui.input.private_scroll.x' // InputState manages focus and input states. The window maintains this state // in a map keyed by w.view_state.id_focus. This state map is cleared when a @@ -34,6 +35,8 @@ pub: // last_click_frame records the frame of the last click for double-click // detection. last_click_frame u64 + // reveal_cursor defers caret scrolling until a fresh post-edit layout exists. + reveal_cursor bool } // InputMemento stores a snapshot of the input state for undo/redo @@ -131,6 +134,8 @@ struct InputRuntimeCfg { form_validate_on FormValidateOn = .inherit form_initial_value ?string text_style TextStyle + private_scroll_key string + text_scroll_id u32 width f32 id_focus u32 padding Padding @@ -158,6 +163,8 @@ fn input_runtime_cfg(cfg InputCfg) InputRuntimeCfg { form_validate_on: cfg.form_validate_on form_initial_value: cfg.form_initial_value text_style: cfg.text_style + private_scroll_key: input_private_scroll_key(cfg) + text_scroll_id: input_text_scroll_id(cfg) width: cfg.width id_focus: cfg.id_focus padding: cfg.padding @@ -293,6 +300,7 @@ pub fn input(cfg InputCfg) View { txt := if placeholder_active { cfg.placeholder } else { cfg.text } txt_style := if placeholder_active { cfg.placeholder_style } else { cfg.text_style } mode := if cfg.mode == .single_line { TextMode.single_line } else { TextMode.wrap_keep_spaces } + text_scroll_id := input_text_scroll_id(cfg) // Capture values needed for callbacks by copy to avoid dangling reference to cfg color_border_focus := cfg.color_border_focus @@ -300,28 +308,54 @@ pub fn input(cfg InputCfg) View { id_focus := cfg.id_focus on_click_icon := cfg.on_click_icon runtime_cfg := input_runtime_cfg(cfg) + mut root_scrollbar_cfg_x := &ScrollbarCfg(unsafe { nil }) + mut root_scrollbar_cfg_y := &ScrollbarCfg(unsafe { nil }) + if cfg.mode == .multiline { + root_scrollbar_cfg_x = cfg.scrollbar_cfg_x + root_scrollbar_cfg_y = cfg.scrollbar_cfg_y + } + mut text_scrollbar_cfg_x := input_hidden_scrollbar_cfg() + mut text_scrollbar_cfg_y := input_hidden_scrollbar_cfg() + if text_scroll_id > 0 { + text_scrollbar_cfg_x = cfg.scrollbar_cfg_x + text_scrollbar_cfg_y = cfg.scrollbar_cfg_y + } - mut txt_content := [ - text( - id_focus: cfg.id_focus - sizing: fill_fill - text: txt - text_style: txt_style - mode: mode - is_password: cfg.is_password - placeholder_active: placeholder_active - on_key_down_hook: cfg.on_key_down - on_mouse_scroll_hook: cfg.on_mouse_scroll - ), - ] + txt_view := text( + id_focus: cfg.id_focus + sizing: fill_fill + text: txt + text_style: txt_style + text_scroll_key: if cfg.mode == .single_line && text_scroll_id == 0 { + runtime_cfg.private_scroll_key + } else { + '' + } + mode: mode + is_password: cfg.is_password + placeholder_active: placeholder_active + on_key_down_hook: cfg.on_key_down + on_mouse_scroll_hook: cfg.on_mouse_scroll + ) + mut txt_content := []View{cap: 2} + if cfg.mode == .single_line { + txt_content << row( + name: 'input text viewport' + id_scroll: text_scroll_id + scroll_mode: .horizontal_only + scrollbar_cfg_x: text_scrollbar_cfg_x + scrollbar_cfg_y: text_scrollbar_cfg_y + padding: padding_none + clip: true + sizing: fill_fill + content: [txt_view] + ) + } else { + txt_content << txt_view + } if cfg.icon.len > 0 { txt_content << [ - rectangle( - color: color_transparent - color_border: color_transparent - sizing: fill_fill - ), row( name: 'input icon' padding: padding_none @@ -389,28 +423,32 @@ pub fn input(cfg InputCfg) View { } amend_layout: fn [color_border_focus, runtime_cfg] (mut layout Layout, mut w Window) { runtime_cfg.form_register(layout, mut w) - if layout.shape.id_focus == 0 { - return - } - focused := !layout.shape.disabled && layout.shape.id_focus == w.id_focus() - was_focused := state_map[u32, bool](mut w, ns_input_focus, cap_many).get(layout.shape.id_focus) or { - false - } - if was_focused && !focused { - runtime_cfg.commit_text(layout, .blur, mut w) - if runtime_cfg.on_blur != unsafe { nil } { - runtime_cfg.on_blur(layout, mut w) + mut focused := false + if layout.shape.id_focus > 0 { + focused = !layout.shape.disabled && layout.shape.id_focus == w.id_focus() + was_focused := state_map[u32, bool](mut w, ns_input_focus, cap_many).get(layout.shape.id_focus) or { + false + } + if was_focused && !focused { + runtime_cfg.commit_text(layout, .blur, mut w) + if runtime_cfg.on_blur != unsafe { nil } { + runtime_cfg.on_blur(layout, mut w) + } + } + if focused && !was_focused { + input_mark_cursor_reveal(layout.shape.id_focus, mut w) + } + mut ifs := state_map[u32, bool](mut w, ns_input_focus, cap_many) + ifs.set(layout.shape.id_focus, focused) + if focused { + layout.shape.color_border = color_border_focus } } - mut ifs := state_map[u32, bool](mut w, ns_input_focus, cap_many) - ifs.set(layout.shape.id_focus, focused) - if focused { - layout.shape.color_border = color_border_focus - } + input_apply_post_layout_scroll(mut layout, mut w, runtime_cfg, focused) } - id_scroll: cfg.id_scroll - scrollbar_cfg_x: cfg.scrollbar_cfg_x - scrollbar_cfg_y: cfg.scrollbar_cfg_y + id_scroll: if cfg.mode == .multiline { cfg.id_scroll } else { u32(0) } + scrollbar_cfg_x: root_scrollbar_cfg_x + scrollbar_cfg_y: root_scrollbar_cfg_y spacing: 0 content: [ row( @@ -419,11 +457,10 @@ pub fn input(cfg InputCfg) View { sizing: fill_fill v_align: if cfg.mode == .single_line { .middle } else { .top } on_click: fn (layout &Layout, mut e Event, mut w Window) { - if layout.children.len < 1 { - return - } - ly := layout.children[0] - if ly.shape.id_focus > 0 { + if ly := layout.find_layout(fn (ly Layout) bool { + return ly.shape.id_focus > 0 && ly.shape.shape_type == .text + }) + { w.set_id_focus(ly.shape.id_focus) } } @@ -433,6 +470,32 @@ pub fn input(cfg InputCfg) View { ) } +fn input_hidden_scrollbar_cfg() &ScrollbarCfg { + return &ScrollbarCfg{ + overflow: .hidden + } +} + +fn input_text_scroll_id(cfg InputCfg) u32 { + if cfg.mode == .single_line && cfg.id_scroll > 0 { + return cfg.id_scroll + } + return 0 +} + +fn input_private_scroll_key(cfg InputCfg) string { + if cfg.id.len > 0 { + return 'id:${cfg.id}' + } + if cfg.field_id.len > 0 { + return 'field:${cfg.field_id}' + } + if cfg.id_focus > 0 { + return 'focus:${cfg.id_focus}' + } + return '' +} + fn (cfg &InputCfg) active_mask_pattern() string { if cfg.mask.len > 0 { return cfg.mask @@ -518,14 +581,343 @@ fn input_state_from_memento(memento InputMemento, undo BoundedStack[InputMemento } } -fn (cfg &InputCfg) exceeds_single_line_fixed_width(next_text string, mut w Window) bool { - if cfg.mode != .single_line || cfg.sizing.width != .fixed { +fn input_state_with_reveal(input_state InputState, reveal bool) InputState { + return InputState{ + ...input_state + reveal_cursor: reveal + } +} + +fn input_mark_cursor_reveal(id_focus u32, mut w Window) { + if id_focus == 0 { + return + } + mut imap := state_map[u32, InputState](mut w, ns_input, cap_many) + input_state := imap.get(id_focus) or { InputState{} } + imap.set(id_focus, input_state_with_reveal(input_state, true)) +} + +fn input_apply_post_layout_scroll(mut layout Layout, mut w Window, cfg InputRuntimeCfg, focused bool) { + id_focus := layout.shape.id_focus + text_layout := input_find_text_layout(layout, id_focus) or { return } + if cfg.mode == .single_line && cfg.text_scroll_id == 0 { + mut reveal_cursor := false + mut reveal_cursor_pos := 0 + if id_focus > 0 { + input_state := state_map[u32, InputState](mut w, ns_input, cap_many).get(id_focus) or { + InputState{} + } + reveal_cursor = input_state.reveal_cursor + reveal_cursor_pos = input_state.cursor_pos + } + mut changed := false + if private_changed := input_apply_private_text_scroll_after_layout(text_layout, mut layout, mut + w, cfg.private_scroll_key, focused) + { + changed = private_changed + } + if reveal_cursor { + if vertical_changed := input_scroll_cursor_vertical_into_view_in_layout(reveal_cursor_pos, + text_layout, mut layout, mut w) + { + changed = changed || vertical_changed + } + } + if changed { + w.update_window() + } + return + } + if id_focus > 0 { + input_state := state_map[u32, InputState](mut w, ns_input, cap_many).get(id_focus) or { + InputState{} + } + if input_state.reveal_cursor { + changed := input_scroll_cursor_into_view_in_layout(input_state.cursor_pos, text_layout, mut + layout, mut w, cfg.mode) or { return } + mut imap := state_map[u32, InputState](mut w, ns_input, cap_many) + imap.set(id_focus, input_state_with_reveal(input_state, false)) + if changed { + w.update_window() + } + return + } + } + if cfg.mode == .single_line && !focused + && input_apply_rest_alignment_scroll(text_layout, mut layout, mut w) { + w.update_window() + } +} + +fn input_find_text_layout(layout &Layout, id_focus u32) ?Layout { + if id_focus > 0 { + return layout.find_layout(fn [id_focus] (ly Layout) bool { + return ly.shape.id_focus == id_focus && ly.shape.shape_type == .text + }) + } + return layout.find_layout(fn (ly Layout) bool { + return ly.shape.shape_type == .text && ly.shape.tc != unsafe { nil } + }) +} + +fn input_apply_private_text_scroll_after_layout(text_layout Layout, mut root Layout, mut w Window, scroll_key string, focused bool) ?bool { + shape := text_layout.shape + if shape.tc == unsafe { nil } || shape.tc.text_mode != .single_line { + return none + } + mut current_x := shape.tc.text_scroll_x + if scroll_key.len > 0 { + current_x = state_map[string, f32](mut w, ns_input_private_scroll_x, cap_scroll).get(scroll_key) or { + shape.tc.text_scroll_x + } + } + mut target_x := current_x + mut clear_reveal := false + if root.shape.id_focus > 0 { + input_state := state_map[u32, InputState](mut w, ns_input, cap_many).get(root.shape.id_focus) or { + InputState{} + } + if input_state.reveal_cursor { + target_x = text_private_cursor_scroll_x(input_state.cursor_pos, &text_layout, + current_x, mut w) or { return none } + clear_reveal = true + } else if !focused { + target_x = input_private_rest_scroll_x(text_layout, current_x, mut w) + } + } else { + target_x = input_private_rest_scroll_x(text_layout, current_x, mut w) + } + viewport := text_private_scroll_viewport(&text_layout) or { return none } + target_x = text_private_clamp_scroll_x(target_x, &text_layout, viewport, mut w) + + mut changed := false + if !f32_are_close(target_x, current_x) { + if scroll_key.len > 0 { + mut sx := state_map[string, f32](mut w, ns_input_private_scroll_x, cap_scroll) + sx.set(scroll_key, target_x) + changed = true + } + } + if clear_reveal { + input_state := state_map[u32, InputState](mut w, ns_input, cap_many).get(root.shape.id_focus) or { + InputState{} + } + mut imap := state_map[u32, InputState](mut w, ns_input, cap_many) + imap.set(root.shape.id_focus, input_state_with_reveal(input_state, false)) + } + input_set_text_scroll_x(mut root, root.shape.id_focus, target_x) + return changed +} + +fn input_private_rest_scroll_x(text_layout Layout, current_x f32, mut w Window) f32 { + shape := text_layout.shape + if shape.tc.text_style.align == .left { + return 0 + } + viewport := text_private_scroll_viewport(&text_layout) or { return current_x } + max_offset := text_private_scroll_max_x(&text_layout, viewport, mut w) + if f32_are_close(max_offset, 0) { + return 0 + } + if shape.tc.text_style.align == .right { + return max_offset + } + return 0 +} + +fn input_scroll_cursor_into_view_in_layout(cursor_pos int, text_layout Layout, mut root Layout, mut w Window, mode InputMode) ?bool { + shape := text_layout.shape + id_scroll_container := shape.id_scroll_container + if id_scroll_container == 0 { + return none + } + mut changed := false + current_x := state_map[u32, f32](mut w, ns_scroll_x, cap_scroll).get(id_scroll_container) or { + f32(0) + } + target_x := cursor_pos_to_scroll_x_in_layout(cursor_pos, shape, root, mut w) + if target_x == -1 { + return none + } + if target_x != -1 && !f32_are_close(target_x, current_x) { + mut sx := state_map[u32, f32](mut w, ns_scroll_x, cap_scroll) + sx.set(id_scroll_container, target_x) + if mode == .single_line { + input_shift_explicit_scroll_contents(mut root, id_scroll_container, + target_x - current_x, 0) + } + changed = true + } + if mode == .single_line { + return changed + } + current_y := state_map[u32, f32](mut w, ns_scroll_y, cap_scroll).get(id_scroll_container) or { + f32(0) + } + target_y := cursor_pos_to_scroll_y_in_layout(cursor_pos, shape, root, mut w) + if target_y == -1 { + return none + } + if target_y != -1 && !f32_are_close(target_y, current_y) { + mut sy := state_map[u32, f32](mut w, ns_scroll_y, cap_scroll) + sy.set(id_scroll_container, target_y) + changed = true + } + return changed +} + +fn input_scroll_cursor_vertical_into_view_in_layout(cursor_pos int, text_layout Layout, mut root Layout, mut w Window) ?bool { + shape := text_layout.shape + id_scroll_container := shape.id_scroll_container + if id_scroll_container == 0 { + return none + } + current_y := state_map[u32, f32](mut w, ns_scroll_y, cap_scroll).get(id_scroll_container) or { + f32(0) + } + scroll_container := input_scroll_container_for_text(text_layout, root, id_scroll_container) or { + return none + } + target_y := input_cursor_pos_to_scroll_y_in_container(cursor_pos, shape, scroll_container, + id_scroll_container, mut w) + if target_y == -1 { + return none + } + if f32_are_close(target_y, current_y) { return false } - ctx := w.ui - ctx.set_text_cfg(cfg.text_style.to_text_cfg()) - width := ctx.text_width(next_text) - return width > cfg.width - cfg.padding.width() - (cfg.size_border * 2) + mut sy := state_map[u32, f32](mut w, ns_scroll_y, cap_scroll) + sy.set(id_scroll_container, target_y) + return true +} + +fn input_scroll_container_for_text(text_layout Layout, root &Layout, id_scroll_container u32) ?Layout { + if scroll_container := find_layout_by_id_scroll(root, id_scroll_container) { + return scroll_container + } + mut parent := text_layout.parent + for parent != unsafe { nil } { + if parent.shape.id_scroll == id_scroll_container { + return *parent + } + parent = parent.parent + } + parent = root.parent + for parent != unsafe { nil } { + if parent.shape.id_scroll == id_scroll_container { + return *parent + } + parent = parent.parent + } + return none +} + +fn input_cursor_pos_to_scroll_y_in_container(cursor_pos int, shape &Shape, scroll_container Layout, id_scroll_container u32, mut w Window) f32 { + scroll_view_height := scroll_container.shape.height - scroll_container.shape.padding_height() + byte_idx := rune_to_byte_index(shape.tc.text, cursor_pos) + if !shape.has_text_layout() { + return -1 + } + rect := shape.tc.vglyph_layout.get_char_rect(byte_idx) or { + if byte_idx <= 0 { + return -1 + } + shape.tc.vglyph_layout.get_char_rect(byte_idx - 1) or { return -1 } + } + + current_scroll_y := state_map[u32, f32](mut w, ns_scroll_y, cap_scroll).get(id_scroll_container) or { + f32(0) + } + + shape_y_in_content := shape.y - current_scroll_y - scroll_container.shape.y + padding_top := scroll_container.shape.padding_top() + cursor_top := shape_y_in_content - padding_top + rect.y + cursor_bottom := cursor_top + rect.height + + view_top := -current_scroll_y + view_bottom := view_top + scroll_view_height + + mut target_scroll := current_scroll_y + if cursor_top < view_top { + target_scroll = -cursor_top + } else if cursor_bottom > view_bottom { + target_scroll = -(cursor_bottom - scroll_view_height) + } + return target_scroll +} + +fn input_apply_rest_alignment_scroll(text_layout Layout, mut root Layout, mut w Window) bool { + shape := text_layout.shape + if shape.tc.text_style.align == .left || shape.id_scroll_container == 0 { + return false + } + scroll_container := find_layout_by_id_scroll(root, shape.id_scroll_container) or { + return false + } + max_offset := f32_min(0, scroll_container.shape.width - scroll_container.shape.padding_width() - + content_width(scroll_container)) + if f32_are_close(max_offset, 0) { + return false + } + target_x := match shape.tc.text_style.align { + .right { max_offset } + else { f32(0) } + } + + current_x := state_map[u32, f32](mut w, ns_scroll_x, cap_scroll).get(shape.id_scroll_container) or { + f32(0) + } + if f32_are_close(target_x, current_x) { + return false + } + mut sx := state_map[u32, f32](mut w, ns_scroll_x, cap_scroll) + sx.set(shape.id_scroll_container, target_x) + input_shift_explicit_scroll_contents(mut root, shape.id_scroll_container, target_x - current_x, + 0) + return true +} + +fn input_shift_explicit_scroll_contents(mut layout Layout, id_scroll u32, dx f32, dy f32) bool { + if layout.shape.id_scroll == id_scroll { + for mut child in layout.children { + if child.shape.over_draw || child.shape.scrollbar_orientation != .none { + continue + } + input_shift_layout_tree(mut child, dx, dy) + } + return true + } + for mut child in layout.children { + if input_shift_explicit_scroll_contents(mut child, id_scroll, dx, dy) { + return true + } + } + return false +} + +fn input_set_text_scroll_x(mut layout Layout, id_focus u32, scroll_x f32) bool { + if layout.shape.shape_type == .text && (id_focus == 0 || layout.shape.id_focus == id_focus) { + if layout.shape.tc != unsafe { nil } { + layout.shape.tc.text_scroll_x = scroll_x + } + return true + } + for mut child in layout.children { + if input_set_text_scroll_x(mut child, id_focus, scroll_x) { + return true + } + } + return false +} + +fn input_shift_layout_tree(mut layout Layout, dx f32, dy f32) { + layout.shape.x += dx + layout.shape.y += dy + layout.shape.shape_clip.x += dx + layout.shape.shape_clip.y += dy + for mut child in layout.children { + input_shift_layout_tree(mut child, dx, dy) + } } fn (cfg &InputCfg) apply_text_edit(input_state InputState, text string, cursor_pos int, mut w Window) string { @@ -547,6 +939,7 @@ fn (cfg &InputCfg) apply_text_edit(input_state InputState, text string, cursor_p select_end: 0 undo: undo cursor_offset: -1 // view_text.v-on_key_down-up/down handler tests for < 0 + reveal_cursor: true }) w.view_state.input_cursor_on = true w.view_state.cursor_on_sticky = true @@ -583,9 +976,6 @@ fn (cfg &InputCfg) masked_insert(s string, mut w Window, compiled CompiledInputM if !res.changed { return cfg.text } - if cfg.exceeds_single_line_fixed_width(res.text, mut w) { - return cfg.text - } return cfg.apply_text_edit(input_state, res.text, res.cursor_pos, mut w) } @@ -645,8 +1035,7 @@ fn (cfg &InputCfg) delete(mut w Window, forward_delete bool) ?string { return cfg.apply_text_edit(input_state, runes.string(), cursor_pos, mut w) } -// insert adds text at the cursor or replaces selection. For single-line -// fixed-width inputs, it validates width constraints. Saves state to undo +// insert adds text at the cursor or replaces selection. Saves state to undo // stack before modification. Returns modified text or error. fn (cfg &InputCfg) insert(insert_text string, mut w Window) !string { if insert_text.len == 0 { @@ -660,10 +1049,6 @@ fn (cfg &InputCfg) insert(insert_text string, mut w Window) !string { log.warn('input insert exceeds ${input_max_insert_runes} runes; truncating') insert_runes = insert_runes[..input_max_insert_runes].clone() } - insert_value := insert_runes.string() - if cfg.exceeds_single_line_fixed_width(cfg.text + insert_value, mut w) { - return cfg.text - } mut runes := cfg.text.runes() input_state := input_state_or_default(cfg.id_focus, mut w) mut cursor_pos := int_min(input_state.cursor_pos, runes.len) @@ -734,7 +1119,8 @@ pub fn (cfg &InputCfg) undo(mut w Window) string { memento := undo.pop() or { return cfg.text } mut redo := input_state.redo redo = input_push_memento(mut redo, cfg.text, input_state) - imap.set(cfg.id_focus, input_state_from_memento(memento, undo, redo)) + imap.set(cfg.id_focus, input_state_with_reveal(input_state_from_memento(memento, undo, redo), + true)) return memento.text } @@ -747,7 +1133,8 @@ pub fn (cfg &InputCfg) redo(mut w Window) string { memento := redo.pop() or { return cfg.text } mut undo := input_state.undo undo = input_push_memento(mut undo, cfg.text, input_state) - imap.set(cfg.id_focus, input_state_from_memento(memento, undo, redo)) + imap.set(cfg.id_focus, input_state_with_reveal(input_state_from_memento(memento, undo, redo), + true)) return memento.text } diff --git a/view_rtf_xtra.v b/view_rtf_xtra.v index 25eeabb..995cc5a 100644 --- a/view_rtf_xtra.v +++ b/view_rtf_xtra.v @@ -134,6 +134,7 @@ pub fn (ts TextStyle) to_vglyph_style() vglyph.TextStyle { color: ts.color.to_gx_color() bg_color: ts.bg_color.to_gx_color() size: ts.size + rise: ts.rise features: ts.features underline: ts.underline strikethrough: ts.strikethrough diff --git a/view_text.v b/view_text.v index 55cf675..4b7202f 100644 --- a/view_text.v +++ b/view_text.v @@ -84,6 +84,8 @@ fn (mut tv TextView) generate_layout(mut window Window) Layout { text_sel_beg: input_state.select_beg text_sel_end: input_state.select_end text_tab_size: tv.tab_size + text_scroll_x: tv.text_scroll_x_for_window(mut window) + text_scroll_key: tv.text_scroll_key } } } @@ -92,7 +94,8 @@ fn (mut tv TextView) generate_layout(mut window Window) Layout { // This provides the "intrinsic width" (single line) which is essential for .fit containers (like Menus). // The main layout pipeline will handle wrapping constraints later if needed. // We use `text_width` which enables `no_hit_testing`, ensuring this is fast. - layout.shape.width = text_width(tv.text, tv.text_style, mut window) + layout.shape.width = text_width(text_display_text(tv.text, tv.is_password, + tv.placeholder_active), tv.text_style, mut window) layout.shape.height = line_height(layout.shape, mut window) if tv.mode == .single_line || layout.shape.sizing.width == .fixed { @@ -115,11 +118,13 @@ fn (mut tv TextView) generate_layout(mut window Window) Layout { @[minify] pub struct TextCfg { A11yCfg - sizing Sizing + sizing Sizing + text_scroll_key string pub: id string text string text_style TextStyle = gui_theme.text_style + text_scroll_x f32 id_focus u32 tab_size u32 = 4 min_width f32 @@ -144,10 +149,13 @@ pub fn text(cfg TextCfg) View { if cfg.invisible { return invisible_container_view() } + sizing := text_cfg_sizing(cfg) return TextView{ id: cfg.id text: cfg.text text_style: cfg.text_style + text_scroll_x: cfg.text_scroll_x + text_scroll_key: cfg.text_scroll_key id_focus: cfg.id_focus tab_size: cfg.tab_size min_width: cfg.min_width @@ -163,7 +171,23 @@ pub fn text(cfg TextCfg) View { on_char_hook: cfg.on_char_hook on_key_down_hook: cfg.on_key_down_hook on_mouse_scroll_hook: cfg.on_mouse_scroll_hook - sizing: if cfg.mode in [.wrap, .wrap_keep_spaces] { fill_fit } else { fit_fit } + sizing: sizing + } +} + +fn text_cfg_sizing(cfg TextCfg) Sizing { + if cfg.sizing.width != .fit || cfg.sizing.height != .fit { + return cfg.sizing + } + return if cfg.mode in [.wrap, .wrap_keep_spaces] { fill_fit } else { fit_fit } +} + +fn (tv &TextView) text_scroll_x_for_window(mut window Window) f32 { + if tv.text_scroll_key.len == 0 { + return tv.text_scroll_x + } + return state_map[string, f32](mut window, ns_input_private_scroll_x, cap_scroll).get(tv.text_scroll_key) or { + tv.text_scroll_x } } diff --git a/view_text_cursor.v b/view_text_cursor.v index 22a24bb..c1bd717 100644 --- a/view_text_cursor.v +++ b/view_text_cursor.v @@ -253,51 +253,96 @@ fn get_cursor_column(shape Shape, cursor_pos int) int { // cursor_position_from_offset finds the character index (rune position) in a string // that corresponds to the given horizontal pixel offset. It calculates the rendered -// width of text up to each character position and returns the index closest to the -// offset. If the offset is beyond the end of the string, it returns the last valid -// character position. +// width of text up to each character position and returns the insertion index +// closest to the offset. If the offset is beyond the end of the string, it returns +// the end position. fn cursor_position_from_offset(str string, offset f32, style TextStyle, mut window Window) int { rune_str := str.runes() - for idx in 1 .. rune_str.len { - width := text_width(rune_str[0..idx].string(), style, mut window) - if width > offset { - char_width := text_width(rune_str[idx].str(), style, mut window) - return if offset - char_width > width { idx } else { idx - 1 } + if rune_str.len == 0 || offset <= 0 { + return 0 + } + mut prev_width := f32(0) + for idx in 0 .. rune_str.len { + width := text_width(rune_str[..idx + 1].string(), style, mut window) + mid := prev_width + ((width - prev_width) / 2) + if offset < mid { + return idx } + prev_width = width } - return int_max(0, rune_str.len - 1) + return rune_str.len } -// offset_from_cursor_position returns the horizontal pixel offset of the cursor -// position using vglyph geometry. -fn offset_from_cursor_position(shape Shape, cursor_position int, mut _ Window) f32 { - byte_idx := rune_to_byte_index(shape.tc.text, cursor_position) - if !shape.has_text_layout() { - return 0 +fn text_password_cursor_pos_from_offset(shape &Shape, offset f32, y f32, mut window Window) int { + if text_width(text_shape_display_text(shape), shape.tc.text_style, mut window) > 0 { + return cursor_position_from_offset(text_shape_display_text(shape), text_password_mask_offset_x(shape, + offset), shape.tc.text_style, mut window) } - rect := shape.tc.vglyph_layout.get_char_rect(byte_idx) or { - // If at end, maybe get end of last line? - if shape.tc.vglyph_layout.lines.len > 0 { - for line in shape.tc.vglyph_layout.lines { - end := line.start_index + line.length - if byte_idx == end { - return line.rect.x + line.rect.width + if shape.has_text_layout() { + byte_idx := shape.tc.vglyph_layout.get_closest_offset(offset, y) + return byte_to_rune_index(shape.tc.text, byte_idx) + } + return cursor_position_from_offset(text_shape_display_text(shape), text_password_mask_offset_x(shape, + offset), shape.tc.text_style, mut window) +} + +fn text_cursor_rect_for_position(shape &Shape, cursor_position int, mut window Window) gg.Rect { + if text_shape_uses_password_display(shape) && shape.tc.text_mode == .single_line { + if shape.has_text_layout() && shape.tc.vglyph_layout.lines.len > 0 { + line := shape.tc.vglyph_layout.lines[0] + return gg.Rect{ + x: text_password_cursor_x(shape, cursor_position, mut window) + y: line.rect.y + height: line.rect.height + } + } + return gg.Rect{ + x: text_password_cursor_x(shape, cursor_position, mut window) + height: line_height(shape, mut window) + } + } + + byte_idx := rune_to_byte_index(shape.tc.text, cursor_position) + if shape.has_text_layout() { + return shape.tc.vglyph_layout.get_char_rect(byte_idx) or { + if byte_idx >= shape.tc.text.len && shape.tc.vglyph_layout.lines.len > 0 { + last_line := shape.tc.vglyph_layout.lines.last() + gg.Rect{ + x: last_line.rect.x + last_line.rect.width + y: last_line.rect.y + height: last_line.rect.height + } + } else { + gg.Rect{ + height: line_height(shape, mut window) } } } - return 0 } - return rect.x + return gg.Rect{ + height: line_height(shape, mut window) + } +} + +// offset_from_cursor_position returns the horizontal pixel offset of the cursor +// position using vglyph geometry. +fn offset_from_cursor_position(shape Shape, cursor_position int, mut window Window) f32 { + return text_cursor_rect_for_position(&shape, cursor_position, mut window).x } // cursor_pos_to_scroll_y calculates the vertical scroll offset using vglyph geometry. fn cursor_pos_to_scroll_y(cursor_pos int, shape &Shape, mut w Window) f32 { + layout := w.layout + return cursor_pos_to_scroll_y_in_layout(cursor_pos, shape, layout, mut w) +} + +fn cursor_pos_to_scroll_y_in_layout(cursor_pos int, shape &Shape, root &Layout, mut w Window) f32 { id_scroll_container := shape.id_scroll_container if id_scroll_container == 0 { return 0 } - scroll_container := find_layout_by_id_scroll(w.layout, id_scroll_container) or { return -1 } - scroll_view_height := scroll_container.shape.height - scroll_container.shape.padding.height() + scroll_container := find_layout_by_id_scroll(root, id_scroll_container) or { return -1 } + scroll_view_height := scroll_container.shape.height - scroll_container.shape.padding_height() byte_idx := rune_to_byte_index(shape.tc.text, cursor_pos) if !shape.has_text_layout() { @@ -334,37 +379,315 @@ fn cursor_pos_to_scroll_y(cursor_pos int, shape &Shape, mut w Window) f32 { return target_scroll } -fn cursor_pos_to_scroll_x(_cursor_pos int, _shape &Shape, mut _w Window) f32 { - return 0 +fn cursor_pos_to_scroll_x(cursor_pos int, shape &Shape, mut w Window) f32 { + layout := w.layout + return cursor_pos_to_scroll_x_in_layout(cursor_pos, shape, layout, mut w) +} + +fn cursor_pos_to_scroll_x_in_layout(cursor_pos int, shape &Shape, root &Layout, mut w Window) f32 { + id_scroll_container := shape.id_scroll_container + if id_scroll_container == 0 { + return 0 + } + scroll_container := find_layout_by_id_scroll(root, id_scroll_container) or { return -1 } + scroll_view_width := scroll_container.shape.width - scroll_container.shape.padding_width() + + if !shape.has_text_layout() { + return -1 + } + if scroll_view_width <= 0 { + return 0 + } + + rect := text_cursor_rect_for_position(shape, cursor_pos, mut w) + current_scroll_x := state_map[u32, f32](mut w, ns_scroll_x, cap_scroll).get(id_scroll_container) or { + f32(0) + } + + shape_x_in_content := shape.x - current_scroll_x - scroll_container.shape.x + padding_left := scroll_container.shape.padding_left() + caret_left_x := shape_x_in_content - padding_left + text_layout_align_offset_x(shape, mut w) + + rect.x + caret_right_x := caret_left_x + f32_max(rect.width, 1) + + view_left := -current_scroll_x + view_right := view_left + scroll_view_width + + mut target_scroll := current_scroll_x + if caret_left_x < view_left { + target_scroll = -caret_left_x + } else if caret_right_x > view_right { + target_scroll = -(caret_right_x - scroll_view_width) + } + + max_offset := f32_min(0, scroll_container.shape.width - scroll_container.shape.padding_width() - + content_width(scroll_container)) + return f32_clamp(target_scroll, max_offset, 0) +} + +struct TextScrollTargets { + target_x f32 + current_x f32 + target_y f32 + current_y f32 +} + +fn text_private_scroll_active(shape &Shape) bool { + return shape.tc != unsafe { nil } && shape.tc.text_scroll_key.len > 0 + && shape.tc.text_mode == .single_line +} + +fn text_private_scroll_state_x(shape &Shape, mut w Window) f32 { + if shape.tc == unsafe { nil } { + return 0 + } + if !text_private_scroll_active(shape) { + return shape.tc.text_scroll_x + } + return state_map[string, f32](mut w, ns_input_private_scroll_x, cap_scroll).get(shape.tc.text_scroll_key) or { + shape.tc.text_scroll_x + } +} + +fn text_private_set_scroll_x(shape &Shape, target_x f32, mut w Window) bool { + if !text_private_scroll_active(shape) { + return false + } + current_x := text_private_scroll_state_x(shape, mut w) + if f32_are_close(current_x, target_x) { + return false + } + mut sx := state_map[string, f32](mut w, ns_input_private_scroll_x, cap_scroll) + sx.set(shape.tc.text_scroll_key, target_x) + return true +} + +fn text_private_scroll_viewport(layout &Layout) ?Layout { + if layout.parent == unsafe { nil } { + return none + } + return *layout.parent +} + +fn text_private_scroll_viewport_width(viewport Layout) f32 { + return f32_max(0, viewport.shape.width - viewport.shape.padding_width()) +} + +fn text_private_scroll_content_width(layout &Layout, mut w Window) f32 { + shape := layout.shape + if shape.has_text_layout() && shape.tc.vglyph_layout.lines.len > 0 { + line := shape.tc.vglyph_layout.lines[0] + visual_width := text_line_visual_width(shape, line, mut w) + right_edge := line.rect.x + visual_width + return f32_max(0, right_edge) + } + return text_width_shape(shape, mut w) +} + +fn text_private_scroll_max_x(layout &Layout, viewport Layout, mut w Window) f32 { + return f32_min(0, text_private_scroll_viewport_width(viewport) - text_private_scroll_content_width(layout, mut + w)) +} + +fn text_private_clamp_scroll_x(scroll_x f32, layout &Layout, viewport Layout, mut w Window) f32 { + return f32_clamp(scroll_x, text_private_scroll_max_x(layout, viewport, mut w), 0) +} + +fn text_private_cursor_scroll_x(cursor_pos int, layout &Layout, current_x f32, mut w Window) ?f32 { + if !text_private_scroll_active(layout.shape) || !layout.shape.has_text_layout() { + return none + } + viewport := text_private_scroll_viewport(layout) or { return none } + view_width := text_private_scroll_viewport_width(viewport) + if view_width <= 0 { + return 0 + } + rect := text_cursor_rect_for_position(layout.shape, cursor_pos, mut w) + caret_left_x := text_layout_align_offset_x(layout.shape, mut w) + rect.x + caret_right_x := caret_left_x + f32_max(rect.width, 1) + view_left := -current_x + view_right := view_left + view_width + mut target_x := current_x + if caret_left_x < view_left { + target_x = -caret_left_x + } else if caret_right_x > view_right { + target_x = -(caret_right_x - view_width) + } + return text_private_clamp_scroll_x(target_x, layout, viewport, mut w) +} + +fn text_scroll_target_active(target f32, current f32) bool { + return target != -1 && !f32_are_close(target, current) +} + +fn (targets TextScrollTargets) needs_scroll() bool { + return text_scroll_target_active(targets.target_x, targets.current_x) + || text_scroll_target_active(targets.target_y, targets.current_y) +} + +fn text_scroll_targets(cursor_pos int, layout &Layout, id_scroll_container u32, mut w Window) TextScrollTargets { + target_x := cursor_pos_to_scroll_x(cursor_pos, layout.shape, mut w) + current_x := state_map[u32, f32](mut w, ns_scroll_x, cap_scroll).get(id_scroll_container) or { + f32(0) + } + target_y := cursor_pos_to_scroll_y(cursor_pos, layout.shape, mut w) + current_y := state_map[u32, f32](mut w, ns_scroll_y, cap_scroll).get(id_scroll_container) or { + f32(0) + } + return TextScrollTargets{ + target_x: target_x + current_x: current_x + target_y: target_y + current_y: current_y + } +} + +fn text_private_scroll_targets(cursor_pos int, layout &Layout, mut w Window) TextScrollTargets { + current_x := text_private_scroll_state_x(layout.shape, mut w) + target_x := text_private_cursor_scroll_x(cursor_pos, layout, current_x, mut w) or { f32(-1) } + return TextScrollTargets{ + target_x: target_x + current_x: current_x + target_y: 0 + current_y: 0 + } +} + +fn text_private_scroll_targets_for_drag(cursor_pos int, layout &Layout, raw_ev Event, mut w Window) TextScrollTargets { + targets := text_private_scroll_targets(cursor_pos, layout, mut w) + viewport := text_private_scroll_viewport(layout) or { return targets } + evs := event_relative_to(viewport.shape, &raw_ev) + view_left := viewport.shape.padding_left() + view_right := view_left + text_private_scroll_viewport_width(viewport) + mut target_x := targets.target_x + if evs.mouse_x < view_left { + target_x = text_private_clamp_scroll_x(0, layout, viewport, mut w) + } else if evs.mouse_x > view_right { + target_x = text_private_scroll_max_x(layout, viewport, mut w) + } + return TextScrollTargets{ + target_x: target_x + current_x: targets.current_x + target_y: targets.target_y + current_y: targets.current_y + } +} + +fn text_public_scroll_targets_for_drag(cursor_pos int, layout &Layout, raw_ev Event, id_scroll_container u32, mut w Window) TextScrollTargets { + targets := text_scroll_targets(cursor_pos, layout, id_scroll_container, mut w) + if id_scroll_container == 0 { + return targets + } + scroll_container := find_layout_by_id_scroll(w.layout, id_scroll_container) or { + return targets + } + evs := event_relative_to(scroll_container.shape, &raw_ev) + view_left := scroll_container.shape.padding_left() + view_width := f32_max(0, scroll_container.shape.width - scroll_container.shape.padding_width()) + view_right := view_left + view_width + max_offset := f32_min(0, view_width - content_width(scroll_container)) + mut target_x := targets.target_x + if evs.mouse_x < view_left { + target_x = f32_clamp(0, max_offset, 0) + } else if evs.mouse_x > view_right { + target_x = max_offset + } + return TextScrollTargets{ + target_x: target_x + current_x: targets.current_x + target_y: targets.target_y + current_y: targets.current_y + } +} + +fn text_scroll_targets_for_layout(cursor_pos int, layout &Layout, id_scroll_container u32, mut w Window) TextScrollTargets { + if text_private_scroll_active(layout.shape) { + return text_private_scroll_targets(cursor_pos, layout, mut w) + } + return text_scroll_targets(cursor_pos, layout, id_scroll_container, mut w) +} + +fn text_scroll_targets_for_drag(cursor_pos int, layout &Layout, raw_ev Event, id_scroll_container u32, mut w Window) TextScrollTargets { + if text_private_scroll_active(layout.shape) { + return text_private_scroll_targets_for_drag(cursor_pos, layout, raw_ev, mut w) + } + return text_public_scroll_targets_for_drag(cursor_pos, layout, raw_ev, id_scroll_container, mut + w) +} + +fn text_auto_scroll_next_cursor_pos(layout &Layout, cursor_pos int, targets TextScrollTargets, mut w Window) ?int { + if text_scroll_target_active(targets.target_y, targets.current_y) { + if targets.target_y > targets.current_y { + return cursor_up(layout.shape, cursor_pos, -1, 1, mut w) + } + return cursor_down(layout.shape, cursor_pos, -1, 1, mut w) + } + if text_scroll_target_active(targets.target_x, targets.current_x) { + if targets.target_x > targets.current_x { + return cursor_left(cursor_pos) + } + return cursor_right(layout.shape, cursor_pos) + } + return none } -// id_scroll_container := shape.id_scroll_container -// scroll_container := find_scroll_container(id_scroll_container, w) or { return -1 } -// -// // Determine the width of the scrollable region -// mut padding_width := scroll_container.shape.padding.width() -// if scroll_container.children.len > 0 { -// padding_width += scroll_container.children[0].shape.padding.width() -// } -// // scroll_view_width := scroll_container.shape.width - padding_width -// -// // Find the index of the line where the cursor is located. -// mut line_idx := 0 -// mut total_len := 0 -// for i, line in shape.tc.text_lines { -// line_idx = i -// total_len += utf8_str_visible_length(line) -// if total_len > cursor_pos { -// break -// } -// } -// -// width := utf8_str_visible_length(shape.tc.text.lines[line_idx][..total_len - cursor_pos]) -// } +fn text_update_auto_scroll_delay(layout &Layout, raw_ev Event, id_scroll_container u32, mut an Animate, mut w Window) { + scroll_container := find_layout_by_id_scroll(w.layout, id_scroll_container) or { return } + evs := event_relative_to(scroll_container.shape, raw_ev) + mut distance := f32(0) + if evs.mouse_y < 0 { + distance = f32_max(distance, -evs.mouse_y) + } else if evs.mouse_y > scroll_container.shape.height { + distance = f32_max(distance, evs.mouse_y - scroll_container.shape.height) + } + if evs.mouse_x < 0 { + distance = f32_max(distance, -evs.mouse_x) + } else if evs.mouse_x > scroll_container.shape.width { + distance = f32_max(distance, evs.mouse_x - scroll_container.shape.width) + } + + lh := f32_max(1, line_height(layout.shape, mut w)) + if distance > 2 * lh { + an.delay = auto_scroll_fast + } else if distance > lh { + an.delay = auto_scroll_medium + } else { + an.delay = auto_scroll_slow + } +} + +fn text_update_private_auto_scroll_delay(layout &Layout, raw_ev Event, mut an Animate, mut w Window) { + viewport := text_private_scroll_viewport(layout) or { return } + evs := event_relative_to(viewport.shape, raw_ev) + mut distance := f32(0) + view_left := viewport.shape.padding_left() + view_right := view_left + text_private_scroll_viewport_width(viewport) + if evs.mouse_x < view_left { + distance = view_left - evs.mouse_x + } else if evs.mouse_x > view_right { + distance = evs.mouse_x - view_right + } + lh := f32_max(1, line_height(layout.shape, mut w)) + if distance > 2 * lh { + an.delay = auto_scroll_fast + } else if distance > lh { + an.delay = auto_scroll_medium + } else { + an.delay = auto_scroll_slow + } +} + +fn text_update_auto_scroll_delay_for_layout(layout &Layout, raw_ev Event, id_scroll_container u32, mut an Animate, mut w Window) { + if text_private_scroll_active(layout.shape) { + text_update_private_auto_scroll_delay(layout, raw_ev, mut an, mut w) + return + } + text_update_auto_scroll_delay(layout, raw_ev, id_scroll_container, mut an, mut w) +} // mouse_cursor_pos determines the character index (cursor position) within // the entire text based on the mouse coordinates using vglyph geometry. -fn (tv &TextView) mouse_cursor_pos(shape &Shape, e &Event, mut _ Window) int { +fn (tv &TextView) mouse_cursor_pos(shape &Shape, e &Event, mut window Window) int { if tv.placeholder_active { return 0 } @@ -372,34 +695,76 @@ fn (tv &TextView) mouse_cursor_pos(shape &Shape, e &Event, mut _ Window) int { // Convert mouse coords to layout-relative. // `layout_text` generates a layout starting at (0,0). // So e.mouse_x/y should be relative to shape pos minus padding. - rel_x := f32(e.mouse_x) - shape.padding.left + rel_x := f32(e.mouse_x) - shape.padding.left - text_layout_render_offset_x(shape, mut window) rel_y := f32(e.mouse_y) - shape.padding.top - - if !shape.has_text_layout() { - return 0 - } - byte_idx := shape.tc.vglyph_layout.get_closest_offset(rel_x, rel_y) - return byte_to_rune_index(shape.tc.text, byte_idx) + return text_mouse_cursor_pos_from_local(shape, rel_x, rel_y, mut window) } // scroll_cursor_into_view ensures that the text cursor is visible within the // scroll container. fn scroll_cursor_into_view(cursor_pos int, layout &Layout, mut w Window) { + if text_private_scroll_active(layout.shape) { + current_x := text_private_scroll_state_x(layout.shape, mut w) + if target_x := text_private_cursor_scroll_x(cursor_pos, layout, current_x, mut w) { + if text_private_set_scroll_x(layout.shape, target_x, mut w) { + w.update_window() + } + } else { + input_mark_cursor_reveal(layout.shape.id_focus, mut w) + } + scroll_cursor_y_into_view(cursor_pos, layout, mut w) + return + } + scroll_cursor_x_into_view(cursor_pos, layout, mut w) + scroll_cursor_y_into_view(cursor_pos, layout, mut w) +} + +fn scroll_cursor_x_into_view(cursor_pos int, layout &Layout, mut w Window) { + if layout.shape.id_scroll_container == 0 { + return + } + new_scroll_x := cursor_pos_to_scroll_x(cursor_pos, layout.shape, mut w) + if new_scroll_x != -1 { + w.scroll_horizontal_to(layout.shape.id_scroll_container, new_scroll_x) + } +} + +fn scroll_cursor_y_into_view(cursor_pos int, layout &Layout, mut w Window) { + if layout.shape.id_scroll_container == 0 { + return + } new_scroll_y := cursor_pos_to_scroll_y(cursor_pos, layout.shape, mut w) - w.scroll_vertical_to(layout.shape.id_scroll_container, new_scroll_y) + if new_scroll_y != -1 { + w.scroll_vertical_to(layout.shape.id_scroll_container, new_scroll_y) + } } // text_mouse_cursor_pos is a standalone version of mouse_cursor_pos that // takes placeholder_active as a parameter instead of capturing tv. -fn text_mouse_cursor_pos(shape &Shape, e &Event, mut _ Window, placeholder_active bool) int { +fn text_mouse_cursor_pos(shape &Shape, e &Event, mut window Window, placeholder_active bool) int { if placeholder_active { return 0 } - rel_x := f32(e.mouse_x) - shape.padding.left + rel_x := f32(e.mouse_x) - shape.padding.left - text_layout_render_offset_x(shape, mut window) rel_y := f32(e.mouse_y) - shape.padding.top + return text_mouse_cursor_pos_from_local(shape, rel_x, rel_y, mut window) +} + +fn text_mouse_cursor_pos_from_local(shape &Shape, rel_x f32, rel_y f32, mut window Window) int { + if text_shape_uses_password_display(shape) && shape.tc.text_mode == .single_line { + return text_password_cursor_pos_from_offset(shape, rel_x, rel_y, mut window) + } if !shape.has_text_layout() { return 0 } + if shape.tc.text_mode == .single_line && shape.tc.vglyph_layout.lines.len > 0 { + line := shape.tc.vglyph_layout.lines[0] + right_edge := line.rect.x + text_line_visual_width(shape, line, mut window) + if rel_x >= right_edge { + line_end := int_min(line.start_index + line.length, shape.tc.text.len) + return byte_to_rune_index(shape.tc.text, line_end) + } + } byte_idx := shape.tc.vglyph_layout.get_closest_offset(rel_x, rel_y) return byte_to_rune_index(shape.tc.text, byte_idx) } @@ -408,19 +773,9 @@ fn text_mouse_cursor_pos(shape &Shape, e &Event, mut _ Window, placeholder_activ // that avoids capturing tv in animation closures. fn text_auto_scroll_cursor(id_focus u32, id_scroll_container u32, mut an Animate, mut w Window, placeholder_active bool) { mut layout := w.layout.find_layout(fn [id_focus] (ly Layout) bool { - return ly.shape.id_scroll == id_focus + return ly.shape.id_focus == id_focus && ly.shape.shape_type == .text }) or { return } - for { - if layout.shape.shape_type == .text { - break - } - if layout.children.len == 0 { - return - } - layout = layout.children[0] - } - mut imap := state_map[u32, InputState](mut w, ns_input, cap_many) cursor_pos := (imap.get(id_focus) or { InputState{} }).cursor_pos start_cursor_pos := w.view_state.mouse_lock.cursor_pos @@ -432,16 +787,9 @@ fn text_auto_scroll_cursor(id_focus u32, id_scroll_container u32, mut an Animate ev := event_relative_to(layout.shape, raw_ev) mut mouse_cursor_pos := text_mouse_cursor_pos(layout.shape, ev, mut w, placeholder_active) - scroll_y := cursor_pos_to_scroll_y(mouse_cursor_pos, layout.shape, mut w) - current_scroll_y := state_map[u32, f32](mut w, ns_scroll_y, cap_scroll).get(id_scroll_container) or { - f32(0) - } - - if scroll_y > current_scroll_y { - mouse_cursor_pos = cursor_up(layout.shape, cursor_pos, -1, 1, mut w) - } else if scroll_y < current_scroll_y { - mouse_cursor_pos = cursor_down(layout.shape, cursor_pos, -1, 1, mut w) - } else { + targets := + text_scroll_targets_for_drag(mouse_cursor_pos, layout, raw_ev, id_scroll_container, mut w) + mouse_cursor_pos = text_auto_scroll_next_cursor_pos(layout, cursor_pos, targets, mut w) or { return } @@ -455,23 +803,7 @@ fn text_auto_scroll_cursor(id_focus u32, id_scroll_container u32, mut an Animate }) scroll_cursor_into_view(mouse_cursor_pos, layout, mut w) - - scroll_container := find_layout_by_id_scroll(w.layout, id_scroll_container) or { return } - evs := event_relative_to(scroll_container.shape, raw_ev) - - distance := match evs.mouse_y < 0 { - true { -evs.mouse_y } - else { evs.mouse_y - scroll_container.shape.height } - } - - lh := line_height(layout.shape, mut w) - if distance > 2 * lh { - an.delay = auto_scroll_fast - } else if distance > lh { - an.delay = auto_scroll_medium - } else { - an.delay = auto_scroll_slow - } + text_update_auto_scroll_delay_for_layout(layout, raw_ev, id_scroll_container, mut an, mut w) } // text_double_click_drag handles mouse-move events during a word-level drag @@ -486,12 +818,8 @@ fn text_double_click_drag(layout &Layout, mut e Event, mut w Window, placeholder ev := event_relative_to(layout.shape, e) mouse_cursor_pos := text_mouse_cursor_pos(layout.shape, ev, mut w, placeholder_active) - scroll_y := cursor_pos_to_scroll_y(mouse_cursor_pos, layout.shape, mut w) - current_scroll_y := state_map[u32, f32](mut w, ns_scroll_y, cap_scroll).get(id_scroll_container) or { - f32(0) - } - - if scroll_y != current_scroll_y { + targets := text_scroll_targets_for_drag(mouse_cursor_pos, layout, e, id_scroll_container, mut w) + if targets.needs_scroll() { if !w.has_animation(id_auto_scroll_animation) { w.animation_add(mut Animate{ id: id_auto_scroll_animation @@ -537,17 +865,8 @@ fn text_double_click_drag(layout &Layout, mut e Event, mut w Window, placeholder // boundaries instead of raw cursor positions. fn text_double_click_auto_scroll_cursor(id_focus u32, id_scroll_container u32, anchor_beg int, anchor_end int, mut an Animate, mut w Window, placeholder_active bool) { mut layout := w.layout.find_layout(fn [id_focus] (ly Layout) bool { - return ly.shape.id_scroll == id_focus + return ly.shape.id_focus == id_focus && ly.shape.shape_type == .text }) or { return } - for { - if layout.shape.shape_type == .text { - break - } - if layout.children.len == 0 { - return - } - layout = layout.children[0] - } mut imap := state_map[u32, InputState](mut w, ns_input, cap_many) cursor_pos := (imap.get(id_focus) or { InputState{} }).cursor_pos raw_ev := Event{ @@ -557,15 +876,9 @@ fn text_double_click_auto_scroll_cursor(id_focus u32, id_scroll_container u32, a ev := event_relative_to(layout.shape, raw_ev) mut mouse_cursor_pos := text_mouse_cursor_pos(layout.shape, ev, mut w, placeholder_active) - scroll_y := cursor_pos_to_scroll_y(mouse_cursor_pos, layout.shape, mut w) - current_scroll_y := state_map[u32, f32](mut w, ns_scroll_y, cap_scroll).get(id_scroll_container) or { - f32(0) - } - if scroll_y > current_scroll_y { - mouse_cursor_pos = cursor_up(layout.shape, cursor_pos, -1, 1, mut w) - } else if scroll_y < current_scroll_y { - mouse_cursor_pos = cursor_down(layout.shape, cursor_pos, -1, 1, mut w) - } else { + targets := + text_scroll_targets_for_drag(mouse_cursor_pos, layout, raw_ev, id_scroll_container, mut w) + mouse_cursor_pos = text_auto_scroll_next_cursor_pos(layout, cursor_pos, targets, mut w) or { return } @@ -589,22 +902,7 @@ fn text_double_click_auto_scroll_cursor(id_focus u32, id_scroll_container u32, a select_end: sel_end }) scroll_cursor_into_view(new_cursor_pos, layout, mut w) - - scroll_container := find_layout_by_id_scroll(w.layout, id_scroll_container) or { return } - evs := event_relative_to(scroll_container.shape, raw_ev) - distance := if evs.mouse_y < 0 { - -evs.mouse_y - } else { - evs.mouse_y - scroll_container.shape.height - } - lh := line_height(layout.shape, mut w) - if distance > 2 * lh { - an.delay = auto_scroll_fast - } else if distance > lh { - an.delay = auto_scroll_medium - } else { - an.delay = auto_scroll_slow - } + text_update_auto_scroll_delay_for_layout(layout, raw_ev, id_scroll_container, mut an, mut w) } // text_mouse_move_locked is a standalone version of mouse_move_locked @@ -622,12 +920,9 @@ fn text_mouse_move_locked(layout &Layout, mut e Event, mut w Window, placeholder ev := event_relative_to(layout.shape, e) mut mouse_cursor_pos := text_mouse_cursor_pos(layout.shape, ev, mut w, placeholder_active) - scroll_y := cursor_pos_to_scroll_y(mouse_cursor_pos, layout.shape, mut w) - current_scroll_y := state_map[u32, f32](mut w, ns_scroll_y, cap_scroll).get(id_scroll_container) or { - f32(0) - } - - if scroll_y != current_scroll_y { + targets := + text_scroll_targets_for_drag(mouse_cursor_pos, layout, e, id_scroll_container, mut w) + if targets.needs_scroll() { if !w.has_animation(id_auto_scroll_animation) { w.animation_add(mut Animate{ id: id_auto_scroll_animation diff --git a/view_text_xtra.v b/view_text_xtra.v index 9c387bb..bec6c1c 100644 --- a/view_text_xtra.v +++ b/view_text_xtra.v @@ -35,15 +35,147 @@ fn rich_text_width(rt RichText, mut window Window) f32 { return layout.width } +fn text_display_text(text string, is_password bool, placeholder_active bool) string { + if is_password && !placeholder_active { + return password_mask_text_keep_newlines(text) + } + return text +} + +fn text_shape_display_text(shape &Shape) string { + return text_display_text(shape.tc.text, shape.tc.text_is_password, shape.tc.text_is_placeholder) +} + +fn text_shape_uses_password_display(shape &Shape) bool { + return shape.tc != unsafe { nil } && shape.tc.text_is_password && !shape.tc.text_is_placeholder +} + +fn text_align_available_width(shape &Shape) f32 { + return f32_max(0, shape.width - shape.padding_width()) +} + +fn text_align_offset_for_width(shape &Shape, visual_width f32) f32 { + if shape.tc == unsafe { nil } || shape.tc.text_mode != .single_line { + return 0 + } + available := text_align_available_width(shape) + if available <= 0 || visual_width > available { + return 0 + } + match shape.tc.text_style.align { + .center { return (available - visual_width) / 2 } + .right { return available - visual_width } + else { return 0 } + } +} + +fn text_line_visual_width(shape &Shape, line vglyph.Line, mut window Window) f32 { + if text_shape_uses_password_display(shape) && shape.tc.text_mode == .single_line { + width := text_width(text_shape_display_text(shape), shape.tc.text_style, mut window) + return if width > 0 { width } else { line.rect.width } + } + return line.rect.width +} + +fn text_line_align_offset_x(shape &Shape, line vglyph.Line, mut window Window) f32 { + return text_align_offset_for_width(shape, text_line_visual_width(shape, line, mut window)) +} + +fn text_layout_align_offset_x(shape &Shape, mut window Window) f32 { + if shape.has_text_layout() && shape.tc.vglyph_layout.lines.len > 0 { + return text_line_align_offset_x(shape, shape.tc.vglyph_layout.lines[0], mut window) + } + return text_align_offset_for_width(shape, 0) +} + +fn text_scroll_offset_x(shape &Shape) f32 { + if shape.tc == unsafe { nil } || shape.tc.text_mode != .single_line { + return 0 + } + return shape.tc.text_scroll_x +} + +fn text_line_render_offset_x(shape &Shape, line vglyph.Line, mut window Window) f32 { + return text_line_align_offset_x(shape, line, mut window) + text_scroll_offset_x(shape) +} + +fn text_layout_render_offset_x(shape &Shape, mut window Window) f32 { + return text_layout_align_offset_x(shape, mut window) + text_scroll_offset_x(shape) +} + +fn text_password_mask_prefix(shape &Shape, cursor_position int) string { + count := int_clamp(cursor_position, 0, utf8_str_visible_length(shape.tc.text)) + if count <= 0 { + return '' + } + return password_char.repeat(count) +} + +fn text_first_line_x(shape &Shape) f32 { + if shape.has_text_layout() && shape.tc.vglyph_layout.lines.len > 0 { + return shape.tc.vglyph_layout.lines[0].rect.x + } + return 0 +} + +fn text_password_cursor_x_from_mask_width(shape &Shape, mask_width f32) f32 { + return text_first_line_x(shape) + mask_width +} + +fn text_password_mask_offset_x(shape &Shape, offset f32) f32 { + return offset - text_first_line_x(shape) +} + +fn text_password_cursor_x(shape &Shape, cursor_position int, mut window Window) f32 { + width := text_width(text_password_mask_prefix(shape, cursor_position), shape.tc.text_style, mut + window) + if width > 0 { + return text_password_cursor_x_from_mask_width(shape, width) + } + if !shape.has_text_layout() || shape.tc.vglyph_layout.lines.len == 0 { + return 0 + } + count := int_clamp(cursor_position, 0, utf8_str_visible_length(shape.tc.text)) + byte_idx := rune_to_byte_index(shape.tc.text, count) + if rect := shape.tc.vglyph_layout.get_char_rect(byte_idx) { + return rect.x + } + for line in shape.tc.vglyph_layout.lines { + line_end := line.start_index + line.length + if byte_idx == line.start_index { + return line.rect.x + } + if byte_idx == line_end { + return line.rect.x + line.rect.width + } + } + return 0 +} + +fn text_password_range_width(shape &Shape, start_cursor int, end_cursor int, mut window Window) f32 { + if end_cursor <= start_cursor { + return 0 + } + width := text_width(password_char.repeat(end_cursor - start_cursor), shape.tc.text_style, mut + window) + if width > 0 { + return width + } + start_x := text_password_cursor_x(shape, start_cursor, mut window) + end_x := text_password_cursor_x(shape, end_cursor, mut window) + return f32_max(0, end_x - start_x) +} + // text_width_shape measures the visual width of the shape's lines, mirroring render rules: // - when in password mode (and not placeholder), measure '*' repeated for visible rune count fn text_width_shape(shape &Shape, mut window Window) f32 { - cfg := shape.tc.text_style.to_vglyph_cfg() + if text_shape_uses_password_display(shape) && shape.tc.text_mode == .single_line { + width := text_width(text_shape_display_text(shape), shape.tc.text_style, mut window) + if width > 0 { + return width + } + } - // Fallback: If layout is not generated yet (e.g. during initial generate_layout), - // measure the raw text. This ensures containers don't collapse to 0 width. - // We measure "unwrapped" width here, essentially treating it as a single line. - // The layout engine will later constrain this width if wrapping is enabled. // Fallback: If layout is not generated yet (e.g. during initial generate_layout), // measure the raw text. This ensures containers don't collapse to 0 width. // We measure "unwrapped" width here, essentially treating it as a single line. @@ -55,42 +187,18 @@ fn text_width_shape(shape &Shape, mut window Window) f32 { else { shape.tc.text } } - return window.text_system.text_width(effective, cfg) or { 0 } + return text_width(effective, shape.tc.text_style, mut window) } // Optimization: If layout is present, use its cached dimensions. - // vglyph layout provides both logical width (width) and ink width (visual_width). - // For wrapping containers, logical width is usually constrained. - // For "fit content", we often want the max line width. - // vglyph.Layout.width is the width of the layout box (or max line width if not wrapping). - // Let's trust vglyph's calculation. if shape.has_text_layout() && shape.tc.vglyph_layout.lines.len > 0 { - // Use visual width (ink) to match previous behavior of measuring visible pixels. - // OR use logical width if visual width is too tight? - // Usually visual_width matches the bounding box of the ink. - // check if password mode needs special verification? - // Password mode is handled by masking text BEFORE layout if we did it right, - // but current logic masks lazily. - // Wait, text_wrap uses shape.tc.text (unmasked). - // If we want accurate width for password, we must rely on the previous fallback - // or layout the masked text. - // Current compromise: If password, use the old measurement loop (it's rare). - // If normal text, use cached layout width. if !shape.tc.text_is_password { - // In vglyph, `width` is the logical width (often the wrap width). - // `visual_width` is the actual ink width. - // We want the content width. - // If we wrapped, `width` might be the constraint (e.g. 300px), but text might be only 50px wide. - // We want the max line width. - // Iterate lines is fast if we rely on line.rect? - // vglyph.Line.rect is relative to layout. mut max_w := f32(0) for line in shape.tc.vglyph_layout.lines { max_w = f32_max(max_w, line.rect.width) } return max_w } - // Fallthrough for password mode (measure '*'s) } mut max_width := f32(0) @@ -113,7 +221,10 @@ fn text_width_shape(shape &Shape, mut window Window) f32 { else { sub } } - width := window.text_system.text_width(effective, cfg) or { 0 } + mut width := text_width(effective, shape.tc.text_style, mut window) + if width <= 0 { + width = line.rect.width + } max_width = f32_max(width, max_width) } return max_width @@ -122,6 +233,9 @@ fn text_width_shape(shape &Shape, mut window Window) f32 { @[inline] fn text_height(shape &Shape, mut window Window) f32 { if (!shape.has_text_layout() || shape.tc.vglyph_layout.lines.len == 0) && shape.tc.text.len > 0 { + if window.text_system == unsafe { nil } { + return 0 + } cfg := shape.tc.text_style.to_vglyph_cfg() return window.text_system.font_height(cfg) or { 0 } } @@ -136,6 +250,13 @@ fn line_height(shape &Shape, mut window Window) f32 { if shape.tc.cached_line_height > 0 { return shape.tc.cached_line_height } + if shape.has_text_layout() && shape.tc.vglyph_layout.lines.len > 0 + && shape.tc.vglyph_layout.lines[0].rect.height > 0 { + return shape.tc.vglyph_layout.lines[0].rect.height + } + if window.text_system == unsafe { nil } { + return 0 + } cfg := shape.tc.text_style.to_vglyph_cfg() height := window.text_system.font_height(cfg) or { 0 } return height + shape.tc.text_style.line_spacing @@ -174,6 +295,9 @@ fn text_wrap(mut shape Shape, mut window Window) { cfg.block.width = width cfg.no_hit_testing = shape.id_focus == 0 + if window.text_system == unsafe { nil } { + return + } layout := window.text_system.layout_text(shape.tc.text, cfg) or { vglyph.Layout{} } shape.tc.vglyph_layout = &layout shape.tc.last_constraint_width = width