benilla: initial public release
A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
//! The EditBox mouse/selection interaction law — click→char-index, drag-select, the clipboard
|
||||
//! pair, and the caret blink — a child module over the mother's focus/editing primitives.
|
||||
//!
|
||||
//! Byte law (RF-0082 §1/§4 + the diffed leaves quoted in wow-re's `rf82-editbox-runtime.md`):
|
||||
//!
|
||||
//! - **Click `0x77b800`**: on a hit — blink reset, click→index (`0x77d0d0`) places the cursor and
|
||||
//! collapses the selection, the drag flag raises (`E+0x364 = 1`), then `SetFocus`
|
||||
//! UNCONDITIONALLY. No shift+click branch exists in the handler (INFERRED by absence — 1.12
|
||||
//! predates extend-on-click).
|
||||
//! - **Drag `0x77a860`** (mouse move while `+0x364`): map x→index, extend the selection there via
|
||||
//! the same helper Shift+arrow uses (`0x77cd10`), cursor following.
|
||||
//! - **Release** (`0x77afc0` case 1): `+0x364 = 0`.
|
||||
//! - **Clipboard** (§4): Ctrl+C/X act only with a non-empty selection (`0x77e1d0`); X then
|
||||
//! deletes = cut. A password box copies a FIXED placeholder (`0x882748`, runtime literal
|
||||
//! unresolved — flagged live-capture), never the real text; benilla stands in the mask run.
|
||||
//! - **Blink `0x77a790`**: the focused box's accumulator (`E+0x374`) advances by dt; crossing the
|
||||
//! period (`E+0x370`, ctor default 0.5 s) toggles the caret and zeroes the accumulator.
|
||||
|
||||
use mlua::Lua;
|
||||
|
||||
use super::{
|
||||
collapse, delete_selection, fire_script, frame_id_of, selection_anchor, set_focus_handle,
|
||||
set_span, sync_text_region, with_eb,
|
||||
};
|
||||
use crate::script::Model;
|
||||
use crate::widget::{FrameHandle, FrameKind, KindState};
|
||||
|
||||
/// Map a screen point (the engine's y-up UI space — the same space resolved rects and the
|
||||
/// pointer API live in) to the box's TEXT byte index: x relative to the text region's left edge,
|
||||
/// shifted into advance space by the scroll window's origin, nearest char boundary (`0x77d0d0`;
|
||||
/// the rounding is INFERRED nearest). A multiline box also reads y — px down from the region's
|
||||
/// top pick the wrapped row ([`crate::widget::EditBoxState::index_at_pos`]). Ends up at
|
||||
/// end-of-text while the advance table is unanswered — the pre-seam behavior, degrading
|
||||
/// gracefully.
|
||||
fn index_at_screen_pos(lua: &Lua, h: FrameHandle, x: f32, y: f32) -> usize {
|
||||
let corner = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let rh = match model.arena.frame(h).map(|f| &f.kind_state) {
|
||||
Some(KindState::EditBox(eb)) => eb.text_region,
|
||||
_ => None,
|
||||
};
|
||||
// The text region's resolved corner; an unanchored region (no insets) draws over its
|
||||
// owner, so the frame's own edges stand in.
|
||||
rh.and_then(|rh| model.region_resolved.get(&rh))
|
||||
.or_else(|| model.resolved.get(&h))
|
||||
.map(|r| (r.left, r.top))
|
||||
};
|
||||
with_eb(lua, h, |eb| {
|
||||
let Some((left, top)) = corner else {
|
||||
return eb.text.len();
|
||||
};
|
||||
let win = eb.advances.get(eb.scroll_start).copied().unwrap_or(0.0);
|
||||
let d = eb.index_at_pos((x - left) + win, top - y);
|
||||
eb.display_to_text(d)
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// A LeftButton press landed on frame `id` (`0x77b800`): if it is an EditBox — blink reset, the
|
||||
/// cursor to the clicked char (collapsing any selection), drag begins, focus UNCONDITIONALLY.
|
||||
pub(in crate::script) fn click(lua: &Lua, id: u32, x: f32, y: f32) {
|
||||
let Some(h) = editbox_of(lua, id) else { return };
|
||||
let idx = index_at_screen_pos(lua, h, x, y);
|
||||
with_eb(lua, h, |eb| {
|
||||
eb.reset_blink();
|
||||
eb.cursor = idx;
|
||||
collapse(eb);
|
||||
eb.drag_active = true;
|
||||
});
|
||||
set_focus_handle(lua, h);
|
||||
}
|
||||
|
||||
/// Mouse move while the press is held (`0x77a860`): the focused, drag-active box extends its
|
||||
/// selection to the hovered index; the cursor follows.
|
||||
pub(in crate::script) fn drag_update(lua: &Lua, x: f32, y: f32) {
|
||||
let Some(h) = focused(lua) else { return };
|
||||
if with_eb(lua, h, |eb| eb.drag_active) != Some(true) {
|
||||
return;
|
||||
}
|
||||
let idx = index_at_screen_pos(lua, h, x, y);
|
||||
with_eb(lua, h, |eb| {
|
||||
if idx != eb.cursor {
|
||||
let anchor = selection_anchor(eb);
|
||||
eb.cursor = idx;
|
||||
set_span(eb, anchor, idx);
|
||||
eb.reset_blink();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The press released (`0x77afc0` case 1): the drag ends. Fires for any release — a box that
|
||||
/// never started a drag ignores it.
|
||||
pub(in crate::script) fn drag_end(lua: &Lua) {
|
||||
if let Some(h) = focused(lua) {
|
||||
with_eb(lua, h, |eb| eb.drag_active = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ctrl+LEFT/RIGHT — the word-granular cursor move (§4's Ctrl branch; the client walks its
|
||||
/// per-byte class array, benilla's alnum-run approximation is INFERRED): `shift` extends the
|
||||
/// selection from the fixed anchor like the char move; else the caret collapses there.
|
||||
pub(in crate::script) fn move_word(lua: &Lua, h: FrameHandle, right: bool, shift: bool) {
|
||||
with_eb(lua, h, |eb| {
|
||||
let target = eb.word_boundary(right);
|
||||
if shift {
|
||||
let anchor = selection_anchor(eb);
|
||||
eb.cursor = target;
|
||||
set_span(eb, anchor, target);
|
||||
} else {
|
||||
eb.cursor = target;
|
||||
collapse(eb);
|
||||
}
|
||||
eb.reset_blink();
|
||||
});
|
||||
}
|
||||
|
||||
/// Ctrl+C (`0x77e1d0`): the focused box's selected substring, `None` with no selection. A
|
||||
/// password box yields the mask run — the client copies a fixed placeholder, NEVER the text.
|
||||
pub(in crate::script) fn copy_selection(lua: &Lua) -> Option<String> {
|
||||
let h = focused(lua)?;
|
||||
with_eb(lua, h, |eb| {
|
||||
if eb.sel_start == eb.sel_end {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = (eb.sel_start.min(eb.sel_end), eb.sel_start.max(eb.sel_end));
|
||||
Some(if eb.password {
|
||||
"*".repeat(eb.text[a..b].chars().count())
|
||||
} else {
|
||||
eb.text[a..b].to_string()
|
||||
})
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Ctrl+X: copy, then delete the selection (an ordinary edit — the region syncs and
|
||||
/// `OnTextChanged` fires).
|
||||
pub(in crate::script) fn cut_selection(lua: &Lua) -> Option<String> {
|
||||
let copied = copy_selection(lua)?;
|
||||
let h = focused(lua)?;
|
||||
with_eb(lua, h, |eb| {
|
||||
delete_selection(eb);
|
||||
eb.reset_blink();
|
||||
});
|
||||
sync_text_region(lua, h);
|
||||
fire_script(lua, frame_id_of(lua, h), "OnTextChanged");
|
||||
Some(copied)
|
||||
}
|
||||
|
||||
/// The blink tick (`0x77a790`, run from the host's frame tick): the focused box's accumulator
|
||||
/// advances; crossing the period toggles the caret and resets. Non-positive period never blinks
|
||||
/// (a solid caret).
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The focused EditBox handle, if any.
|
||||
fn focused(lua: &Lua) -> Option<FrameHandle> {
|
||||
lua.app_data_ref::<Model>()
|
||||
.expect("model app_data")
|
||||
.focused_editbox
|
||||
}
|
||||
|
||||
/// Resolve a frame id to a live EditBox handle.
|
||||
fn editbox_of(lua: &Lua, id: u32) -> Option<FrameHandle> {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let h = model.id_to_frame.get(&id).copied()?;
|
||||
matches!(
|
||||
model.arena.frame(h).map(|f| f.kind),
|
||||
Some(FrameKind::EditBox)
|
||||
)
|
||||
.then_some(h)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! The EditBox Lua method surface — `install` builds the method table
|
||||
//! (`REG_EDITBOX_METHODS`) the dispatcher consults before the shared frame table for EditBox
|
||||
//! frames: text/cursor/selection accessors, focus, history, the config setters, and the blink
|
||||
//! dial. Every method routes through the mother module's primitives (`set_text`,
|
||||
//! `highlight_text`, …), so the byte-verified law lives exactly once.
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
|
||||
use crate::script::object::frame_handle_of;
|
||||
use crate::script::Model;
|
||||
use crate::widget::EditBoxState;
|
||||
|
||||
use super::{
|
||||
clear_focus_handle, highlight_text, insert, set_focus_handle, set_text, set_text_insets,
|
||||
with_eb, REG_EDITBOX_METHODS,
|
||||
};
|
||||
|
||||
/// Run `f` over a frame's EditBox state for a Lua method call; errors if `this` is not a live EditBox.
|
||||
fn with_editbox<T>(
|
||||
lua: &Lua,
|
||||
this: &Table,
|
||||
f: impl FnOnce(&mut EditBoxState) -> T,
|
||||
) -> mlua::Result<T> {
|
||||
let h = frame_handle_of(lua, this)?;
|
||||
with_eb(lua, h, f).ok_or_else(|| mlua::Error::runtime("not an EditBox"))
|
||||
}
|
||||
|
||||
pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let m = lua.create_table()?;
|
||||
|
||||
m.set(
|
||||
"SetText",
|
||||
lua.create_function(|lua, (this, s): (Table, Option<String>)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
// Programmatic SetText ends a history browse (the engine-internal recall path calls
|
||||
// `set_text` directly and keeps its browse position).
|
||||
with_eb(lua, h, EditBoxState::end_history_browse);
|
||||
set_text(lua, h, &s.unwrap_or_default());
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetText",
|
||||
lua.create_function(|lua, this: Table| with_editbox(lua, &this, |eb| eb.text.clone()))?,
|
||||
)?;
|
||||
// GetNumber: atof of the real text (0 on failure), matching `0x798790`.
|
||||
m.set(
|
||||
"GetNumber",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let text = with_editbox(lua, &this, |eb| eb.text.clone())?;
|
||||
Ok(text.trim().parse::<f64>().unwrap_or(0.0))
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"Insert",
|
||||
lua.create_function(|lua, (this, s): (Table, String)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
insert(lua, h, &s, true);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"SetFocus",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
set_focus_handle(lua, h);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"ClearFocus",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
clear_focus_handle(lua, h);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"HasFocus",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(model.focused_editbox == Some(h))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// HighlightText([start [, end]]) — defaults (0, -1) = select-all.
|
||||
m.set(
|
||||
"HighlightText",
|
||||
lua.create_function(
|
||||
|lua, (this, start, end): (Table, Option<i64>, Option<i64>)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
highlight_text(lua, h, start.unwrap_or(0), end.unwrap_or(-1));
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
|
||||
m.set(
|
||||
"SetMaxLetters",
|
||||
lua.create_function(|lua, (this, n): (Table, i64)| {
|
||||
with_editbox(lua, &this, |eb| eb.max_letters = n.max(0) as usize)
|
||||
})?,
|
||||
)?;
|
||||
// The submitted-line history (`historyLines`): FrameXML pushes each sent line
|
||||
// (ChatEdit_AddHistory), UP/DOWN recall it (see `key_input`).
|
||||
m.set(
|
||||
"AddHistoryLine",
|
||||
lua.create_function(|lua, (this, line): (Table, Option<String>)| {
|
||||
with_editbox(lua, &this, |eb| {
|
||||
eb.add_history_line(line.as_deref().unwrap_or(""));
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"SetHistoryLines",
|
||||
lua.create_function(|lua, (this, n): (Table, i64)| {
|
||||
with_editbox(lua, &this, |eb| {
|
||||
eb.history_max = n.max(0) as usize;
|
||||
let over = eb.history.len().saturating_sub(eb.history_max);
|
||||
if over > 0 {
|
||||
eb.history.drain(..over);
|
||||
}
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetHistoryLines",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
with_editbox(lua, &this, |eb| eb.history_max as i64)
|
||||
})?,
|
||||
)?;
|
||||
// SetBlinkSpeed/GetBlinkSpeed — the caret half-period (`E+0x370`, XML `blinkSpeed`; ctor 0.5).
|
||||
m.set(
|
||||
"SetBlinkSpeed",
|
||||
lua.create_function(|lua, (this, s): (Table, f32)| {
|
||||
with_editbox(lua, &this, |eb| eb.blink_period = s)
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetBlinkSpeed",
|
||||
lua.create_function(|lua, this: Table| with_editbox(lua, &this, |eb| eb.blink_period))?,
|
||||
)?;
|
||||
m.set(
|
||||
"SetTextInsets",
|
||||
lua.create_function(
|
||||
|lua,
|
||||
(this, l, r, t, b): (
|
||||
Table,
|
||||
Option<f32>,
|
||||
Option<f32>,
|
||||
Option<f32>,
|
||||
Option<f32>,
|
||||
)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
set_text_insets(
|
||||
lua,
|
||||
h,
|
||||
l.unwrap_or(0.0),
|
||||
r.unwrap_or(0.0),
|
||||
t.unwrap_or(0.0),
|
||||
b.unwrap_or(0.0),
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetTextInsets",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let ins = with_editbox(lua, &this, |eb| eb.text_insets)?;
|
||||
Ok((ins[0], ins[1], ins[2], ins[3]))
|
||||
})?,
|
||||
)?;
|
||||
// GetNumLetters: the CHARACTER count (not bytes) — `0x7992c0` walks the class array.
|
||||
m.set(
|
||||
"GetNumLetters",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
with_editbox(lua, &this, |eb| eb.text.chars().count() as i64)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// The config flags. SetAutoFocus/SetNumeric/SetPassword/SetMultiLine mirror the live API;
|
||||
// SetIgnoreArrows has no client method (the flag is XML-only) — a benilla convenience so the
|
||||
// loader can drive every flag through one method surface.
|
||||
for (name, set) in flag_setters() {
|
||||
let refresh_justify = name == "SetMultiLine";
|
||||
m.set(
|
||||
name,
|
||||
lua.create_function(move |lua, (this, v): (Table, Value)| {
|
||||
let on = !matches!(v, Value::Nil | Value::Boolean(false));
|
||||
with_editbox(lua, &this, |eb| set(eb, on))?;
|
||||
if refresh_justify {
|
||||
// The text-anchoring law reads multiLine (TOP vs MIDDLE), and the loader
|
||||
// wires the declared `<FontString>` BEFORE the editbox flags (LoadXML order
|
||||
// 5·b vs 5b) — re-seat an already-wired region so `multiLine="true"` lands.
|
||||
super::refresh_text_region_justify(lua, &this)?;
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
lua.set_named_registry_value(REG_EDITBOX_METHODS, m)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A `Set<Flag>` name paired with the EditBoxState mutator it drives (Lua truthiness).
|
||||
type FlagSetter = (&'static str, fn(&mut EditBoxState, bool));
|
||||
|
||||
/// The config-flag setters, in one place so the loader and the Lua surface share the list.
|
||||
fn flag_setters() -> [FlagSetter; 5] {
|
||||
[
|
||||
("SetAutoFocus", |eb, on| eb.auto_focus = on),
|
||||
("SetNumeric", |eb, on| eb.numeric = on),
|
||||
("SetPassword", |eb, on| eb.password = on),
|
||||
("SetMultiLine", |eb, on| eb.multi_line = on),
|
||||
("SetIgnoreArrows", |eb, on| eb.ignore_arrows = on),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! The EditBox text-UI seam — the `impl UiScript` surface the host drives each frame: the
|
||||
//! advance-table round trip (request → host measures → answer), the caret/selection/scroll
|
||||
//! geometry derived from it, and the clipboard pair. Split beside its concern per the layout.rs
|
||||
//! pattern (the measure-seam siblings live there).
|
||||
|
||||
use crate::script::{types, UiScript};
|
||||
use crate::widget::KindState;
|
||||
|
||||
impl UiScript {
|
||||
/// The focused EditBox's advance-table request (the metrics half of the mouse/selection law):
|
||||
/// `Some` when the box's DISPLAY string needs its per-byte cumulative widths measured (text or
|
||||
/// font changed since the last answer). Answer via [`Self::set_editbox_advances`] before
|
||||
/// [`Self::focused_editbox_text_ui`] — an empty display settles engine-side without a trip.
|
||||
pub fn editbox_advances_request(&mut self) -> Option<types::EditBoxAdvanceRequest> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let h = self.model_mut().focused_editbox?;
|
||||
// Materialize the lazy text region first — its resolved font is the table's identity
|
||||
// (a just-focused CreateFrame'd box has none until here).
|
||||
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();
|
||||
// A multiline box also needs its wrap rows — at the text region's resolved width, the
|
||||
// same width the draw wraps at. Folded into the cache key: a resize re-wraps.
|
||||
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);
|
||||
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,
|
||||
text: display,
|
||||
wrap_width,
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
/// Answer an [`Self::editbox_advances_request`]: the per-byte cumulative laid-out widths of
|
||||
/// the requested display string (len+1 entries, `[0] = 0`), plus the wrapped-row starts and
|
||||
/// row pitch (single-line: `rows = [0]`, `cell_h` the line em — only a multiline box reads
|
||||
/// them, but the answer is uniform).
|
||||
pub fn set_editbox_advances(
|
||||
&mut self,
|
||||
id: u32,
|
||||
key: u64,
|
||||
cum: Vec<f32>,
|
||||
rows: Vec<usize>,
|
||||
cell_h: f32,
|
||||
) {
|
||||
let mut model = self.model_mut();
|
||||
let Some(h) = model.id_to_frame.get(&id).copied() else {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// The focused EditBox's text-UI geometry for this frame (RF-0082 leaves caret/highlight
|
||||
/// geometry to the host): which Text quad is the box's (`target`), the scroll window to draw
|
||||
/// (`display_from`), and the caret/selection x-spans within it — all advance-table-derived.
|
||||
/// Clamps the scroll window against the text region's resolved width first (the
|
||||
/// scroll-into-view invariant, `0x77da80`). `None` when nothing is focused, the box is
|
||||
/// off-screen, or the advance table hasn't been answered yet (text still draws; the caret and
|
||||
/// highlight sit that frame out).
|
||||
pub fn focused_editbox_text_ui(&mut self) -> Option<types::EditBoxTextUi> {
|
||||
let h = {
|
||||
let model = self.model_mut();
|
||||
let h = model.focused_editbox?;
|
||||
let f = model.arena.frame(h)?;
|
||||
if !f.effective_visible || !matches!(f.kind_state, KindState::EditBox(_)) {
|
||||
return None;
|
||||
}
|
||||
h
|
||||
};
|
||||
// Focus means "about to type": materialize the lazy text region (the same lazy
|
||||
// constructor `sync_text_region` uses), so an empty just-focused box has a caret home.
|
||||
let rh = super::ensure_text_region(self.lua(), h)?;
|
||||
let mut model = self.model_mut();
|
||||
// The text region's resolved width; an unanchored region (a CreateFrame'd box without
|
||||
// insets) draws over its owner, so the frame's own rect is its width.
|
||||
let avail = model
|
||||
.region_resolved
|
||||
.get(&rh)
|
||||
.or_else(|| model.resolved.get(&h))
|
||||
.map(|r| r.right - r.left)
|
||||
.unwrap_or(0.0);
|
||||
let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let display = eb.display();
|
||||
if !display.is_empty() && eb.advances.len() != display.len() + 1 {
|
||||
return None;
|
||||
}
|
||||
if display.is_empty() {
|
||||
// Trivial table — no host trip needed for an empty box.
|
||||
eb.advances = vec![0.0];
|
||||
eb.rows = vec![0];
|
||||
}
|
||||
// Keep the caret inside the window, leaving it a pixel to stand in at the right edge
|
||||
// (multiline: pins the window to 0 — the box wraps instead).
|
||||
eb.clamp_scroll((avail - 2.0).max(0.0));
|
||||
let from = eb.scroll_start.min(display.len());
|
||||
let origin = eb.advances[from];
|
||||
let cursor_d = eb.text_to_display(eb.cursor).min(display.len());
|
||||
let (a, b) = (eb.sel_start.min(eb.sel_end), eb.sel_start.max(eb.sel_end));
|
||||
let (da, db) = (
|
||||
eb.text_to_display(a).min(display.len()),
|
||||
eb.text_to_display(b).min(display.len()),
|
||||
);
|
||||
let (caret_row, caret_x, selection) = if eb.multi_line {
|
||||
let (row, x) = eb.caret_row_x(cursor_d);
|
||||
// The selection as per-row spans: each touched row contributes the intersection of
|
||||
// its byte range with [da, db), x-measured from the row's own origin.
|
||||
let mut spans = Vec::new();
|
||||
if da != db {
|
||||
for i in 0..eb.rows.len() {
|
||||
let (rs, re) = eb.row_range(i, display.len());
|
||||
let (s0, s1) = (da.max(rs), db.min(re));
|
||||
if s0 < s1 {
|
||||
let ro = eb.advances[rs.min(display.len())];
|
||||
spans.push((i, eb.advances[s0] - ro, eb.advances[s1] - ro));
|
||||
}
|
||||
}
|
||||
}
|
||||
(row, x, spans)
|
||||
} else {
|
||||
let spans = if da != db {
|
||||
vec![(
|
||||
0,
|
||||
(eb.advances[da] - origin).max(0.0),
|
||||
eb.advances[db] - origin,
|
||||
)]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
(0, eb.advances[cursor_d] - origin, spans)
|
||||
};
|
||||
Some(types::EditBoxTextUi {
|
||||
target: crate::order::ZTarget::Region(rh),
|
||||
display_from: from,
|
||||
multi_line: eb.multi_line,
|
||||
caret_x,
|
||||
caret_row,
|
||||
cell_h: eb.cell_h,
|
||||
caret_on: eb.caret_shown,
|
||||
selection,
|
||||
highlight_color: eb.highlight_color,
|
||||
})
|
||||
}
|
||||
|
||||
/// Ctrl/Cmd+C for the focused EditBox: the selected substring for the OS clipboard (`None` =
|
||||
/// no selection; a password box yields its mask run, never the real text — RF-0082 §4).
|
||||
pub fn editbox_copy(&mut self) -> Option<String> {
|
||||
super::copy_selection(self.lua())
|
||||
}
|
||||
|
||||
/// Ctrl/Cmd+X for the focused EditBox: copy, then delete the selection.
|
||||
pub fn editbox_cut(&mut self) -> Option<String> {
|
||||
super::cut_selection(self.lua())
|
||||
}
|
||||
|
||||
/// Push one submitted line into the named box's recall history — the ref's
|
||||
/// `ChatEdit_AddHistory` slot (the canonical slash line, added by the app's router AFTER it
|
||||
/// parses the send; decision 0301). By name, not focus: the submit pipeline is asynchronous,
|
||||
/// so by the time the router runs, the box has already cleared and dropped focus. Returns
|
||||
/// `false` when `box_name` names no live EditBox.
|
||||
pub fn editbox_add_history(&mut self, box_name: &str, line: &str) -> bool {
|
||||
let lua = self.lua();
|
||||
let Ok(t) = lua.globals().get::<mlua::Table>(box_name) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(h) = crate::script::object::frame_handle_of(lua, &t) else {
|
||||
return false;
|
||||
};
|
||||
super::with_eb(lua, h, |eb| eb.add_history_line(line)).is_some()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,909 @@
|
||||
//! Rust-driven tests of the EditBox runtime (RF-0082): focus acquisition + routing, text/cursor/
|
||||
//! selection editing, the specialized script fires, caps, numeric/password, and text-region sync.
|
||||
//! Frames are built programmatically via `CreateFrame("EditBox", …)` and driven through the public
|
||||
//! keyboard API (`char_input`/`key_input`/`editbox_action`/`has_keyboard_focus`) and the Lua
|
||||
//! method surface.
|
||||
|
||||
use crate::script::{EditAction, EditUnit, QuadContent, UiScript};
|
||||
|
||||
fn script() -> UiScript {
|
||||
UiScript::new().expect("construct UiScript")
|
||||
}
|
||||
|
||||
/// The display text of the (single) text quad an extract produces, if any.
|
||||
fn text_quad(s: &UiScript) -> Option<String> {
|
||||
s.extract().into_iter().find_map(|q| match q.content {
|
||||
QuadContent::Text { text, .. } => text,
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
// ── §1/§2 focus acquisition + routing ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn autofocus_does_not_focus_on_show_but_self_acquires_first_event() {
|
||||
let mut s = script();
|
||||
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetAutoFocus(true)"#)
|
||||
.unwrap();
|
||||
// autoFocus does NOT focus on show — nothing owns the keyboard yet.
|
||||
assert!(!s.has_keyboard_focus());
|
||||
assert!(!s.eval::<bool>("return E:HasFocus()").unwrap());
|
||||
|
||||
// The first char self-acquires focus AND processes that same event.
|
||||
assert!(
|
||||
s.char_input("a"),
|
||||
"an autoFocus box consumes the acquiring event"
|
||||
);
|
||||
assert!(s.has_keyboard_focus());
|
||||
assert!(s.eval::<bool>("return E:HasFocus()").unwrap());
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_autofocus_unfocused_box_ignores_input() {
|
||||
let mut s = script();
|
||||
s.run(r#"E = CreateFrame("EditBox", "E")"#).unwrap();
|
||||
assert!(!s.char_input("a"), "no focus, no autoFocus → not consumed");
|
||||
assert!(!s.editbox_action(EditAction::Move {
|
||||
unit: EditUnit::Char,
|
||||
back: true,
|
||||
extend: false
|
||||
}));
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "");
|
||||
assert!(!s.has_keyboard_focus());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focused_box_consumes_everything() {
|
||||
let mut s = script();
|
||||
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetFocus()"#)
|
||||
.unwrap();
|
||||
// Empty text: these do nothing, but a focused box still consumes them.
|
||||
assert!(s.editbox_action(EditAction::Move {
|
||||
unit: EditUnit::Char,
|
||||
back: true,
|
||||
extend: false
|
||||
}));
|
||||
assert!(s.editbox_action(EditAction::Move {
|
||||
unit: EditUnit::Edge,
|
||||
back: true,
|
||||
extend: false
|
||||
}));
|
||||
assert!(s.editbox_action(EditAction::Delete {
|
||||
unit: EditUnit::Char,
|
||||
back: false
|
||||
}));
|
||||
assert!(s.char_input("x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_focus_on_hidden_box_is_a_noop() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
gained = 0
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetScript("OnEditFocusGained", function() gained = gained + 1 end)
|
||||
E:Hide()
|
||||
E:SetFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!s.eval::<bool>("return E:HasFocus()").unwrap());
|
||||
assert!(!s.has_keyboard_focus());
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return gained").unwrap(),
|
||||
0,
|
||||
"no focus gained"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_focuses_regardless_of_autofocus_and_transition_order_is_lost_then_gained() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
log = {}
|
||||
local function wire(name, y)
|
||||
local f = CreateFrame("EditBox", name)
|
||||
f:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, y)
|
||||
f:SetSize(100, 20)
|
||||
f:SetScript("OnEditFocusGained", function() table.insert(log, "gained"..name) end)
|
||||
f:SetScript("OnEditFocusLost", function() table.insert(log, "lost"..name) end)
|
||||
end
|
||||
wire("A", 0) -- rect bottom 0..20
|
||||
wire("B", 100) -- rect bottom 100..120
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// Neither box has autoFocus, yet a click focuses each.
|
||||
s.mouse_button(50.0, 10.0, "LeftButton", true);
|
||||
s.mouse_button(50.0, 10.0, "LeftButton", false);
|
||||
assert!(s.eval::<bool>("return A:HasFocus()").unwrap());
|
||||
|
||||
s.mouse_button(50.0, 110.0, "LeftButton", true);
|
||||
s.mouse_button(50.0, 110.0, "LeftButton", false);
|
||||
assert!(s.eval::<bool>("return B:HasFocus()").unwrap());
|
||||
|
||||
let log: Vec<String> = s.eval("return log").unwrap();
|
||||
assert_eq!(log, vec!["gainedA", "lostA", "gainedB"]);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
// ── §3 text buffer + editing + OnTextChanged/OnTextSet ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn typing_builds_text_and_fires_ontextchanged_per_mutation() {
|
||||
let mut s = script();
|
||||
s.run(
|
||||
r#"
|
||||
changed = 0
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetScript("OnTextChanged", function() changed = changed + 1 end)
|
||||
E:SetFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.char_input("a"));
|
||||
assert!(s.char_input("b"));
|
||||
assert!(s.char_input("c"));
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "abc");
|
||||
assert_eq!(s.eval::<i64>("return changed").unwrap(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_text_short_circuits_on_same_value_else_fires_textset_then_textchanged() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
log = {}
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetScript("OnTextSet", function() table.insert(log, "set") end)
|
||||
E:SetScript("OnTextChanged", function() table.insert(log, "changed") end)
|
||||
E:SetText("hi") -- fires set then changed
|
||||
E:SetText("hi") -- unchanged → short-circuit, no events
|
||||
E:SetText("bye") -- fires set then changed
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let log: Vec<String> = s.eval("return log").unwrap();
|
||||
assert_eq!(log, vec!["set", "changed", "set", "changed"]);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "bye");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_aborts_a_mixed_insert_wholesale() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetNumeric(true)
|
||||
E:Insert("12") -- all digits: accepted
|
||||
E:Insert("3a") -- one non-digit: the WHOLE insert aborts
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_letters_trims_from_the_end() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetMaxLetters(3)
|
||||
E:Insert("abcde")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "abc");
|
||||
assert_eq!(s.eval::<i64>("return E:GetNumLetters()").unwrap(), 3);
|
||||
}
|
||||
|
||||
// ── §4 selection / highlight / keys ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn highlight_all_then_typing_replaces_the_selection() {
|
||||
let mut s = script();
|
||||
s.run(
|
||||
r#"
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetText("hello")
|
||||
E:HighlightText(0, -1) -- select all
|
||||
E:SetFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.char_input("X"), "focused: consumed");
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_inserts_at_the_cursor_and_replaces_the_selection() {
|
||||
let mut s = script();
|
||||
s.run(
|
||||
r#"
|
||||
changed = 0
|
||||
E = CreateFrame("EditBox", "E")
|
||||
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();
|
||||
// Cursor sits at end after SetText → paste appends.
|
||||
assert!(s.paste("CD"), "focused box consumes the paste");
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "abCD");
|
||||
// Select-all then paste replaces the whole selection.
|
||||
s.run("E:HighlightText(0, -1)").unwrap();
|
||||
assert!(s.paste("xyz"));
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "xyz");
|
||||
// Each paste that changed the text fired OnTextChanged once.
|
||||
assert_eq!(s.eval::<i64>("return changed").unwrap(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_into_an_unfocused_box_is_not_consumed() {
|
||||
let mut s = script();
|
||||
s.run(r#"E = CreateFrame("EditBox", "E")"#).unwrap();
|
||||
assert!(!s.paste("hi"), "no focus, no autoFocus → not consumed");
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_strips_newlines_in_a_single_line_box_but_keeps_them_when_multiline() {
|
||||
let mut s = script();
|
||||
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetFocus()"#)
|
||||
.unwrap();
|
||||
// Single-line: newlines/tabs and other control chars are dropped; spaces survive.
|
||||
assert!(s.paste("one\ntwo\tthree\r"));
|
||||
assert_eq!(
|
||||
s.eval::<String>("return E:GetText()").unwrap(),
|
||||
"onetwothree"
|
||||
);
|
||||
|
||||
// Multi-line box: the newline survives (a tab is still a dropped control char).
|
||||
let mut s = script();
|
||||
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetMultiLine(true); E:SetFocus()"#)
|
||||
.unwrap();
|
||||
assert!(s.paste("one\ntwo"));
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "one\ntwo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_honors_max_letters_and_numeric() {
|
||||
// maxLetters trims the paste from the end.
|
||||
let mut s = script();
|
||||
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetMaxLetters(3); E:SetFocus()"#)
|
||||
.unwrap();
|
||||
assert!(s.paste("abcdef"));
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "abc");
|
||||
|
||||
// numeric aborts a paste that carries any non-digit (matching the typed-insert rule).
|
||||
let mut s = script();
|
||||
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetNumeric(true); E:SetFocus()"#)
|
||||
.unwrap();
|
||||
assert!(s.paste("12a3"), "consumed even though the insert aborts");
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "");
|
||||
assert!(s.paste("456"));
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "456");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_does_not_fire_onspacepressed() {
|
||||
let mut s = script();
|
||||
s.run(
|
||||
r#"
|
||||
spaces = 0
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetScript("OnSpacePressed", function() spaces = spaces + 1 end)
|
||||
E:SetFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.paste("a b c"));
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "a b c");
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return spaces").unwrap(),
|
||||
0,
|
||||
"a paste is not a typed space"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_deletes_the_selection() {
|
||||
let mut s = script();
|
||||
s.run(
|
||||
r#"
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetText("hello")
|
||||
E:HighlightText(0, -1)
|
||||
E:SetFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.editbox_action(EditAction::Delete {
|
||||
unit: EditUnit::Char,
|
||||
back: true
|
||||
}));
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_escape_tab_space_fire_their_slots() {
|
||||
let mut s = script();
|
||||
s.run(
|
||||
r#"
|
||||
log = {}
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetScript("OnEnterPressed", function() table.insert(log, "enter") end)
|
||||
E:SetScript("OnEscapePressed", function() table.insert(log, "escape") end)
|
||||
E:SetScript("OnTabPressed", function() table.insert(log, "tab") end)
|
||||
E:SetScript("OnSpacePressed", function() table.insert(log, "space") end)
|
||||
E:SetFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.key_input("ENTER")); // single-line → OnEnterPressed
|
||||
assert!(s.key_input("ESCAPE")); // fires, does NOT release focus
|
||||
assert!(
|
||||
s.eval::<bool>("return E:HasFocus()").unwrap(),
|
||||
"ESCAPE keeps focus"
|
||||
);
|
||||
assert!(s.key_input("TAB"));
|
||||
assert!(s.char_input(" ")); // a literal space fires OnSpacePressed
|
||||
|
||||
let log: Vec<String> = s.eval("return log").unwrap();
|
||||
assert_eq!(log, vec!["enter", "escape", "tab", "space"]);
|
||||
// ENTER on a single-line box must NOT insert; only the space landed.
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), " ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_enter_inserts_a_newline_without_onspacepressed() {
|
||||
let mut s = script();
|
||||
s.run(
|
||||
r#"
|
||||
spaces = 0
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetMultiLine(true)
|
||||
E:SetScript("OnSpacePressed", function() spaces = spaces + 1 end)
|
||||
E:SetFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.key_input("ENTER"));
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "\n");
|
||||
assert_eq!(s.eval::<i64>("return spaces").unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_arrows_swallows_char_moves_but_not_word_or_edge() {
|
||||
let mut s = script();
|
||||
s.run(
|
||||
r#"
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetIgnoreArrows(true)
|
||||
E:SetText("abc")
|
||||
E:SetFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// ignoreArrows: a Char move (a plain arrow) is consumed but inert; the cursor stays put — a
|
||||
// subsequent BACKSPACE removes the char before end-of-text ('c'), proving it didn't move.
|
||||
assert!(s.editbox_action(EditAction::Move {
|
||||
unit: EditUnit::Char,
|
||||
back: true,
|
||||
extend: false
|
||||
}));
|
||||
assert!(s.editbox_action(EditAction::Delete {
|
||||
unit: EditUnit::Char,
|
||||
back: true
|
||||
}));
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "ab");
|
||||
// The ref's Ctrl bypass (guard 0x77b18e) maps to Word — which passes the gate; Edge too.
|
||||
s.editbox_action(EditAction::Move {
|
||||
unit: EditUnit::Word,
|
||||
back: true,
|
||||
extend: false,
|
||||
});
|
||||
assert!(s.editbox_action(EditAction::Delete {
|
||||
unit: EditUnit::Char,
|
||||
back: false
|
||||
}));
|
||||
assert_eq!(
|
||||
s.eval::<String>("return E:GetText()").unwrap(),
|
||||
"b",
|
||||
"the word move must have reached text start"
|
||||
);
|
||||
}
|
||||
|
||||
// ── password + GetNumber ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn password_masks_the_display_but_gettext_returns_the_real_text() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetPassword(true)
|
||||
E:SetText("secret")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "secret");
|
||||
assert_eq!(
|
||||
text_quad(&s).as_deref(),
|
||||
Some("******"),
|
||||
"the text region shows one '*' per character"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_number_parses_the_text() {
|
||||
let s = script();
|
||||
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetText("12.5")"#)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<f64>("return E:GetNumber()").unwrap(), 12.5);
|
||||
// Non-numeric text → 0.
|
||||
s.run(r#"E:SetText("hello")"#).unwrap();
|
||||
assert_eq!(s.eval::<f64>("return E:GetNumber()").unwrap(), 0.0);
|
||||
}
|
||||
|
||||
// ── the EditBox override never fires generic OnChar/OnKeyDown (§2) ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn key_and_char_paths_never_fire_generic_handlers() {
|
||||
// OnKeyDown/OnChar aren't even in SCRIPT_KINDS, so SetScript rejects them — proving the runtime
|
||||
// has no generic keyboard slots to fire (the C++ override replaced them).
|
||||
let s = script();
|
||||
let err = s
|
||||
.run(r#"CreateFrame("EditBox", "E"):SetScript("OnChar", function() end)"#)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("OnChar"), "{err}");
|
||||
}
|
||||
|
||||
// ── history recall (`historyLines` / AddHistoryLine — decision 0288 P2) ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn history_recall_walks_up_and_down_and_restores_the_draft() {
|
||||
let mut s = script();
|
||||
s.run(
|
||||
r#"
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetHistoryLines(32)
|
||||
E:AddHistoryLine("/say one")
|
||||
E:AddHistoryLine("/g two")
|
||||
E:SetText("draft")
|
||||
E:SetFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// UP recalls newest-first; a second UP walks older; at the oldest it holds.
|
||||
assert!(s.editbox_action(EditAction::HistoryPrev));
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "/g two");
|
||||
s.editbox_action(EditAction::HistoryPrev);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "/say one");
|
||||
s.editbox_action(EditAction::HistoryPrev);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "/say one");
|
||||
// DOWN walks newer; past the newest the stashed live draft comes back.
|
||||
s.editbox_action(EditAction::HistoryNext);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "/g two");
|
||||
s.editbox_action(EditAction::HistoryNext);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "draft");
|
||||
// Not browsing: DOWN does nothing.
|
||||
s.editbox_action(EditAction::HistoryNext);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_ends_the_history_browse() {
|
||||
let mut s = script();
|
||||
s.run(
|
||||
r#"
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetHistoryLines(32)
|
||||
E:AddHistoryLine("older")
|
||||
E:AddHistoryLine("newer")
|
||||
E:SetFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.editbox_action(EditAction::HistoryPrev);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "newer");
|
||||
// A typed char turns the recalled line into an ordinary draft: the next UP starts a FRESH
|
||||
// browse from the newest entry (stashing the edited line as the new draft).
|
||||
s.char_input("!");
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "newer!");
|
||||
s.editbox_action(EditAction::HistoryPrev);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "newer");
|
||||
s.editbox_action(EditAction::HistoryNext);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "newer!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_caps_at_history_lines_drop_oldest() {
|
||||
let mut s = script();
|
||||
s.run(
|
||||
r#"
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetHistoryLines(2)
|
||||
E:AddHistoryLine("a")
|
||||
E:AddHistoryLine("b")
|
||||
E:AddHistoryLine("c")
|
||||
E:SetFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<i64>("return E:GetHistoryLines()").unwrap(), 2);
|
||||
// Only b/c survive: two UPs land on 'b', a third holds there.
|
||||
s.editbox_action(EditAction::HistoryPrev);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "c");
|
||||
s.editbox_action(EditAction::HistoryPrev);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "b");
|
||||
s.editbox_action(EditAction::HistoryPrev);
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_off_by_default_and_up_is_still_consumed() {
|
||||
let mut s = script();
|
||||
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetText("t"); E:SetFocus()"#)
|
||||
.unwrap();
|
||||
// No historyLines: AddHistoryLine is a no-op, UP consumed but inert (a focused box eats every
|
||||
// key — RF-0082 §2).
|
||||
s.run(r#"E:AddHistoryLine("x")"#).unwrap();
|
||||
assert!(s.editbox_action(EditAction::HistoryPrev));
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "t");
|
||||
}
|
||||
|
||||
// ── SetTextInsets (0288 P2 — the header-driven text-rect shrink) ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn text_insets_shrink_the_text_region_rect() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetPoint("BOTTOMLEFT", 100, 50)
|
||||
E:SetWidth(400)
|
||||
E:SetHeight(32)
|
||||
E:SetTextInsets(15, 13, 2, 3)
|
||||
E:SetText("hello")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let quads = s.extract();
|
||||
let rect = quads
|
||||
.iter()
|
||||
.find_map(|q| match (&q.content, q.rect) {
|
||||
(crate::script::QuadContent::Text { text: Some(t), .. }, Some(r)) if t == "hello" => {
|
||||
Some(r)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("the edit box text renders");
|
||||
assert!((rect.left - 115.0).abs() < 0.01, "left inset 15: {rect:?}");
|
||||
assert!(
|
||||
(rect.right - 487.0).abs() < 0.01,
|
||||
"right inset 13: {rect:?}"
|
||||
);
|
||||
assert!((rect.top - 80.0).abs() < 0.01, "top inset 2: {rect:?}");
|
||||
assert!(
|
||||
(rect.bottom - 53.0).abs() < 0.01,
|
||||
"bottom inset 3: {rect:?}"
|
||||
);
|
||||
// The getter echoes.
|
||||
let (l, r_, t, b) = s
|
||||
.eval::<(f32, f32, f32, f32)>("return E:GetTextInsets()")
|
||||
.unwrap();
|
||||
assert_eq!((l, r_, t, b), (15.0, 13.0, 2.0, 3.0));
|
||||
}
|
||||
|
||||
// ── the host text-UI seam: advances, caret/selection geometry, mouse, clipboard ─────────────
|
||||
|
||||
/// Build a focused 200×32 box at BOTTOMLEFT(100,50), resolve it, type `text`, and answer its
|
||||
/// advance table with a synthetic monospace 7 px/byte — the standard rig for the geometry tests.
|
||||
fn seam_rig(s: &mut UiScript, text: &str) {
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
E = CreateFrame("EditBox", "E")
|
||||
E:SetWidth(200); E:SetHeight(32)
|
||||
E:SetPoint("BOTTOMLEFT", 100, 50)
|
||||
E:SetFocus()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
for ch in text.chars() {
|
||||
s.char_input(&ch.to_string());
|
||||
}
|
||||
s.resolve();
|
||||
answer_advances(s);
|
||||
}
|
||||
|
||||
/// Answer any pending advance request at 7 px per byte.
|
||||
fn answer_advances(s: &mut UiScript) {
|
||||
if let Some(req) = s.editbox_advances_request() {
|
||||
let cum: Vec<f32> = (0..=req.text.len()).map(|i| i as f32 * 7.0).collect();
|
||||
s.set_editbox_advances(req.id, req.key, cum, vec![0], 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The seam reports caret geometry from the advance table, and vanishes with focus or
|
||||
/// visibility — the old prefix-string seam's laws, now in pixels.
|
||||
#[test]
|
||||
fn text_ui_reports_caret_geometry_and_focus() {
|
||||
let mut s = script();
|
||||
seam_rig(&mut s, "");
|
||||
// Empty box: the advance table settles engine-side; the caret stands at the origin.
|
||||
let ui = s
|
||||
.focused_editbox_text_ui()
|
||||
.expect("focused box has text-UI");
|
||||
assert_eq!(ui.caret_x, 0.0);
|
||||
assert_eq!(ui.display_from, 0);
|
||||
assert!(ui.selection.is_empty());
|
||||
// The empty box still emits its Text quad (Some("")) — the caret's ride-along.
|
||||
assert!(
|
||||
s.extract().iter().any(|q| matches!(
|
||||
(&q.target, &q.content),
|
||||
(t, QuadContent::Text { text: Some(x), .. }) if *t == ui.target && x.is_empty()
|
||||
)),
|
||||
"an empty focused box must still emit its text-region quad"
|
||||
);
|
||||
|
||||
s.char_input("a");
|
||||
s.char_input("b");
|
||||
answer_advances(&mut s);
|
||||
assert_eq!(s.focused_editbox_text_ui().unwrap().caret_x, 14.0);
|
||||
s.editbox_action(EditAction::Move {
|
||||
unit: EditUnit::Char,
|
||||
back: true,
|
||||
extend: false,
|
||||
});
|
||||
assert_eq!(s.focused_editbox_text_ui().unwrap().caret_x, 7.0);
|
||||
|
||||
s.run("E:ClearFocus()").unwrap();
|
||||
assert!(s.focused_editbox_text_ui().is_none(), "no focus, no caret");
|
||||
s.run("E:SetFocus(); E:Hide()").unwrap();
|
||||
assert!(s.focused_editbox_text_ui().is_none(), "hidden box: none");
|
||||
}
|
||||
|
||||
/// Click places the cursor at the nearest char boundary (`0x77b800` → `0x77d0d0`), collapsing
|
||||
/// any selection; dragging extends it (`0x77a860`); release ends the drag.
|
||||
#[test]
|
||||
fn click_places_cursor_and_drag_selects() {
|
||||
let mut s = script();
|
||||
seam_rig(&mut s, "hello world");
|
||||
// Box left = 100; the text region fills it (no insets). Byte i sits at x = 100 + 7i.
|
||||
// Click at x=121 → advance-space 21 → boundary 3.
|
||||
assert!(s.mouse_button(121.0, 60.0, "LeftButton", true));
|
||||
let ui = s.focused_editbox_text_ui().unwrap();
|
||||
assert_eq!(ui.caret_x, 21.0, "cursor at byte 3");
|
||||
assert!(ui.selection.is_empty(), "click collapses");
|
||||
// Drag right to x=149 (byte 7): selection 3..7, cursor at 7.
|
||||
s.mouse_move(149.0, 60.0);
|
||||
let ui = s.focused_editbox_text_ui().unwrap();
|
||||
assert_eq!(ui.selection, vec![(0, 21.0, 49.0)]);
|
||||
assert_eq!(ui.caret_x, 49.0);
|
||||
// Drag back LEFT past the anchor to x=107 (byte 1): the selection flips to 1..3.
|
||||
s.mouse_move(107.0, 60.0);
|
||||
let ui = s.focused_editbox_text_ui().unwrap();
|
||||
assert_eq!(ui.selection, vec![(0, 7.0, 21.0)]);
|
||||
// Release ends the drag: further moves change nothing.
|
||||
s.mouse_button(107.0, 60.0, "LeftButton", false);
|
||||
s.mouse_move(170.0, 60.0);
|
||||
assert_eq!(
|
||||
s.focused_editbox_text_ui().unwrap().selection,
|
||||
vec![(0, 7.0, 21.0)]
|
||||
);
|
||||
// Nearest-boundary rounding: x=124.9 (advance 24.9): |24.9−21|=3.9 vs |28−24.9|=3.1 → byte 4.
|
||||
s.mouse_button(124.9, 60.0, "LeftButton", true);
|
||||
assert_eq!(s.focused_editbox_text_ui().unwrap().caret_x, 28.0);
|
||||
s.mouse_button(124.9, 60.0, "LeftButton", false);
|
||||
}
|
||||
|
||||
/// Ctrl+arrows jump word boundaries (alnum runs); Ctrl+Shift extends the selection there.
|
||||
#[test]
|
||||
fn ctrl_arrows_jump_words() {
|
||||
let mut s = script();
|
||||
seam_rig(&mut s, "abc def");
|
||||
s.editbox_action(EditAction::Move {
|
||||
unit: EditUnit::Edge,
|
||||
back: true,
|
||||
extend: false,
|
||||
});
|
||||
s.editbox_action(EditAction::Move {
|
||||
unit: EditUnit::Word,
|
||||
back: false,
|
||||
extend: false,
|
||||
});
|
||||
assert_eq!(
|
||||
s.focused_editbox_text_ui().unwrap().caret_x,
|
||||
21.0,
|
||||
"end of 'abc'"
|
||||
);
|
||||
s.editbox_action(EditAction::Move {
|
||||
unit: EditUnit::Word,
|
||||
back: false,
|
||||
extend: false,
|
||||
});
|
||||
assert_eq!(
|
||||
s.focused_editbox_text_ui().unwrap().caret_x,
|
||||
49.0,
|
||||
"end of 'def'"
|
||||
);
|
||||
s.editbox_action(EditAction::Move {
|
||||
unit: EditUnit::Word,
|
||||
back: true,
|
||||
extend: false,
|
||||
});
|
||||
assert_eq!(
|
||||
s.focused_editbox_text_ui().unwrap().caret_x,
|
||||
28.0,
|
||||
"start of 'def'"
|
||||
);
|
||||
// Ctrl+Shift+LEFT from here selects back across 'abc'.
|
||||
s.editbox_action(EditAction::Move {
|
||||
unit: EditUnit::Word,
|
||||
back: true,
|
||||
extend: true,
|
||||
});
|
||||
assert_eq!(
|
||||
s.focused_editbox_text_ui().unwrap().selection,
|
||||
vec![(0, 0.0, 28.0)]
|
||||
);
|
||||
}
|
||||
|
||||
/// Copy needs a selection; cut copies then deletes; the password box yields the mask run, never
|
||||
/// the real text (RF-0082 §4's placeholder law, mask stand-in).
|
||||
#[test]
|
||||
fn copy_cut_and_the_password_placeholder() {
|
||||
let mut s = script();
|
||||
seam_rig(&mut s, "secret");
|
||||
assert_eq!(s.editbox_copy(), None, "no selection, no copy");
|
||||
s.run("E:HighlightText(0, 3)").unwrap();
|
||||
assert_eq!(s.editbox_copy().as_deref(), Some("sec"));
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "secret");
|
||||
assert_eq!(s.editbox_cut().as_deref(), Some("sec"));
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "ret");
|
||||
|
||||
s.run(r#"P = CreateFrame("EditBox", "P"); P:SetPassword(true); P:SetFocus()"#)
|
||||
.unwrap();
|
||||
s.char_input("h");
|
||||
s.char_input("i");
|
||||
s.run("P:HighlightText(0, -1)").unwrap();
|
||||
assert_eq!(
|
||||
s.editbox_copy().as_deref(),
|
||||
Some("**"),
|
||||
"password copy must never yield the real text"
|
||||
);
|
||||
}
|
||||
|
||||
/// The OS-native delete family (no 1.12 counterpart — the host keymap's Option/Cmd/Ctrl
|
||||
/// Backspace-Delete chords): a word delete takes the adjacent alnum run (+ the separators toward
|
||||
/// it), an edge delete clears to the line end — and both collapse to "delete the selection" when
|
||||
/// one exists, like every deletion gesture.
|
||||
#[test]
|
||||
fn word_and_edge_deletes() {
|
||||
let mut s = script();
|
||||
seam_rig(&mut s, "abc def ghi");
|
||||
// Word-delete back from the end: "ghi" goes (boundary at 8).
|
||||
s.editbox_action(EditAction::Delete {
|
||||
unit: EditUnit::Word,
|
||||
back: true,
|
||||
});
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "abc def ");
|
||||
// Again: the space + "def" go together (back-walk skips separators first).
|
||||
s.editbox_action(EditAction::Delete {
|
||||
unit: EditUnit::Word,
|
||||
back: true,
|
||||
});
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "abc ");
|
||||
// Word-delete forward from HOME: the "abc" run goes, its trailing space survives.
|
||||
s.editbox_action(EditAction::Move {
|
||||
unit: EditUnit::Edge,
|
||||
back: true,
|
||||
extend: false,
|
||||
});
|
||||
s.editbox_action(EditAction::Delete {
|
||||
unit: EditUnit::Word,
|
||||
back: false,
|
||||
});
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), " ");
|
||||
|
||||
// Selection-first: with a live selection, a word delete removes exactly it.
|
||||
s.run(r#"E:SetText("hello world"); E:HighlightText(2, 5)"#)
|
||||
.unwrap();
|
||||
s.editbox_action(EditAction::Delete {
|
||||
unit: EditUnit::Word,
|
||||
back: true,
|
||||
});
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "he world");
|
||||
|
||||
// Edge-delete back (macOS Cmd+Backspace): clears to the start — the whole line from the end.
|
||||
s.run(r#"E:SetText("clear me")"#).unwrap();
|
||||
s.editbox_action(EditAction::Delete {
|
||||
unit: EditUnit::Edge,
|
||||
back: true,
|
||||
});
|
||||
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "");
|
||||
// Inert at the boundary: nothing to delete consumes without firing OnTextChanged.
|
||||
s.run("n = 0; E:SetScript('OnTextChanged', function() n = n + 1 end)")
|
||||
.unwrap();
|
||||
assert!(s.editbox_action(EditAction::Delete {
|
||||
unit: EditUnit::Edge,
|
||||
back: true
|
||||
}));
|
||||
assert_eq!(s.eval::<i64>("return n").unwrap(), 0);
|
||||
}
|
||||
|
||||
/// SelectAll (the ref's Ctrl+A → `HighlightText(0, -1)` law): whole text selected, caret to the
|
||||
/// end; typing replaces the lot.
|
||||
#[test]
|
||||
fn select_all_action() {
|
||||
let mut s = script();
|
||||
seam_rig(&mut s, "abc def");
|
||||
s.editbox_action(EditAction::SelectAll);
|
||||
let ui = s.focused_editbox_text_ui().unwrap();
|
||||
assert_eq!(ui.selection, vec![(0, 0.0, 49.0)], "all 7 bytes selected");
|
||||
assert_eq!(ui.caret_x, 49.0, "caret at the end");
|
||||
s.char_input("z");
|
||||
assert_eq!(
|
||||
s.eval::<String>("return E:GetText()").unwrap(),
|
||||
"z",
|
||||
"an insert replaces the whole selection"
|
||||
);
|
||||
}
|
||||
|
||||
/// The scroll window follows the cursor: typing past the box edge advances `display_from`
|
||||
/// (whole chars); HOME snaps it back (`0x77da80`'s stay-visible invariant, the char-granular
|
||||
/// `E+0x348` window).
|
||||
#[test]
|
||||
fn scroll_window_keeps_the_cursor_visible() {
|
||||
let mut s = script();
|
||||
// 40 bytes × 7px = 280px in a 200px box (avail 198): the window must slide.
|
||||
seam_rig(&mut s, &"x".repeat(40));
|
||||
let ui = s.focused_editbox_text_ui().unwrap();
|
||||
assert!(ui.display_from > 0, "overlong text must scroll: {ui:?}");
|
||||
assert!(ui.caret_x <= 198.0, "caret inside the window: {ui:?}");
|
||||
assert_eq!(
|
||||
ui.caret_x,
|
||||
(40 - ui.display_from) as f32 * 7.0,
|
||||
"caret x measures from the window origin"
|
||||
);
|
||||
// HOME: the window snaps back to 0.
|
||||
s.editbox_action(EditAction::Move {
|
||||
unit: EditUnit::Edge,
|
||||
back: true,
|
||||
extend: false,
|
||||
});
|
||||
let ui = s.focused_editbox_text_ui().unwrap();
|
||||
assert_eq!(ui.display_from, 0);
|
||||
assert_eq!(ui.caret_x, 0.0);
|
||||
}
|
||||
|
||||
/// The blink law (`0x77a790`): 0.5 s half-period toggle, reset shown by every edit.
|
||||
#[test]
|
||||
fn caret_blinks_on_tick_and_resets_on_edit() {
|
||||
let mut s = script();
|
||||
seam_rig(&mut s, "a");
|
||||
assert!(s.focused_editbox_text_ui().unwrap().caret_on);
|
||||
s.tick(0.6); // crosses the 0.5 default → hidden
|
||||
assert!(!s.focused_editbox_text_ui().unwrap().caret_on);
|
||||
s.tick(0.6); // crosses again → shown
|
||||
assert!(s.focused_editbox_text_ui().unwrap().caret_on);
|
||||
s.tick(0.6);
|
||||
assert!(!s.focused_editbox_text_ui().unwrap().caret_on);
|
||||
// An edit shows the caret immediately (blink reset).
|
||||
s.char_input("b");
|
||||
answer_advances(&mut s);
|
||||
assert!(s.focused_editbox_text_ui().unwrap().caret_on);
|
||||
}
|
||||
Reference in New Issue
Block a user