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
@@ -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));
}