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
+41 -18
View File
@@ -48,7 +48,7 @@ use mlua::Value;
use crate::layout::Rect;
use crate::order;
use crate::widget::FrameHandle;
use crate::widget::{ButtonState, FrameHandle};
use super::clip::{effective_clip, scroll_clip_sources};
use super::{button, cursor, editbox, event, Model, UiScript};
@@ -316,14 +316,17 @@ impl UiScript {
});
let old_handle = model.mouseover;
model.mouseover = new_handle;
// The client's `0x7793f0` (leave) and `0x7791ed` (enter) both call `SetState`:
// a press held over a button and then walked off it drops back to NORMAL, and
// walking back on picks PUSHED up again. Ours latches the same transition from
// the moved hover — both sides of the boundary, since each one's `hovered`
// changed.
for h in [old_handle, new_handle].into_iter().flatten() {
button::settle(&mut model, h);
}
// **Crossing a button's boundary moves NO button state**, and the comment that
// used to sit here claimed the opposite off two mis-attributed addresses:
// `0x7793f0` is the drag-start override, not a leave, and `0x7791e0` is the hide
// override, not an enter. The real enter/leave notifies `0x779490`/`0x7794e0`
// read `[+0x328]` only as a DISABLED guard and never write it (wow-re
// `scratch/button-state-edge-set.md`, a §5 census of every store to the field).
//
// So a press held over a button and walked off it stays PUSHED in the reference —
// it is the *drag threshold* (below) that un-presses one, and only for a frame
// that registered for drag. Decision 2134.
let _ = old_handle;
(old_id, drag_start, true, slider_change, color_change)
}
};
@@ -546,11 +549,25 @@ impl UiScript {
Some(h) => model.mouse_down_on.insert(button.to_string(), h),
None => model.mouse_down_on.remove(button),
};
// `0x7792ad`: the press SetState(PUSHED), unconditional past the registration
// and hit gates. The displaced entry is settled too — a second press of the same
// button elsewhere releases whatever it was holding.
for h in [displaced, hit_handle].into_iter().flatten() {
button::settle(&mut model, h);
// `0x7792b1`: the press edge, `SetButtonState(PUSHED, 0)`.
//
// It is unconditional *past* `0x779210`'s own two gates, and the first of them is
// the registration mask `[this+0x330]` tested as `m | m << 8` at `0x77924b` —
// "registered for this mouse button EITHER WAY, up or down". That is
// [`button::wants_press_visual`], and it is why a right-click lights up an action
// slot (it registers `RightButtonUp`) and does nothing at all to a plain
// `LeftButtonUp` button. Past it the edge is gated only on the button's own
// `locked == 0 && state != DISABLED`.
if let Some(h) = hit_handle.filter(|&h| button::wants_press_visual(&model, h, button))
{
button::edge(&mut model, h, ButtonState::on_mouse_down);
}
// A press arriving while this same mouse button was already holding another
// frame is a release we never got (the OS ate it — a focus loss, a modal). Give
// the displaced frame that release rather than leaving it pushed forever; the
// reference never reaches this because its capture guarantees the release.
if let Some(h) = displaced {
button::edge(&mut model, h, ButtonState::on_mouse_up);
}
// `0x7663e6` writes the resolved target into `root+0x80` — capture-else-hover, so
// this is an `or`, not an assignment: an existing capture is not displaced by a
@@ -588,10 +605,6 @@ impl UiScript {
(click, None, false, jump, None, abandoned, color_jump)
} else {
let pressed = model.mouse_down_on.remove(button);
// `0x7793c2`: the release SetState(NORMAL) on the frame the press captured.
if let Some(h) = pressed {
button::settle(&mut model, h);
}
// `root+0x80` is cleared at `0x7664bb` **only when the post-event button mask is
// zero** — a chorded release keeps the capture for the button still held. With the
// per-button map already drained above, "mask is zero" is "the map is empty".
@@ -609,6 +622,16 @@ impl UiScript {
);
let release = cursor::take_drag(&mut model, button);
let started = release.as_ref().is_some_and(|r| r.started);
// **The release edge** — `0x7793de`'s `SetButtonState(NORMAL, 0)`, on the frame
// the PRESS captured and regardless of where the cursor now is (`0x7792d0` runs
// no hit test of its own). It is guarded on the button's own `locked == 0` and on
// the state actually being PUSHED, and it is **skipped when the base finalized a
// drag** — `0x7792df`'s `0x76c040` test, which is this `started` (the button
// already un-pressed at the drag-start edge below, and un-pressing again would
// fire nothing but would be a second transition the reference does not make).
if let (Some(h), false) = (pressed, started) {
button::edge(&mut model, h, ButtonState::on_mouse_up);
}
// The world drop: press AND release both over THE WORLD, no drag started —
// a completed left click on the game world (never a drag release, which just
// keeps carrying; decision 0218's byte-verified trigger). What actually drops