EditBox caret keys, selection and focus blink follow 1.12.1 (#750)
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
be9e3d8aac
commit
f22ec0016c
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -515,22 +515,54 @@ pub(crate) fn caret_bar<C: Bundle>(
|
||||
});
|
||||
}
|
||||
|
||||
/// 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<E: Bundle, T: Bundle + Clone>(
|
||||
..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<E: Bundle, T: Bundle + Clone>(
|
||||
}
|
||||
};
|
||||
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<A: Component>(
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<KeyName>, 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<Chord> {
|
||||
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<Chord> {
|
||||
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<Chord> {
|
||||
|
||||
/// Windows/Linux: the reference's chords (`0x77b160`) plus the Ctrl word deletes.
|
||||
fn chord_pc(key: KeyCode, m: Mods) -> Option<Chord> {
|
||||
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<Chord> {
|
||||
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() {
|
||||
|
||||
@@ -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::<String>("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::<String>("return ChatFrameEditBox:GetText()")
|
||||
.unwrap(),
|
||||
|
||||
@@ -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<f32>, Vec<usize>, 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::<String>("return HelpFrameOpenTicketText:GetText()")
|
||||
.unwrap(),
|
||||
"Stuck in,\na cave"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<f32>, Vec<usize>, 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::<String>("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::<f64>("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::<String>("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::<String>("return SendMailMoneyGold:GetText()")
|
||||
.unwrap(),
|
||||
""
|
||||
);
|
||||
frames(&mut s, 1);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return MoneyInputFrame_GetCopper(SendMailMoney)")
|
||||
.unwrap(),
|
||||
0.0,
|
||||
"OnTextChanged told the money frame"
|
||||
);
|
||||
assert!(s.take_errors().is_empty(), "and nothing raised on the way");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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::<Model>()
|
||||
.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 {
|
||||
|
||||
@@ -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<f32>, Vec<usize>, 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::<String>("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::<String>("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());
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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::<String>("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::<String>("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::<String>("return E:GetText()").unwrap(), "/g two");
|
||||
s.editbox_action(EditAction::HistoryPrev);
|
||||
s.editbox_action(UP);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "/say one");
|
||||
s.editbox_action(EditAction::HistoryPrev);
|
||||
s.editbox_action(UP);
|
||||
assert_eq!(s.eval::<String>("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::<String>("return E:GetText()").unwrap(), "/g two");
|
||||
s.editbox_action(EditAction::HistoryNext);
|
||||
s.editbox_action(DOWN);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "draft");
|
||||
// Not browsing: DOWN does nothing.
|
||||
s.editbox_action(EditAction::HistoryNext);
|
||||
s.editbox_action(DOWN);
|
||||
assert_eq!(s.eval::<String>("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::<String>("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::<String>("return E:GetText()").unwrap(), "newer!");
|
||||
s.editbox_action(EditAction::HistoryPrev);
|
||||
s.editbox_action(UP);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "newer");
|
||||
s.editbox_action(EditAction::HistoryNext);
|
||||
s.editbox_action(DOWN);
|
||||
assert_eq!(s.eval::<String>("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::<String>("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::<String>("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::<String>("return E:GetText()").unwrap(), "/g two");
|
||||
}
|
||||
|
||||
@@ -770,11 +817,11 @@ fn history_caps_at_history_lines_drop_oldest() {
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<i64>("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::<String>("return E:GetText()").unwrap(), "c");
|
||||
s.editbox_action(EditAction::HistoryPrev);
|
||||
s.editbox_action(UP);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "b");
|
||||
s.editbox_action(EditAction::HistoryPrev);
|
||||
s.editbox_action(UP);
|
||||
assert_eq!(s.eval::<String>("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::<String>("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}"));
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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<RegionHandle>,
|
||||
/// 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<String>,
|
||||
/// `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<String> {
|
||||
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<usize> = 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;
|
||||
|
||||
@@ -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<usize>) -> 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));
|
||||
}
|
||||
Reference in New Issue
Block a user