EditBox events fire from each box's flush, as 1.12.1 orders them (#734)
EditBox events fired from hoisted drains before any OnUpdate, OnTextChanged before OnCursorChanged, and only on an edit. In 1.12.1: - Each box's update `0x77a790` fires its own `OnUpdate` and then its flush `0x77d3e0`, and a key-down or click flushes first (`0x77b1e2`, `0x77b819`). - The flush runs off dirty bits in `[E+0x31c]`: relayout on bit 0, then `OnCursorChanged` on bit 2, then `OnTextChanged`. - The ctor sets bit 0, so a box fires at its first show. Every text-region re-seat raises bits 0–2 (`0x77ba7f`), so a resize, new insets, `SetMultiLine` or a font change fires it again. benilla now keeps the same bits and flushes each box in the walk and at its keys and clicks. So typing DELETE and pressing Enter in one frame enables the popup's button, as in 1.12.1. Tests cover the order, first show, re-seats, a key's flush and no-op moves. Fixes #724 Fixes #727 🤖 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
87f65faffd
commit
e9f0e89a0b
@@ -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<f32> = 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,
|
||||
|
||||
@@ -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::<i64>("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(),
|
||||
|
||||
@@ -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<f32>, Vec<usize>, 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<f32>, Vec<usize>, 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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::<Model>().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::<Model>()
|
||||
.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::<Model>().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::<Model>().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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Model>()
|
||||
.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::<Model>()
|
||||
.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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<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 },
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
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::<String>("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::<String>("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::<i64>("return N").unwrap()));
|
||||
}
|
||||
assert_eq!(
|
||||
fires,
|
||||
[
|
||||
("a resize", 1),
|
||||
("SetTextInsets", 1),
|
||||
("GetTextInsets", 1),
|
||||
("a font change", 1),
|
||||
("SetMultiLine", 1),
|
||||
]
|
||||
);
|
||||
assert_eq!(s.eval::<String>("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::<i64>("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());
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<FrameHandle> {
|
||||
lua.app_data_ref::<Model>()
|
||||
.expect("model app_data")
|
||||
|
||||
@@ -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<FrameHandle> = {
|
||||
/// `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::<Model>().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::<Model>().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::<Model>().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
|
||||
|
||||
@@ -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<types::EditBoxAdvanceRequest> {
|
||||
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<types::EditBoxAdvanceRequest> {
|
||||
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<f32>, rows: Vec<usize>, 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::<Model>().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
|
||||
}
|
||||
|
||||
@@ -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::<String>("return E:GetText()").unwrap(), "abc");
|
||||
assert_eq!(
|
||||
s.eval::<i64>("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::<String>("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::<String>("return E:GetText()").unwrap(), "xyz");
|
||||
// Both pastes marked the one box, so the drain fires once.
|
||||
assert_eq!(s.eval::<i64>("return changed").unwrap(), 0);
|
||||
s.tick(0.0);
|
||||
assert_eq!(s.eval::<i64>("return changed").unwrap(), 1);
|
||||
s.tick(0.0);
|
||||
assert_eq!(s.eval::<i64>("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::<i64>("return n").unwrap(), 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<f32>, Vec<usize>, f32)> {
|
||||
let _ = req;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure region `rh` now if a measurer is installed and its stored measure is stale; a current
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -186,10 +186,6 @@ pub(crate) struct Model {
|
||||
pub(crate) model_facts: HashMap<String, std::sync::Arc<crate::widget::ModelFileFacts>>,
|
||||
/// Model files still waiting for their facts, for the host to load; deduplicated.
|
||||
pub(crate) model_facts_wanted: Vec<String>,
|
||||
/// 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<FrameHandle>,
|
||||
/// 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<String, Vec<FrameHandle>>,
|
||||
@@ -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(),
|
||||
|
||||
@@ -70,37 +70,40 @@ pub(crate) fn fire_event_into(lua: &Lua, event: &str, args: Vec<ScriptValue>) {
|
||||
}
|
||||
|
||||
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<u32> = {
|
||||
// Each entry: the frame's id, whether it has an `OnUpdate`, and the box if an EditBox.
|
||||
let walk: Vec<(u32, bool, Option<FrameHandle>)> = {
|
||||
let mut model = self.model_mut();
|
||||
// `SetScript` keeps the OnUpdate list; a destroyed frame's handle compacts out here.
|
||||
let frames: Vec<FrameHandle> = 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<u32> = frames.into_iter().map(|h| model.frame_id(h)).collect();
|
||||
let mut walk: Vec<(u32, bool, Option<FrameHandle>)> = 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);
|
||||
|
||||
@@ -98,9 +98,13 @@ pub struct EditBoxState {
|
||||
pub rows: Vec<usize>,
|
||||
/// 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
|
||||
|
||||
@@ -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<FrameHandle>,
|
||||
/// 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<FrameHandle>,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user