diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs index 903459e8..972afda4 100644 --- a/crates/benilla-app/src/ui_script/extract/mod.rs +++ b/crates/benilla-app/src/ui_script/extract/mod.rs @@ -663,23 +663,8 @@ pub(super) fn paint_script( // The focused editbox's per-byte advances, measured as it draws, for click and scroll. if let Some(atlas) = font_atlas.as_deref_mut() { if let Some(req) = script.editbox_advances_request() { - let spec = crate::ui_text::FontSpec { - path: req.font.as_deref(), - // At the drawn size, divided by the seam alone: screen UI units, like the mouse. - height: crate::ui_text::drawn_px(req.height, None, s * req.scale), - outline: req.outline, - alpha_gradient: None, // alpha never changes metrics - }; - let cum: Vec = crate::ui_text::line_advances(&mut atlas.lock(), &req.text, spec) - .iter() - .map(|a| a / s) - .collect(); - // A multiline box also gets the draw's row starts and pitch, in UI units. - let (rows, cell_h) = match req.wrap_width { - Some(w) => crate::ui_text::line_rows(&mut atlas.lock(), &req.text, w * s, spec), - None => (vec![0], 0.0), - }; - script.set_editbox_advances(req.id, req.key, cum, rows, cell_h / s); + let (cum, rows, cell_h) = crate::ui_text::editbox_advances(&mut atlas.lock(), s, &req); + script.set_editbox_advances(req.id, req.key, cum, rows, cell_h); } } // The focused editbox's text UI: its Text quad, scroll window, caret and selection spans, diff --git a/crates/benilla-app/src/ui_script/layer_tests.rs b/crates/benilla-app/src/ui_script/layer_tests.rs index 7d8858de..bd6eb28f 100644 --- a/crates/benilla-app/src/ui_script/layer_tests.rs +++ b/crates/benilla-app/src/ui_script/layer_tests.rs @@ -988,7 +988,8 @@ fn a_click_on_the_detail_selects_all_and_copy_returns_the_whole_error() { } /// Typing, pasting, Enter, Backspace and cut leave the text as it was, the selection is put back -/// so a copy still takes it all, and the restore's own change stops after one more round. +/// so a copy still takes it all, and the restore's own change stops after one more round, bar the +/// box's resize, whose re-seat fires once more with the text unchanged (`0x77ba7f`). #[test] fn an_edit_of_the_detail_box_is_undone() { benilla_formats::wow_data_or_skip!(); @@ -1010,10 +1011,12 @@ fn an_edit_of_the_detail_box_is_undone() { ); settle(&mut s); assert_eq!(detail_text(&s), msg, "typing"); + // The edit shrank the multi-line box to one line and the restore grows it back, and each + // resize re-seats the text, raising the text bit again. assert_eq!( s.eval::("return CHANGES").unwrap(), - 2, - "the edit, then its restore" + 3, + "the edit, its restore, then the restore's resize" ); assert_eq!( s.editbox_copy().as_deref(), diff --git a/crates/benilla-app/src/ui_text/measurer.rs b/crates/benilla-app/src/ui_text/measurer.rs index 70d22f07..805c9c30 100644 --- a/crates/benilla-app/src/ui_text/measurer.rs +++ b/crates/benilla-app/src/ui_text/measurer.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; -use benilla_ui::script::{MeasureRequest, TextMeasure}; +use benilla_ui::script::{EditBoxAdvanceRequest, MeasureRequest, TextMeasure}; use super::engine::TextEngine; @@ -41,6 +41,32 @@ pub(crate) fn measure_request( (w / rs, h / rs, natural / rs) } +/// Answer one EditBox advance table as `(cumulative widths, row starts, row pitch)` in screen UI +/// units: measured at the drawn size, the seam times the box's scale, and divided by the seam +/// alone, like the mouse feed. A multi-line box also gets the draw's row starts and pitch. The one +/// body for the extract's round trip and [`AtlasMeasurer`]'s inline answer. +pub(crate) fn editbox_advances( + e: &mut TextEngine, + seam: f32, + req: &EditBoxAdvanceRequest, +) -> (Vec, Vec, f32) { + let spec = super::FontSpec { + path: req.font.as_deref(), + height: super::drawn_px(req.height, None, seam * req.scale), + outline: req.outline, + alpha_gradient: None, // alpha never changes metrics + }; + let cum = super::line_advances(e, &req.text, spec) + .iter() + .map(|a| a / seam) + .collect(); + let (rows, cell_h) = match req.wrap_width { + Some(w) => super::line_rows(e, &req.text, w * seam, spec), + None => (vec![0], 0.0), + }; + (cum, rows, cell_h / seam) +} + /// The measurer installed into the VM: the shared engine and the host's screen seam. It answers /// only for the seam it was built under, so the host installs a fresh one when the seam moves /// (`ui_script::extract::seat_text_measurer`). @@ -65,6 +91,17 @@ impl TextMeasure for AtlasMeasurer { .unwrap_or_else(std::sync::PoisonError::into_inner); measure_request(&mut e, self.seam, req) } + + fn editbox_advances( + &mut self, + req: &EditBoxAdvanceRequest, + ) -> Option<(Vec, Vec, f32)> { + let mut e = self + .engine + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + Some(editbox_advances(&mut e, self.seam, req)) + } } #[cfg(test)] diff --git a/crates/benilla-app/src/ui_text/mod.rs b/crates/benilla-app/src/ui_text/mod.rs index a5c21c9a..ede81d20 100644 --- a/crates/benilla-app/src/ui_text/mod.rs +++ b/crates/benilla-app/src/ui_text/mod.rs @@ -24,7 +24,7 @@ pub(crate) use engine::{UiFontAtlas, UiTextPlugin}; use benilla_ui::script::Outline; use benilla_ui::widget::RegionHandle; -pub(crate) use measurer::{measure_request, AtlasMeasurer}; +pub(crate) use measurer::{editbox_advances, measure_request, AtlasMeasurer}; pub(crate) use layout::{ ellipsize_to_fit, layout_text_quads, layout_text_quads_links, line_advances, line_origin, diff --git a/crates/benilla-ui/src/script/editbox.rs b/crates/benilla-ui/src/script/editbox.rs index 8a808f18..cc8a86cb 100644 --- a/crates/benilla-ui/src/script/editbox.rs +++ b/crates/benilla-ui/src/script/editbox.rs @@ -10,6 +10,10 @@ //! 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`). +//! - 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 +//! `OnTextChanged`. //! //! Mouse, selection and caret (click to index `0x77d0d0`, drag `0x77a860`) live in [`interact`] //! and [`seam`]; the OS clipboard is host-side, [`paste`] in and `UiScript::editbox_copy` or @@ -18,7 +22,7 @@ //! Deviation: word and edge deletes (`Delete{Word,Edge}`), which 1.12 does not have, exist because //! the host's OS-native chords (Option/Cmd+Backspace, Ctrl+Backspace/Delete) expect them. -use mlua::{Lua, Table, Value}; +use mlua::{Lua, Table}; use super::object::frame_handle_of; use super::types::{EditAction, EditUnit}; @@ -39,6 +43,8 @@ pub(super) fn char_input(lua: &Lua, text: &str) -> bool { let Some(h) = route(lua) else { return false; }; + // The char's own key-down came first, and `OnKeyDown` flushes (`0x77b1e2`). + flush::flush(lua, h); if text == "\u{1}" { // Ctrl+A selects all (the client's keydown case 0x41/0x61). highlight_text(lua, h, 0, -1); @@ -56,9 +62,9 @@ pub(super) fn paste(lua: &Lua, text: &str) -> bool { let Some(h) = route(lua) else { return false; }; + flush::flush(lua, h); // Ctrl+V is a key-down (`0x77b1e2`) if with_eb(lua, h, |eb| eb.paste(text)).is_some_and(|o| o.text_changed) { sync_text_region(lua, h); - mark_text_changed(lua, h); } true } @@ -68,6 +74,9 @@ pub(super) fn key_input(lua: &Lua, key: &str) -> bool { let Some(h) = route(lua) else { return false; }; + // `OnKeyDown` flushes the box before it handles the key (`0x77b1e2`), so the events of the + // edits ahead of the key fire first, and per key. + flush::flush(lua, h); let id = frame_id_of(lua, h); match key.to_ascii_uppercase().as_str() { // multiLine: Enter inserts a newline (no OnSpacePressed); else fire OnEnterPressed. @@ -91,6 +100,7 @@ pub(super) fn action(lua: &Lua, a: EditAction) -> bool { let Some(h) = route(lua) else { return false; }; + flush::flush(lua, h); // a key-down (`0x77b1e2`) match a { EditAction::Move { unit, back, extend } => match unit { // The alt-arrow gate lives in the host (`editbox_alt_arrow_mode`), which declines the @@ -118,15 +128,16 @@ pub(super) fn action(lua: &Lua, a: EditAction) -> bool { true } -// Mouse, selection, clipboard and blink (`interact`); the host's advance round trip and text -// geometry (`seam`); the text region's rect and a multi-line box's height (`relayout`). +// Mouse, selection and clipboard (`interact`); the host's advance round trip and text geometry +// (`seam`); the text region's rect and a multi-line box's height (`relayout`); the per-frame +// update and its flush (`flush`). +mod flush; mod interact; mod relayout; mod seam; -pub(super) use interact::{ - click, copy_selection, cut_selection, drag_end, drag_update, tick_blink, -}; -pub(in crate::script) use relayout::{relayout_multi_line, reseat_resized}; +pub(in crate::script) use flush::update; +pub(super) use interact::{click, copy_selection, cut_selection, drag_end, drag_update}; +pub(in crate::script) use relayout::reseat_resized; // ───────────────────────────────────────────────────────────────────────────────────────────── // Focus model @@ -182,6 +193,10 @@ fn set_focus_handle(lua: &Lua, h: FrameHandle) { } let old = model.focused_editbox; model.focused_editbox = Some(h); + if let Some(o) = old { + raise_cursor(&mut model, o); + } + raise_cursor(&mut model, h); // A focus gain ends history browsing (`SetText` keeps it for the chat parser's rewrite). if let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) { eb.end_history_browse(); @@ -194,6 +209,14 @@ fn set_focus_handle(lua: &Lua, h: FrameHandle) { fire_script(lua, new_id, "OnEditFocusGained"); } +/// The focus transition's caret write (`0x77afa8`, `or 4`), on the box that gains the keyboard +/// and on the one that loses it, whose next flush places or hides the caret. +fn raise_cursor(model: &mut Model, h: FrameHandle) { + if let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) { + eb.dirty |= EditBoxState::DIRTY_CURSOR; + } +} + /// The EditBox's OnShow/OnHide vtable overrides (`0x81c910` slots +0x30/+0x34), run after the /// frame's Lua handler, since both call the base notify first. Show (`0x77a750`) focuses an /// `autoFocus` box when nothing holds the focus, first come first served; hide (`0x77a780`) @@ -227,6 +250,7 @@ fn clear_focus_handle(lua: &Lua, h: FrameHandle) { return; } model.focused_editbox = None; + raise_cursor(&mut model, h); model.frame_id(h) }; fire_script(lua, id, "OnEditFocusLost"); @@ -237,7 +261,8 @@ fn clear_focus_handle(lua: &Lua, h: FrameHandle) { // ───────────────────────────────────────────────────────────────────────────────────────────── /// `Insert` (`0x77bee0`): replace the selection, abort on a non-digit when `numeric`, splice, -/// enforce caps, mark `OnTextChanged`, and with `fire_space` fire one `OnSpacePressed` per space. +/// 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 @@ -248,7 +273,7 @@ fn insert(lua: &Lua, h: FrameHandle, ins: &str, fire_space: bool) { 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`), before the deferred `OnTextChanged`; `SetText` does not. + // varargs firer `0x7026f0`), ahead of the flush's `OnTextChanged`; `SetText` does not. let on_char = lua .create_string(ins) .and_then(|s| event::fire_widget_handler(lua, id, "OnChar", vec![mlua::Value::String(s)])); @@ -257,7 +282,6 @@ fn insert(lua: &Lua, h: FrameHandle, ins: &str, fire_space: bool) { .expect("model app_data") .record_script_error(e.to_string()); } - mark_text_changed(lua, h); if fire_space { for _ in 0..out.spaces { fire_script(lua, id, "OnSpacePressed"); @@ -266,14 +290,13 @@ fn insert(lua: &Lua, h: FrameHandle, ins: &str, fire_space: bool) { } /// `SetText` (`0x77be00`): nothing when unchanged; else clear the selection, replace, cursor to -/// the end, enforce caps, fire `OnTextSet` and mark `OnTextChanged`. +/// the end, enforce caps, raise the dirty bits for the flush and fire `OnTextSet`. fn set_text(lua: &Lua, h: FrameHandle, s: &str) { let changed = with_eb(lua, h, |eb| eb.set_text(s)); if changed == Some(true) { sync_text_region(lua, h); let id = frame_id_of(lua, h); fire_script(lua, id, "OnTextSet"); - mark_text_changed(lua, h); } } @@ -286,7 +309,6 @@ fn delete_span(lua: &Lua, h: FrameHandle, target_of: impl FnOnce(&EditBoxState) }); if changed == Some(true) { sync_text_region(lua, h); - mark_text_changed(lua, h); } } @@ -295,7 +317,6 @@ fn delete_dir(lua: &Lua, h: FrameHandle, forward: bool) { let changed = with_eb(lua, h, |eb| eb.delete_dir(forward)); if changed == Some(true) { sync_text_region(lua, h); - mark_text_changed(lua, h); } } @@ -488,113 +509,17 @@ fn fire_script(lua: &Lua, id: u32, name: &str) { } } -/// `OnCursorChanged(x, y, w, h)` from the caret flush (`0x77da80`, in UI units via `0x77dd5f`): `x` -/// is 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. It fires only when the caret moved: the flush's one -/// caller, `0x77d475`, is gated on dirty bit 2. -/// -/// Deviation: `h` is the row pitch, which is taller than the line height only for a font with -/// extra spacing (none in the stock UI), because the host's advance answer carries one measure. -/// -/// Deviation: only the focused box fires, where the reference flushes any dirty box, because only -/// the focused box gets an advance table from the host. -pub(in crate::script) fn drain_cursor_changed(lua: &Lua) { - let fire = { - let mut model = lua.app_data_mut::().expect("model app_data"); - let Some(h) = model.focused_editbox else { - return; - }; - if !model.arena.frame(h).is_some_and(|f| f.effective_visible) { - return; - } - let id = model.frame_id(h); - let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) - else { - return; - }; - // No advance table yet: the tick after the host answers reports it. - let display = eb.display(); - if eb.advances.len() != display.len() + 1 { - return; - } - let cursor_d = eb.text_to_display(eb.cursor).min(display.len()); - let (row, x) = if eb.multi_line { - eb.caret_row_x(cursor_d) - } else { - (0, eb.advances[cursor_d]) - }; - if eb.cursor_fired == Some((row, x)) { - return; - } - eb.cursor_fired = Some((row, x)); - let pitch = eb.cell_h; - (id, x, -(row as f32) * pitch, pitch) - }; - let (id, x, y, h) = fire; - if let Err(e) = event::fire_widget_handler( - lua, - id, - "OnCursorChanged", - vec![ - Value::Number(f64::from(x)), - Value::Number(f64::from(y)), - Value::Number(f64::from(CARET_WIDTH)), - Value::Number(f64::from(h)), - ], - ) { - lua.app_data_mut::() - .expect("model app_data") - .record_script_error(e.to_string()); - } -} - -/// The caret width `OnCursorChanged` reports, a constant in the reference. +/// The caret width `OnCursorChanged` reports: 4.0 UI units on every raster (`0x77b8c0` sets it, +/// `0x77ba67`). const CARET_WIDTH: f32 = 4.0; // The EditBox Lua methods, consulted before the shared frame table for EditBox frames. mod methods; pub(super) use methods::install; +#[cfg(test)] +mod flush_tests; #[cfg(test)] mod relayout_tests; #[cfg(test)] mod tests; - -/// Raise the `textChanged` dirty bit (`[E+0x31c]` bit 0): the reference's edits only mark it, and -/// its one fire site (`0x77d498`) is in the drain `0x77d3e0`, so a Lua caller gets control back -/// before the handler runs and repeated changes coalesce into one fire with the final text. -pub(super) fn mark_text_changed(lua: &Lua, h: FrameHandle) { - let mut model = lua.app_data_mut::().expect("model app_data"); - if !model.dirty_editboxes.contains(&h) { - model.dirty_editboxes.push(h); - } -} - -/// The dirty-word drain (`0x77d3e0`): fire the pending `OnTextChanged`s from the frame tick, as the -/// box's `OnUpdate` (`0x77a790`) does. A hidden box stays pending until shown: `Hide` unlinks it -/// from the update chain (that it then gets no `OnUpdate` is inferred). The list is taken before -/// any Lua runs, so a box a handler re-marks fires on the next drain, as the reference clears its -/// bit before the fire. -/// -/// Deviation: not drained from `OnKeyDown` (`0x77b160`) or `OnMouseDown` (`0x77b800`), because -/// those only bring the fire sooner within a frame and our key and mouse paths reach the tick. -pub(in crate::script) fn drain_text_changed(lua: &Lua) { - let pending = { - let mut model = lua.app_data_mut::().expect("model app_data"); - if model.dirty_editboxes.is_empty() { - return; - } - let (ready, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut model.dirty_editboxes) - .into_iter() - .partition(|&h| model.arena.frame(h).is_some_and(|f| f.effective_visible)); - // A frame that has been destroyed drops out of both halves. - model.dirty_editboxes = waiting - .into_iter() - .filter(|&h| model.arena.frame(h).is_some()) - .collect(); - ready - }; - for h in pending { - fire_script(lua, frame_id_of(lua, h), "OnTextChanged"); - } -} diff --git a/crates/benilla-ui/src/script/editbox/flush.rs b/crates/benilla-ui/src/script/editbox/flush.rs new file mode 100644 index 00000000..ff7018c1 --- /dev/null +++ b/crates/benilla-ui/src/script/editbox/flush.rs @@ -0,0 +1,128 @@ +//! The box's own per-frame update (`0x77a790`, vtable `0x81c910` slot `+0x38`), which the tick's +//! walk runs in the box's place, and its flush (`0x77d3e0`), which drains the dirty word. + +use mlua::{Lua, Value}; + +use super::{event, fire_script, frame_id_of, relayout, with_eb, Model, CARET_WIDTH}; +use crate::widget::{EditBoxState, FrameHandle}; + +/// The box's update after its Lua `OnUpdate` (`0x77a79a`): the flush (`0x77a7a1`), then the caret +/// blink (`0x77a7a6`). The walk runs it for every shown box, with an `OnUpdate` or without. +pub(in crate::script) fn update(lua: &Lua, h: FrameHandle, dt: f32) { + flush(lua, h); + blink(lua, h, dt); +} + +/// 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. +/// Besides the walk, the box's `OnKeyDown` (`0x77b1e2`) and `OnMouseDown` (`0x77b819`) run it +/// before the key or click acts. +/// +/// Deviation: the flush's promotion of bit 2 to bit 0 when the caret has left the display window +/// (`0x77d3f5`–`0x77d436`), with its IME legs (`0x77d417`–`0x77d431`), and the IME writer's +/// `or 6` (`0x77acde`, in `0x77ac60`) are not modelled: the promotion only relayouts, which +/// re-windows a single-line box whose window benilla keeps host-side, and fires nothing, since +/// bit 0 is sampled before it; benilla has no IME composition. +pub(super) fn flush(lua: &Lua, h: FrameHandle) { + let Some((entry, owed)) = with_eb(lua, h, |eb| (eb.dirty, eb.relayout_owed)) else { + return; + }; + let text_changed = entry & EditBoxState::DIRTY_TEXT != 0; + if text_changed || owed { + let done = relayout::relayout(lua, h); + with_eb(lua, h, |eb| { + eb.dirty &= !EditBoxState::DIRTY_TEXT; + eb.relayout_owed = !done; + }); + } + if entry & EditBoxState::DIRTY_CURSOR != 0 && caret(lua, h) { + with_eb(lua, h, |eb| eb.dirty &= !EditBoxState::DIRTY_CURSOR); + } + if text_changed { + fire_script(lua, frame_id_of(lua, h), "OnTextChanged"); + } +} + +/// 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. +/// +/// 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 { + let id = frame_id_of(lua, h); + if !event::has_widget_handler(lua, id, "OnCursorChanged") { + return true; + } + if !super::seam::advances_current(lua, h) { + return false; + } + let args = with_eb(lua, h, |eb| { + let display = eb.display(); + if eb.advances.len() != display.len() + 1 { + return None; + } + let cursor_d = eb.text_to_display(eb.cursor).min(display.len()); + let (row, x) = if eb.multi_line { + eb.caret_row_x(cursor_d) + } else { + (0, eb.advances[cursor_d]) + }; + let pitch = eb.cell_h; + Some((x, -(row as f32) * pitch, pitch)) + }) + .flatten(); + let Some((x, y, pitch)) = args else { + return false; + }; + if let Err(e) = event::fire_widget_handler( + lua, + id, + "OnCursorChanged", + vec![ + Value::Number(f64::from(x)), + Value::Number(f64::from(y)), + Value::Number(f64::from(CARET_WIDTH)), + Value::Number(f64::from(pitch)), + ], + ) { + lua.app_data_mut::() + .expect("model app_data") + .record_script_error(e.to_string()); + } + true +} + +/// The caret blink (`0x77a7a6`–`0x77a84c`): past the period (0.5 s by default) the focused box's +/// caret toggles and the accumulator resets; only a period of exactly 0.0 keeps the caret solid +/// (`0x77a7b4`). +/// +/// Deviation: not gated on the cursor lying inside the scroll window (`0x77a7bd`–`0x77a7d7`), +/// which benilla's host keeps around the caret. +fn blink(lua: &Lua, h: FrameHandle, dt: f32) { + let focused = lua + .app_data_ref::() + .expect("model app_data") + .focused_editbox + == Some(h); + if !focused { + return; + } + with_eb(lua, h, |eb| { + if eb.blink_period != 0.0 { + eb.blink_accum += dt; + if eb.blink_accum > eb.blink_period { + eb.caret_shown = !eb.caret_shown; + eb.blink_accum = 0.0; + } + } + }); +} diff --git a/crates/benilla-ui/src/script/editbox/flush_tests.rs b/crates/benilla-ui/src/script/editbox/flush_tests.rs new file mode 100644 index 00000000..ffb458d0 --- /dev/null +++ b/crates/benilla-ui/src/script/editbox/flush_tests.rs @@ -0,0 +1,375 @@ +//! The box's flush (`0x77d3e0`) in its place in the tick's walk: after the box's own `OnUpdate` +//! (`0x77a790`), `OnCursorChanged` then `OnTextChanged`, on the dirty word `[E+0x31c]`. + +use crate::script::{ + EditAction, EditBoxAdvanceRequest, EditUnit, MeasureRequest, TextMeasure, UiScript, +}; + +/// 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. +struct Mono; + +impl TextMeasure for Mono { + fn measure(&mut self, req: &MeasureRequest) -> (f32, f32, f32) { + let w = req.text.len() as f32 * 7.0; + (w, 14.0, w) + } + + 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(); + Some(( + cum, + vec![0], + if req.wrap_width.is_some() { 14.0 } else { 0.0 }, + )) + } +} + +fn script() -> UiScript { + let mut s = UiScript::new().expect("construct UiScript"); + s.set_screen_size(800.0, 600.0); + s.set_text_measurer(Box::new(Mono)); + s +} + +/// The `LOG` entries since the last call, joined by spaces. +fn take_log(s: &UiScript) -> String { + s.eval::("local l = table.concat(LOG, ' ') LOG = {} return l") + .unwrap() +} + +/// A 200×32 box `E` at `(100, 50)` with nothing focused, logging its `OnUpdate`, `OnCursorChanged` +/// (with `x`) and `OnTextChanged` (with the text) into `LOG`, between a frame made before it and +/// one made after, whose `OnUpdate`s log too; its first-show fires are drained. +fn logged_box(s: &mut UiScript) { + s.run( + r#" + LOG = {} + EARLY = CreateFrame("Frame", "Early") + EARLY:SetScript("OnUpdate", function() table.insert(LOG, "early") end) + E = CreateFrame("EditBox", "E") + E:SetAutoFocus(false) + E:SetPoint("BOTTOMLEFT", 100, 50) + E:SetWidth(200); E:SetHeight(32) + E:SetScript("OnUpdate", function() table.insert(LOG, "update") end) + E:SetScript("OnCursorChanged", function() table.insert(LOG, "cursor@" .. arg1) end) + E:SetScript("OnTextChanged", function() table.insert(LOG, "text:" .. this:GetText()) end) + LATE = CreateFrame("Frame", "Late") + LATE:SetScript("OnUpdate", function() table.insert(LOG, "late") end) + "#, + ) + .unwrap(); + s.resolve(); + s.tick(0.016); + s.resolve(); + take_log(s); +} + +/// The box's update fires its own `OnUpdate`, then the flush (`0x77a79a`, then `0x77a7a1`), whose +/// caret leg fires `OnCursorChanged` (`0x77d475` → `0x77de1b`) before `OnTextChanged` fires +/// (`0x77d498`); a frame walked after the box updates after all three. +#[test] +fn the_flush_follows_the_boxs_own_on_update_cursor_then_text() { + let mut s = script(); + logged_box(&mut s); + s.run(r#"E:SetText("abc")"#).unwrap(); + s.tick(0.016); + assert_eq!( + take_log(&s), + "early update cursor@21 text:abc late", + "one tick, each box flushing in its place in the walk" + ); + s.tick(0.016); + assert_eq!( + take_log(&s), + "early update late", + "a quiet tick fires nothing" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// An edit a box makes in its own `OnUpdate` is flushed by the same update, so its events fire in +/// the same tick. +#[test] +fn an_edit_in_the_boxs_own_on_update_fires_its_events_the_same_tick() { + let mut s = script(); + logged_box(&mut s); + s.run( + r#" + E:SetScript("OnUpdate", function() + table.insert(LOG, "update") + if not EDITED then EDITED = true this:SetText("hello") end + end) + "#, + ) + .unwrap(); + s.tick(0.016); + assert_eq!(take_log(&s), "early update cursor@35 text:hello late"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// The ctor raises dirty bit 0 (`0x779a34`), so a new box fires `OnTextChanged` once at its first +/// flush, with its text unchanged; a hidden box is not walked, so its bit waits for the show. +#[test] +fn a_new_box_fires_on_text_changed_once_at_its_first_flush_once_shown() { + let mut s = script(); + s.run( + r#" + SHOWN, HIDDEN = 0, 0 + local a = CreateFrame("EditBox", "A") + a:SetAutoFocus(false) + a:SetScript("OnTextChanged", function() SHOWN = SHOWN + 1 end) + local b = CreateFrame("EditBox", "B") + b:SetAutoFocus(false) + b:Hide() + b:SetScript("OnTextChanged", function() HIDDEN = HIDDEN + 1 end) + "#, + ) + .unwrap(); + let counts = |s: &UiScript| s.eval::<(i64, i64)>("return SHOWN, HIDDEN").unwrap(); + s.tick(0.016); + assert_eq!(counts(&s), (1, 0), "the shown box's first flush"); + s.tick(0.016); + assert_eq!(counts(&s), (1, 0), "once"); + s.run("B:Show()").unwrap(); + s.tick(0.016); + assert_eq!( + counts(&s), + (1, 1), + "the hidden box's bit waited for the show" + ); + s.tick(0.016); + assert_eq!(counts(&s), (1, 1)); + assert_eq!(s.eval::("return B:GetText()").unwrap(), ""); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// Every run of the text seat `0x77b8c0` on a box with a rect ends in `or eax,7` (`0x77ba7f`), so +/// its next flush fires `OnTextChanged` once, with the text unchanged: from a resize (the +/// `OnSizeChanged` override, `0x77a8e7`), `SetTextInsets` (`0x77a707`), `GetTextInsets` +/// (`0x77a73f`), `SetMultiLine` (`0x77a63f`) and a font change (`0x77e2b5`). +#[test] +fn each_re_seat_of_the_text_fires_one_on_text_changed_at_the_next_flush() { + let mut s = script(); + s.run( + r#" + N = 0 + E = CreateFrame("EditBox", "E") + E:SetAutoFocus(false) + E:SetPoint("BOTTOMLEFT", 100, 50) + E:SetWidth(200); E:SetHeight(32) + E:SetText("same") + E:SetScript("OnTextChanged", function() N = N + 1 end) + "#, + ) + .unwrap(); + s.resolve(); + s.tick(0.016); + let mut fires = Vec::new(); + for (what, lua, resolve) in [ + ("a resize", "E:SetWidth(250)", true), + ("SetTextInsets", "E:SetTextInsets(4, 4, 2, 2)", false), + ("GetTextInsets", "E:GetTextInsets()", false), + ( + "a font change", + r#"E:SetFont("Fonts\\FRIZQT__.TTF", 14)"#, + false, + ), + ("SetMultiLine", "E:SetMultiLine(true)", false), + ] { + s.run(&format!("N = 0 {lua}")).unwrap(); + if resolve { + s.resolve(); + } + s.tick(0.016); + fires.push((what, s.eval::("return N").unwrap())); + } + assert_eq!( + fires, + [ + ("a resize", 1), + ("SetTextInsets", 1), + ("GetTextInsets", 1), + ("a font change", 1), + ("SetMultiLine", 1), + ] + ); + assert_eq!(s.eval::("return E:GetText()").unwrap(), "same"); + + // A box with no rect yet returns before the seat (`0x77b8c7`) and raises nothing. + s.run( + r#" + M = 0 + F = CreateFrame("EditBox", "F") + F:SetAutoFocus(false) + F:SetScript("OnTextChanged", function() M = M + 1 end) + "#, + ) + .unwrap(); + s.tick(0.016); + s.run("F:SetTextInsets(4, 4, 2, 2)").unwrap(); + s.tick(0.016); + assert_eq!( + s.eval::("return M").unwrap(), + 1, + "the ctor's fire alone" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// A handler's edit raises the bits the flush has already cleared (`0x77d44c`, `0x77d47a`), so an +/// `OnTextChanged` that sets its own text fires once more, at the next frame's flush, and never +/// loops inside one; `SetText` of an equal text raises nothing (`0x77be4b`), which ends it. +#[test] +fn an_on_text_changed_that_sets_its_own_text_fires_once_more_next_frame() { + let mut s = script(); + logged_box(&mut s); + s.run( + r#" + E:SetScript("OnTextChanged", function() + table.insert(LOG, "text:" .. this:GetText()) + this:SetText(strupper(this:GetText())) + end) + E:SetText("abc") + "#, + ) + .unwrap(); + s.tick(0.016); + assert_eq!(take_log(&s), "early update cursor@21 text:abc late"); + s.tick(0.016); + assert_eq!(take_log(&s), "early update cursor@21 text:ABC late"); + s.tick(0.016); + assert_eq!(take_log(&s), "early update late"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// Bit 2 clears after the caret leg (`0x77d47a`), so a caret an `OnCursorChanged` handler moves is +/// not reported again; the text it inserts keeps bit 0 for the next frame. +#[test] +fn a_caret_moved_by_on_cursor_changed_is_cleared_with_the_bit() { + let mut s = script(); + logged_box(&mut s); + s.run( + r#" + E:SetScript("OnCursorChanged", function() + table.insert(LOG, "cursor@" .. arg1) + if not EDITED then EDITED = true this:Insert("!") end + end) + E:SetText("ab") + "#, + ) + .unwrap(); + s.tick(0.016); + assert_eq!(take_log(&s), "early update cursor@14 text:ab! late"); + s.tick(0.016); + assert_eq!( + take_log(&s), + "early update text:ab! late", + "the insert's text bit, without its caret bit" + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// The focus transition raises bit 2 on both boxes (`0x77af50`, `0x77afa8`): each fires +/// `OnCursorChanged` at its next flush, the one that lost the keyboard included. +#[test] +fn a_focus_change_runs_the_caret_leg_of_both_boxes() { + let mut s = script(); + s.run( + r#" + LOG = {} + for _, name in ipairs({ "A", "B" }) do + local f = CreateFrame("EditBox", name) + f:SetAutoFocus(false) + f:SetScript("OnCursorChanged", function() table.insert(LOG, this:GetName()) end) + end + "#, + ) + .unwrap(); + s.tick(0.016); + take_log(&s); + s.run("A:SetFocus()").unwrap(); + s.tick(0.016); + assert_eq!(take_log(&s), "A"); + s.run("B:SetFocus()").unwrap(); + s.tick(0.016); + assert_eq!(take_log(&s), "A B"); + s.run("B:ClearFocus()").unwrap(); + s.tick(0.016); + assert_eq!(take_log(&s), "B"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// The move helpers step the caret only while it can (`0x77c750` `cursor < len`, `0x77c870` +/// `cursor > 0`), and only the step raises bit 2 (`0x77c73b`): Right at the end and Home at 0 fire +/// nothing. +#[test] +fn a_caret_move_that_goes_nowhere_raises_nothing() { + let mut s = script(); + logged_box(&mut s); + s.run(r#"E:SetText("ab") E:SetFocus()"#).unwrap(); + s.tick(0.016); + take_log(&s); + let mut logs = Vec::new(); + for (what, unit, back) in [ + ("Right at the end", EditUnit::Char, false), + ("Home from the end", EditUnit::Edge, true), + ("Home at 0", EditUnit::Edge, true), + ] { + assert!(s.editbox_action(EditAction::Move { + unit, + back, + extend: false, + })); + s.tick(0.016); + logs.push((what, take_log(&s))); + } + assert_eq!( + logs, + [ + ("Right at the end", "early update late".to_string()), + ( + "Home from the end", + "early update cursor@0 late".to_string() + ), + ("Home at 0", "early update late".to_string()), + ] + ); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +/// `OnKeyDown` flushes the box before it handles the key (`0x77b1e2`), and a char's key-down comes +/// before it: keys typed within one frame fire their events per key, ahead of the next key's own +/// script, so `OnEnterPressed` sees the `OnTextChanged` of the text it ends. +#[test] +fn a_key_flushes_the_box_before_it_acts() { + let mut s = script(); + logged_box(&mut s); + s.run( + r#" + E:SetScript("OnEnterPressed", function() table.insert(LOG, "enter") end) + E:SetFocus() + "#, + ) + .unwrap(); + s.tick(0.016); + take_log(&s); + assert!(s.char_input("a")); + assert!(s.char_input("b")); + assert!(s.key_input("ENTER")); + assert_eq!( + take_log(&s), + "cursor@7 text:a cursor@14 text:ab enter", + "one frame, no tick between the keys" + ); + s.tick(0.016); + assert_eq!( + take_log(&s), + "early update late", + "nothing left for the walk" + ); + 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 c1b4b093..8809ea77 100644 --- a/crates/benilla-ui/src/script/editbox/interact.rs +++ b/crates/benilla-ui/src/script/editbox/interact.rs @@ -1,4 +1,4 @@ -//! EditBox mouse and clipboard interaction: click, drag-select, copy, cut and the caret blink. +//! EditBox mouse and clipboard interaction: click, drag-select, copy and cut. use mlua::Lua; @@ -33,13 +33,17 @@ fn index_at_screen_pos(lua: &Lua, h: FrameHandle, x: f32, y: f32) -> usize { .unwrap_or(0) } -/// A LeftButton press on EditBox `id` (`0x77b800`) places the cursor at the clicked char, -/// collapsing the selection, starts a drag and takes focus unconditionally; no shift+click branch. +/// A LeftButton press on EditBox `id` (`0x77b800`) flushes the box first (`0x77b819`), then +/// places the cursor at the clicked char, collapsing the selection, starts a drag and takes focus +/// unconditionally; no shift+click branch. pub(in crate::script) fn click(lua: &Lua, id: u32, x: f32, y: f32) { let Some(h) = editbox_of(lua, id) else { return }; + super::flush::flush(lua, h); let idx = index_at_screen_pos(lua, h, x, y); with_eb(lua, h, |eb| { eb.move_caret_to(idx, false); + // The setter raises bit 2 whether or not the caret moved (`0x77b868` → `0x77e380`). + eb.dirty |= crate::widget::EditBoxState::DIRTY_CURSOR; eb.drag_active = true; }); set_focus_handle(lua, h); @@ -85,26 +89,9 @@ pub(in crate::script) fn cut_selection(lua: &Lua) -> Option { let h = focused(lua)?; let copied = with_eb(lua, h, |eb| eb.cut_selection()).flatten()?; sync_text_region(lua, h); - super::mark_text_changed(lua, h); Some(copied) } -/// The caret blink (`0x77a790`), each frame: past the period (0.5 s by default) the focused box's -/// caret toggles and the accumulator resets; a non-positive period keeps the caret solid. -pub(in crate::script) fn tick_blink(lua: &Lua, dt: f32) { - if let Some(h) = focused(lua) { - with_eb(lua, h, |eb| { - if eb.blink_period > 0.0 { - eb.blink_accum += dt; - if eb.blink_accum > eb.blink_period { - eb.caret_shown = !eb.caret_shown; - eb.blink_accum = 0.0; - } - } - }); - } -} - fn focused(lua: &Lua) -> Option { lua.app_data_ref::() .expect("model app_data") diff --git a/crates/benilla-ui/src/script/editbox/relayout.rs b/crates/benilla-ui/src/script/editbox/relayout.rs index 7884a4f0..50474074 100644 --- a/crates/benilla-ui/src/script/editbox/relayout.rs +++ b/crates/benilla-ui/src/script/editbox/relayout.rs @@ -5,13 +5,14 @@ use mlua::Lua; use crate::layout::{Anchor, Point, Rect}; use crate::script::{MeasureRequest, Model}; -use crate::widget::{FrameHandle, KindState, RegionHandle}; +use crate::widget::{EditBoxState, FrameHandle, KindState, RegionHandle}; /// `0x77b8c0`, the text FontString's rect. The reference writes nothing while the box's rect is /// unresolved (`0x77b8c7`); then it clears the points (`0x77b8da`), sets the width /// `boxW − (right + left)` for either kind (`0x77b905`), the height 0 multi-line (`0x77b91e`) or /// `boxH − (top + bottom)` single-line (`0x77b940`), and one `TOPLEFT` point at `(left, −top)` -/// (`0x77b96b`). +/// (`0x77b96b`), and raises dirty bits 0-2 (`0x77ba7f or eax,7`): the box's next flush fires +/// `OnTextChanged` again, with the text unchanged. /// /// Multi-line, benilla writes that shape, the width once the box has a rect: until then only the /// point is written, and the size is kept. @@ -23,9 +24,13 @@ use crate::widget::{FrameHandle, KindState, RegionHandle}; /// region answers two points where the reference answers one, and `GetWidth`/`GetHeight` answer /// the text's measure where the reference answers the explicit size. pub(super) fn seat_text_region(model: &mut Model, h: FrameHandle) { - let Some(KindState::EditBox(eb)) = model.arena.frame(h).map(|f| &f.kind_state) else { + let resolved = model.resolved.contains_key(&h); + let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) else { return; }; + if resolved { + eb.dirty |= EditBoxState::DIRTY_TEXT | EditBoxState::DIRTY_CURSOR; + } let (Some(rh), [l, r, t, b], multi_line) = (eb.text_region, eb.text_insets, eb.multi_line) else { return; @@ -102,57 +107,47 @@ fn multi_line_text_width(model: &Model, h: FrameHandle, [l, r]: [f32; 2]) -> Opt Some(rect.width() / crate::script::object::eff_scale(model, h) - (r + l)) } -/// The flush's relayout tail (`0x77d4d0` @`0x77d863`–`0x77d8ad`) for every shown multi-line box, -/// run from the tick ahead of the `OnTextChanged` drain, as the flush relayouts before it fires. -/// The box's own height becomes `(insetTop + insetBottom) + h`, `h` its text's measured wrapped -/// height (`0x7729b0`), or one line's (`0x7727b0(fs, 1)`) when that is exactly 0, as for empty -/// text. It reads neither the authored height nor the rect, so the box grows and shrinks with its -/// text and a `SetHeight` lasts until the next tick; a single-line box is never sized. The text -/// region's width is the one [`seat_text_region`] last wrote, on a resize ([`reseat_resized`]), -/// `SetTextInsets`, `GetTextInsets`, `SetMultiLine` or a font change. +/// The flush's relayout tail (`0x77d4d0` @`0x77d863`–`0x77d8ad`), run on dirty bit 0 by the +/// box's flush ([`super::flush`]): a multi-line box's own height becomes +/// `(insetTop + insetBottom) + h`, `h` its text's measured wrapped height (`0x7729b0`), or one +/// line's (`0x7727b0(fs, 1)`) when that is exactly 0, as for empty text. It reads neither the +/// authored height nor the rect, so the box grows and shrinks with its text and a `SetHeight` +/// lasts until the flush after its resize re-seats the text; a single-line box is never sized +/// (`0x77d86a`). The text region's width is the one [`seat_text_region`] last wrote. /// -/// Each tick relayouts every shown multi-line box, where the reference relayouts a box whose dirty -/// bit 0 is set; every input of the height sets it (an edit, a resize, the insets, the font, -/// `SetMultiLine`, the ctor), so both write the same height. -pub(in crate::script) fn relayout_multi_line(lua: &Lua) { - let boxes: Vec = { +/// `false` when the text's measure is pending, for the flush to retry. +pub(super) fn relayout(lua: &Lua, h: FrameHandle) -> bool { + let multi_line = { let model = lua.app_data_ref::().expect("model app_data"); - // The flush runs from the box's own update, pumped for shown frames only. - model - .arena - .editbox_kinds() - .iter() - .copied() - .filter(|&h| { - model.arena.frame(h).is_some_and(|f| { - f.effective_visible - && matches!(&f.kind_state, KindState::EditBox(eb) if eb.multi_line) - }) - }) - .collect() + matches!( + model.arena.frame(h).map(|f| &f.kind_state), + Some(KindState::EditBox(eb)) if eb.multi_line + ) }; - for h in boxes { - let Some(rh) = super::ensure_text_region(lua, h) else { - continue; - }; - // `0x7729b0` measures in the call; with no engine installed the measure lands a tick later. - crate::script::measure::ensure_measured(lua, rh); - let Some(text_h) = text_height(lua, h, rh) else { - continue; // the measure is pending: the height waits for it - }; - let mut model = lua.app_data_mut::().expect("model app_data"); - let [_, _, top, bottom] = match model.arena.frame(h).map(|f| &f.kind_state) { - Some(KindState::EditBox(eb)) => eb.text_insets, - _ => continue, - }; - // `fld top; fadd bottom; fadd st, st(1)`. - let height = (top + bottom) + text_h; - let input = model.layout_inputs.entry(h).or_default(); - if input.height.to_bits() != height.to_bits() { - input.height = height; - model.touch_layout_frame(h); - } + if !multi_line { + return true; } + let Some(rh) = super::ensure_text_region(lua, h) else { + return true; + }; + // `0x7729b0` measures in the call; with no engine installed the measure lands a tick later. + crate::script::measure::ensure_measured(lua, rh); + let Some(text_h) = text_height(lua, h, rh) else { + return false; + }; + let mut model = lua.app_data_mut::().expect("model app_data"); + let [_, _, top, bottom] = match model.arena.frame(h).map(|f| &f.kind_state) { + Some(KindState::EditBox(eb)) => eb.text_insets, + _ => return true, + }; + // `fld top; fadd bottom; fadd st, st(1)`. + let height = (top + bottom) + text_h; + let input = model.layout_inputs.entry(h).or_default(); + if input.height.to_bits() != height.to_bits() { + input.height = height; + model.touch_layout_frame(h); + } + true } /// The text's height for the box: its measure under the current key, or one line's when that is diff --git a/crates/benilla-ui/src/script/editbox/seam.rs b/crates/benilla-ui/src/script/editbox/seam.rs index c9147761..b68f257b 100644 --- a/crates/benilla-ui/src/script/editbox/seam.rs +++ b/crates/benilla-ui/src/script/editbox/seam.rs @@ -1,76 +1,20 @@ //! The EditBox seam the host drives each frame: the advance-table round trip, the caret, //! selection and scroll geometry derived from it, and the clipboard pair. -use crate::script::{types, UiScript}; -use crate::widget::KindState; +use mlua::Lua; + +use crate::script::{types, Model, UiScript}; +use crate::widget::{EditBoxState, FrameHandle, KindState, RegionHandle}; impl UiScript { /// The focused EditBox's request to measure its display string's per-byte cumulative widths, /// when its text, font, scale or wrap width changed; an empty display needs no trip. Answer it /// with [`Self::set_editbox_advances`] before [`Self::focused_editbox_text_ui`]. pub fn editbox_advances_request(&mut self) -> Option { - use std::hash::{Hash, Hasher}; let h = self.model_mut().focused_editbox?; // The lazy text region first: its font keys the table, and a new box has none until now. let rh = super::ensure_text_region(self.lua(), h)?; - let mut model = self.model_mut(); - let (display, multi_line) = match model.arena.frame(h).map(|f| &f.kind_state) { - Some(KindState::EditBox(eb)) => (eb.display(), eb.multi_line), - _ => return None, - }; - let d = model.region_data.get(&rh); - let font = d.and_then(|d| d.font_path.clone()); - let height = d.and_then(|d| d.font_height); - let outline = d.map(|d| d.outline).unwrap_or_default(); - // The host measures at the drawn raster size, so the advances land on the drawn glyphs. - let scale = model - .arena - .frame(h) - .map(|f| f.effective_scale) - .unwrap_or(1.0); - // A multi-line box wraps at the text region's width, as the draw does; a resize re-keys. - let wrap_width = multi_line - .then(|| { - model - .region_resolved - .get(&rh) - .or_else(|| model.resolved.get(&h)) - .map(|r| r.right - r.left) - }) - .flatten() - .filter(|w| *w > 0.0); - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - display.hash(&mut hasher); - font.hash(&mut hasher); - height.map(f32::to_bits).hash(&mut hasher); - (outline as u8).hash(&mut hasher); - wrap_width.map(f32::to_bits).hash(&mut hasher); - scale.to_bits().hash(&mut hasher); - let key = hasher.finish(); - let id = model.frame_id(h); - let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) - else { - return None; - }; - if eb.advances_key == key && eb.advances.len() == display.len() + 1 { - return None; // cache warm - } - if display.is_empty() { - eb.advances = vec![0.0]; - eb.rows = vec![0]; - eb.advances_key = key; - return None; - } - Some(types::EditBoxAdvanceRequest { - id, - font, - height, - outline, - scale, - text: display, - wrap_width, - key, - }) + stale_advances(&mut self.model_mut(), h, rh) } /// Answer an [`Self::editbox_advances_request`]: the cumulative widths (len+1 entries from 0), @@ -88,10 +32,7 @@ impl UiScript { return; }; if let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) { - eb.advances = cum; - eb.rows = if rows.is_empty() { vec![0] } else { rows }; - eb.cell_h = cell_h; - eb.advances_key = key; + store_advances(eb, key, cum, rows, cell_h); } } @@ -206,3 +147,102 @@ impl UiScript { super::with_eb(lua, h, |eb| eb.add_history_line(line)).is_some() } } + +/// Box `h`'s advance-table request, `None` when its table is current for the display string, +/// font, raster scale and, multi-line, wrap width; an empty display needs no trip and is settled +/// here. +fn stale_advances( + model: &mut Model, + h: FrameHandle, + rh: RegionHandle, +) -> Option { + use std::hash::{Hash, Hasher}; + let (display, multi_line) = match model.arena.frame(h).map(|f| &f.kind_state) { + Some(KindState::EditBox(eb)) => (eb.display(), eb.multi_line), + _ => return None, + }; + let d = model.region_data.get(&rh); + let font = d.and_then(|d| d.font_path.clone()); + let height = d.and_then(|d| d.font_height); + let outline = d.map(|d| d.outline).unwrap_or_default(); + // The host measures at the drawn raster size, so the advances land on the drawn glyphs. + let scale = model + .arena + .frame(h) + .map(|f| f.effective_scale) + .unwrap_or(1.0); + // A multi-line box wraps at the text region's width, as the draw does; a resize re-keys. + let wrap_width = multi_line + .then(|| { + model + .region_resolved + .get(&rh) + .or_else(|| model.resolved.get(&h)) + .map(|r| r.right - r.left) + }) + .flatten() + .filter(|w| *w > 0.0); + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + display.hash(&mut hasher); + font.hash(&mut hasher); + height.map(f32::to_bits).hash(&mut hasher); + (outline as u8).hash(&mut hasher); + wrap_width.map(f32::to_bits).hash(&mut hasher); + scale.to_bits().hash(&mut hasher); + let key = hasher.finish(); + let id = model.frame_id(h); + let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) else { + return None; + }; + if eb.advances_key == key && eb.advances.len() == display.len() + 1 { + return None; // cache warm + } + if display.is_empty() { + let cell_h = eb.cell_h; + store_advances(eb, key, vec![0.0], vec![0], cell_h); + return None; + } + Some(types::EditBoxAdvanceRequest { + id, + font, + height, + outline, + scale, + text: display, + wrap_width, + key, + }) +} + +fn store_advances(eb: &mut EditBoxState, key: u64, cum: Vec, rows: Vec, cell_h: f32) { + eb.advances = cum; + eb.rows = if rows.is_empty() { vec![0] } else { rows }; + eb.cell_h = cell_h; + eb.advances_key = key; +} + +/// Whether box `h`'s advance table is current, measured here by the installed font engine when +/// its text or font moved, as the caret leg measures in the call (`0x772ae0`). `false` with no +/// engine, or one that leaves the table to the host's round trip. +pub(super) fn advances_current(lua: &Lua, h: FrameHandle) -> bool { + let Some(rh) = super::ensure_text_region(lua, h) else { + return false; + }; + let mut model = lua.app_data_mut::().expect("model app_data"); + let Some(req) = stale_advances(&mut model, h, rh) else { + return true; + }; + // Taken out and put back, as `ensure_measured` does: the engine cannot re-enter the VM. + let Some(mut engine) = model.measurer.take() else { + return false; + }; + let answer = engine.editbox_advances(&req); + model.measurer = Some(engine); + let Some((cum, rows, cell_h)) = answer else { + return false; + }; + if let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) { + store_advances(eb, req.key, cum, rows, cell_h); + } + true +} diff --git a/crates/benilla-ui/src/script/editbox/tests.rs b/crates/benilla-ui/src/script/editbox/tests.rs index 8aedec31..4d06b3d9 100644 --- a/crates/benilla-ui/src/script/editbox/tests.rs +++ b/crates/benilla-ui/src/script/editbox/tests.rs @@ -208,29 +208,34 @@ fn click_focuses_regardless_of_autofocus_and_transition_order_is_lost_then_gaine // ── text buffer + editing + OnTextChanged/OnTextSet ───────────────────────────────────────── -/// An edit only raises the box's `textChanged` bit; `OnTextChanged` fires once, at the drain its -/// OnUpdate runs (`0x77d3e0`). +/// An edit only raises the box's `textChanged` bit; `OnTextChanged` fires once, at the flush its +/// update runs after its `OnUpdate` (`0x77d3e0`), so edits between two flushes coalesce. (A typed +/// key flushes the box before it acts, `0x77b1e2`, so keys fire per key.) #[test] -fn typing_coalesces_into_one_deferred_ontextchanged() { +fn edits_between_two_flushes_coalesce_into_one_ontextchanged() { let mut s = script(); s.run( r#" changed = 0 E = CreateFrame("EditBox", "E") seen = "" - E:SetScript("OnTextChanged", function() changed = changed + 1 seen = E:GetText() end) E:SetFocus() "#, ) .unwrap(); - assert!(s.char_input("a")); - assert!(s.char_input("b")); - assert!(s.char_input("c")); + s.tick(0.0); + s.run( + r#" + E:SetScript("OnTextChanged", function() changed = changed + 1 seen = E:GetText() end) + E:Insert("a") E:Insert("b") E:Insert("c") + "#, + ) + .unwrap(); assert_eq!(s.eval::("return E:GetText()").unwrap(), "abc"); assert_eq!( s.eval::("return changed").unwrap(), 0, - "the text is already there, but nothing has drained yet" + "the text is already there, but nothing has flushed yet" ); s.tick(0.0); @@ -329,20 +334,22 @@ fn paste_inserts_at_the_cursor_and_replaces_the_selection() { E:SetScript("OnTextChanged", function() changed = changed + 1 end) E:SetText("ab") E:SetFocus() - changed = 0 -- ignore the setup SetText's fire; count only the pastes below "#, ) .unwrap(); - // SetText leaves the cursor at the end, so the paste appends. + s.tick(0.0); + s.run("changed = 0").unwrap(); // count only the pastes below + // SetText leaves the cursor at the end, so the paste appends. assert!(s.paste("CD"), "focused box consumes the paste"); assert_eq!(s.eval::("return E:GetText()").unwrap(), "abCD"); s.run("E:HighlightText(0, -1)").unwrap(); + // Ctrl+V is a key-down, which flushes the box before it pastes (`0x77b1e2`): the first + // paste's change fires at the second, the second's at the walk. assert!(s.paste("xyz")); assert_eq!(s.eval::("return E:GetText()").unwrap(), "xyz"); - // Both pastes marked the one box, so the drain fires once. - assert_eq!(s.eval::("return changed").unwrap(), 0); - s.tick(0.0); assert_eq!(s.eval::("return changed").unwrap(), 1); + s.tick(0.0); + assert_eq!(s.eval::("return changed").unwrap(), 2); } #[test] @@ -1084,10 +1091,14 @@ fn word_and_edge_deletes() { // Inert at the boundary: nothing to delete consumes without firing OnTextChanged. s.run("n = 0; E:SetScript('OnTextChanged', function() n = n + 1 end)") .unwrap(); + s.tick(0.0); + s.run("n = 0").unwrap(); // the edits above fire at that tick + assert!(s.errors().is_empty(), "{:?}", s.errors()); assert!(s.editbox_action(EditAction::Delete { unit: EditUnit::Edge, back: true })); + s.tick(0.0); assert_eq!(s.eval::("return n").unwrap(), 0); } diff --git a/crates/benilla-ui/src/script/measure.rs b/crates/benilla-ui/src/script/measure.rs index 1662a9a0..41eb46c1 100644 --- a/crates/benilla-ui/src/script/measure.rs +++ b/crates/benilla-ui/src/script/measure.rs @@ -16,6 +16,20 @@ pub trait TextMeasure { /// unwrapped width, is what `GetStringWidth` reports. It runs with the model mutably borrowed, /// so it must not re-enter Lua. fn measure(&mut self, req: &MeasureRequest) -> (f32, f32, f32); + + /// An EditBox's advance table, answered inline as + /// [`UiScript::set_editbox_advances`](super::UiScript::set_editbox_advances) answers it a + /// frame later: the cumulative widths, the wrapped-row starts and the row pitch, as the draw + /// measures them. The box's flush reads it for `OnCursorChanged`, whose position the caret + /// leg measures in the call (`0x77da80` → `0x772ae0`). `None`, the default, leaves the table + /// to the host. + fn editbox_advances( + &mut self, + req: &super::EditBoxAdvanceRequest, + ) -> Option<(Vec, Vec, f32)> { + let _ = req; + None + } } /// Measure region `rh` now if a measurer is installed and its stored measure is stale; a current diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index d7010e0d..0f99293b 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -258,9 +258,9 @@ pub use trainer::{ TrainerState, TrainerTooltip, TRAINER_GROUP_KNOWN, }; pub use types::{ - BlendMode, EditAction, EditBoxTextUi, EditOutcome, EditUnit, ExtractedQuad, FontObject, - FontShadow, Gradient, JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline, - QuadContent, ScriptValue, TexCoords, + BlendMode, EditAction, EditBoxAdvanceRequest, EditBoxTextUi, EditOutcome, EditUnit, + ExtractedQuad, FontObject, FontShadow, Gradient, JustifyH, JustifyV, LineMeasureRequest, + MeasureRequest, Outline, QuadContent, ScriptValue, TexCoords, }; pub(crate) use types::{FontExplicit, MeasuredText, RegionData}; pub use unit::{ @@ -422,8 +422,9 @@ const SCRIPT_KINDS: [&str; 39] = [ "OnTabPressed", "OnTextChanged", "OnTextSet", - // The caret flush's own (`0x77da80`), fired by the tick's `drain_cursor_changed` when the - // caret moved: the edge `ScrollingEdit_OnCursorChanged` scrolls a multiline box by. + // The caret leg's own (`0x77da80`), fired by the box's flush on dirty bit 2, which a caret + // move, an edit, a focus change and a re-seat raise: the edge `ScrollingEdit_OnCursorChanged` + // scrolls a multiline box by. "OnCursorChanged", "OnEditFocusGained", "OnEditFocusLost", @@ -972,6 +973,9 @@ impl UiScript { KindState::EditBox(eb) => { eb.advances_key = eb.advances_key.wrapping_add(1); eb.line_height = None; + // A multi-line box's height is its text's measure: owed again on the new + // raster, which moves no rect here to re-seat it. + eb.relayout_owed = true; } _ => {} } diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index ca4f7d36..49c0d1a3 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -186,10 +186,6 @@ pub(crate) struct Model { pub(crate) model_facts: HashMap>, /// Model files still waiting for their facts, for the host to load; deduplicated. pub(crate) model_facts_wanted: Vec, - /// Edit boxes owed `OnTextChanged`: the reference's dirty bit (`[E+0x31c]` bit 0), set by - /// `SetText`/`Insert`, cleared only by `0x77d3e0`: one fire per drain, with the final text, as - /// `MoneyInputFrame_SetCopper` needs. A hidden box's fire waits for a show, key or mouse-down. - pub(crate) dirty_editboxes: Vec, /// Event name to its frames in registration order, never a set: `SignalEvent` (`0x703e50`) /// dispatches in that order across frames. Re-registering keeps a frame's position. pub(crate) event_to_frames: HashMap>, @@ -1091,7 +1087,6 @@ impl Model { on_update_model_frames: Vec::new(), model_facts: HashMap::new(), model_facts_wanted: Vec::new(), - dirty_editboxes: Vec::new(), event_to_frames: HashMap::new(), frame_events: HashMap::new(), all_event_frames: Vec::new(), diff --git a/crates/benilla-ui/src/script/tick.rs b/crates/benilla-ui/src/script/tick.rs index a7f4e49e..d2e0aebf 100644 --- a/crates/benilla-ui/src/script/tick.rs +++ b/crates/benilla-ui/src/script/tick.rs @@ -70,37 +70,40 @@ pub(crate) fn fire_event_into(lua: &Lua, event: &str, args: Vec) { } impl super::UiScript { - /// Advance a frame: the `GetTime()` clock, the edit boxes and queued events, then - /// `OnUpdate(self, elapsed)` on every visible frame that has one (`0x704f10`), then the - /// engine's own fades, model panes and hover. + /// Advance a frame: the `GetTime()` clock and queued events, then the walk (`0x765650`), + /// each shown frame's update in turn: its `OnUpdate(self, elapsed)` (`0x704f10`), and for an + /// EditBox its flush and caret blink after it; then the engine's own fades, model panes and + /// hover. pub fn tick(&mut self, elapsed: f32) { self.model_mut().now += f64::from(elapsed); - // The focused edit box's caret blink (`0x77a790`, on the client's frame tick). - editbox::tick_blink(&self.lua, elapsed); - // Then the flush (`0x77d3e0`): a multi-line box's relayout sizes it to its text - // (`0x77d4d0`), then the `OnTextChanged`s an edit only marked fire. benilla hoists it ahead - // of the OnUpdate sweep; the reference runs it per box inside the pump's walk, from the - // box's update (`0x77a790`, vtable `+0x38`), after that box's own Lua `OnUpdate` - // (`0x77a79a`, then `0x77a7a1`). - editbox::relayout_multi_line(&self.lua); - editbox::drain_text_changed(&self.lua); - // Then the caret flush (`0x77d3e0` → `0x77da80`): `OnCursorChanged` when the caret moved, - // which `ScrollingEdit_OnUpdate` scrolls by. - editbox::drain_cursor_changed(&self.lua); // Events queued since the last tick fire before this tick's OnUpdate. let pending = std::mem::take(&mut self.model_mut().pending_events); for (event, args) in pending { self.fire_event(&event, args); } - let ids: Vec = { + // Each entry: the frame's id, whether it has an `OnUpdate`, and the box if an EditBox. + let walk: Vec<(u32, bool, Option)> = { let mut model = self.model_mut(); // `SetScript` keeps the OnUpdate list; a destroyed frame's handle compacts out here. - let frames: Vec = model + let shown = |h: FrameHandle| model.arena.frame(h).is_some_and(|f| f.effective_visible); + let mut frames: Vec<(FrameHandle, bool)> = model .on_update_frames .iter() .copied() - .filter(|&h| model.arena.frame(h).is_some_and(|f| f.effective_visible)) + .filter(|&h| shown(h)) + .map(|h| (h, false)) .collect(); + // The walk reaches every shown frame, not only those with a script, and an EditBox's + // update (`0x77a790`, vtable `+0x38`) flushes it whether or not it has an `OnUpdate`. + frames.extend( + model + .arena + .editbox_kinds() + .iter() + .copied() + .filter(|&h| shown(h)) + .map(|h| (h, true)), + ); if model .on_update_frames .iter() @@ -115,16 +118,35 @@ impl super::UiScript { .collect(); model.on_update_frames = live; } - let mut ids: Vec = frames.into_iter().map(|h| model.frame_id(h)).collect(); + let mut walk: Vec<(u32, bool, Option)> = frames + .into_iter() + .map(|(h, editbox)| (model.frame_id(h), !editbox, editbox.then_some(h))) + .collect(); // Creation order, by frame id: getters settle on demand, so a handler sees what an // earlier one did this sweep, and the order must be stable. The reference walks each - // strata level's list of shown frames (`0x765650`). - ids.sort_unstable(); - ids + // strata level's list of shown frames (`0x765650`). A box with an `OnUpdate` is + // listed twice, and the two entries merge. + walk.sort_unstable_by_key(|&(id, ..)| id); + walk.dedup_by(|later, earlier| { + let same = later.0 == earlier.0; + if same { + earlier.1 |= later.1; + earlier.2 = earlier.2.or(later.2); + } + same + }); + walk }; - for id in ids { - if let Err(e) = event::fire_update_handler(&self.lua, id, elapsed) { - self.push_error(e); + for (id, on_update, editbox) in walk { + if on_update { + if let Err(e) = event::fire_update_handler(&self.lua, id, elapsed) { + self.push_error(e); + } + } + // The box's own update runs its flush after its `OnUpdate` (`0x77a79a`, then + // `0x77a7a1`), so the events an edit raised fire in the box's place in the walk. + if let Some(h) = editbox { + editbox::update(&self.lua, h, elapsed); } } self.tick_model_panes(elapsed); diff --git a/crates/benilla-ui/src/widget/kinds/editbox.rs b/crates/benilla-ui/src/widget/kinds/editbox.rs index ca915cec..6411138b 100644 --- a/crates/benilla-ui/src/widget/kinds/editbox.rs +++ b/crates/benilla-ui/src/widget/kinds/editbox.rs @@ -98,9 +98,13 @@ pub struct EditBoxState { pub rows: Vec, /// The row pitch in pixels (the snapped font em), answered with the advances; 0 until then. pub cell_h: f32, - /// The `(row, x)` of the last `OnCursorChanged`, fired per change (`0x77da80`, dirty bit 2 at - /// `0x77d475`); `None` lets the first flush after focus fire with the caret at home. - pub cursor_fired: Option<(usize, f32)>, + /// The dirty word (`[E+0x31c]`) the box's flush (`0x77d3e0`) drains: [`Self::DIRTY_TEXT`] and + /// [`Self::DIRTY_CURSOR`]. The ctor sets bit 0 (`0x779a34`), so a box fires `OnTextChanged` at + /// its first flush once shown. + pub dirty: u8, + /// A multi-line relayout that found its text's measure pending, retried at each flush until it + /// lands; with no font engine installed the measure arrives from the host a tick later. + pub relayout_owed: bool, /// The first visible display byte of a single-line box (`E+0x348`), scrolled by whole chars /// to keep the caret in view; `0x77da80` hides a caret outside the window. pub scroll_start: usize, @@ -126,6 +130,14 @@ impl EditBoxState { pub const JUSTIFY_H_MASK: u32 = crate::justify::H_MASK; /// The vertical justify bits (3-5). pub const JUSTIFY_V_MASK: u32 = crate::justify::V_MASK; + /// Dirty bit 0, the text changed: every edit raises it, and the flush relayouts (`0x77d447`) + /// and fires `OnTextChanged` (`0x77d498`). + pub const DIRTY_TEXT: u8 = 1; + /// Dirty bit 2, the caret: the cursor setter (`0x77e380`), a step that moves it (`0x77c73b`), + /// an edit, a focus change and a re-seat of the text raise it, and the flush runs the caret + /// leg (`0x77d475` → `0x77da80`), which fires `OnCursorChanged`. Bit 1, the + /// highlight's (`0x77d950`), has no counterpart: the host paints the selection each frame. + pub const DIRTY_CURSOR: u8 = 4; /// A justify token's bit; `None` makes the caller raise the reference's /// `Usage: %s:SetJustifyH("justify")`. @@ -173,7 +185,8 @@ impl Default for EditBoxState { advances_key: 0, rows: vec![0], cell_h: 0.0, - cursor_fired: None, + dirty: Self::DIRTY_TEXT, + relayout_owed: false, scroll_start: 0, drag_active: false, blink_period: 0.5, @@ -508,6 +521,7 @@ impl EditBoxState { self.collapse(); self.enforce_caps(); self.reset_blink(); + self.dirty |= Self::DIRTY_TEXT | Self::DIRTY_CURSOR; // `0x77c033 or edx,5` EditOutcome { text_changed: true, spaces: ins.matches(' ').count(), @@ -546,6 +560,7 @@ impl EditBoxState { self.collapse(); self.enforce_caps(); self.reset_blink(); + self.dirty |= Self::DIRTY_TEXT | Self::DIRTY_CURSOR; true } @@ -584,7 +599,13 @@ impl EditBoxState { } self.sel_start = snap_down(&self.text, s as usize); self.sel_end = snap_down(&self.text, e as usize); - self.cursor = self.sel_end; + // 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(); } @@ -637,8 +658,11 @@ impl EditBoxState { } /// Left or Right one step, `extend` dragging the selection; without it, a selection collapses - /// to its edge instead. + /// 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`). 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| { @@ -656,6 +680,9 @@ impl EditBoxState { self.collapse(); } self.reset_blink(); + if self.cursor != from { + self.dirty |= Self::DIRTY_CURSOR; + } } /// Ctrl/Option+arrow: the caret to the [`word_boundary`](Self::word_boundary) by single atomic @@ -686,8 +713,11 @@ impl EditBoxState { self.move_caret_to(target, extend); } - /// Place the caret at `target`, extending the selection from its anchor when `extend`. + /// 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. 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())); if extend { let anchor = self.selection_anchor(); @@ -698,6 +728,9 @@ impl EditBoxState { self.collapse(); } self.reset_blink(); + if self.cursor != from { + self.dirty |= Self::DIRTY_CURSOR; + } } fn delete_selection(&mut self) { @@ -718,6 +751,7 @@ impl EditBoxState { self.cursor = span.start; self.text.replace_range(span, ""); self.collapse(); + self.dirty |= Self::DIRTY_TEXT | Self::DIRTY_CURSOR; // `0x77c683 or ecx,5` } /// Collapse the selection onto the caret (`0x77ccf0`), as every delete does and as a screen diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index 7cf9c32e..a0126481 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -250,7 +250,7 @@ pub struct WidgetArena { /// The live Minimaps, kept the same way, for the containment, zoom and arrow-facing feeds. minimap_kinds: Vec, /// The live EditBoxes, kept the same way, for the resize re-seat (`reseat_resized`) and the - /// per-tick relayout of multi-line boxes. + /// tick's walk, which flushes every shown box. editbox_kinds: Vec, }