From f22ec0016c046af5b218eefc62b24cba7fe65220 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:56:03 +0700 Subject: [PATCH] EditBox caret keys, selection and focus blink follow 1.12.1 (#750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several EditBox keys differed from 1.12.1: - Up/Down did nothing in a multi-line box; - Home/End went to the text's ends; - `HighlightText` and Ctrl+A moved the caret; - focus did not restart the blink; - a non-digit kept a numeric box's selection; - Option+Up could not recall chat history on a Mac. In 1.12.1, Up/Down move a wrapped row, keeping the letter column (`0x77cb20`), or recall history in a single-line box with any modifier but Shift. Home/End stop at a newline (`0x77c980`, `0x77c9f0`). `HighlightText` writes only the selection (`0x77cca0`), and Shift extends by `0x77cd10`. Focus restarts the blink (`0x77dd68`), and Insert deletes the selection before its digit test (`0x77bf41`). benilla now does each. Down onto the last row lands at the end, and history stays shell-style, both deviations. Tests cover each key, in the engine and the stock mail and ticket boxes. Fixes #735 Fixes #736 Fixes #737 Fixes #738 Fixes #745 Fixes #747 πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/char_select/dialog.rs | 15 +- crates/benilla-app/src/glue/dialog.rs | 4 +- crates/benilla-app/src/glue/widgets.rs | 130 +++++--- crates/benilla-app/src/login/mod.rs | 24 +- crates/benilla-app/src/textinput/keymap.rs | 178 +++++++++-- .../benilla-app/src/ui_script/chat_tests.rs | 12 +- .../src/ui_script/help_frame_tests.rs | 64 +++- crates/benilla-app/src/ui_script/input.rs | 2 +- crates/benilla-app/tests/mail_frame.rs | 167 ++++++++++- crates/benilla-ui/src/script/editbox.rs | 46 ++- crates/benilla-ui/src/script/editbox/flush.rs | 46 ++- .../src/script/editbox/flush_tests.rs | 148 ++++++++- .../benilla-ui/src/script/editbox/interact.rs | 4 +- crates/benilla-ui/src/script/editbox/tests.rs | 99 ++++-- crates/benilla-ui/src/script/mod.rs | 2 +- crates/benilla-ui/src/widget/kinds/editbox.rs | 281 ++++++++++++++---- .../src/widget/kinds/editbox_move_tests.rs | 261 ++++++++++++++++ 17 files changed, 1269 insertions(+), 214 deletions(-) create mode 100644 crates/benilla-ui/src/widget/kinds/editbox_move_tests.rs diff --git a/crates/benilla-app/src/char_select/dialog.rs b/crates/benilla-app/src/char_select/dialog.rs index 92faf5fe..ce4d150c 100644 --- a/crates/benilla-app/src/char_select/dialog.rs +++ b/crates/benilla-app/src/char_select/dialog.rs @@ -387,8 +387,8 @@ fn spawn_dialog( ..default() },)) .with_children(|f| { - // The five-part field row: segments either side of the - // selection, a caret slot at each edge. + // The five-part field row: the text either side of the + // selection, which splits at the caret. let segment = |f: &mut ChildSpawnerCommands, part| { outlined_text( f, @@ -406,14 +406,9 @@ fn spawn_dialog( ); }; segment(f, GlueFieldPart::Before); - caret_bar( - f, - (DeleteCaret, GlueFieldPart::CaretAtStart), - 15.0, - s, - ); - segment(f, GlueFieldPart::Selected); - caret_bar(f, (DeleteCaret, GlueFieldPart::CaretAtEnd), 15.0, s); + segment(f, GlueFieldPart::SelectedHead); + caret_bar(f, (DeleteCaret, GlueFieldPart::Caret), 15.0, s); + segment(f, GlueFieldPart::SelectedTail); segment(f, GlueFieldPart::After); }); }); diff --git a/crates/benilla-app/src/glue/dialog.rs b/crates/benilla-app/src/glue/dialog.rs index 1099b5d7..6b10171e 100644 --- a/crates/benilla-app/src/glue/dialog.rs +++ b/crates/benilla-app/src/glue/dialog.rs @@ -140,8 +140,8 @@ impl GlueDialog { self.kind = Some(DialogKind::Realmlist); self.edit = crate::textinput::field(crate::realmlist::MAX_LETTERS, false); self.edit.set_text(current); - // `HighlightText(0, -1)`, the reference's select-all (`0x77cca0`), also resets the blink - // so the box opens on a solid caret. + // `HighlightText(0, -1)`, the reference's select-all (`0x77cca0`); the dialog's spawn + // starts the caret solid. self.edit.highlight_text(0, -1); self.set_text(prompt); } diff --git a/crates/benilla-app/src/glue/widgets.rs b/crates/benilla-app/src/glue/widgets.rs index 9bfa7824..f203aafe 100644 --- a/crates/benilla-app/src/glue/widgets.rs +++ b/crates/benilla-app/src/glue/widgets.rs @@ -515,22 +515,54 @@ pub(crate) fn caret_bar( }); } -/// Which item of a glue edit box's row `[before][caret][selected][caret][after]` an entity is. -/// Flex places the caret and selection with no text measuring; an empty `Selected` has zero -/// width, and the caret slots are zero-width seams, so the text is one unbroken line. +/// Which item of a glue edit box's row `[before][selected][caret][selected][after]` an entity is. +/// Flex places the caret and selection with no text measuring: the selection splits at the caret, +/// an empty run has zero width and the caret is a zero-width seam, so the text is one unbroken line. #[derive(Component, Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum GlueFieldPart { - /// `display[..sel_start]` + /// The text before the selection. Before, - /// The caret at the selection's start, and whenever nothing is selected. - CaretAtStart, - /// `display[sel_start..sel_end]`, the highlighted run. - Selected, - CaretAtEnd, - /// `display[sel_end..]` + /// The highlighted run from the selection's start to the caret. + SelectedHead, + /// The caret, which can sit inside the selection: `HighlightText` leaves it (`0x77cca0`). + Caret, + /// The highlighted run from the caret to the selection's end. + SelectedTail, + /// The text after the selection. After, } +/// What `part` of `field`'s row draws: its slice of `display`, for a text run, and whether it +/// shows. `focused` gates the caret, not the highlight: the reference's caret flush hides when +/// `E != [0xcf4dc8]` (`0x77da80`), but the selection flush (`0x77d950`, `0x77de70`) never reads +/// focus, so an unfocused box paints the selection it holds. +fn glue_field_part<'d>( + field: &EditBoxState, + display: &'d str, + focused: bool, + part: GlueFieldPart, +) -> (Option<&'d str>, bool) { + let (mut lo, mut hi) = ( + field.sel_start.min(field.sel_end), + field.sel_start.max(field.sel_end), + ); + // An empty selection has no place of its own: it sits at the caret. + if lo == hi { + (lo, hi) = (field.cursor, field.cursor); + } + // A caret outside a selection, which no glue field makes, draws at its nearer end. + let caret = field.cursor.clamp(lo, hi); + let [lo, caret, hi] = [lo, caret, hi].map(|b| field.text_to_display(b).min(display.len())); + match part { + GlueFieldPart::Before => (Some(&display[..lo]), true), + GlueFieldPart::SelectedHead => (Some(&display[lo..caret]), true), + // `caret_shown` is ticked by `textinput::tick_caret`, the chat caret's clock too. + GlueFieldPart::Caret => (None, focused && field.caret_shown), + GlueFieldPart::SelectedTail => (Some(&display[caret..hi]), true), + GlueFieldPart::After => (Some(&display[hi..]), true), + } +} + /// Paint one glue edit box's segments, selection and caret from its [`EditBoxState`]. pub(crate) fn paint_glue_field<'a>( field: &EditBoxState, @@ -543,24 +575,9 @@ pub(crate) fn paint_glue_field<'a>( ), >, ) { - // `focused` gates the caret, not the highlight: the reference's caret flush hides when - // `E != [0xcf4dc8]` (`0x77da80`), but the selection flush (`0x77d950`, `0x77de70`) never reads - // focus, so an unfocused box paints the selection it holds. let display = field.display(); - let lo = field.sel_start.min(field.sel_end); - let hi = field.sel_start.max(field.sel_end); - let (d_lo, d_hi) = (field.text_to_display(lo), field.text_to_display(hi)); - let d_cursor = field.text_to_display(field.cursor); - // `caret_shown` is ticked by `textinput::tick_caret`, the chat caret's clock too. - let caret_on = focused && field.caret_shown; for (part, text, mut vis) in parts { - let (want_text, want_vis) = match part { - GlueFieldPart::Before => (Some(&display[..d_lo]), true), - GlueFieldPart::Selected => (Some(&display[d_lo..d_hi]), true), - GlueFieldPart::After => (Some(&display[d_hi..]), true), - GlueFieldPart::CaretAtStart => (None, caret_on && d_cursor <= d_lo), - GlueFieldPart::CaretAtEnd => (None, caret_on && d_cursor > d_lo), - }; + let (want_text, want_vis) = glue_field_part(field, &display, focused, *part); if let (Some(want), Some(mut t)) = (want_text, text) { if t.0 != want { t.0 = want.to_string(); @@ -650,7 +667,10 @@ pub(crate) fn glue_edit_box( ..default() }, )); - if part == GlueFieldPart::Selected { + if matches!( + part, + GlueFieldPart::SelectedHead | GlueFieldPart::SelectedTail + ) { // `SetHighlightColor`'s default, opaque medium grey, as the chat box. e.insert(BackgroundColor(Color::srgb( 96.0 / 255.0, @@ -660,19 +680,9 @@ pub(crate) fn glue_edit_box( } }; segment(f, GlueFieldPart::Before); - caret_bar( - f, - (marker.clone(), GlueFieldPart::CaretAtStart), - EDIT_FONT_SIZE, - s, - ); - segment(f, GlueFieldPart::Selected); - caret_bar( - f, - (marker.clone(), GlueFieldPart::CaretAtEnd), - EDIT_FONT_SIZE, - s, - ); + segment(f, GlueFieldPart::SelectedHead); + caret_bar(f, (marker.clone(), GlueFieldPart::Caret), EDIT_FONT_SIZE, s); + segment(f, GlueFieldPart::SelectedTail); segment(f, GlueFieldPart::After); }); }); @@ -761,6 +771,46 @@ pub(crate) fn glue_button( mod tests { use super::*; + /// The selection splits at the caret, so a caret that `HighlightText` left inside the + /// selection (`0x77cca0`) draws where it is; an empty selection sits at the caret. + #[test] + fn the_caret_draws_where_it_is_inside_a_selection() { + use GlueFieldPart::*; + let mut f = EditBoxState { + text: "hello".into(), + cursor: 2, + ..Default::default() + }; + f.highlight_text(0, -1); + let d = f.display(); + let row = |f: &EditBoxState, focused| { + [Before, SelectedHead, Caret, SelectedTail, After] + .map(|part| glue_field_part(f, &d, focused, part)) + }; + assert_eq!( + row(&f, true), + [ + (Some(""), true), + (Some("he"), true), + (None, true), + (Some("llo"), true), + (Some(""), true), + ] + ); + f.highlight_text(0, 0); + assert_eq!( + row(&f, false), + [ + (Some("he"), true), + (Some(""), true), + (None, false), + (Some(""), true), + (Some("llo"), true), + ], + "nothing selected, the caret hidden out of focus" + ); + } + #[test] fn colour_escapes_become_spans_not_text() { let base = GOLD; diff --git a/crates/benilla-app/src/login/mod.rs b/crates/benilla-app/src/login/mod.rs index a09d3d85..58cfefd4 100644 --- a/crates/benilla-app/src/login/mod.rs +++ b/crates/benilla-app/src/login/mod.rs @@ -596,17 +596,17 @@ impl Default for LoginForm { } impl LoginForm { - /// Give `field` the keyboard, select all its text and collapse the selection in the box left. - /// - /// Deviation: select on focus and collapse on leaving, because a player entering a login - /// field means to replace what is there. The reference's focus gain (`0x77e3f6`) and loss - /// (`0x77af50`) leave the selection alone, and its click collapses it (`0x77b800` calls - /// `0x77ccf0` before `SetFocus`). `HighlightText(0, -1)` (`0x77cca0`) also resets the blink, - /// so the caret opens solid. + /// Give `field` the keyboard as the stock `AccountLogin.xml` boxes take it: the box left drops + /// its selection (`OnEditFocusLost`, `HighlightText(0, 0)`), and the box gaining it selects + /// all (`OnEditFocusGained`, `HighlightText()`), which leaves its caret where it was + /// (`0x77cca0`). The focus change raises the caret bit (`0x77afa8`), whose flush restarts the + /// blink (`0x77dd68`, `0x77de35`), so the caret opens solid. fn focus(&mut self, field: Field) { - self.focused().collapse(); + self.focused().highlight_text(0, 0); self.focus = field; - self.focused().highlight_text(0, -1); + let f = self.focused(); + f.highlight_text(0, -1); + f.reset_blink(); } fn focused(&mut self) -> &mut EditBoxState { @@ -1415,10 +1415,16 @@ mod tests { form.account.set_text("remembered"); form.password.set_text("secret"); + // Mid-blink, caret off and mid-text: the focus restarts it solid, where it was. + form.account.caret_shown = false; + form.account.blink_accum = 0.3; + form.account.cursor = 3; form.focus(Field::Account); assert_eq!(form.focus, Field::Account); assert_eq!(form.account.selected_text().as_deref(), Some("remembered")); + assert_eq!(form.account.cursor, 3, "HighlightText leaves the caret"); assert!(form.account.caret_shown, "a fresh focus starts solid"); + assert_eq!(form.account.blink_accum, 0.0, "with a full half-period"); // Checked as a range: a password box's `selected_text` returns the `*` mask. form.focus(Field::Password); diff --git a/crates/benilla-app/src/textinput/keymap.rs b/crates/benilla-app/src/textinput/keymap.rs index 50ee60e6..7065ab2c 100644 --- a/crates/benilla-app/src/textinput/keymap.rs +++ b/crates/benilla-app/src/textinput/keymap.rs @@ -1,6 +1,7 @@ //! The per-OS text-editing keymap: a keypress and modifiers to an engine [`EditAction`] or a //! host-side clipboard operation. The Windows/Linux table is the reference's key handler -//! (`0x77b160`: Ctrl+arrows by word, Ctrl+A/C/X/V, Ctrl/Shift+Insert, Shift+Delete). +//! (`0x77b160`: Ctrl+arrows by word, Ctrl+HOME/END to the text's edge, Ctrl+A/C/X/V, +//! Ctrl/Shift+Insert, Shift+Delete). //! Deviation: macOS takes the Cocoa text-field chords and Ctrl+Backspace/Delete delete a word, //! because editing follows each OS's own text fields. @@ -56,10 +57,11 @@ pub(crate) fn chord(key: KeyCode, name: Option, m: Mods, mac: bool) -> } } -/// macOS, the Cocoa text-field chords: Option is a word, Cmd the line edge; plain Up/Down recall -/// history. Cocoa's Emacs Ctrl set is left unbound. +/// macOS, the Cocoa text-field chords: Option+Left/Right is a word and Cmd+Left/Right the line's +/// edge; Cmd+Up/Down and Shift+Up/Down go to the text's edge, and Up/Down, plain or with Option, +/// move a row, or recall history in a single-line box. Cocoa's Emacs Ctrl set is left unbound. fn chord_mac(key: KeyCode, m: Mods) -> Option { - use EditUnit::{Char, Edge, Word}; + use EditUnit::{Char, Edge, Line, Row, Word}; let mv = |unit, back| { Some(Chord::Edit(EditAction::Move { unit, @@ -72,24 +74,25 @@ fn chord_mac(key: KeyCode, m: Mods) -> Option { KeyCode::ArrowLeft | KeyCode::ArrowRight => { let back = key == KeyCode::ArrowLeft; if m.sup { - mv(Edge, back) + mv(Line, back) } else if m.alt { mv(Word, back) } else { mv(Char, back) } } - // Cmd+Up/Down is the box's start/end; Shift extends there. + // Cmd+Up/Down go to the text's start or end and Shift+Up/Down select to it, as a Cocoa + // text field's do. Option is the reference's Alt, which its UP and DOWN arms do not read + // (`0x77b64e`, `0x77b675`), and the one modifier that keeps the arrows in an alt-arrow box + // (`0x77b1b3`): the chat box's history recall. KeyCode::ArrowUp | KeyCode::ArrowDown => { let back = key == KeyCode::ArrowUp; if m.sup || m.shift { mv(Edge, back) - } else if m.alt || m.ctrl { + } else if m.ctrl { None - } else if back { - Some(Chord::Edit(EditAction::HistoryPrev)) } else { - Some(Chord::Edit(EditAction::HistoryNext)) + mv(Row, back) } } KeyCode::Home => mv(Edge, true), @@ -118,7 +121,7 @@ fn chord_mac(key: KeyCode, m: Mods) -> Option { /// Windows/Linux: the reference's chords (`0x77b160`) plus the Ctrl word deletes. fn chord_pc(key: KeyCode, m: Mods) -> Option { - use EditUnit::{Char, Edge, Word}; + use EditUnit::{Char, Edge, Line, Row, Word}; let mv = |unit, back| { Some(Chord::Edit(EditAction::Move { unit, @@ -137,15 +140,11 @@ fn chord_pc(key: KeyCode, m: Mods) -> Option { mv(Char, back) } } - // Plain Up/Down only: history recall. - KeyCode::ArrowUp if !(m.ctrl || m.alt || m.shift || m.sup) => { - Some(Chord::Edit(EditAction::HistoryPrev)) - } - KeyCode::ArrowDown if !(m.ctrl || m.alt || m.shift || m.sup) => { - Some(Chord::Edit(EditAction::HistoryNext)) - } - KeyCode::Home => mv(Edge, true), - KeyCode::End => mv(Edge, false), + // UP/DOWN read only Shift, as the extend flag (`0x77b65d`, `0x77b684`): a row in a + // multi-line box, history in a single-line one. + KeyCode::ArrowUp | KeyCode::ArrowDown if !m.sup => mv(Row, key == KeyCode::ArrowUp), + // HOME/END stop at a newline; Ctrl goes to the text's edge (`0x77b49e`, `0x77b4de`). + KeyCode::Home | KeyCode::End => mv(if m.ctrl { Edge } else { Line }, key == KeyCode::Home), // Shift+Delete cuts, as the reference does (`0x77b160`). KeyCode::Backspace => { if m.ctrl { @@ -233,7 +232,7 @@ mod tests { assert_eq!( edit(chord(KeyCode::ArrowLeft, us(KeyCode::ArrowLeft), SUP, true)), Move { - unit: Edge, + unit: Line, back: true, extend: false } @@ -253,7 +252,11 @@ mod tests { ); assert_eq!( edit(chord(KeyCode::ArrowUp, us(KeyCode::ArrowUp), NONE, true)), - HistoryPrev + Move { + unit: Row, + back: true, + extend: false + } ); assert_eq!( edit(chord( @@ -262,7 +265,11 @@ mod tests { NONE, true )), - HistoryNext + Move { + unit: Row, + back: false, + extend: false + } ); assert_eq!( edit(chord(KeyCode::ArrowUp, us(KeyCode::ArrowUp), SHIFT, true)), @@ -353,16 +360,29 @@ mod tests { ); assert_eq!( edit(chord(KeyCode::ArrowUp, us(KeyCode::ArrowUp), NONE, false)), - HistoryPrev + Move { + unit: Row, + back: true, + extend: false + } ); assert_eq!( - chord(KeyCode::ArrowUp, us(KeyCode::ArrowUp), SHIFT, false), - None + edit(chord( + KeyCode::ArrowDown, + us(KeyCode::ArrowDown), + SHIFT, + false + )), + Move { + unit: Row, + back: false, + extend: true + } ); assert_eq!( edit(chord(KeyCode::End, us(KeyCode::End), SHIFT, false)), Move { - unit: Edge, + unit: Line, back: false, extend: true } @@ -413,6 +433,110 @@ mod tests { assert_eq!(chord(KeyCode::KeyA, us(KeyCode::KeyA), SUP, false), None); } + /// On a Mac, Cmd+Left/Right go to the caret's line's start or end, as a Cocoa text view's do; + /// in a single-line box, with no newline typed, that is the text's edge. + #[test] + fn mac_cmd_left_right_go_to_the_lines_edge() { + use EditAction::Move; + use EditUnit::Line; + for (key, back) in [(KeyCode::ArrowLeft, true), (KeyCode::ArrowRight, false)] { + assert_eq!( + edit(chord(key, us(key), SUP, true)), + Move { + unit: Line, + back, + extend: false + } + ); + assert_eq!( + edit(chord(key, us(key), Mods { shift: true, ..SUP }, true)), + Move { + unit: Line, + back, + extend: true + } + ); + } + } + + /// On a Mac, Option is the reference's Alt: Option+Up/Down reach the UP and DOWN arms + /// (`0x77b64e`, `0x77b675`), which read only Shift, so the alt-arrow chat box recalls its + /// history with them (`0x77b1b3`, then `0x77d030`/`0x77cfd0`). Ctrl stays unbound. + #[test] + fn mac_option_up_down_move_a_row_or_recall_history() { + use EditAction::Move; + use EditUnit::Row; + for (key, back) in [(KeyCode::ArrowUp, true), (KeyCode::ArrowDown, false)] { + assert_eq!( + edit(chord(key, us(key), ALT, true)), + Move { + unit: Row, + back, + extend: false + } + ); + assert_eq!(chord(key, us(key), CTRL, true), None); + } + } + + /// The reference's UP and DOWN arms read only Shift (`0x77b64e`, `0x77b675`), so Ctrl and Alt + /// reach them too: Alt+Up is how the alt-arrow chat box recalls its history. HOME and END + /// stop at a newline and Ctrl+HOME/END go to the text's edge (`0x77b499`, `0x77b4d9`). + #[test] + fn pc_up_down_take_any_modifier_and_home_end_split_on_ctrl() { + use EditAction::Move; + use EditUnit::*; + for m in [NONE, CTRL, ALT, Mods { shift: true, ..ALT }] { + assert_eq!( + edit(chord(KeyCode::ArrowUp, us(KeyCode::ArrowUp), m, false)), + Move { + unit: Row, + back: true, + extend: m.shift + } + ); + } + assert_eq!( + chord(KeyCode::ArrowDown, us(KeyCode::ArrowDown), SUP, false), + None + ); + for (key, back) in [(KeyCode::Home, true), (KeyCode::End, false)] { + assert_eq!( + edit(chord(key, us(key), NONE, false)), + Move { + unit: Line, + back, + extend: false + } + ); + assert_eq!( + edit(chord( + key, + us(key), + Mods { + shift: true, + ..CTRL + }, + false + )), + Move { + unit: Edge, + back, + extend: true + } + ); + // macOS keeps the text-field chord: HOME and END go to the text's edge. + assert_eq!( + edit(chord(key, us(key), NONE, true)), + Move { + unit: Edge, + back, + extend: false + } + ); + } + } + /// AltGr arrives as Ctrl+Alt and types letters on European layouts (Polish `Δ…`, `Δ‡`, `ΕΊ`). #[test] fn altgr_letters_are_not_clipboard_chords() { diff --git a/crates/benilla-app/src/ui_script/chat_tests.rs b/crates/benilla-app/src/ui_script/chat_tests.rs index c5d8265c..0d07bae2 100644 --- a/crates/benilla-app/src/ui_script/chat_tests.rs +++ b/crates/benilla-app/src/ui_script/chat_tests.rs @@ -244,7 +244,11 @@ fn chat_box_arrows_edit_and_history_recalls() { ); assert!(s.focus_editbox("ChatFrameEditBox")); s.char_input("dra"); - s.editbox_action(EditAction::HistoryPrev); + s.editbox_action(EditAction::Move { + unit: EditUnit::Row, + back: true, + extend: false, + }); // `ChatEdit_AddHistory` filed "/y hi" (`ChatFrame.lua:1916-1937`); the recall's `OnTextSet` // parse (l.2077-2079) turns it back into YELL with "hi". assert!( @@ -258,7 +262,11 @@ fn chat_box_arrows_edit_and_history_recalls() { s.eval::("return ChatFrameEditBox:GetText()") .unwrap() ); - s.editbox_action(EditAction::HistoryNext); + s.editbox_action(EditAction::Move { + unit: EditUnit::Row, + back: false, + extend: false, + }); assert_eq!( s.eval::("return ChatFrameEditBox:GetText()") .unwrap(), diff --git a/crates/benilla-app/src/ui_script/help_frame_tests.rs b/crates/benilla-app/src/ui_script/help_frame_tests.rs index 4a080675..6a615011 100644 --- a/crates/benilla-app/src/ui_script/help_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/help_frame_tests.rs @@ -1,7 +1,10 @@ //! The stock GM help window (`HelpFrame.xml`): the two faces of `UPDATE_TICKET`, the ticket //! toast and its repoll, and the status ask on show. -use benilla_ui::script::{GmTicketIntent, ScriptValue, UiScript}; +use benilla_ui::script::{ + EditAction, EditBoxAdvanceRequest, EditUnit, GmTicketIntent, MeasureRequest, ScriptValue, + TextMeasure, UiScript, +}; use super::test_ui::load_ui as load_xml; @@ -237,3 +240,62 @@ fn the_ticket_text_grows_with_its_lines_and_its_scroll_frame_ranges_over_them() ); assert!(s.errors().is_empty(), "{:?}", s.errors()); } + +/// A stand-in font engine: each byte 7 wide, each line 14 tall, a multi-line box's rows starting +/// after each newline, a trailing one opening an empty row as the reference's break routine does +/// (`0x5c250b`–`0x5c2522`); the texts here never wrap. +struct Rows; + +impl TextMeasure for Rows { + fn measure(&mut self, req: &MeasureRequest) -> (f32, f32, f32) { + let natural = req.text.split('\n').map(str::len).max().unwrap_or(0) as f32 * 7.0; + (natural, req.text.split('\n').count() as f32 * 14.0, natural) + } + + fn editbox_advances( + &mut self, + req: &EditBoxAdvanceRequest, + ) -> Option<(Vec, Vec, f32)> { + let cum = (0..=req.text.len()).map(|i| i as f32 * 7.0).collect(); + if req.wrap_width.is_none() { + return Some((cum, vec![0], 0.0)); + } + let breaks = req.text.match_indices('\n').map(|(i, _)| i + 1); + let rows = std::iter::once(0).chain(breaks).collect(); + Some((cum, rows, 14.0)) + } +} + +/// The ticket text is multi-line: UP moves the caret a row at its letter column (`0x77cb20`), and +/// Shift+HOME selects back to the line's start (`0x77c980`), not the ticket's. +#[test] +fn the_ticket_texts_caret_moves_by_rows_and_lines() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = setup(); + s.set_text_measurer(Box::new(Rows)); + s.run("ShowUIPanel(HelpFrame) HelpFrame_ShowFrame(\"OpenTicket\")") + .unwrap(); + s.resolve(); + s.run( + "HelpFrameOpenTicketText:SetText('Stuck in\\nthe mine') \ + HelpFrameOpenTicketText:SetFocus()", + ) + .unwrap(); + s.tick(0.016); + s.resolve(); + let mv = |s: &mut UiScript, unit, back, extend| { + assert!(s.editbox_action(EditAction::Move { unit, back, extend })); + }; + // From the end, column 8: the end of "Stuck in". + mv(&mut s, EditUnit::Row, true, false); + s.char_input(","); + mv(&mut s, EditUnit::Edge, false, false); + mv(&mut s, EditUnit::Line, true, true); + s.char_input("a cave"); + assert_eq!( + s.eval::("return HelpFrameOpenTicketText:GetText()") + .unwrap(), + "Stuck in,\na cave" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-app/src/ui_script/input.rs b/crates/benilla-app/src/ui_script/input.rs index 1dac148e..dd1b5801 100644 --- a/crates/benilla-app/src/ui_script/input.rs +++ b/crates/benilla-app/src/ui_script/input.rs @@ -285,7 +285,7 @@ pub(super) fn feed_ui_input( } } else if let Some(chord) = chord { // The gate is on the key, not the action: a gated arrow never reaches the box (the - // reference's `return 0`), while HOME/END, which also make `Move { unit: Edge }`, do. + // reference's `return 0`), while HOME/END, which also make a `Move`, do. let gated_arrow = capture.arrows_fall_through && matches!( ev.key_code, diff --git a/crates/benilla-app/tests/mail_frame.rs b/crates/benilla-app/tests/mail_frame.rs index 66f529e8..bdc692ad 100644 --- a/crates/benilla-app/tests/mail_frame.rs +++ b/crates/benilla-app/tests/mail_frame.rs @@ -3,7 +3,10 @@ mod common; -use benilla_ui::script::{MailInboxRow, MailInvoice, MailState, UiScript}; +use benilla_ui::script::{ + EditAction, EditBoxAdvanceRequest, EditUnit, MailInboxRow, MailInvoice, MailState, + MeasureRequest, TextMeasure, UiScript, +}; /// The mail window's load prefix, in the app's order. const FILES: &[&str] = &[ @@ -815,3 +818,165 @@ fn the_compose_reset_clears_the_subject_and_the_attachment_together() { "and the attachment is gone with it" ); } + +/// A stand-in font engine: each byte 7 wide, each line 14 tall, a multi-line box's rows starting +/// after each newline, a trailing one opening an empty row as the reference's break routine does +/// (`0x5c250b`–`0x5c2522`); the texts here never wrap. +struct Mono; + +impl TextMeasure for Mono { + fn measure(&mut self, req: &MeasureRequest) -> (f32, f32, f32) { + let text = req.text.strip_suffix('\n').unwrap_or(&req.text); + let natural = text.split('\n').map(str::len).max().unwrap_or(0) as f32 * 7.0; + (natural, text.split('\n').count() as f32 * 14.0, natural) + } + + fn editbox_advances( + &mut self, + req: &EditBoxAdvanceRequest, + ) -> Option<(Vec, Vec, f32)> { + let cum = (0..=req.text.len()).map(|i| i as f32 * 7.0).collect(); + if req.wrap_width.is_none() { + return Some((cum, vec![0], 0.0)); + } + let breaks = req.text.match_indices('\n').map(|(i, _)| i + 1); + let rows = std::iter::once(0).chain(breaks).collect(); + Some((cum, rows, 14.0)) + } +} + +/// The send tab, open, its boxes measured by [`Mono`]. +fn send_tab() -> UiScript { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(1024.0, 768.0); + s.set_text_measurer(Box::new(Mono)); + load_ui(&s); + s.fire_event("MAIL_SHOW", vec![]); + s.run("MailFrameTab_OnClick(2)").unwrap(); + s.resolve(); + s +} + +fn frames(s: &mut UiScript, n: usize) { + for _ in 0..n { + s.tick(0.016); + s.resolve(); + } +} + +fn mv(s: &mut UiScript, unit: EditUnit, back: bool) { + assert!(s.editbox_action(EditAction::Move { + unit, + back, + extend: false, + })); +} + +/// The letter body is multi-line: UP and DOWN move a row at the caret's letter column (`0x77cb20`), +/// HOME and END stop at the line's newline (`0x77c980`, `0x77c9f0`), and Ctrl+HOME and Ctrl+END +/// go to the letter's ends (`0x77ca60`, `0x77cac0`). +#[test] +fn the_letter_body_moves_its_caret_by_rows_and_lines() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = send_tab(); + s.run( + "SendMailBodyEditBox:SetText('Dear Thrall,\\nthe ore\\nis late.') \ + SendMailBodyEditBox:SetFocus()", + ) + .unwrap(); + frames(&mut s, 1); + let body = |s: &UiScript| { + s.eval::("return SendMailBodyEditBox:GetText()") + .unwrap() + }; + // From the end, column 8: past "the ore", so it holds before that line's newline. + mv(&mut s, EditUnit::Row, true); + s.char_input("!"); + assert_eq!(body(&s), "Dear Thrall,\nthe ore!\nis late."); + mv(&mut s, EditUnit::Line, true); + s.char_input("-"); + assert_eq!( + body(&s), + "Dear Thrall,\n-the ore!\nis late.", + "HOME: this line's start" + ); + mv(&mut s, EditUnit::Line, false); + mv(&mut s, EditUnit::Row, false); + s.char_input("?"); + assert_eq!( + body(&s), + "Dear Thrall,\n-the ore!\nis late.?", + "END, then DOWN at column 9: past the last line's letters, to the text's end" + ); + mv(&mut s, EditUnit::Edge, true); + s.char_input(">"); + assert_eq!(body(&s), ">Dear Thrall,\n-the ore!\nis late.?", "Ctrl+HOME"); + assert!(s.take_errors().is_empty(), "and nothing raised on the way"); +} + +/// Ctrl+A is `HighlightText(0, -1)` (`0x77b239`), which leaves the caret (`0x77cca0`): no +/// `OnCursorChanged` reaches `ScrollingEdit_OnCursorChanged`, so a long letter scrolled to its top +/// stays there. +#[test] +fn select_all_in_a_long_letter_keeps_its_scroll() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = send_tab(); + s.run( + "SendMailBodyEditBox:SetText(string.rep('line\\n', 29) .. 'end') \ + SendMailBodyEditBox:SetFocus()", + ) + .unwrap(); + frames(&mut s, 4); + let scroll = |s: &UiScript| { + s.eval::("return SendMailScrollFrame:GetVerticalScroll()") + .unwrap() + }; + assert!( + scroll(&s) > 0.0, + "the caret at the end scrolled the letter down" + ); + mv(&mut s, EditUnit::Edge, true); + frames(&mut s, 4); + assert_eq!(scroll(&s), 0.0, "Ctrl+HOME scrolled it back to the top"); + assert!(s.editbox_action(EditAction::SelectAll)); + frames(&mut s, 4); + assert_eq!( + scroll(&s), + 0.0, + "Ctrl+A left the caret, and the view, at the top" + ); + s.char_input("x"); + assert_eq!( + s.eval::("return SendMailBodyEditBox:GetText()") + .unwrap(), + "x", + "with all of it selected" + ); + assert!(s.take_errors().is_empty(), "and nothing raised on the way"); +} + +/// The money boxes select all on focus (`MoneyInputFrame.xml`, `HighlightText()`), and `Insert` +/// deletes a selection before its `numeric` test refuses a letter (`0x77bf13`, `0x77bf41`): a +/// letter typed into the gold box clears the amount. +#[test] +fn a_letter_typed_over_the_selected_gold_clears_it() { + let _data = benilla_formats::wow_data_or_skip!(); + let mut s = send_tab(); + s.run("SendMailMoneyGold:SetText('12') SendMailMoneyGold:SetFocus()") + .unwrap(); + frames(&mut s, 1); + assert!(s.char_input("g"), "the focused box consumes"); + assert_eq!( + s.eval::("return SendMailMoneyGold:GetText()") + .unwrap(), + "" + ); + frames(&mut s, 1); + assert_eq!( + s.eval::("return MoneyInputFrame_GetCopper(SendMailMoney)") + .unwrap(), + 0.0, + "OnTextChanged told the money frame" + ); + assert!(s.take_errors().is_empty(), "and nothing raised on the way"); +} diff --git a/crates/benilla-ui/src/script/editbox.rs b/crates/benilla-ui/src/script/editbox.rs index cc8a86cb..8ce2847a 100644 --- a/crates/benilla-ui/src/script/editbox.rs +++ b/crates/benilla-ui/src/script/editbox.rs @@ -8,8 +8,8 @@ //! - Routing: a focused box consumes every key and char and fires only its specialized scripts //! (Enter, Escape, Space, Tab, TextChanged, TextSet, focus), never a generic `OnKeyDown`; an //! unfocused box that is not `autoFocus` ignores input. -//! - Editing: an insert replaces the selection; `numeric` aborts a whole insert on any non-digit; -//! caps trim from the end (`maxBytes`, then `maxLetters`). +//! - Editing: an insert deletes the selection, then `numeric` aborts a whole insert on any +//! non-digit; caps trim from the end (`maxBytes`, then `maxLetters`). //! - Events: an edit, a caret move, a focus change or a re-seat of the text only raises the box's //! dirty bits (`[E+0x31c]`); the box's flush, run after its own `OnUpdate` in the tick's walk //! ([`update`]) and before a key or click acts on it, fires `OnCursorChanged` and then @@ -108,22 +108,32 @@ pub(super) fn action(lua: &Lua, a: EditAction) -> bool { EditUnit::Char => move_horizontal(lua, h, !back, extend), // Ctrl (Option on macOS) moves by word (the client's Ctrl check, `0x41f8f0(1)`). EditUnit::Word => interact::move_word(lua, h, !back, extend), + EditUnit::Line => { + with_eb(lua, h, |eb| eb.move_to_line_edge(!back, extend)); + } EditUnit::Edge => move_to_edge(lua, h, !back, extend), + // UP/DOWN fork on `multiLine` (`0x77b64e`, `0x77b675`): a row in a multi-line box, + // else history recall (`0x77d030`, `0x77cfd0`; benilla's is shell-style, see + // `EditBoxState::history_step`). On an alt-arrow box, as the stock chat box is, that is + // Alt+Up/Down. + EditUnit::Row => { + if with_eb(lua, h, |eb| eb.multi_line).unwrap_or(false) { + // The rows the move reads, measured now when an engine is installed. + seam::advances_current(lua, h); + with_eb(lua, h, |eb| eb.move_by_row(!back, extend)); + } else { + history_step_key(lua, h, back); + } + } }, EditAction::Delete { unit, back } => match unit { EditUnit::Char => delete_dir(lua, h, !back), EditUnit::Word => delete_span(lua, h, |eb| eb.word_boundary(!back)), EditUnit::Edge => delete_span(lua, h, |eb| if back { 0 } else { eb.text.len() }), + // No keymap deletes by line or row. + EditUnit::Line | EditUnit::Row => {} }, EditAction::SelectAll => highlight_text(lua, h, 0, -1), - // History recall (`historyLines`): older, newer, then back to the live draft. A multiLine - // box consumes it inert (it has no vertical caret movement). On an alt-arrow box, as the - // stock chat box is, recall is Alt+Up/Down; the reference's history controller is untraced. - EditAction::HistoryPrev | EditAction::HistoryNext => { - if !with_eb(lua, h, |eb| eb.multi_line).unwrap_or(false) { - history_step_key(lua, h, a == EditAction::HistoryPrev); - } - } } true } @@ -260,17 +270,21 @@ fn clear_focus_handle(lua: &Lua, h: FrameHandle) { // Editing primitives: mutate under one borrow, then sync and fire // ───────────────────────────────────────────────────────────────────────────────────────────── -/// `Insert` (`0x77bee0`): replace the selection, abort on a non-digit when `numeric`, splice, +/// `Insert` (`0x77bee0`): delete the selection, abort on a non-digit when `numeric`, splice, /// enforce caps, raise the dirty bits for the flush, and with `fire_space` fire one /// `OnSpacePressed` per space. fn insert(lua: &Lua, h: FrameHandle, ins: &str, fire_space: bool) { let Some(out) = with_eb(lua, h, |eb| eb.insert(ins)) else { return; // not an EditBox }; - if !out.text_changed { - return; // numeric-aborted + if out.text_changed { + sync_text_region(lua, h); + } + if !out.inserted { + // Refused: a numeric abort leaves at most the selection's deletion, whose text bit fires + // `OnTextChanged` at the flush, and returns before `OnChar` (`0x77bf6d` β†’ `0x77c1b8`). + return; } - sync_text_region(lua, h); let id = frame_id_of(lua, h); // Insert fires the generic `OnChar` with the spliced string as `arg1` (`0x77c13c`, through the // varargs firer `0x7026f0`), ahead of the flush's `OnTextChanged`; `SetText` does not. @@ -329,12 +343,12 @@ fn history_step_key(lua: &Lua, h: FrameHandle, older: bool) { } } -/// Left/Right: `shift` extends from the anchor, else collapse the selection or move one char. +/// Left/Right: `shift` extends the selection, else collapse it or move one char. fn move_horizontal(lua: &Lua, h: FrameHandle, right: bool, shift: bool) { with_eb(lua, h, |eb| eb.move_by_char(right, shift)); } -/// Home/End; `shift` extends from the anchor. +/// Ctrl+Home/End and Cmd+Up/Down, to the text's edge; `shift` extends the selection. fn move_to_edge(lua: &Lua, h: FrameHandle, end: bool, shift: bool) { with_eb(lua, h, |eb| eb.move_to_edge(end, shift)); } diff --git a/crates/benilla-ui/src/script/editbox/flush.rs b/crates/benilla-ui/src/script/editbox/flush.rs index ff7018c1..06427294 100644 --- a/crates/benilla-ui/src/script/editbox/flush.rs +++ b/crates/benilla-ui/src/script/editbox/flush.rs @@ -14,11 +14,12 @@ pub(in crate::script) fn update(lua: &Lua, h: FrameHandle, dt: f32) { } /// The flush `0x77d3e0`. It samples bit 0 at entry (`0x77d3f2`); on bit 0 the relayout runs -/// (`0x77d447`) and the bit clears (`0x77d44c`); on bit 2 the caret leg runs (`0x77d475`), firing -/// `OnCursorChanged`, and only then does the bit clear (`0x77d47a`); last, `OnTextChanged` fires -/// if bit 0 was set at entry (`0x77d481`–`0x77d498`). So a handler's edit raises bits for the next -/// flush, a frame later, and never loops inside one: an `OnTextChanged` that sets its own text -/// fires once more, next frame, and a caret moved by `OnCursorChanged` is cleared with the bit. +/// (`0x77d447`) and the bit clears (`0x77d44c`); on bit 2 the caret leg runs (`0x77d475`), with +/// the blink and `OnCursorChanged`, and only then does the bit clear (`0x77d47a`); last, +/// `OnTextChanged` fires if bit 0 was set at entry (`0x77d481`–`0x77d498`). So a handler's edit +/// raises bits for the next flush, a frame later, and never loops inside one: an `OnTextChanged` +/// that sets its own text fires once more, next frame, and a caret moved by `OnCursorChanged` is +/// cleared with the bit. /// Besides the walk, the box's `OnKeyDown` (`0x77b1e2`) and `OnMouseDown` (`0x77b819`) run it /// before the key or click acts. /// @@ -47,17 +48,33 @@ pub(super) fn flush(lua: &Lua, h: FrameHandle) { } } -/// The caret leg `0x77da80`: `OnCursorChanged(x, y, w, h)` (`0x77de1b`, in UI units via -/// `0x77dd5f`), `x` the caret's advance along its line, `y` minus the row index times the row -/// pitch, `w` the constant 4.0 and `h` the line height. The arguments are computed only for a box -/// with the script (`0x77dd8c`), from the box's advance table, which the installed font engine -/// measures here when the text or font moved. `false` while that table is pending, with no engine -/// installed, for the next flush to retry: the host answers the focused box's a tick later. +/// The caret leg `0x77da80`: it restarts the blink (`0x77dd68`), fires `OnCursorChanged`, then +/// shows the caret in the focused box (`0x77de35`) and hides it elsewhere (`0x77de51`). So a box +/// that takes the keyboard, whose focus change raises bit 2 (`0x77afa8`), starts its caret solid. +/// `false` while the advance table is pending, for the next flush to retry. +fn caret(lua: &Lua, h: FrameHandle) -> bool { + with_eb(lua, h, |eb| eb.blink_accum = 0.0); + let placed = cursor_changed(lua, h); + let focused = lua + .app_data_ref::() + .expect("model app_data") + .focused_editbox + == Some(h); + with_eb(lua, h, |eb| eb.caret_shown = focused); + placed +} + +/// `OnCursorChanged(x, y, w, h)` (`0x77de1b`, in UI units via `0x77dd5f`), `x` the caret's advance +/// along its line, `y` minus the row index times the row pitch, `w` the constant 4.0 and `h` the +/// line height. The arguments are computed only for a box with the script (`0x77dd8c`), from the +/// box's advance table, which the installed font engine measures here when the text or font +/// moved. `false` while that table is pending, with no engine installed: the host answers the +/// focused box's a tick later. /// /// Deviation: `h` is the row pitch the advance answer carries, where the reference passes the line /// height (`0x7727b0`): 0 for a single-line box, whose answer has no pitch, and for a multi-line /// box taller than the line height only for a font with extra spacing (none in the stock UI). -fn caret(lua: &Lua, h: FrameHandle) -> bool { +fn cursor_changed(lua: &Lua, h: FrameHandle) -> bool { let id = frame_id_of(lua, h); if !event::has_widget_handler(lua, id, "OnCursorChanged") { return true; @@ -77,7 +94,10 @@ fn caret(lua: &Lua, h: FrameHandle) -> bool { (0, eb.advances[cursor_d]) }; let pitch = eb.cell_h; - Some((x, -(row as f32) * pitch, pitch)) + // `y` starts at 0 and loses one pitch per row above the caret (`0x77dae1`, `0x77db31`), + // so row 0 reports +0, never the -0 that Lua prints as "-0". + let y = 0.0 - row as f32 * pitch; + Some((x, y, pitch)) }) .flatten(); let Some((x, y, pitch)) = args else { diff --git a/crates/benilla-ui/src/script/editbox/flush_tests.rs b/crates/benilla-ui/src/script/editbox/flush_tests.rs index ffb458d0..268c7ac7 100644 --- a/crates/benilla-ui/src/script/editbox/flush_tests.rs +++ b/crates/benilla-ui/src/script/editbox/flush_tests.rs @@ -6,7 +6,8 @@ use crate::script::{ }; /// A stand-in font engine: each byte 7 wide and each line 14 tall, answering the advance table -/// inline as the host's engine does. +/// inline as the host's engine does. A multi-line box's rows start after each newline, a trailing +/// one opening an empty row, as the reference's break routine adds one (`0x5c250b`–`0x5c2522`). struct Mono; impl TextMeasure for Mono { @@ -20,11 +21,12 @@ impl TextMeasure for Mono { req: &EditBoxAdvanceRequest, ) -> Option<(Vec, Vec, f32)> { let cum = (0..=req.text.len()).map(|i| i as f32 * 7.0).collect(); - Some(( - cum, - vec![0], - if req.wrap_width.is_some() { 14.0 } else { 0.0 }, - )) + let Some(_) = req.wrap_width else { + return Some((cum, vec![0], 0.0)); + }; + let breaks = req.text.match_indices('\n').map(|(i, _)| i + 1); + let rows = std::iter::once(0).chain(breaks).collect(); + Some((cum, rows, 14.0)) } } @@ -373,3 +375,137 @@ fn a_key_flushes_the_box_before_it_acts() { ); assert!(s.errors().is_empty(), "{:?}", s.errors()); } + +/// `HighlightText` writes only the selection (`0x77cca0`), and Ctrl+A is that call (`0x77b239`): +/// the caret bit stays down, so no `OnCursorChanged` fires at the flush. +#[test] +fn highlight_text_and_ctrl_a_fire_no_on_cursor_changed() { + let mut s = script(); + logged_box(&mut s); + s.run(r#"E:SetText("hello") E:SetFocus()"#).unwrap(); + s.tick(0.016); + take_log(&s); + assert!(s.editbox_action(EditAction::Move { + unit: EditUnit::Edge, + back: true, + extend: false, + })); + s.tick(0.016); + assert_eq!(take_log(&s), "early update cursor@0 late"); + s.run("E:HighlightText()").unwrap(); + s.tick(0.016); + assert!(s.editbox_action(EditAction::SelectAll)); + s.tick(0.016); + assert_eq!( + take_log(&s), + "early update late early update late", + "the caret stayed at 0" + ); + s.char_input("X"); + assert_eq!( + s.eval::("return E:GetText()").unwrap(), + "X", + "and the selection is all of it" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A focus change raises bit 2 (`0x77afa8`), and the caret leg restarts the blink (`0x77dd68`) +/// and shows the caret in the focused box (`0x77de35`): a box that takes the keyboard back starts +/// solid, whatever phase it lost it in. +#[test] +fn taking_the_keyboard_restarts_the_caret_blink() { + let mut s = script(); + logged_box(&mut s); + s.run(r#"E:SetText("ab") E:SetFocus()"#).unwrap(); + s.tick(0.016); + s.tick(0.6); + s.resolve(); + assert!( + !s.focused_editbox_text_ui().unwrap().caret_on, + "past the half-period: off" + ); + s.run("E:ClearFocus()").unwrap(); + s.tick(0.3); + s.run("E:SetFocus()").unwrap(); + s.tick(0.016); + assert!( + s.focused_editbox_text_ui().unwrap().caret_on, + "the focus's flush shows it" + ); + s.tick(0.45); + assert!( + s.focused_editbox_text_ui().unwrap().caret_on, + "with a whole half-period ahead" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// UP and DOWN in a multi-line box move a row (`0x77cb20`), and `OnCursorChanged` reports it a +/// row pitch down (`0x77db31`); a single-line box would recall history instead. +#[test] +fn up_and_down_move_a_multi_line_boxs_caret_by_rows() { + let mut s = script(); + logged_box(&mut s); + s.run( + r#" + E:SetScript("OnCursorChanged", function() + table.insert(LOG, format("cursor@%d,%d", arg1, arg2)) + end) + E:SetMultiLine(true) + E:SetText("abc\ndefg") + E:SetFocus() + "#, + ) + .unwrap(); + s.tick(0.016); + take_log(&s); + let row = |back| EditAction::Move { + unit: EditUnit::Row, + back, + extend: false, + }; + assert!(s.editbox_action(row(true))); + s.tick(0.016); + assert!(s.editbox_action(row(false))); + s.tick(0.016); + assert_eq!( + take_log(&s), + "early update cursor@21,0 late early update cursor@21,-14 late", + "column 4 holds before the newline above, then comes back down to column 3" + ); + s.char_input("|"); + assert_eq!( + s.eval::("return E:GetText()").unwrap(), + "abc\ndef||g" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// `OnCursorChanged`'s `y` starts at 0 and loses one row pitch per row above the caret +/// (`0x77dae1`, `0x77db31`): a single-line box, and a multi-line box's first row, report +0, which +/// Lua prints as "0", never "-0". +#[test] +fn on_cursor_changed_reports_the_first_row_at_plus_zero() { + let mut s = script(); + logged_box(&mut s); + s.run( + r#" + E:SetScript("OnCursorChanged", function() table.insert(LOG, tostring(arg2)) end) + E:SetText("ab") + "#, + ) + .unwrap(); + s.tick(0.016); + assert_eq!(take_log(&s), "early update 0 text:ab late", "single-line"); + s.run(r#"E:SetMultiLine(true) E:SetText("a\nb")"#).unwrap(); + s.tick(0.016); + s.run(r#"E:SetText("c")"#).unwrap(); + s.tick(0.016); + assert_eq!( + take_log(&s), + "early update -14 text:a\nb late early update 0 text:c late", + "multi-line, the second row and then the first" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/editbox/interact.rs b/crates/benilla-ui/src/script/editbox/interact.rs index 8809ea77..fe9122cd 100644 --- a/crates/benilla-ui/src/script/editbox/interact.rs +++ b/crates/benilla-ui/src/script/editbox/interact.rs @@ -50,7 +50,7 @@ pub(in crate::script) fn click(lua: &Lua, id: u32, x: f32, y: f32) { } /// Mouse move with the button held (`0x77a860`): a dragging focused box extends its selection to -/// the hovered index through the Shift+arrow helper (`0x77cd10`). +/// the hovered index ([`crate::widget::EditBoxState::move_caret_to`]). pub(in crate::script) fn drag_update(lua: &Lua, x: f32, y: f32) { let Some(h) = focused(lua) else { return }; if with_eb(lua, h, |eb| eb.drag_active) != Some(true) { @@ -72,7 +72,7 @@ pub(in crate::script) fn drag_end(lua: &Lua) { } /// Ctrl+Left/Right, the word move: the client walks its per-byte class array (`0x41f8f0(1)`), -/// benilla stops at alphanumeric runs. `shift` extends the selection from its anchor. +/// benilla stops at alphanumeric runs. `shift` extends the selection. pub(in crate::script) fn move_word(lua: &Lua, h: FrameHandle, right: bool, shift: bool) { with_eb(lua, h, |eb| eb.move_by_word(right, shift)); } diff --git a/crates/benilla-ui/src/script/editbox/tests.rs b/crates/benilla-ui/src/script/editbox/tests.rs index 4d06b3d9..a49078b0 100644 --- a/crates/benilla-ui/src/script/editbox/tests.rs +++ b/crates/benilla-ui/src/script/editbox/tests.rs @@ -6,6 +6,18 @@ fn script() -> UiScript { UiScript::new().expect("construct UiScript") } +/// UP and DOWN, which recall history in a single-line box. +const UP: EditAction = EditAction::Move { + unit: EditUnit::Row, + back: true, + extend: false, +}; +const DOWN: EditAction = EditAction::Move { + unit: EditUnit::Row, + back: false, + extend: false, +}; + fn text_quad(s: &UiScript) -> Option { s.extract().into_iter().find_map(|q| match q.content { QuadContent::Text { text, .. } => text, @@ -291,6 +303,41 @@ fn numeric_aborts_a_mixed_insert_wholesale() { assert_eq!(s.eval::("return E:GetText()").unwrap(), "12"); } +/// `Insert` deletes the selection before its `numeric` test (`0x77bf13`, then `0x77bf41`): a +/// letter typed over a numeric box's selection empties it, so `OnTextChanged` fires at the flush, +/// while the abort comes before `OnChar` (`0x77c13c`). +#[test] +fn a_non_digit_typed_over_a_numeric_selection_deletes_it() { + let mut s = script(); + s.run( + r#" + CHARS, CHANGES = 0, 0 + E = CreateFrame("EditBox", "E") + E:SetNumeric(true) + E:SetText("125") + E:SetScript("OnChar", function() CHARS = CHARS + 1 end) + E:SetScript("OnTextChanged", function() CHANGES = CHANGES + 1 end) + E:SetFocus() + E:HighlightText() + "#, + ) + .unwrap(); + s.tick(0.016); + s.run("CHANGES = 0").unwrap(); + assert!(s.char_input("g"), "focused: consumed"); + assert_eq!(s.eval::("return E:GetText()").unwrap(), ""); + s.tick(0.016); + assert_eq!( + s.eval::<(i64, i64)>("return CHARS, CHANGES").unwrap(), + (0, 1) + ); + assert_eq!( + text_quad(&s).as_deref(), + Some(""), + "the text region follows" + ); +} + #[test] fn max_letters_trims_from_the_end() { let s = script(); @@ -687,19 +734,19 @@ fn history_recall_walks_up_and_down_and_restores_the_draft() { ) .unwrap(); // UP recalls newest-first; a second UP walks older; at the oldest it holds. - assert!(s.editbox_action(EditAction::HistoryPrev)); + assert!(s.editbox_action(UP)); assert_eq!(s.eval::("return E:GetText()").unwrap(), "/g two"); - s.editbox_action(EditAction::HistoryPrev); + s.editbox_action(UP); assert_eq!(s.eval::("return E:GetText()").unwrap(), "/say one"); - s.editbox_action(EditAction::HistoryPrev); + s.editbox_action(UP); assert_eq!(s.eval::("return E:GetText()").unwrap(), "/say one"); // DOWN walks newer; past the newest the stashed live draft comes back. - s.editbox_action(EditAction::HistoryNext); + s.editbox_action(DOWN); assert_eq!(s.eval::("return E:GetText()").unwrap(), "/g two"); - s.editbox_action(EditAction::HistoryNext); + s.editbox_action(DOWN); assert_eq!(s.eval::("return E:GetText()").unwrap(), "draft"); // Not browsing: DOWN does nothing. - s.editbox_action(EditAction::HistoryNext); + s.editbox_action(DOWN); assert_eq!(s.eval::("return E:GetText()").unwrap(), "draft"); } @@ -716,15 +763,15 @@ fn typing_ends_the_history_browse() { "#, ) .unwrap(); - s.editbox_action(EditAction::HistoryPrev); + s.editbox_action(UP); assert_eq!(s.eval::("return E:GetText()").unwrap(), "newer"); // A typed char turns the recalled line into a draft: the next UP starts a fresh browse from // the newest entry, stashing the edited line as the draft. s.char_input("!"); assert_eq!(s.eval::("return E:GetText()").unwrap(), "newer!"); - s.editbox_action(EditAction::HistoryPrev); + s.editbox_action(UP); assert_eq!(s.eval::("return E:GetText()").unwrap(), "newer"); - s.editbox_action(EditAction::HistoryNext); + s.editbox_action(DOWN); assert_eq!(s.eval::("return E:GetText()").unwrap(), "newer!"); } @@ -743,14 +790,14 @@ fn programmatic_set_text_keeps_the_browse_and_focus_gain_resets_it() { .unwrap(); // Rewrite the recalled line as the chat parser does ("/g two" β†’ Guild + "two"); the next UP // must reach the older entry, not the newest again. - s.editbox_action(EditAction::HistoryPrev); + s.editbox_action(UP); assert_eq!(s.eval::("return E:GetText()").unwrap(), "/g two"); s.run(r#"E:SetText("two")"#).unwrap(); - s.editbox_action(EditAction::HistoryPrev); + s.editbox_action(UP); assert_eq!(s.eval::("return E:GetText()").unwrap(), "/say one"); // Refocusing starts a fresh session: the stale walk drops, UP recalls the newest again. s.run(r#"E:ClearFocus() E:SetFocus()"#).unwrap(); - s.editbox_action(EditAction::HistoryPrev); + s.editbox_action(UP); assert_eq!(s.eval::("return E:GetText()").unwrap(), "/g two"); } @@ -770,11 +817,11 @@ fn history_caps_at_history_lines_drop_oldest() { .unwrap(); assert_eq!(s.eval::("return E:GetHistoryLines()").unwrap(), 2); // Only b/c survive: two UPs land on 'b', a third holds there. - s.editbox_action(EditAction::HistoryPrev); + s.editbox_action(UP); assert_eq!(s.eval::("return E:GetText()").unwrap(), "c"); - s.editbox_action(EditAction::HistoryPrev); + s.editbox_action(UP); assert_eq!(s.eval::("return E:GetText()").unwrap(), "b"); - s.editbox_action(EditAction::HistoryPrev); + s.editbox_action(UP); assert_eq!(s.eval::("return E:GetText()").unwrap(), "b"); } @@ -786,7 +833,7 @@ fn history_off_by_default_and_up_is_still_consumed() { // No historyLines: AddHistoryLine does nothing, and UP is consumed but inert, since a focused // box eats every key (both handlers return 1, `0x77a900`/`0x77b160`). s.run(r#"E:AddHistoryLine("x")"#).unwrap(); - assert!(s.editbox_action(EditAction::HistoryPrev)); + assert!(s.editbox_action(UP)); assert_eq!(s.eval::("return E:GetText()").unwrap(), "t"); } @@ -1242,9 +1289,17 @@ fn shift_arrow_selects_the_whole_link_and_typing_replaces_it() { #[test] fn typing_strictly_inside_a_link_is_refused() { - // Only the mouse can put the caret there, so place it the way a click would. - let mut s = box_with_link(""); - s.run("E:HighlightText(35, 35)").unwrap(); // mid-"Ironfoe" + // Only the mouse can put the caret there: a click, which stops on each visible character. + let mut s = script(); + seam_rig(&mut s, ""); + s.run(&format!("E:SetWidth(400) E:Insert(\"{LINK}\")")) + .unwrap(); + s.resolve(); + answer_advances(&mut s); + // Byte 35, mid-"Ironfoe", at 7 px per byte from the box's left edge at 100. + assert!(s.mouse_button(100.0 + 35.0 * 7.0, 60.0, "LeftButton", true)); + s.mouse_button(100.0 + 35.0 * 7.0, 60.0, "LeftButton", false); + assert_eq!(s.focused_editbox_text_ui().unwrap().caret_x, 245.0); s.char_input("x"); assert_eq!( text_of(&s), @@ -1252,7 +1307,11 @@ fn typing_strictly_inside_a_link_is_refused() { "the client swallows it rather than splitting the link" ); // At the link's leading edge the keystroke lands: the guard also tests the previous token. - s.run("E:HighlightText(0, 0)").unwrap(); + s.editbox_action(EditAction::Move { + unit: EditUnit::Edge, + back: true, + extend: false, + }); s.char_input("x"); assert_eq!(text_of(&s), format!("x{LINK}")); } diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 0f99293b..55f204f8 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -1058,7 +1058,7 @@ impl UiScript { /// Whether the focused EditBox is in alt-arrow mode (XML `ignoreArrows`, Lua /// `SetAltArrowKeyMode`, `[editbox+0x318] & 0x10`): without ALT the reference declines the four /// arrows (`0x77b1c4`), so they reach the world's bindings and turn the player while chat has - /// focus. The gate is on the key, not the [`EditAction`]: HOME and END also move to an edge. + /// focus. The gate is on the key, not the [`EditAction`]: HOME and END also move the caret. pub fn editbox_alt_arrow_mode(&self) -> bool { let model = self.model_ref(); model.focused_editbox.is_some_and(|h| { diff --git a/crates/benilla-ui/src/widget/kinds/editbox.rs b/crates/benilla-ui/src/widget/kinds/editbox.rs index 6411138b..73ec3777 100644 --- a/crates/benilla-ui/src/widget/kinds/editbox.rs +++ b/crates/benilla-ui/src/widget/kinds/editbox.rs @@ -7,16 +7,23 @@ pub enum EditUnit { Char, /// One word run ([`EditBoxState::word_boundary`]): Ctrl/Option+arrow. Word, - /// Text start going back, text end going forward: Home/End, Cmd+arrow. + /// The caret's line, which ends at a newline: HOME/END (`0x77c980`/`0x77c9f0`), and + /// Cmd+Left/Right on a Mac. + Line, + /// Text start going back, text end going forward: Ctrl+HOME/END (`0x77ca60`/`0x77cac0`), + /// and Cmd+Up/Down on a Mac. Edge, + /// One wrapped row up or down: UP/DOWN in a multi-line box (`0x77cb20`). A single-line box + /// recalls its history instead (`0x77d030`/`0x77cfd0`), older going back. + Row, } /// One semantic editing operation for [`EditBoxState::apply`]: the host's per-OS keymap picks the /// action, the reference's edit-box law decides its effect. Clipboard operations stay host-side. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum EditAction { - /// Move the caret one `unit`; `extend` (Shift) drags the selection from its fixed anchor. The - /// alt-arrow gate (`0x77b18e`) acts upstream, on the key. + /// Move the caret one `unit`; `extend` (Shift) drags the selection by the reference's rule + /// ([`EditBoxState::extend_to`]). The alt-arrow gate (`0x77b18e`) acts upstream, on the key. Move { unit: EditUnit, back: bool, @@ -24,12 +31,8 @@ pub enum EditAction { }, /// Delete one `unit` from the caret, or the selection if any; `Edge` back is Cmd+Backspace. Delete { unit: EditUnit, back: bool }, - /// Ctrl+A: select all, caret to the end (`HighlightText(0, -1)`). + /// Ctrl+A: select all (`HighlightText(0, -1)`), the caret left where it is. SelectAll, - /// Recall the next older submitted line (`historyLines`). - HistoryPrev, - /// Step back toward the newest line; past it, restore the stashed draft. - HistoryNext, } /// A `CSimpleEditBox`'s runtime state; `E+` offsets are from its `CScriptObject` `this`. Byte @@ -38,9 +41,10 @@ pub enum EditAction { pub struct EditBoxState { /// The real text (`E+0x32c`); `password` masks only the display (`E+0x334`, `0x77d4d0`). pub text: String, - /// The caret, a byte offset into the text (`E+0x36c`, clamped to `[0, len]`). + /// The caret, a byte offset into the text (`E+0x36c`, clamped to `[0, len]`). It can sit + /// anywhere against the selection: `HighlightText` writes only the selection. pub cursor: usize, - /// Selection start (`E+0x35c`); equal to `sel_end` and the cursor when nothing is selected. + /// Selection start (`E+0x35c`); equal to `sel_end` when nothing is selected. pub sel_start: usize, /// Selection end (`E+0x360`); an insert replaces a non-empty selection first (`0x77cd70`). pub sel_end: usize, @@ -69,8 +73,8 @@ pub struct EditBoxState { /// The caret texture (`E+0x368`, ctor `0x779c86`), a solid quad above the text. It paints /// nothing: the host draws the caret from `caret_shown`. pub caret_region: Option, - /// The submitted lines, oldest first. UP/DOWN recall and the restored draft are inferred: the - /// reference's history controller (`0x77b730`) is untraced. + /// The submitted lines, oldest first, which UP and DOWN recall in a single-line box + /// ([`Self::history_step`]). pub history: Vec, /// `historyLines`, the most lines kept; 0 (the default) is no history. pub history_max: usize, @@ -114,7 +118,9 @@ pub struct EditBoxState { pub blink_period: f32, /// Blink accumulator (`E+0x374`): grows while focused; crossing the period toggles the caret. pub blink_accum: f32, - /// The caret shows this half-period; every cursor, text or selection change turns it on. + /// The caret shows this half-period. The flush's caret leg, which every caret move, edit and + /// focus change raises, turns it on in the focused box (`0x77de35`), off in others + /// (`0x77de51`). pub caret_shown: bool, /// The selection tint (`SetHighlightColor`), RGBA 0..1; the ctor default is `0xFF606060`. pub highlight_color: [f32; 4], @@ -220,6 +226,14 @@ impl EditBoxState { /// One UP (`older`) or DOWN step, returning the text to show: the first UP stashes the draft, /// and DOWN past the newest entry restores it. + /// + /// Deviation: the history browses as a shell's does, a small everyday gain: the first UP + /// stashes the line being typed and DOWN past the newest line brings it back, UP holds at the + /// oldest, DOWN does nothing when not browsing, typing or a focus gain ends the browse, and an + /// empty line is not kept. The reference's history is a ring of `historyLines` slots that + /// keeps any line (`0x77cf40`); UP scans back for the previous filled slot, wrapping + /// (`0x77d030`), DOWN forward with `idiv` (`0x77cfd0`), each a `SetText` (`0x77be00`), with no + /// draft and no stop at either end. pub fn history_step(&mut self, older: bool) -> Option { if self.history.is_empty() { return None; @@ -402,7 +416,8 @@ impl EditBoxState { } } - /// Show the caret and restart its blink, as the client's dirty flush does on every change. + /// Show the caret and restart its blink, as the flush's caret leg does for the focused box + /// (`0x77dd68`, `0x77de35`); the glue fields, which have no flush, call it on each change. pub fn reset_blink(&mut self) { self.caret_shown = true; self.blink_accum = 0.0; @@ -455,16 +470,19 @@ impl EditBoxState { pub struct EditOutcome { /// The text changed: `OnTextChanged`. pub text_changed: bool, + /// An insert went in, so `OnChar` fires (`0x77c13c`); a `numeric` abort that only deleted the + /// selection did not. + pub inserted: bool, /// Typed spaces inserted, one `OnSpacePressed` each; a paste reports none. pub spaces: usize, } impl EditOutcome { - /// An outcome with no typed spaces. + /// An outcome with nothing inserted. fn changed(text_changed: bool) -> Self { EditOutcome { text_changed, - spaces: 0, + ..EditOutcome::default() } } } @@ -479,7 +497,11 @@ impl EditBoxState { // it becomes an action (`UiScript::editbox_alt_arrow_mode`). EditUnit::Char => self.move_by_char(!back, extend), EditUnit::Word => self.move_by_word(!back, extend), + EditUnit::Line => self.move_to_line_edge(!back, extend), EditUnit::Edge => self.move_to_edge(!back, extend), + EditUnit::Row if self.multi_line => self.move_by_row(!back, extend), + // The caller recalls history through `SetText`, so `OnTextSet` fires. + EditUnit::Row => {} } EditOutcome::default() } @@ -493,29 +515,35 @@ impl EditBoxState { let t = if back { 0 } else { self.text.len() }; self.delete_to(t) } + // No keymap deletes by line or row. + EditUnit::Line | EditUnit::Row => false, }), EditAction::SelectAll => { self.highlight_text(0, -1); EditOutcome::default() } - // The caller recalls history through `SetText`, so `OnTextSet` fires. - EditAction::HistoryPrev | EditAction::HistoryNext => EditOutcome::default(), } } - /// Insert at the cursor (`0x77bee0`), replacing any selection; `numeric` refuses the whole - /// insert on any non-digit (`0x77bf41`). + /// Insert at the cursor (`0x77bee0`), in its order: the link guard, the selection's deletion + /// (`0x77bf13`–`0x77bf23`), then the `numeric` test (`0x77bf41`), which refuses the whole + /// insert on any non-digit and leaves the deletion standing. pub fn insert(&mut self, ins: &str) -> EditOutcome { - if self.numeric && !ins.chars().all(|c| c.is_ascii_digit()) { - return EditOutcome::default(); - } // Typing with the caret inside a hyperlink, where only the mouse can put it, is // swallowed: the opening guard of `0x77bee0`. if !crate::markup::ClassMap::new(&self.text).insert_allowed(self.cursor) { return EditOutcome::default(); } - self.end_history_browse(); + let replaced = self.sel_start != self.sel_end; self.delete_selection(); + if self.numeric && !ins.chars().all(|c| c.is_ascii_digit()) { + if replaced { + self.end_history_browse(); + self.reset_blink(); + } + return EditOutcome::changed(replaced); + } + self.end_history_browse(); self.text.insert_str(self.cursor, ins); self.cursor += ins.len(); self.collapse(); @@ -524,6 +552,7 @@ impl EditBoxState { self.dirty |= Self::DIRTY_TEXT | Self::DIRTY_CURSOR; // `0x77c033 or edx,5` EditOutcome { text_changed: true, + inserted: true, spaces: ins.matches(' ').count(), } } @@ -589,7 +618,9 @@ impl EditBoxState { Some(taken) } - /// `HighlightText` (`0x77cca0`) with the client's clamp, so `(0, -1)` selects all. + /// `HighlightText` (`0x77cca0`) with the client's clamp, so `(0, -1)` selects all. It writes + /// only the selection (`+0x35c`/`+0x360`) and raises bit 1 (`0x77ccc8`): the caret stays put, + /// so no `OnCursorChanged` fires and the blink runs on. pub fn highlight_text(&mut self, start: i64, end: i64) { let len = self.text.len() as i64; let s = start.clamp(0, len); @@ -599,14 +630,6 @@ impl EditBoxState { } self.sel_start = snap_down(&self.text, s as usize); self.sel_end = snap_down(&self.text, e as usize); - // benilla moves the caret to the selection's end where the reference does not: - // `0x77cca0` writes only the selection (`+0x35c`/`+0x360`) and raises bit 1 (`0x77ccc8`). - // Bit 2 rises here only with that move. - if self.cursor != self.sel_end { - self.cursor = self.sel_end; - self.dirty |= Self::DIRTY_CURSOR; - } - self.reset_blink(); } /// Backspace (`forward = false`) or Delete: the selection if any, else one step. @@ -657,30 +680,33 @@ impl EditBoxState { did } - /// Left or Right one step, `extend` dragging the selection; without it, a selection collapses - /// to its edge instead. Bit 2 rises only when the caret moved: the helpers step it only while - /// it can (`0x77c750` `cursor < len`, `0x77c870` `cursor > 0`), and the step raises the bit - /// (`0x77c73b`). + /// Left or Right one step, `extend` dragging the selection ([`Self::extend_to`]). Bit 2 and + /// the blink's restart come only when the caret moved: the helpers step it only while it can + /// (`0x77c750` `cursor < len`, `0x77c870` `cursor > 0`), and the step raises the bit + /// (`0x77c73b`), whose flush restarts the blink (`0x77dd68`). pub fn move_by_char(&mut self, right: bool, extend: bool) { let from = self.cursor; // One token step, links atomic (`0x77bb30`, `atomicLinks = 1` at `0x77c6d2`): a press // crosses a whole link, never into an escape, and Shift+arrow selects all of it. - let step = |s: &str, i: usize| { - crate::markup::ClassMap::new(s).advance(i, if right { 1 } else { -1 }, true) - }; + let target = crate::markup::ClassMap::new(&self.text).advance( + self.cursor, + if right { 1 } else { -1 }, + true, + ); if extend { - let anchor = self.selection_anchor(); - self.cursor = step(&self.text, self.cursor); - self.set_span(anchor, self.cursor); + self.extend_to(target); } else if self.sel_start != self.sel_end { + // Deviation: a plain Left or Right with a selection collapses it to that edge, as + // every OS's own text fields do; the reference clears the selection and steps once + // from the caret (`0x77c6b0` from `0x77c70b`). self.cursor = if right { self.sel_end } else { self.sel_start }; self.collapse(); } else { - self.cursor = step(&self.text, self.cursor); + self.cursor = target; self.collapse(); } - self.reset_blink(); if self.cursor != from { + self.reset_blink(); self.dirty |= Self::DIRTY_CURSOR; } } @@ -689,33 +715,158 @@ impl EditBoxState { /// steps (`0x77c8c0`/`0x77c7a0` loop `0x77c6b0`), so it always lands on a reachable stop. pub fn move_by_word(&mut self, right: bool, extend: bool) { let word = self.word_boundary(right); - let mut target = self.cursor; + self.walk(right, extend, |_, _, next| { + if right { + next <= word + } else { + next >= word + } + }); + } + + /// HOME/END: the caret back to its line's start or on to its end, a step at a time while the + /// byte before the caret (`0x77c99a`), or at it (`0x77ca0e`), is not a newline. With no + /// newline it goes to the text's edge, as in a single-line box whose text was typed: typing + /// drops a newline there (`0x77c234`–`0x77c240`), though `SetText` keeps one. + pub fn move_to_line_edge(&mut self, end: bool, extend: bool) { + self.walk(end, extend, |text, at, _| { + let bytes = text.as_bytes(); + if end { + bytes.get(at) != Some(&b'\n') + } else { + at > 0 && bytes[at - 1] != b'\n' + } + }); + } + + /// Ctrl+HOME/END and Cmd+Up/Down: the caret to `0` / `len`, a step at a time (`0x77ca60`, + /// `0x77cac0`). + pub fn move_to_edge(&mut self, end: bool, extend: bool) { + self.walk(end, extend, |_, _, _| true); + } + + /// UP/DOWN in a multi-line box (`0x77cb20`, through `0x77cc80`/`0x77cc60`): the caret to the + /// same column of the wrapped row above or below. The column is the letters from the row's + /// start to the caret (`0x77bc80`), walked out from the target row's start with links not + /// atomic (`0x77bb30`, `atomicLinks = 0` pushed at `0x77cba4`). A walk that reaches the next + /// row's start steps back one stop (`0x77cbc6`–`0x77cbdd`). On the first or last row the caret + /// stays, and without `extend` the selection clears (`0x77cb50`). The rows are the draw's + /// ([`Self::rows`]), one while they are unmeasured. + /// + /// Deviation: on the last row a walk may end at the text's end, because the end starts no row; + /// the reference steps back from it as from a row's start (its line table ends with the + /// text's length, `0x77d6ef`), so DOWN onto a short last line stops before its last letter. + pub fn move_by_row(&mut self, down: bool, extend: bool) { + let len = self.text.len(); + let display_len = self.display().len(); + let measured = self.advances.len() == display_len + 1 + && self.rows.first() == Some(&0) + && self.rows.windows(2).all(|w| w[0] < w[1]) + && self.rows.last().is_some_and(|&r| r <= display_len); + // Each row's first text byte, then the text's length. + let mut starts: Vec = if measured { + self.rows + .iter() + .map(|&d| snap_down(&self.text, self.display_to_text(d))) + .collect() + } else { + vec![0] + }; + let rows = starts.len(); + starts.push(len); + // The caret's row (`0x77bd50`): a caret on a row's start heads that row. + let mut row = 0; + while row + 1 < rows && self.cursor >= starts[row + 1] { + row += 1; + } + let target = if down { + (row + 1).min(rows - 1) + } else { + row.saturating_sub(1) + }; + if target == row { + if !extend { + self.collapse(); + } + return; + } + let map = crate::markup::ClassMap::new(&self.text); + let column = map.letters(starts[row], self.cursor - starts[row]); + let (start, next) = (starts[target], starts[target + 1]); + let mut to = map.advance(start, column as isize, false); + if target + 1 < rows && next > start && to >= next { + to = map.advance(next, -1, false); + } + if extend { + self.extend_to(to); + } else { + self.cursor = to; + self.collapse(); + } + self.reset_blink(); + self.dirty |= Self::DIRTY_CURSOR; // `0x77cc40` + } + + /// The loops of single steps behind the word, line and edge moves (`0x77c6b0`, links atomic): + /// the caret steps while `take(text, caret, next)` allows the step to `next`, each extending + /// step dragging the selection by [`Self::extend_to`]; a plain move collapses it at the end. + fn walk(&mut self, right: bool, extend: bool, take: impl Fn(&str, usize, usize) -> bool) { + let from = self.cursor; + let map = crate::markup::ClassMap::new(&self.text); loop { - let next = crate::markup::ClassMap::new(&self.text).advance( - target, - if right { 1 } else { -1 }, - true, - ); - if next == target || (right && next > word) || (!right && next < word) { + let next = map.advance(self.cursor, if right { 1 } else { -1 }, true); + if next == self.cursor || !take(&self.text, self.cursor, next) { break; } - target = next; - if target == word { - break; + if extend { + self.extend_to(next); + } else { + self.cursor = next; } } - self.move_caret_to(target, extend); + if !extend { + self.collapse(); + } + if self.cursor != from { + self.reset_blink(); + self.dirty |= Self::DIRTY_CURSOR; + } } - /// HOME/END (and Cmd+arrow): the caret to `0` / `len`. - pub fn move_to_edge(&mut self, end: bool, extend: bool) { - let target = if end { self.text.len() } else { 0 }; - self.move_caret_to(target, extend); + /// One extending caret move to `to`, the Shift leg of `0x77c6b0` and `0x77cb20`: an empty + /// selection first collapses onto the caret (`0x77ccf0`), then `0x77cd10` moves one edge by + /// the move's delta. A move left past `sel_start` moves `sel_start`, any other move left moves + /// `sel_end`, and a move right mirrors that, so a caret inside a selection trims it. + pub fn extend_to(&mut self, to: usize) { + if self.sel_start == self.sel_end { + self.collapse(); + } + if to < self.cursor { + if to < self.sel_start { + self.sel_start = to; + } else { + self.sel_end = to; + } + } else if to > self.cursor { + if to > self.sel_end { + self.sel_end = to; + } else { + self.sel_start = to; + } + } + self.cursor = to; } - /// Place the caret at `target`, extending the selection from its anchor when `extend`. Bit 2 - /// rises only when the caret moved, as for the word and edge helpers, which step it only while - /// it can (`0x77c7a0`/`0x77c8c0`, `0x77ca60`/`0x77cac0`); a click raises it on its own. + /// Place the caret at `target`, extending the selection from its anchor when `extend`: the + /// click (`0x77b800`) and the drag (`0x77a860`). Bit 2 rises only when the caret moved; a + /// click raises it on its own. + /// + /// Deviation: the drag selects from its anchor, as every OS's own text fields do, where the + /// reference's applies `0x77cd10` to each mouse move's whole delta (`0x77a8a1`). The two part + /// in two cases. One move that carries the caret across the anchor keeps the far end selected + /// too in the reference. And a drag that starts with the caret inside a selection, as after a + /// focus that selects all, gives `(0, t)` here dragging right to `t`, where the reference + /// gives `(t, len)`. pub fn move_caret_to(&mut self, target: usize, extend: bool) { let from = self.cursor; let target = snap_down(&self.text, target.min(self.text.len())); @@ -862,3 +1013,7 @@ mod row_law_tests { assert_eq!(eb.index_at_pos(15.0, 500.0), 2); } } + +#[cfg(test)] +#[path = "editbox_move_tests.rs"] +mod move_law_tests; diff --git a/crates/benilla-ui/src/widget/kinds/editbox_move_tests.rs b/crates/benilla-ui/src/widget/kinds/editbox_move_tests.rs new file mode 100644 index 00000000..5fec7a2a --- /dev/null +++ b/crates/benilla-ui/src/widget/kinds/editbox_move_tests.rs @@ -0,0 +1,261 @@ +//! The caret and selection law of [`EditBoxState`]: the row, line and edge moves, the Shift +//! extension (`0x77cd10`), `HighlightText` (`0x77cca0`) and the `numeric` insert (`0x77bee0`). + +use super::*; + +/// A multi-line box holding `text`, measured at 7 px per byte with its rows at `rows`. +fn multi_line(text: &str, rows: Vec) -> EditBoxState { + EditBoxState { + text: text.into(), + multi_line: true, + advances: (0..=text.len()).map(|i| i as f32 * 7.0).collect(), + rows, + cell_h: 14.0, + ..EditBoxState::default() + } +} + +/// A single-line box holding `text` with the caret at `cursor` and nothing selected. +fn at(text: &str, cursor: usize) -> EditBoxState { + let mut eb = EditBoxState { + text: text.into(), + cursor, + ..EditBoxState::default() + }; + eb.collapse(); + eb +} + +fn mv(eb: &mut EditBoxState, unit: EditUnit, back: bool, extend: bool) { + eb.apply(EditAction::Move { unit, back, extend }); +} + +/// UP/DOWN keep the caret's letter column across rows (`0x77bc80` then `0x77bb30`), and a walk +/// that reaches the next row's start steps back one (`0x77cbc6`–`0x77cbdd`), before the newline +/// that ends a row. On the last row it may end at the text's end (the declared deviation). +#[test] +fn up_and_down_keep_the_letter_column_across_rows() { + // "abc\n" | "defgh\n" | "ij" + let mut eb = multi_line("abc\ndefgh\nij", vec![0, 4, 10]); + eb.cursor = 8; // "defg|h" + eb.collapse(); + mv(&mut eb, EditUnit::Row, true, false); + assert_eq!( + eb.cursor, 3, + "column 4 is past \"abc\": held before its newline" + ); + mv(&mut eb, EditUnit::Row, false, false); + assert_eq!(eb.cursor, 7, "column 3 on the middle row"); + mv(&mut eb, EditUnit::Row, false, false); + assert_eq!(eb.cursor, 12, "column 3 on \"ij\": the text's end"); + assert_eq!( + eb.dirty & EditBoxState::DIRTY_CURSOR, + EditBoxState::DIRTY_CURSOR + ); +} + +/// A soft-wrapped row is a row: its swallowed space belongs to it, so a column past its letters +/// holds before that space. +#[test] +fn a_wrapped_row_is_a_row() { + // "ab " | "cdefgh", wrapped after "ab" with the space swallowed. + let mut eb = multi_line("ab cdefgh", vec![0, 3]); + eb.cursor = 9; + eb.collapse(); + mv(&mut eb, EditUnit::Row, true, false); + assert_eq!( + eb.cursor, 2, + "column 6 is past \"ab\": held before the space" + ); + mv(&mut eb, EditUnit::Row, false, false); + assert_eq!(eb.cursor, 5, "column 2 below"); + mv(&mut eb, EditUnit::Row, true, false); + assert_eq!(eb.cursor, 2); +} + +/// A trailing newline opens a row of its own (`0x5c250b`–`0x5c2522`), empty and starting at the +/// text's end: DOWN reaches it, and UP leaves it for column 0 above. +#[test] +fn a_trailing_newline_is_a_row_of_its_own() { + let mut eb = multi_line("ab\n", vec![0, 3]); + eb.cursor = 2; + eb.collapse(); + mv(&mut eb, EditUnit::Row, false, false); + assert_eq!(eb.cursor, 3); + mv(&mut eb, EditUnit::Row, true, false); + assert_eq!(eb.cursor, 0); +} + +/// On the first or last row the caret stays, and a plain UP or DOWN clears the selection there +/// (`0x77cb50`–`0x77cb8f`) without raising the caret bit. +#[test] +fn up_on_the_first_row_moves_nothing_and_clears_the_selection() { + let mut eb = multi_line("abc\ndef", vec![0, 4]); + eb.cursor = 2; + eb.highlight_text(0, 3); + eb.dirty = 0; + mv(&mut eb, EditUnit::Row, true, true); + assert_eq!( + (eb.cursor, eb.sel_start, eb.sel_end), + (2, 0, 3), + "Shift keeps it" + ); + mv(&mut eb, EditUnit::Row, true, false); + assert_eq!(eb.cursor, 2); + assert_eq!(eb.sel_start, eb.sel_end, "the selection cleared"); + assert_eq!( + eb.dirty & EditBoxState::DIRTY_CURSOR, + 0, + "the caret did not move" + ); +} + +/// Shift+DOWN extends by the row's whole delta through `0x77cd10`. +#[test] +fn shift_down_selects_to_the_same_column_below() { + let mut eb = multi_line("abc\ndef", vec![0, 4]); + eb.cursor = 1; + eb.collapse(); + mv(&mut eb, EditUnit::Row, false, true); + assert_eq!((eb.cursor, eb.sel_start, eb.sel_end), (5, 1, 5)); + mv(&mut eb, EditUnit::Row, true, true); + assert_eq!((eb.cursor, eb.sel_start, eb.sel_end), (1, 1, 1)); +} + +/// A single-line box's UP and DOWN are its history's, which the caller recalls; the caret stays. +#[test] +fn a_single_line_box_has_no_rows_to_move_over() { + let mut eb = at("abc", 1); + mv(&mut eb, EditUnit::Row, false, false); + assert_eq!(eb.cursor, 1); +} + +/// HOME and END stop at a newline (`0x77c99a`, `0x77ca0e`); the text's edges are Ctrl+HOME and +/// Ctrl+END (`0x77ca60`, `0x77cac0`). +#[test] +fn home_and_end_stop_at_the_lines_newlines() { + let mut eb = multi_line("ab\ncd\nef", vec![0, 3, 6]); + eb.cursor = 4; // "c|d" + eb.collapse(); + mv(&mut eb, EditUnit::Line, true, false); + assert_eq!(eb.cursor, 3, "HOME: after the newline before it"); + mv(&mut eb, EditUnit::Line, true, false); + assert_eq!(eb.cursor, 3, "HOME again stays"); + mv(&mut eb, EditUnit::Line, false, true); + assert_eq!( + (eb.cursor, eb.sel_start, eb.sel_end), + (5, 3, 5), + "Shift+END" + ); + mv(&mut eb, EditUnit::Edge, true, false); + assert_eq!( + (eb.cursor, eb.sel_start, eb.sel_end), + (0, 0, 0), + "Ctrl+HOME" + ); + mv(&mut eb, EditUnit::Edge, false, false); + assert_eq!(eb.cursor, 8, "Ctrl+END"); + // A line is not a wrapped row: with no newline, HOME goes to the text's start. + let mut eb = multi_line("the quick brown", vec![0, 10]); + eb.cursor = 13; + eb.collapse(); + mv(&mut eb, EditUnit::Line, true, false); + assert_eq!(eb.cursor, 0); +} + +/// `HighlightText` writes only the selection (`0x77cca0`): the caret stays put and its bit stays +/// down, so neither the blink nor `OnCursorChanged` hears of it. +#[test] +fn highlight_text_leaves_the_caret_where_it_is() { + let mut eb = at("hello world", 3); + eb.dirty = 0; + eb.caret_shown = false; + eb.highlight_text(0, -1); + assert_eq!((eb.cursor, eb.sel_start, eb.sel_end), (3, 0, 11)); + assert_eq!(eb.dirty & EditBoxState::DIRTY_CURSOR, 0); + assert!(!eb.caret_shown, "the blink runs on"); + eb.apply(EditAction::SelectAll); + assert_eq!(eb.cursor, 3, "Ctrl+A is the same call"); +} + +/// Shift+arrow moves one selection edge by the step (`0x77cd10`): from a caret inside a select-all, +/// Shift+Right trims the start up to the caret and Shift+Left trims the end down to it. +#[test] +fn shift_arrow_from_inside_a_selection_trims_it_toward_the_caret() { + let mut eb = at("hello world", 5); + eb.highlight_text(0, -1); + mv(&mut eb, EditUnit::Char, false, true); + assert_eq!((eb.cursor, eb.sel_start, eb.sel_end), (6, 6, 11)); + let mut eb = at("hello world", 5); + eb.highlight_text(0, -1); + mv(&mut eb, EditUnit::Char, true, true); + assert_eq!((eb.cursor, eb.sel_start, eb.sel_end), (4, 0, 4)); + // Back at the selection's end, Shift+Right extends it again. + mv(&mut eb, EditUnit::Char, false, true); + assert_eq!((eb.sel_start, eb.sel_end), (0, 5)); +} + +/// The word, line and edge moves are loops of single steps (`0x77c6b0`), each extending through +/// `0x77cd10`: from inside a selection, Shift+END trims it to nothing at its end, then selects on +/// from there. +#[test] +fn a_multi_step_shift_move_extends_step_by_step() { + let mut eb = at("0123456789", 5); + eb.highlight_text(2, 8); + mv(&mut eb, EditUnit::Line, false, true); + assert_eq!((eb.cursor, eb.sel_start, eb.sel_end), (10, 8, 10)); +} + +/// Left at the text's start does not step (`0x77c870` tests `cursor > 0`), so neither the caret +/// bit nor the blink's restart comes; a real step brings both. +#[test] +fn a_char_move_that_goes_nowhere_leaves_the_blink() { + let mut eb = at("ab", 0); + eb.dirty = 0; + eb.caret_shown = false; + eb.blink_accum = 0.3; + mv(&mut eb, EditUnit::Char, true, false); + assert_eq!((eb.cursor, eb.dirty, eb.caret_shown), (0, 0, false)); + assert_eq!(eb.blink_accum, 0.3); + mv(&mut eb, EditUnit::Char, false, false); + assert_eq!(eb.cursor, 1); + assert_eq!(eb.dirty, EditBoxState::DIRTY_CURSOR); + assert!(eb.caret_shown && eb.blink_accum == 0.0); +} + +/// A plain Left or Right with a selection collapses it to that edge (the declared deviation). +#[test] +fn a_plain_arrow_collapses_a_selection_to_its_edge() { + let mut eb = at("hello world", 5); + eb.highlight_text(2, 8); + mv(&mut eb, EditUnit::Char, false, false); + assert_eq!((eb.cursor, eb.sel_start, eb.sel_end), (8, 8, 8)); + let mut eb = at("hello world", 5); + eb.highlight_text(2, 8); + mv(&mut eb, EditUnit::Char, true, false); + assert_eq!((eb.cursor, eb.sel_start, eb.sel_end), (2, 2, 2)); +} + +/// `Insert` deletes the selection before the `numeric` test refuses a non-digit (`0x77bf13`, then +/// `0x77bf41`): the text changes, though nothing went in. +#[test] +fn a_non_digit_over_a_numeric_selection_deletes_it() { + let mut eb = EditBoxState { + numeric: true, + ..at("1234", 4) + }; + eb.highlight_text(1, 3); + eb.dirty = 0; + let out = eb.insert("a"); + assert_eq!(eb.text, "14"); + assert_eq!((eb.cursor, eb.sel_start, eb.sel_end), (1, 1, 1)); + assert!(out.text_changed && !out.inserted, "{out:?}"); + assert_eq!( + eb.dirty & EditBoxState::DIRTY_TEXT, + EditBoxState::DIRTY_TEXT + ); + // With nothing selected the refusal changes nothing. + eb.dirty = 0; + assert_eq!(eb.insert("b"), EditOutcome::default()); + assert_eq!((eb.text.as_str(), eb.dirty), ("14", 0)); +}