a slider snaps to its step, a pushed button releases, and warnings show

- a slider with a value step snaps to it: a stepped drag fires once per row instead of once per pixel, a scrollbar cannot rest between two rows, and one wheel notch on the trainer list reaches the bottom (2133)
- setting a step on a live slider moves its value onto the new lattice and runs its handler, which an addon on the real client can count on (2143)
- a button pushed from script releases on the next mouse-up, so Tablet-2.0's rows stop staying pressed forever; a release off the button's rect un-presses it, and a locked state holds (2134)
- the mail body and the GM ticket box scroll as you type past their bottom, and the Use UI Scale checkbox reads a registered setting (2141)
- an engine warning, a call that was accepted and did nothing, reaches the /errors window in grey instead of one terminal line; a text row trimmed mid-character no longer kills the window's repaint (2135, 2138)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-11 20:52:20 +07:00
co-authored by Claude Fable 5.1
parent 94945e8339
commit 5432a8bfbd
44 changed files with 1525 additions and 265 deletions
@@ -30,7 +30,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
m.set(
"SetText",
lua.create_function(|lua, (this, s): (Table, Option<String>)| {
lua.create_function(|lua, (this, s): (Table, Option<mlua::Value>)| {
let s = crate::script::binding_abi::text_arg(lua, s)?;
let h = frame_handle_of(lua, &this)?;
// Programmatic SetText KEEPS a history browse in progress: the chat live parse
// rewrites the box on every recalled slash line ("/s hi" → Say + "hi"), and ending
@@ -100,7 +101,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// `Option<String>` coercion follows it.
m.set(
"Insert",
lua.create_function(|lua, (this, s): (Table, Option<String>)| {
lua.create_function(|lua, (this, s): (Table, Option<mlua::Value>)| {
let s = crate::script::binding_abi::text_arg(lua, s)?;
let h = frame_handle_of(lua, &this)?;
if let Some(s) = s {
insert(lua, h, &s, true);
@@ -1585,3 +1585,87 @@ fn set_max_bytes_caps_the_buffer_in_bytes_with_minus_one_unlimited() {
assert!(s.run("E:SetMaxBytes()").is_err(), "too few raises");
assert!(s.run("E:SetMaxBytes(1, 2)").is_err(), "too many raises");
}
/// **`OnCursorChanged(x, y, w, h)` fires when the caret moves** — the edge the shipped
/// `MailFrame.xml` and `HelpFrame.xml` wire `ScrollingEdit_OnCursorChanged` to, and the reason a
/// multiline box follows its caret as you type past the bottom (decision 2141).
///
/// The four args are the reference's (wow-re's RF-0085 caret law, VERIFIED): `x` the caret's
/// advance along its line, `y` **negative-downward** by row (which is what
/// `ScrollingEdit_OnCursorChanged`'s `cursorOffset = y` then `-this.cursorOffset` reads back as a
/// positive distance), `w` the constant `4.0`, `h` the line height.
#[test]
fn the_caret_flush_fires_on_cursor_changed_with_the_references_four_args() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
fires = {}
E = CreateFrame("EditBox", "E")
E:SetWidth(200); E:SetHeight(64)
E:SetPoint("BOTTOMLEFT", 100, 50)
E:SetMultiLine(true)
E:SetScript("OnCursorChanged", function()
table.insert(fires, { x = arg1, y = arg2, w = arg3, h = arg4 })
end)
E:SetFocus()
"#,
)
.unwrap();
s.resolve();
// Three bytes on row 0, then a wrap onto row 1 — the host answers the rows and the pitch.
for ch in "abcdef".chars() {
s.char_input(&ch.to_string());
}
s.resolve();
if let Some(req) = s.editbox_advances_request() {
// A plain monotonic 7 px/byte table (the rig's own), wrapped into two rows at byte 3 —
// `caret_row_x` subtracts the row start's cumulative advance, which is the whole point.
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, 3], 12.0);
}
s.tick(0.016);
assert!(s.errors().is_empty(), "{:?}", s.errors());
let n: i64 = s.eval("return table.getn(fires)").unwrap();
assert!(n >= 1, "the flush fired at least once");
let (x, y, w, h): (f64, f64, f64, f64) = s
.eval("local f = fires[table.getn(fires)] return f.x, f.y, f.w, f.h")
.unwrap();
// The caret sits after 6 bytes: row 1 (rows start at 0 and 3), 3 bytes along it.
assert_eq!(
x, 21.0,
"x is the advance from the ROW's start, not the text's"
);
assert_eq!(
y, -12.0,
"y is minus the row index times the pitch — downward is negative"
);
assert_eq!(w, 4.0, "w is the reference's constant, not a measurement");
assert_eq!(h, 12.0, "h is the line height");
// **The edge is a CHANGE.** A tick that moves nothing fires nothing — which is what makes
// `ScrollingEdit_OnUpdate`'s `if (this.cursorOffset)` loop terminate instead of re-scrolling
// every frame.
let before: i64 = s.eval("return table.getn(fires)").unwrap();
s.tick(0.016);
s.tick(0.016);
assert_eq!(
s.eval::<i64>("return table.getn(fires)").unwrap(),
before,
"two quiet ticks fire nothing"
);
// …and a caret move fires again.
s.editbox_action(EditAction::Move {
unit: EditUnit::Char,
back: true,
extend: false,
});
s.tick(0.016);
assert!(
s.eval::<i64>("return table.getn(fires)").unwrap() > before,
"moving the caret one char fires the flush"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}