A multi-line EditBox grows with its text; a Lua-made box draws its text (#729)

A multi-line EditBox kept its authored height, so a scroll frame around a long mail body or ticket had no range. A box made in Lua with no template never placed its text, so it drew nothing.

In 1.12.1, the flush's relayout sets a multi-line box's height to its insets plus its wrapped text height, or one line when empty (`0x77d4d0` at `0x77d8ad`). Every resize, including the first layout, re-seats the text at the top-left inset (`0x77a8d0` → `0x77b8c0`). `SetTextInsets`, `GetTextInsets`, `SetMultiLine` and a font change re-seat it too.

benilla now does the same. A focused multi-line box's caret and text are clipped only by a scroll frame, as the reference clips neither.

Tests cover growth and shrink, the stock mail and ticket scroll ranges, each re-seat trigger, a Lua box's first layout, and the caret in an empty box and after a trailing newline.

Fixes #722
Fixes #726

🤖 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:
Sam Sung
2026-09-30 18:37:30 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 4c18c23c94
commit f2e71ccebd
17 changed files with 1104 additions and 67 deletions
@@ -107,8 +107,13 @@ pub(super) fn emit(
let (x0, top, cell_h) =
crate::ui_text::line_origin(&mut atlas.lock(), drawn, host.rect, draw_justify, spec);
ebox_geom = Some((x0, top, cell_h));
text_clip = Some(host.clip.map_or(host.rect, |c| c.intersect(host.rect)));
// A single-line box shows only what fits its text region (`0x77d858` sets the window's
// substring), so the whole string drawn here is cut at the region. A multi-line region is
// only as tall as its text, and the reference clips neither it nor the caret texture
// (`E+0x368`), which after a trailing break stands a line below it: only a ScrollFrame's
// clip applies.
if !ui.multi_line {
text_clip = Some(host.clip.map_or(host.rect, |c| c.intersect(host.rect)));
draw_text = drawn;
draw_rect = Rect::new(x0, host.rect.min.y, x0 + 100_000.0, host.rect.max.y);
draw_justify = crate::ui_text::Justify {
@@ -419,3 +424,133 @@ mod tests {
assert_eq!(gaps, [1, 2].into_iter().collect());
}
}
/// The focused multi-line box's caret through the whole UI pass, on the client faces: the region
/// that draws its text is only as tall as that text, and the caret must not be cut to it.
#[cfg(test)]
mod caret_tests {
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use benilla_ui::script::UiScript;
use super::super::{paint_script, tick_script, UiQuad, UiQuads};
use crate::portrait::PortraitImages;
use crate::ui_text::UiFontAtlas;
/// A focused 270-wide multi-line box at 14 px on a 1024×768 window (`s = 1`), holding `text`.
fn app_with_focused_box(text: &str) -> App {
let script = UiScript::new().unwrap();
script
.run(&format!(
r#"
E = CreateFrame("EditBox", "E")
E:SetPoint("TOPLEFT", 100, -100)
E:SetWidth(270); E:SetHeight(200)
E:SetFont("Fonts\\FRIZQT__.TTF", 14)
E:SetMultiLine(true)
E:SetFocus()
E:SetText({text:?})
"#
))
.unwrap();
let mut app = App::new();
app.insert_non_send_resource(script);
app.insert_resource(UiFontAtlas::for_test(1.0).expect("the client faces open"));
app.init_resource::<UiQuads>();
app.init_resource::<crate::ui_script::UiPassState>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::ui_models::UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::ui_script::UiFrameCost>();
app.init_resource::<crate::ui_script::UiCostWanted>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<crate::ui_script::UiClock>();
app.init_resource::<crate::ui_script::UiScaleCvar>();
app.world_mut().spawn((
Window {
resolution: UVec2::new(1024, 768).into(),
..default()
},
PrimaryWindow,
));
app.add_systems(Update, (tick_script, paint_script).chain());
// The box seats and sizes itself, the host answers the advances, then the caret draws.
for _ in 0..4 {
app.update();
}
app
}
/// The caret: the one untextured white quad.
fn caret(app: &App) -> UiQuad {
let quads = &app.world().resource::<UiQuads>().quads;
let mut carets = quads
.iter()
.filter(|q| q.texture.is_none() && q.color[..3] == [1.0, 1.0, 1.0]);
let c = carets
.next()
.expect("the focused box draws its caret")
.clone();
assert!(carets.next().is_none(), "one caret");
c
}
/// Whole and visible: a line tall, and no clip cuts any of it.
fn assert_whole(c: &UiQuad) {
assert!(c.rect.height() >= 10.0, "a line tall: {:?}", c.rect);
if let Some(clip) = c.clip {
assert_eq!(
clip.intersect(c.rect),
c.rect,
"the clip cuts the caret: {clip:?}"
);
}
}
#[test]
fn an_empty_focused_multi_line_box_draws_its_whole_caret() {
let _data = benilla_formats::wow_data_or_skip!();
let app = app_with_focused_box("");
assert_whole(&caret(&app));
}
/// After a trailing break the caret stands on the line the break opened, below the text.
#[test]
fn a_caret_after_a_trailing_newline_is_drawn_whole_below_the_text() {
let _data = benilla_formats::wow_data_or_skip!();
let one = caret(&app_with_focused_box("abc"));
let after = caret(&app_with_focused_box("abc\n"));
assert_whole(&after);
assert_eq!(
after.rect.min.y,
one.rect.min.y + one.rect.height(),
"one line down: {:?} against {:?}",
after.rect,
one.rect
);
}
/// The last line's descenders hang below its measured line, as a drop shadow would: no clip
/// cuts them.
#[test]
fn a_focused_multi_line_boxs_last_line_keeps_its_descenders() {
let _data = benilla_formats::wow_data_or_skip!();
let app = app_with_focused_box("gjpqy");
let quads = &app.world().resource::<UiQuads>().quads;
let glyphs: Vec<&UiQuad> = quads.iter().filter(|q| q.texture.is_some()).collect();
assert_eq!(glyphs.len(), 5, "the five glyphs draw");
for g in glyphs {
if let Some(clip) = g.clip {
assert_eq!(
clip.intersect(g.rect),
g.rect,
"a clip cuts a glyph: {clip:?}"
);
}
}
}
}
@@ -187,3 +187,53 @@ fn toggling_the_window_opens_it_and_asks_for_the_queue_status() {
s.run("ToggleHelpFrame()").unwrap();
assert!(!s.eval::<bool>("return HelpFrame:IsVisible()").unwrap());
}
/// The ticket text is a multi-line box and the scroll child itself (`HelpFrame.xml`, 541×357 in
/// the 378-tall `HelpFrameOpenTicketScrollFrame`): the box sizes itself to its wrapped text
/// (`0x77d4d0` @`0x77d8ad`), so `ScrollingEdit_OnTextChanged`'s recompute ranges over all of it.
#[test]
fn the_ticket_text_grows_with_its_lines_and_its_scroll_frame_ranges_over_them() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
// Wide glyphs and the stand-in's 12-unit line: the box's 500-letter cap must still wrap
// past the frame.
const GLYPH: f64 = 60.0;
s.set_text_measurer(Box::new(super::FixedWidthFont(GLYPH as f32)));
s.run("ShowUIPanel(HelpFrame) HelpFrame_ShowFrame(\"OpenTicket\")")
.unwrap();
s.resolve();
s.tick(0.016);
s.resolve();
assert_eq!(
s.eval::<f64>("return HelpFrameOpenTicketText:GetHeight()")
.unwrap(),
12.0,
"empty, the box is one line: the authored 357 lasts until the first flush"
);
s.run(r#"HelpFrameOpenTicketText:SetText(string.rep("x", 400))"#)
.unwrap();
s.tick(0.016);
s.resolve();
// Wrapped at the box's own 541: no insets.
let height = (400.0 * GLYPH / 541.0).ceil() * 12.0;
assert_eq!(
s.eval::<f64>("return HelpFrameOpenTicketText:GetHeight()")
.unwrap(),
height
);
assert_eq!(
s.eval::<f64>("return HelpFrameOpenTicketScrollFrame:GetVerticalScrollRange()")
.unwrap(),
height - 378.0
);
assert_eq!(
s.eval::<f64>(
"local _, max = HelpFrameOpenTicketScrollFrameScrollBar:GetMinMaxValues() return max"
)
.unwrap(),
height - 378.0,
"the range reached the scroll bar through OnScrollRangeChanged"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -653,6 +653,18 @@ pub(super) fn test_engine(dpi: f32) -> Option<TextEngine> {
#[cfg(test)]
pub(super) const TEST_FACES: &[&str] = CLIENT_FONTS;
#[cfg(test)]
impl UiFontAtlas {
/// [`test_engine`] as the app holds it, for a host test that draws text; `None` as there.
pub(crate) fn for_test(dpi: f32) -> Option<Self> {
Some(UiFontAtlas {
engine: Arc::new(Mutex::new(test_engine(dpi)?)),
generation: 0,
ellipsis: crate::ui_text::EllipsisMemo::default(),
})
}
}
#[cfg(test)]
mod differential_tests {
use super::*;
@@ -66,3 +66,52 @@ impl TextMeasure for AtlasMeasurer {
measure_request(&mut e, self.seam, req)
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use benilla_ui::script::UiScript;
use super::AtlasMeasurer;
/// A multi-line EditBox is its text's measured height tall (`0x77d4d0` @`0x77d8ad`), one
/// line's when empty, and the line law the host measures by (`0x5c2070`) folds a trailing
/// break into the line it ends, so the break alone adds no line.
#[test]
fn a_multi_line_boxs_trailing_newline_adds_no_line() {
benilla_formats::wow_data_or_skip!();
let engine = super::super::engine::test_engine(1.0).expect("the client faces open");
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_text_measurer(Box::new(AtlasMeasurer::new(
Arc::new(Mutex::new(engine)),
1.0,
)));
s.run(
r#"
E = CreateFrame("EditBox", "E")
E:SetAutoFocus(false); E:SetMultiLine(true)
E:SetPoint("TOPLEFT", 100, -100); E:SetWidth(270); E:SetHeight(200)
E:SetFont("Fonts\\MORPHEUS.TTF", 15)
"#,
)
.unwrap();
s.resolve();
let mut height = |text: &str| {
s.run(&format!("E:SetText({text:?})")).unwrap();
s.tick(0.016);
s.resolve();
s.eval::<f32>("return E:GetHeight()").unwrap()
};
let empty = height("");
let one = height("abc");
assert_eq!(one, empty, "an empty box is one line tall");
assert_eq!(height("abc\n"), one, "the break alone");
let two = height("abc\nd");
assert!(
(two - 2.0 * one).abs() < 1e-4,
"the first character on the new line: {two} against {one}"
);
}
}
+28 -33
View File
@@ -23,7 +23,6 @@ use mlua::{Lua, Table, Value};
use super::object::frame_handle_of;
use super::types::{EditAction, EditUnit};
use super::{event, Model, RegionData};
use crate::layout::{Anchor, Point};
use crate::order::{self, DrawLayer, ZTarget};
use crate::widget::{EditBoxState, FrameHandle, FrameKind, KindState, RegionHandle, RegionKind};
@@ -120,12 +119,14 @@ pub(super) fn action(lua: &Lua, a: EditAction) -> bool {
}
// Mouse, selection, clipboard and blink (`interact`); the host's advance round trip and text
// geometry (`seam`).
// geometry (`seam`); the text region's rect and a multi-line box's height (`relayout`).
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};
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Focus model
@@ -345,37 +346,22 @@ fn sync_text_region(lua: &Lua, h: FrameHandle) {
}
}
/// `SetTextInsets(l, r, t, b)`: store the insets and re-anchor the text region by them.
/// `SetTextInsets(l, r, t, b)` (`0x77a6e0`): store the insets and re-seat the text region by
/// them (`0x77a707` → `0x77b8c0`).
fn set_text_insets(lua: &Lua, h: FrameHandle, l: f32, r: f32, t: f32, b: f32) {
if with_eb(lua, h, |eb| eb.text_insets = [l, r, t, b]).is_none() {
return;
}
let Some(rh) = ensure_text_region(lua, h) else {
return;
};
write_inset_anchors(lua, h, rh, [l, r, t, b]);
reseat_text_region(lua, h);
}
/// Pin the text region's corners inside the box by the insets (y-up: the top inset moves its top
/// down, the bottom inset its bottom up).
fn write_inset_anchors(lua: &Lua, h: FrameHandle, rh: RegionHandle, [l, r, t, b]: [f32; 4]) {
let owner = frame_id_of(lua, h);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let pair = [
Anchor::new(Point::TopLeft, owner, Point::TopLeft, l, -t),
Anchor::new(Point::BottomRight, owner, Point::BottomRight, -r, b),
];
let data = model.region_data.entry(rh).or_default();
let same = data.anchors.len() == 2
&& data
.anchors
.iter()
.zip(&pair)
.all(|(a, b)| super::object::anchor_bits_eq(a, b));
if !same {
data.anchors = pair.to_vec();
model.touch_layout();
/// Run `0x77b8c0` on the box's text region, creating the region's data if nothing has yet.
fn reseat_text_region(lua: &Lua, h: FrameHandle) {
if ensure_text_region(lua, h).is_none() {
return;
}
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
relayout::seat_text_region(&mut model, h);
}
/// The wrapper for an EditBox's embedded text FontString, built by its ctor (`0x779bee`). The
@@ -396,15 +382,15 @@ pub(crate) fn editbox_text_region_wrapper(lua: &Lua, frame: &Table) -> Option<Ta
pub(crate) fn adopt_text_region(lua: &Lua, frame: &Table, region: &Table) -> mlua::Result<()> {
let h = frame_handle_of(lua, frame)?;
let rh = super::region::region_handle_of(lua, region)?;
let Some((insets, multi_line)) = with_eb(lua, h, |eb| {
let Some(multi_line) = with_eb(lua, h, |eb| {
eb.text_region = Some(rh);
(eb.text_insets, eb.multi_line)
eb.multi_line
}) else {
return Ok(()); // not an EditBox
};
write_inset_anchors(lua, h, rh, insets);
// Always `Some` text: an empty box still emits its Text quad for the host's caret.
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
relayout::seat_text_region(&mut model, h);
// Always `Some` text: an empty box still emits its Text quad for the host's caret.
let data = model.region_data.entry(rh).or_default();
data.text.get_or_insert_with(String::new);
apply_text_region_justify(data, multi_line);
@@ -414,6 +400,11 @@ pub(crate) fn adopt_text_region(lua: &Lua, frame: &Table, region: &Table) -> mlu
/// Get or create the box's text FontString; an XML-declared one is wired by [`adopt_text_region`].
pub(super) fn ensure_text_region(lua: &Lua, h: FrameHandle) -> Option<RegionHandle> {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
ensure_text_region_in(&mut model, h)
}
/// [`ensure_text_region`] on a borrowed model.
fn ensure_text_region_in(model: &mut Model, h: FrameHandle) -> Option<RegionHandle> {
let (existing, multi_line) = match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::EditBox(eb)) => (eb.text_region, eb.multi_line),
_ => return None,
@@ -443,9 +434,10 @@ pub(super) fn ensure_text_region(lua: &Lua, h: FrameHandle) -> Option<RegionHand
Some(rh)
}
/// Re-apply the text region's justify for the current `multiLine` flag: LoadXML adopts the
/// `<FontString>` before it applies the box's flags.
pub(super) fn refresh_text_region_justify(lua: &Lua, this: &Table) -> mlua::Result<()> {
/// Re-apply the text region's justify and rect for the current `multiLine` flag, as
/// `SetMultiLine` re-seats it (`0x77a63f` → `0x77b8c0`): LoadXML adopts the `<FontString>` before
/// it applies the box's flags.
pub(super) fn refresh_text_region(lua: &Lua, this: &Table) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(KindState::EditBox(eb)) = model.arena.frame(h).map(|f| &f.kind_state) else {
@@ -455,6 +447,7 @@ pub(super) fn refresh_text_region_justify(lua: &Lua, this: &Table) -> mlua::Resu
return Ok(());
};
apply_text_region_justify(model.region_data.entry(rh).or_default(), multi_line);
relayout::seat_text_region(&mut model, h);
Ok(())
}
@@ -562,6 +555,8 @@ const CARET_WIDTH: f32 = 4.0;
mod methods;
pub(super) use methods::install;
#[cfg(test)]
mod relayout_tests;
#[cfg(test)]
mod tests;
@@ -7,8 +7,8 @@ use crate::script::object::frame_handle_of;
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,
clear_focus_handle, highlight_text, insert, reseat_text_region, set_focus_handle, set_text,
set_text_insets, with_eb, REG_EDITBOX_METHODS,
};
fn with_editbox<T>(
@@ -243,10 +243,13 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
},
)?,
)?;
// GetTextInsets (`0x798c30` → `0x77a710`) copies the four out, then re-seats the text region
// (`0x77a73f` → `0x77b8c0`), so the getter puts a script-moved region back.
m.set(
"GetTextInsets",
lua.create_function(|lua, this: Table| {
let ins = with_editbox(lua, &this, |eb| eb.text_insets)?;
reseat_text_region(lua, frame_handle_of(lua, &this)?);
Ok((ins[0], ins[1], ins[2], ins[3]))
})?,
)?;
@@ -287,16 +290,17 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
for (name, set) in flag_setters() {
let refresh_justify = name == "SetMultiLine";
let refresh_text = 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 {
// A multi-line box anchors its text TOP, else MIDDLE, and the loader wires the
if refresh_text {
// A multi-line box seats its text TOP and as tall as the text, a single-line
// one MIDDLE between the inset corners, and the loader wires the
// `<FontString>` before the flags, so re-seat it.
super::refresh_text_region_justify(lua, &this)?;
super::refresh_text_region(lua, &this)?;
}
Ok(())
})?,
@@ -325,6 +329,12 @@ fn install_font_block(lua: &Lua, m: &Table) -> mlua::Result<()> {
super::ensure_text_region(lua, h).ok_or_else(|| mlua::Error::runtime("not an EditBox"))
},
"EditBox",
// The box listens on its font (vtable `0x81c8dc` slot 0, `0x77e2a0`): a change of face or
// spacing (mask `0x11`) re-seats its text region (`0x77e2b5` → `0x77b8c0`).
Some(|lua, this| {
reseat_text_region(lua, frame_handle_of(lua, this)?);
Ok(())
}),
)?;
// SetJustifyH/SetJustifyV return nothing. An unknown token raises `Usage:` (`0x87c77c`); a
@@ -0,0 +1,227 @@
//! The text region's rect and a multi-line box's own height: `0x77b8c0` seats the FontString by
//! the insets, and the flush's relayout `0x77d4d0` sizes a multi-line box to its text.
use mlua::Lua;
use crate::layout::{Anchor, Point, Rect};
use crate::script::{MeasureRequest, Model};
use crate::widget::{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`).
///
/// 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.
///
/// Single-line, benilla pins `TOPLEFT` and `BOTTOMRIGHT` at the inset corners and keeps no
/// explicit size. That is a gap: it covers the same rect, but an explicit width here would be a
/// wrap width, since benilla's single-line region holds the whole text where the reference's holds
/// only the window's substring (`0x77d858`). A script sees the difference: `GetPoint` on the
/// 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 {
return;
};
let (Some(rh), [l, r, t, b], multi_line) = (eb.text_region, eb.text_insets, eb.multi_line)
else {
return;
};
let owner = model.frame_id(h);
let top_left = Anchor::new(Point::TopLeft, owner, Point::TopLeft, l, -t);
let (anchors, size) = if multi_line {
let width = multi_line_text_width(model, h, [l, r]);
(vec![top_left], width.map(|w| Some((w, 0.0))))
} else {
let bottom_right = Anchor::new(Point::BottomRight, owner, Point::BottomRight, -r, b);
(vec![top_left, bottom_right], Some(None))
};
let data = model.region_data.entry(rh).or_default();
let same_anchors = data.anchors.len() == anchors.len()
&& data
.anchors
.iter()
.zip(&anchors)
.all(|(a, b)| crate::script::object::anchor_bits_eq(a, b));
// `None` keeps the size: a multi-line box with no rect yet.
let size = size.unwrap_or(data.size);
let size_moved = !crate::script::region::size_bits_eq(data.size, size);
if !same_anchors {
data.anchors = anchors;
}
data.size = size;
if !same_anchors {
model.touch_layout();
} else if size_moved {
model.touch_layout_region(rh);
}
if size_moved {
// The width is the wrap width, which keys the measure.
model.touch_measure(rh);
}
}
/// The EditBox's `OnSizeChanged` override (`0x77a8d0`, vtable `0x81c910` slot `+0x48`): after the
/// base script fire (`0x77a8e0`), `0x77a8e7` re-seats the text region (`0x77b8c0`) on every resize
/// of any box. `ApplyRect` (`0x76b580`) calls it when the width or height moved by the resize
/// epsilon, the first resolve included, against the ctor's zero rect. `true` when a re-seat moved
/// a layout input, for the caller to resolve it in the same pass, as the reference's drain does.
pub(in crate::script) fn reseat_resized(model: &mut Model) -> bool {
let epoch = model.layout_epoch;
for i in 0..model.arena.editbox_kinds().len() {
let h = model.arena.editbox_kinds()[i];
let Some(&now) = model.resolved.get(&h) else {
continue; // no rect, no `ApplyRect`
};
let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state)
else {
continue;
};
let before = eb
.notified_rect
.replace(now)
.unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0));
if !crate::layout::size_changed(before, now) {
continue;
}
// The ctor built the text region; its data is seeded here if nothing has touched it.
if super::ensure_text_region_in(model, h).is_some() {
seat_text_region(model, h);
}
}
model.layout_epoch != epoch
}
/// A multi-line box's text width in its own units, `boxW − (right + left)`, from the rect the
/// last resolve gave it; `None` before it has one.
fn multi_line_text_width(model: &Model, h: FrameHandle, [l, r]: [f32; 2]) -> Option<f32> {
let rect = model.resolved.get(&h)?;
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.
///
/// 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> = {
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()
};
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);
}
}
}
/// The text's height for the box: its measure under the current key, or one line's when that is
/// exactly 0.0 (`0x77d877` `fcom 0.0` → `0x77d88e`), as it is for empty text, which is never
/// measured (`0x7729ff`). `None` while either measure is pending.
fn text_height(lua: &Lua, h: FrameHandle, rh: RegionHandle) -> Option<f32> {
let measured = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let d = model.region_data.get(&rh)?;
if d.text.as_deref().is_none_or(str::is_empty) {
0.0
} else {
let key = d.measure_key(owner_scale(&model, h));
d.measured.filter(|m| m.key == key)?.h
}
};
if measured == 0.0 {
line_height(lua, h, rh)
} else {
Some(measured)
}
}
/// Any text that lays out as one line: its measured height is one line's.
const ONE_LINE: &str = "0";
/// `0x7727b0(fs, 1)`, one line's height in the text region's font: the installed engine's measure
/// of one line, the same measure a line of text takes, cached on the box under its key. `None`
/// with no engine installed.
fn line_height(lua: &Lua, h: FrameHandle, rh: RegionHandle) -> Option<f32> {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let scale = owner_scale(&model, h);
let req = {
let d = model.region_data.get(&rh)?;
let key = d.measure_key_of(ONE_LINE, scale);
if let Some(KindState::EditBox(eb)) = model.arena.frame(h).map(|f| &f.kind_state) {
if let Some((k, lh)) = eb.line_height {
if k == key {
return Some(lh);
}
}
}
MeasureRequest {
id: model.region_to_id.get(&rh).copied().unwrap_or(0),
font: d.font_path.clone(),
height: d.font_height,
text_height: d.text_height,
wrap_width: None,
outline: d.outline,
scale,
text: ONE_LINE.to_string(),
key,
}
};
// Taken out and put back, as `ensure_measured` does: the engine cannot re-enter the VM.
let mut engine = model.measurer.take()?;
let (_, lh, _) = engine.measure(&req);
model.measurer = Some(engine);
if let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
eb.line_height = Some((req.key, lh));
}
Some(lh)
}
/// The scale a measure is keyed under, the owner's effective scale, as the measure loop reads it.
fn owner_scale(model: &Model, h: FrameHandle) -> f32 {
model
.arena
.frame(h)
.map(|f| f.effective_scale)
.unwrap_or(1.0)
}
@@ -0,0 +1,484 @@
//! The text region's rect (`0x77b8c0`, re-seated on every resize by `0x77a8d0`) and a multi-line
//! box's own height (`0x77d4d0` @`0x77d8ad`), driven through the Lua methods and the tick.
use crate::script::{MeasureRequest, QuadContent, TextMeasure, UiScript};
/// A stand-in font engine: each character [`CW`] wide and each line `.0` tall. Hard breaks split
/// lines, a trailing one opening none as in the host's line law, and each line wraps by
/// characters at the request's wrap width.
struct Lines(f32);
const CW: f32 = 7.0;
const LH: f32 = 14.0;
impl TextMeasure for Lines {
fn measure(&mut self, req: &MeasureRequest) -> (f32, f32, f32) {
let mut lines: Vec<&str> = req.text.split('\n').collect();
if lines.len() > 1 && lines.last() == Some(&"") {
lines.pop();
}
let width = |l: &str| l.chars().count() as f32 * CW;
let natural = lines.iter().map(|l| width(l)).fold(0.0, f32::max);
let wrap = req.wrap_width.filter(|w| *w > 0.0);
let rows: f32 = lines
.iter()
.map(|l| match wrap {
Some(w) if width(l) > w => (width(l) / w).ceil(),
_ => 1.0,
})
.sum();
(
wrap.map_or(natural, |w| natural.min(w)),
rows * self.0,
natural,
)
}
}
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(Lines(LH)));
s
}
/// One frame of the host loop: the tick (where the flush relayouts), then the resolve.
fn frame(s: &mut UiScript) {
s.tick(0.016);
s.resolve();
}
fn height(s: &UiScript, name: &str) -> f64 {
s.eval(&format!("return {name}:GetHeight()")).unwrap()
}
/// A 200-wide multi-line box with insets (left 15, right 13, top 2, bottom 3), authored 200 tall.
fn inset_box(s: &mut UiScript) {
s.run(
r#"
E = CreateFrame("EditBox", "E")
E:SetAutoFocus(false)
E:SetPoint("BOTTOMLEFT", 100, 50)
E:SetWidth(200); E:SetHeight(200)
E:SetTextInsets(15, 13, 2, 3)
E:SetMultiLine(true)
"#,
)
.unwrap();
s.resolve();
}
/// The box is `(top + bottom) + h` tall, `h` the text's wrapped height, one line's when empty; it
/// grows and shrinks with its text, and the authored 200 lasts only until the first flush.
#[test]
fn a_multi_line_box_is_as_tall_as_its_text_and_its_insets() {
let mut s = script();
inset_box(&mut s);
assert_eq!(
height(&s, "E"),
200.0,
"the authored height, before any flush"
);
frame(&mut s);
assert_eq!(
height(&s, "E"),
f64::from(5.0 + LH),
"an empty box is one line tall: the first flush replaces the authored 200"
);
s.run(r#"E:SetText("one\ntwo\nthree")"#).unwrap();
frame(&mut s);
assert_eq!(height(&s, "E"), f64::from(5.0 + 3.0 * LH), "three lines");
// 30 characters are 210 wide against the 172 between the side insets: two rows.
s.run(r#"E:SetText(string.rep("x", 30))"#).unwrap();
frame(&mut s);
assert_eq!(
height(&s, "E"),
f64::from(5.0 + 2.0 * LH),
"a line wraps at the box's width less its side insets"
);
s.run(r#"E:SetText("")"#).unwrap();
frame(&mut s);
assert_eq!(height(&s, "E"), f64::from(5.0 + LH), "it shrinks back");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The relayout runs ahead of the `OnTextChanged` fire (`0x77d447` before `0x77d498`), so the
/// handler reads the new height.
#[test]
fn on_text_changed_sees_the_height_the_flush_just_set() {
let mut s = script();
inset_box(&mut s);
s.run(r#"E:SetScript("OnTextChanged", function() SEEN = this:GetHeight() end)"#)
.unwrap();
frame(&mut s);
s.run(r#"E:SetText("a\nb\nc\nd")"#).unwrap();
s.tick(0.016);
assert_eq!(
s.eval::<f64>("return SEEN").unwrap(),
f64::from(5.0 + 4.0 * LH)
);
}
/// A new raster re-measures the empty box's line: the host's `invalidate_text_measures` drops the
/// cached line height with every other measure.
#[test]
fn a_new_raster_re_measures_the_empty_boxs_line() {
let mut s = script();
inset_box(&mut s);
frame(&mut s);
assert_eq!(height(&s, "E"), f64::from(5.0 + LH));
s.set_text_measurer(Box::new(Lines(2.0 * LH)));
s.invalidate_text_measures();
frame(&mut s);
assert_eq!(height(&s, "E"), f64::from(5.0 + 2.0 * LH));
}
/// A `SetHeight` lasts until the next flush, which writes the text's height back over it.
#[test]
fn a_scripted_height_is_written_over_at_the_next_flush() {
let mut s = script();
inset_box(&mut s);
s.run(r#"E:SetText("a\nb")"#).unwrap();
frame(&mut s);
s.run("E:SetHeight(300)").unwrap();
assert_eq!(
height(&s, "E"),
300.0,
"the script's height, until the flush"
);
frame(&mut s);
assert_eq!(height(&s, "E"), f64::from(5.0 + 2.0 * LH));
}
/// A trailing newline opens no line: the text's measure folds it into the line it ends, and the
/// box takes that measure as it is.
#[test]
fn a_trailing_newline_alone_adds_no_line() {
let mut s = script();
inset_box(&mut s);
s.run(r#"E:SetText("abc")"#).unwrap();
frame(&mut s);
let one = height(&s, "E");
s.run(r#"E:SetText("abc\n")"#).unwrap();
frame(&mut s);
assert_eq!(height(&s, "E"), one, "the break alone");
s.run(r#"E:SetText("abc\nd")"#).unwrap();
frame(&mut s);
assert_eq!(
height(&s, "E"),
one + f64::from(LH),
"the first character on the new line"
);
}
/// Only a multi-line box is sized (`0x77d86a je` skips the write).
#[test]
fn a_single_line_box_keeps_its_height() {
let mut s = script();
s.run(
r#"
E = CreateFrame("EditBox", "E")
E:SetAutoFocus(false)
E:SetPoint("BOTTOMLEFT", 100, 50)
E:SetWidth(200); E:SetHeight(32)
E:SetText("a\nb\nc")
"#,
)
.unwrap();
s.resolve();
frame(&mut s);
frame(&mut s);
assert_eq!(height(&s, "E"), 32.0);
}
/// The multi-line text region sits `TOPLEFT` at `(left, −top)`, as wide as the box less its side
/// insets and as tall as its text, inside the box.
#[test]
fn the_text_region_is_pinned_top_left_as_wide_as_the_box_and_as_tall_as_its_text() {
let mut s = script();
inset_box(&mut s);
s.run(r#"E:SetText("hello")"#).unwrap();
frame(&mut s);
frame(&mut s);
let rect = s
.extract()
.into_iter()
.find_map(|q| match (&q.content, q.rect) {
(QuadContent::Text { text: Some(t), .. }, Some(r)) if t == "hello" => Some(r),
_ => None,
})
.expect("the text draws");
// The box is 5 + 14 tall from y 50: its top is 69.
assert_eq!(
(rect.left, rect.right, rect.top, rect.bottom),
(115.0, 287.0, 67.0, 53.0)
);
}
/// The height's move fires the box's `OnSizeChanged(width, height)`, and a flush that keeps it
/// fires nothing.
#[test]
fn on_size_changed_fires_when_the_text_moves_the_height() {
let mut s = script();
inset_box(&mut s);
s.run(
r#"
fires = {}
E:SetScript("OnSizeChanged", function() table.insert(fires, { w = arg1, h = arg2 }) end)
"#,
)
.unwrap();
frame(&mut s);
let fires = |s: &UiScript| s.eval::<i64>("return table.getn(fires)").unwrap();
let last = |s: &UiScript| {
s.eval::<(f64, f64)>("local f = fires[table.getn(fires)] return f.w, f.h")
.unwrap()
};
assert_eq!(fires(&s), 1, "the first flush replaces the authored height");
assert_eq!(last(&s), (200.0, f64::from(5.0 + LH)));
s.run(r#"E:SetText("a\nb\nc")"#).unwrap();
frame(&mut s);
assert_eq!(fires(&s), 2);
assert_eq!(last(&s), (200.0, f64::from(5.0 + 3.0 * LH)));
frame(&mut s);
s.run(r#"E:SetText("x\ny\nz")"#).unwrap();
frame(&mut s);
assert_eq!(fires(&s), 2, "same line count, same height: no fire");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The stock mail shape (`MailFrame.xml`): `SendMailBodyEditBox`, 270×200 at `TOPLEFT (20, −10)`
/// in the 300×255 scroll child of the 296×257 `SendMailScrollFrame`, whose `OnTextChanged` is
/// `ScrollingEdit_OnTextChanged`. The box's height carries the text into the range union, so 30
/// lines give `max(255, 10 + 30·L) − 257`.
#[test]
fn a_scroll_frame_ranges_over_the_whole_text_of_a_multi_line_box() {
let mut s = script();
s.run(
r#"
local sf = CreateFrame("ScrollFrame", "MailScroll", UIParent)
sf:SetPoint("TOPLEFT", 21, -97); sf:SetWidth(296); sf:SetHeight(257)
local child = CreateFrame("Frame", "MailScrollChild", sf)
child:SetWidth(300); child:SetHeight(255)
child:SetPoint("TOPLEFT", 0, 0)
sf:SetScrollChild(child)
local body = CreateFrame("EditBox", "MailBody", child)
body:SetMultiLine(true); body:SetAutoFocus(false)
body:SetWidth(270); body:SetHeight(200)
body:SetPoint("TOPLEFT", 20, -10)
body:SetScript("OnTextChanged", function() ScrollingEdit_OnTextChanged(MailScroll) end)
sf:SetScript("OnScrollRangeChanged", function() RANGE = arg2 end)
function ScrollingEdit_OnTextChanged(scrollFrame) scrollFrame:UpdateScrollChildRect() end
"#,
)
.unwrap();
s.resolve();
frame(&mut s);
assert_eq!(
s.eval::<f64>("return MailScroll:GetVerticalScrollRange()")
.unwrap(),
0.0,
"an empty body is one line: the 255-tall child fits the 257-tall frame"
);
s.run(
r#"
local lines = {}
for i = 1, 30 do lines[i] = "line " .. i end
MailBody:SetText(table.concat(lines, "\n"))
"#,
)
.unwrap();
frame(&mut s);
assert_eq!(
height(&s, "MailBody"),
f64::from(30.0 * LH),
"the box itself spans the text, not only its text region"
);
let want = f64::from((10.0 + 30.0 * LH).max(255.0) - 257.0);
assert_eq!(
s.eval::<f64>("return RANGE").unwrap(),
want,
"the range ScrollingEdit_OnTextChanged's recompute reports"
);
assert_eq!(
s.eval::<f64>("return MailScroll:GetVerticalScrollRange()")
.unwrap(),
want
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The text quad's rect as `(left, right, top, bottom)`, `None` when it draws nowhere.
fn text_rect(s: &UiScript, text: &str) -> Option<(f32, f32, f32, f32)> {
s.extract().into_iter().find_map(|q| match &q.content {
QuadContent::Text { text: Some(t), .. } if t == text => {
q.rect.map(|r| (r.left, r.right, r.top, r.bottom))
}
_ => None,
})
}
/// A box built in Lua, with no `<FontString>` and no `SetTextInsets`, has its text seated at its
/// first resolve: `ApplyRect` (`0x76b580`) sees it resized from the ctor's zero rect and the
/// override `0x77a8d0` runs `0x77b8c0` (`0x77a8e7`).
#[test]
fn a_lua_box_with_a_font_seats_its_text_at_its_first_resolve() {
let mut s = script();
s.run(
r#"
E = CreateFrame("EditBox", "E")
E:SetAutoFocus(false)
E:SetPoint("BOTTOMLEFT", 100, 50)
E:SetWidth(200); E:SetHeight(32)
E:SetFont("Fonts\\FRIZQT__.TTF", 14)
E:SetText("hello")
"#,
)
.unwrap();
s.resolve();
assert_eq!(text_rect(&s, "hello"), Some((100.0, 300.0, 82.0, 50.0)));
}
/// A later resize re-seats the text of any box: a multi-line box re-wraps at its new width, and a
/// single-line box's moved text region is put back between its insets.
#[test]
fn a_resized_box_re_seats_its_text() {
let mut s = script();
inset_box(&mut s);
// 30 characters are 210 wide: two rows in 200 less the side insets, one row in 300 less them.
s.run(r#"E:SetText(string.rep("x", 30))"#).unwrap();
frame(&mut s);
frame(&mut s);
assert_eq!(height(&s, "E"), f64::from(5.0 + 2.0 * LH));
s.run("E:SetWidth(300)").unwrap();
frame(&mut s);
frame(&mut s);
assert_eq!(
text_rect(&s, &"x".repeat(30)).map(|r| (r.0, r.1)),
Some((115.0, 387.0)),
"the text region takes the new width less the side insets"
);
assert_eq!(
height(&s, "E"),
f64::from(5.0 + LH),
"and the text re-wraps in it"
);
s.run(
r#"
S = CreateFrame("EditBox", "S")
S:SetAutoFocus(false)
S:SetPoint("BOTTOMLEFT", 100, 200)
S:SetWidth(200); S:SetHeight(32)
S:SetText("single")
"#,
)
.unwrap();
frame(&mut s);
s.run(
r#"
local text = S:GetRegions()
text:ClearAllPoints(); text:SetPoint("TOPLEFT", S, "TOPLEFT", 40, 40)
"#,
)
.unwrap();
frame(&mut s);
assert_ne!(
text_rect(&s, "single").map(|r| r.0),
Some(100.0),
"the script moved the text region"
);
s.run("S:SetWidth(250)").unwrap();
frame(&mut s);
assert_eq!(text_rect(&s, "single"), Some((100.0, 350.0, 232.0, 200.0)));
}
/// A single-line box at `(100, 200)`, 200×32 with insets (10, 20, 3, 5), whose text region a
/// script has moved to `TOPLEFT (40, 40)`; `SEATED` is where `0x77b8c0` puts it.
fn moved_text_region(s: &mut UiScript) {
s.run(
r#"
S = CreateFrame("EditBox", "S")
S:SetAutoFocus(false)
S:SetPoint("BOTTOMLEFT", 100, 200)
S:SetWidth(200); S:SetHeight(32)
S:SetTextInsets(10, 20, 3, 5)
S:SetText("single")
"#,
)
.unwrap();
frame(s);
assert_eq!(text_rect(s, "single"), Some(SEATED));
move_text_region(s);
}
const SEATED: (f32, f32, f32, f32) = (110.0, 280.0, 229.0, 205.0);
fn move_text_region(s: &mut UiScript) {
s.run(
r#"
local text = S:GetRegions()
text:ClearAllPoints(); text:SetPoint("TOPLEFT", S, "TOPLEFT", 40, 40)
"#,
)
.unwrap();
frame(s);
assert_eq!(
text_rect(s, "single").map(|r| r.0),
Some(140.0),
"the script moved the text region"
);
}
/// `GetTextInsets` (`0x77a710`) copies the insets out, then re-seats (`0x77a73f` → `0x77b8c0`).
#[test]
fn get_text_insets_puts_a_moved_text_region_back() {
let mut s = script();
moved_text_region(&mut s);
let insets = s
.eval::<(f32, f32, f32, f32)>("return S:GetTextInsets()")
.unwrap();
assert_eq!(insets, (10.0, 20.0, 3.0, 5.0));
frame(&mut s);
assert_eq!(text_rect(&s, "single"), Some(SEATED));
}
/// A new face or size notifies the box (`0x79f342` → `0x77e2a0`), which re-seats its text
/// (`0x77e2b5`); the same font again sets no change bit (`0x79f2ef`), so it does not.
#[test]
fn set_font_puts_a_moved_text_region_back_when_the_font_changes() {
let mut s = script();
moved_text_region(&mut s);
s.run(r#"S:SetFont("Fonts\\FRIZQT__.TTF", 14)"#).unwrap();
frame(&mut s);
assert_eq!(text_rect(&s, "single"), Some(SEATED));
move_text_region(&mut s);
s.run(r#"S:SetFont("Fonts\\FRIZQT__.TTF", 14)"#).unwrap();
frame(&mut s);
assert_eq!(
text_rect(&s, "single").map(|r| r.0),
Some(140.0),
"the same font changes nothing"
);
}
/// Linking another font object propagates into the box's font (`0x770c60` → `0x77e4b0`), whose
/// notify re-seats the text (`0x77e4d7` → `0x77e2a0`).
#[test]
fn set_font_object_puts_a_moved_text_region_back() {
let mut s = script();
s.run(r#"F = CreateFont("ScratchFont") F:SetFont("Fonts\\FRIZQT__.TTF", 12)"#)
.unwrap();
moved_text_region(&mut s);
s.run("S:SetFontObject(F)").unwrap();
frame(&mut s);
assert_eq!(text_rect(&s, "single"), Some(SEATED));
}
+49 -16
View File
@@ -24,6 +24,11 @@ use crate::widget::RegionHandle;
/// `EditBox` creates its own on demand. An error means the wrong receiver.
pub(super) type ResolveRegion = fn(&Lua, &Table) -> mlua::Result<RegionHandle>;
/// A widget's listener on its font instance, run after `SetFont` loads a new face or size and
/// after `SetFontObject` links another object: the notify `[vt+0x14]` that `0x79f342` and
/// `0x77e4d7` call, which the EditBox answers (`0x77e2a0`).
pub(super) type FontChanged = fn(&Lua, &Table) -> mlua::Result<()>;
/// `SetFont`'s shared argument gate (`0x79f210`, reached from Font `0x7a0270`, FontString
/// `0x79d4f0` and EditBox `0x797210`): arg 2 must pass `lua_isstring` and arg 3 `lua_isnumber`,
/// both coercing, else it raises `Usage: %s:SetFont("font", fontHeight [, flags])` (`0x87c69c`).
@@ -58,6 +63,7 @@ pub(super) fn install(
m: &Table,
resolve: ResolveRegion,
widget: &'static str,
on_font_change: Option<FontChanged>,
) -> mlua::Result<()> {
// ── the font object ──
// SetFontObject(font | "font" | nil) → nothing (`0x79ef10`, usage string `0x87c5cc`). Any
@@ -67,23 +73,37 @@ pub(super) fn install(
lua.create_function(move |lua, (this, font): (Table, Value)| {
let name = super::font::resolve("SetFontObject", &font)?;
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
// nil severs the link and keeps the paint; the reference only nulls the parent.
let Some(name) = name else {
model.region_data.entry(rh).or_default().font_object = None;
return Ok(());
let relinked = {
let mut model = lua.app_data_mut::<Model>().expect("model");
// nil severs the link and keeps the paint; the reference only nulls the parent.
let Some(name) = name else {
model.region_data.entry(rh).or_default().font_object = None;
return Ok(());
};
let Some(fo) = model.font_object(&name).cloned() else {
return Err(mlua::Error::runtime(format!(
"SetFontObject: no font object named '{name}' is registered"
)));
};
let d = model.region_data.entry(rh).or_default();
// The same object again changes nothing (`0x770c6a`).
let relinked = d
.font_object
.as_deref()
.is_none_or(|old| !old.eq_ignore_ascii_case(&name));
d.font_object = Some(name);
// The inherit mask stays: each local setter clears its bit (`FONTINSTANCE+0x2c`)
// and nothing restores it, so a property set locally survives a later
// `SetFontObject`.
super::font::repaint(d, &fo);
model.touch_measure(rh);
relinked
};
let Some(fo) = model.font_object(&name).cloned() else {
return Err(mlua::Error::runtime(format!(
"SetFontObject: no font object named '{name}' is registered"
)));
};
let d = model.region_data.entry(rh).or_default();
d.font_object = Some(name);
// The inherit mask stays: each local setter clears its bit (`FONTINSTANCE+0x2c`) and
// nothing restores it, so a property set locally survives a later `SetFontObject`.
super::font::repaint(d, &fo);
model.touch_measure(rh);
if relinked {
if let Some(changed) = on_font_change {
changed(lua, &this)?;
}
}
Ok(())
})?,
)?;
@@ -118,6 +138,10 @@ pub(super) fn install(
let (path, height) = set_font_args(&file, &height, widget)?;
let rh = resolve(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let before = model
.region_data
.get(&rh)
.map(|d| (d.font_path.clone(), d.font_height, d.outline));
// A failed load (`0x5c1ae0` under the `0x44d040` font cache) is the host's probe to
// judge, as only the store knows the files; with no probe, a non-empty path loads.
let ok =
@@ -137,7 +161,16 @@ pub(super) fn install(
d.outline = Outline::flags(&f);
d.font_explicit.outline = true;
}
let after = (d.font_path.clone(), d.font_height, d.outline);
model.touch_measure(rh);
drop(model);
// A loaded face that changed the path, height or flags notifies (`0x79f32f` sets
// the change bits, `0x79f342` calls the notify); a failed load does not.
if ok && before.as_ref() != Some(&after) {
if let Some(changed) = on_font_change {
changed(lua, &this)?;
}
}
Ok(if ok { Value::Number(1.0) } else { Value::Nil })
},
)?,
@@ -164,6 +164,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
.ok_or_else(|| mlua::Error::runtime("not a MessageFrame"))
},
"MessageFrame",
None,
)?;
lua.set_named_registry_value(REG_MESSAGEFRAME_METHODS, m)?;
@@ -243,6 +243,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
.ok_or_else(|| mlua::Error::runtime("not a ScrollingMessageFrame"))
},
"ScrollingMessageFrame",
None,
)?;
lua.set_named_registry_value(REG_SCROLLINGMESSAGEFRAME_METHODS, m)?;
+16 -4
View File
@@ -879,19 +879,30 @@ impl UiScript {
}
/// Resolve and cache every frame's rect, which `GetWidth`/`GetHeight`/`extract` read, then fire
/// `OnSizeChanged` for frames whose size moved ([`event::fire_size_changes`]).
/// `OnSizeChanged` for frames whose size moved ([`event::fire_size_changes`]) and re-seat the
/// text of every resized EditBox.
pub fn resolve(&mut self) {
self.resolve_and_measure();
event::fire_size_changes(&self.lua);
// The EditBox's own `OnSizeChanged` (`0x77a8d0`) runs after the script fire and re-seats a
// resized box's text, which the reference's drain resolves in the same pass.
let reseated = editbox::reseat_resized(&mut self.model_mut());
if reseated {
self.resolve_and_measure();
}
}
/// Resolve, and with a font engine installed ([`Self::set_text_measurer`]) measure what the
/// solve revealed and solve again, so a FontString's box is right in the frame its text was set.
fn resolve_and_measure(&mut self) {
{
let mut model = self.model_mut();
Self::resolve_layout(&mut model);
}
// With a font engine installed ([`Self::set_text_measurer`]), measure what the solve
// revealed and solve again, so a FontString's box is right in the frame its text was set.
if self.fill_measures() {
let mut model = self.model_mut();
Self::resolve_layout(&mut model);
}
event::fire_size_changes(&self.lua);
}
/// Store host measurements `(id, w, h, natural_w, key)` for [`MeasureRequest`]s; the next
@@ -960,6 +971,7 @@ impl UiScript {
}
KindState::EditBox(eb) => {
eb.advances_key = eb.advances_key.wrapping_add(1);
eb.line_height = None;
}
_ => {}
}
+1 -1
View File
@@ -200,5 +200,5 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
//
// The font object, font, text colour and shadow getters and setters are type-guard shims over
// one implementation each (`0x79f210` SetFont, `0x79f3b0` GetFont), shared with EditBox.
super::super::font_block::install(lua, m, region_handle_of, "FontString")
super::super::font_block::install(lua, m, region_handle_of, "FontString", None)
}
+6 -2
View File
@@ -77,8 +77,12 @@ impl super::UiScript {
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 `0x77a790`'s drain of the `OnTextChanged`s an edit only marked (`0x77a7a1`), before
// the OnUpdate sweep, as in the reference.
// 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.
+8 -3
View File
@@ -470,11 +470,16 @@ impl RegionData {
/// read treats a measure under another key as absent; the layout keeps the old box until the
/// new one lands, except for empty text, which is never measured.
pub(crate) fn measure_key(&self, scale: f32) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
// By reference: `String` hashes as `str` does, and this runs for every FontString every
// frame, so a clone would allocate for nothing.
self.text.as_deref().unwrap_or("").hash(&mut hasher);
self.measure_key_of(self.text.as_deref().unwrap_or(""), scale)
}
/// [`Self::measure_key`] with `text` in place of the region's own.
pub(crate) fn measure_key_of(&self, text: &str, scale: f32) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut hasher);
self.font_path.hash(&mut hasher);
self.font_height.map(f32::to_bits).hash(&mut hasher);
self.text_height.map(f32::to_bits).hash(&mut hasher);
+10 -1
View File
@@ -79,8 +79,15 @@ pub struct EditBoxState {
pub history_pos: Option<usize>,
/// The live line stashed when browsing starts, restored when DOWN walks past the newest entry.
pub history_draft: Option<String>,
/// `SetTextInsets(l, r, t, b)`, applied as the text region's two corner anchors.
/// `SetTextInsets(l, r, t, b)` (`+0x3ec`, `+0x3f4`, `+0x3f0`, `+0x3e8`), which seat the text
/// region and pad a multi-line box's height.
pub text_insets: [f32; 4],
/// One line's measured height in the text region's font (`0x7727b0(fs, 1)`), the height of an
/// empty multi-line box's text, with the measure key it was taken under.
pub line_height: Option<(u64, f32)>,
/// The box's rect when its resize was last checked, `ApplyRect`'s old rect (`0x76b580`);
/// `None` before its first resolve, which compares against the ctor's zero rect.
pub notified_rect: Option<crate::layout::Rect>,
/// The host-measured width of `display[..i]` per byte `i`, a continuation byte repeating its
/// lead's; hit-testing (`0x77d0d0`) and the scroll window read it. Empty until answered.
pub advances: Vec<f32>,
@@ -160,6 +167,8 @@ impl Default for EditBoxState {
history_pos: None,
history_draft: None,
text_insets: [0.0; 4],
line_height: None,
notified_rect: None,
advances: Vec::new(),
advances_key: 0,
rows: vec![0],
+10
View File
@@ -249,6 +249,9 @@ pub struct WidgetArena {
tooltip_kinds: Vec<FrameHandle>,
/// 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.
editbox_kinds: Vec<FrameHandle>,
}
impl Default for WidgetArena {
@@ -291,6 +294,7 @@ impl WidgetArena {
ticked_kinds: Vec::new(),
tooltip_kinds: Vec::new(),
minimap_kinds: Vec::new(),
editbox_kinds: Vec::new(),
}
}
@@ -312,6 +316,10 @@ impl WidgetArena {
&self.minimap_kinds
}
pub fn editbox_kinds(&self) -> &[FrameHandle] {
&self.editbox_kinds
}
// ── Read access ────────────────────────────────────────────────────────────────────────────
pub fn frame(&self, h: FrameHandle) -> Option<&Frame> {
@@ -499,6 +507,7 @@ impl WidgetArena {
self.names.entry(n).or_insert(handle);
}
if matches!(kind, FrameKind::EditBox) {
self.editbox_kinds.push(handle);
self.build_editbox_engine_regions(handle);
}
if matches!(kind, FrameKind::Minimap) {
@@ -556,6 +565,7 @@ impl WidgetArena {
self.ticked_kinds.retain(|&t| t != h);
self.tooltip_kinds.retain(|&t| t != h);
self.minimap_kinds.retain(|&t| t != h);
self.editbox_kinds.retain(|&t| t != h);
for c in children {
self.destroy(c);