Files
benilla-octo/crates/benilla-ui/src/script/slider.rs
T
SamandClaude Fable 5 9f4595597d fishing becomes whole: the bite, the line, the splash and the reel
- the whole loop plays: the line arcs rod tip to bobber, the bite splashes with its own sound, and clicking the bobber reels in
- the verdicts read like 1.12: yellow "your fish got away" lines, the reel-in sound, the fishing loot window
- "You feel rested." prints on the transition, rest states come from the client's own Exhaustion table, and level 60 trades the XP strip for the max-level rail
- the Skills pane lists what the real client lists: the hidden rows, the sort, gray numberless single-rank lines

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:03:16 +07:00

416 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The `Slider` method surface — the `CSimpleSlider` widget behavior over the kind tag (factory
//! `0x6eee40`). A value in `[min, max]` with a step and orientation, driving a thumb texture along
//! the track.
//!
//! Grounded in wow-re's byte-verified LoadXML table (RF-28, `rf28-typed-widget-loadxml.md`): the
//! `<ThumbTexture>` sub-element (default layer OVERLAY=3), `minValue`/`maxValue`/`valueStep`/
//! `defaultValue`, the `orientation` shared enum (HORIZONTAL=0/VERTICAL=1, `0x811b00`), and the
//! widget's own `OnValueChanged` script slot (`+0x330`). The **thumb-position** mechanism — the
//! thumb's rect placed at the value fraction along the orientation axis, applied at extract — is
//! faithful to the *documented* widget model, not byte-pinned (same posture as StatusBar's fill and
//! ScrollFrame's scroll; decisions 0112/0250).
//!
//! `SetValue` fires `OnValueChanged` **only on an actual change** ([`SliderState::store_value`]).
//! That is load-bearing, not an optimization: the real scrollbar template wires
//! `OnValueChanged → this:GetParent():SetVerticalScroll(arg1)` and the ScrollFrame's
//! `OnVerticalScroll → scrollbar:SetValue(arg1)` back the other way, so a fire-always `SetValue`
//! would recurse forever (`UIPanelTemplates.xml`). The change-gate breaks the loop after one hop.
//!
//! The methods live in their own registry table, consulted by the frame `__index` dispatcher only
//! for Slider frames — so duck-typing addons (`if frame:GetThumbTexture() then …`) see `nil` on
//! every other kind, exactly as against the client's per-class method sets.
use mlua::{Lua, Table, Value};
use super::object::{draw_layer_from_str, frame_handle_of};
use super::pointer::point_in_rect;
use super::region::region_wrapper;
use super::{event, Model, RegionData};
use crate::layout::Rect;
use crate::order::DrawLayer;
use crate::widget::{FrameHandle, KindState, RegionKind, SliderState};
/// Registry key of the Slider method table (the MAXCSTACK discipline: Lua-side root, named key).
pub(super) const REG_SLIDER_METHODS: &str = "__benilla_slider_methods";
/// Run `f` over a frame's Slider state under one short write borrow. Errors if `this` is not a live
/// Slider (unreachable through the kind dispatcher, but the method table is a plain Lua value — a
/// caller could fish it out and misapply it).
fn with_slider<T>(
lua: &Lua,
this: &Table,
f: impl FnOnce(&mut SliderState) -> T,
) -> mlua::Result<T> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let frame = model
.arena
.frame_mut(h)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
match &mut frame.kind_state {
KindState::Slider(s) => Ok(f(s)),
_ => Err(mlua::Error::runtime("not a Slider")),
}
}
/// Get-or-create the thumb texture region (`SetThumbTexture`/`<ThumbTexture>`); `layer` re-layers an
/// existing thumb. Returns the region's id (for wrapper lookup). The widget default layer is OVERLAY
/// (RF-28 — the Slider's `drawLayer` attr defaults OVERLAY=3, unlike StatusBar's ARTWORK bar).
fn ensure_thumb(lua: &Lua, this: &Table, layer: Option<DrawLayer>) -> mlua::Result<u32> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let existing = match &model
.arena
.frame(h)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?
.kind_state
{
KindState::Slider(s) => s.thumb,
_ => return Err(mlua::Error::runtime("not a Slider")),
};
let rh = match existing {
Some(rh) => {
if let (Some(l), Some(region)) = (layer, model.arena.region_mut(rh)) {
region.draw_layer = l;
}
rh
}
None => {
let rh = model
.arena
.create_region(
h,
RegionKind::Texture,
layer.unwrap_or(DrawLayer::Overlay),
0,
)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
model.region_data.insert(rh, RegionData::default());
model.touch_layout(); // a region entered the layout gate's read set (decision 0740)
if let Some(frame) = model.arena.frame_mut(h) {
if let KindState::Slider(s) = &mut frame.kind_state {
s.thumb = Some(rh);
}
}
rh
}
};
Ok(model.region_id(rh))
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
m.set(
"SetMinMaxValues",
lua.create_function(|lua, (this, min, max): (Table, f32, f32)| {
// Unlike StatusBar, a reversed pair is NOT swapped (the Slider LoadXML stores min +
// (max−min) and does no swap, RF-28). The held value re-clamps into the new range; a
// move fires OnValueChanged — a value change the caller didn't set explicitly is still a
// change (the store_value clamp guards a degenerate max<min range).
let changed = with_slider(lua, &this, |s| {
(s.min, s.max) = (min, max);
s.store_value(s.value)
})?;
fire_value_changed(lua, &this, changed)
})?,
)?;
m.set(
"GetMinMaxValues",
lua.create_function(|lua, this: Table| with_slider(lua, &this, |s| (s.min, s.max)))?,
)?;
m.set(
"SetValue",
lua.create_function(|lua, (this, v): (Table, f32)| {
let changed = with_slider(lua, &this, |s| s.store_value(v))?;
fire_value_changed(lua, &this, changed)
})?,
)?;
m.set(
"GetValue",
lua.create_function(|lua, this: Table| with_slider(lua, &this, |s| s.value))?,
)?;
m.set(
"SetValueStep",
lua.create_function(|lua, (this, step): (Table, f32)| {
with_slider(lua, &this, |s| s.step = step)
})?,
)?;
m.set(
"GetValueStep",
lua.create_function(|lua, this: Table| with_slider(lua, &this, |s| s.step))?,
)?;
m.set(
"SetOrientation",
lua.create_function(|lua, (this, o): (Table, String)| {
let vertical = match o.to_ascii_uppercase().as_str() {
"HORIZONTAL" => false,
"VERTICAL" => true,
_ => {
return Err(mlua::Error::runtime(format!(
"SetOrientation: unknown orientation '{o}'"
)))
}
};
with_slider(lua, &this, |s| s.vertical = vertical)
})?,
)?;
m.set(
"GetOrientation",
lua.create_function(|lua, this: Table| {
let v = with_slider(lua, &this, |s| s.vertical)?;
Ok(if v { "VERTICAL" } else { "HORIZONTAL" }.to_string())
})?,
)?;
// Enable/Disable/IsEnabled — a disabled slider ignores thumb drag (gated in the pointer path).
m.set(
"Enable",
lua.create_function(|lua, this: Table| with_slider(lua, &this, |s| s.enabled = true))?,
)?;
m.set(
"Disable",
lua.create_function(|lua, this: Table| with_slider(lua, &this, |s| s.enabled = false))?,
)?;
m.set(
"IsEnabled",
// 1.12 returns 1/nil; the house API target is the Era boolean (decision 0068) — every
// transcribed `if slider:IsEnabled()` reads both identically.
lua.create_function(|lua, this: Table| with_slider(lua, &this, |s| s.enabled))?,
)?;
// SetThumbTexture(path [, drawLayer]) | SetThumbTexture(r, g, b [, a]) — the same two forms as a
// region's SetTexture, targeting the thumb region (created on first use). Mirrors
// SetStatusBarTexture; the thumb defaults to the OVERLAY layer.
m.set(
"SetThumbTexture",
lua.create_function(
|lua, (this, a1, a2, a3, a4): (Table, Value, Value, Value, Value)| {
let layer = match &a2 {
Value::String(s) => s.to_str().ok().and_then(|l| draw_layer_from_str(&l)),
_ => None,
};
let id = ensure_thumb(lua, &this, layer)?;
let rh = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
*model.id_to_region.get(&id).expect("thumb region id")
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let data = model.region_data.entry(rh).or_default();
match &a1 {
Value::String(s) => {
data.texture = Some(s.to_str()?.to_string());
data.fill = None;
}
// A solid thumb writes the same slot the path form does — each clears the other.
Value::Number(_) | Value::Integer(_) => {
data.texture = None;
data.fill = Some([
num_f32(&a1),
num_f32(&a2),
num_f32(&a3),
match &a4 {
Value::Nil => 1.0,
v => num_f32(v),
},
]);
}
_ => {}
}
Ok(())
},
)?,
)?;
m.set(
"GetThumbTexture",
lua.create_function(|lua, this: Table| {
let thumb = with_slider(lua, &this, |s| s.thumb)?;
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
thumb.map(|rh| model.region_id(rh))
};
match id {
Some(id) => Ok(Value::Table(region_wrapper(lua, id)?)),
None => Ok(Value::Nil),
}
})?,
)?;
lua.set_named_registry_value(REG_SLIDER_METHODS, m)?;
Ok(())
}
/// Fire `OnValueChanged(self, value)` if `changed` carries the new value (RF-28: the Slider's own
/// script slot `+0x330`). Fired outside any model borrow; errors go to [`Model::errors`].
fn fire_value_changed(lua: &Lua, this: &Table, changed: Option<f32>) -> mlua::Result<()> {
let Some(value) = changed else { return Ok(()) };
let id = {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.frame_id(h)
};
if let Err(e) = event::fire_widget_handler(
lua,
id,
"OnValueChanged",
vec![Value::Number(f64::from(value))],
) {
lua.app_data_mut::<Model>()
.expect("model app_data")
.errors
.push(e.to_string());
}
Ok(())
}
/// A Lua number-ish → f32 (nil/other → 0.0), for the color-form arguments.
fn num_f32(v: &Value) -> f32 {
match v {
Value::Number(n) => *n as f32,
Value::Integer(i) => *i as f32,
_ => 0.0,
}
}
// ── Thumb geometry + drag (decision 0250 §4/§5) ──────────────────────────────────────────────
/// A Slider's thumb rect: the thumb of size `thumb_size` placed at `fraction` along the slider `r`'s
/// track and centered on the cross-axis. The thumb stays **inset** within the track (travel =
/// trackLen − thumbLen), the way WoW scrollbar knobs visibly do. VERTICAL runs y-up-inverted —
/// `fraction` 0 (value=min) sits the thumb flush at the **top** (a scrollbar at 0 is scrolled to the
/// top), `fraction` 1 flush at the bottom. A thumb with no explicit size fills the slider (zero
/// travel). This is the single geometry source shared by `extract` (render) and [`begin_drag`]
/// (hit-test), so render and input never disagree. Returns a [`Rect`] as `(bottom, left, top, right)`.
pub(super) fn thumb_rect(
r: Rect,
thumb_size: Option<(f32, f32)>,
vertical: bool,
fraction: f32,
) -> Rect {
let (tw, th) = thumb_size.unwrap_or((r.width(), r.height()));
if vertical {
let travel = (r.height() - th).max(0.0);
let top = r.top - fraction * travel; // f=0 → track top, f=1 → track bottom
let cx = (r.left + r.right) * 0.5;
Rect::new(top - th, cx - tw * 0.5, top, cx + tw * 0.5)
} else {
let travel = (r.width() - tw).max(0.0);
let left = r.left + fraction * travel;
let cy = (r.bottom + r.top) * 0.5;
Rect::new(cy - th * 0.5, left, cy + th * 0.5, left + tw)
}
}
/// The in-flight thumb drag (decision 0250 §5): the slider being dragged + the grab offset (the
/// cursor's distance from the thumb's leading edge along the axis at grab time), so the thumb tracks
/// the cursor without jumping. Engine C++-equivalent — no Lua, like the real client's scrollbar.
#[derive(Clone, Copy)]
pub(crate) struct SliderDrag {
pub(crate) slider: FrameHandle,
/// cursor-along-axis − thumb-leading-edge at grab (VERTICAL: `y − thumb.top`; HORIZONTAL:
/// `x − thumb.left`).
pub(crate) grab_offset: f32,
}
/// On a LeftButton press at `(x, y)` whose hit frame is `hit`: if that frame is an **enabled**
/// Slider with a resolved rect, begin a drag capture (records [`Model::slider_drag`]). A press ON
/// the thumb grabs it **offset-preserving** (the grabbed point stays under the cursor — 0250 §5);
/// a press on the TRACK seats the thumb's **center** under the cursor — the value jumps there and
/// the same capture begins, one continuous press-drag gesture (decision 0989: "the only way to
/// set the bar was the arrows"). Returns `Some((frame id, new value))` when the press itself
/// changed the value (the track jump) — the caller fires `OnValueChanged` outside the model
/// borrow, exactly like [`drag_move`]'s changes — else `None`.
pub(super) fn begin_drag(
model: &mut Model,
hit: Option<FrameHandle>,
x: f32,
y: f32,
) -> Option<(u32, f32)> {
let h = hit?;
let r = model.resolved.get(&h).copied()?;
let (enabled, vertical, fraction, thumb) = match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Slider(s)) => (s.enabled, s.vertical, s.fraction(), s.thumb),
_ => return None,
};
if !enabled {
return None;
}
let size = thumb
.and_then(|rh| model.region_data.get(&rh))
.and_then(|d| d.size);
let trect = thumb_rect(r, size, vertical, fraction);
let on_thumb = point_in_rect(trect, x, y);
let grab_offset = if on_thumb {
// The grabbed thumb point rides under the cursor for the whole drag.
if vertical {
y - trect.top
} else {
x - trect.left
}
} else {
// Track press: grab the thumb by its center, so the seat-under-cursor jump below and
// every subsequent move share one formula ([`drag_move`]). The offset is
// cursor − leading edge at grab: +tw/2 horizontal; −th/2 vertical (top is the leading
// edge and sits ABOVE the center in y-up, hence bottom − top, which is −th).
if vertical {
(trect.bottom - trect.top) * 0.5
} else {
(trect.right - trect.left) * 0.5
}
};
model.slider_drag = Some(SliderDrag {
slider: h,
grab_offset,
});
if on_thumb {
return None; // no value change from the grab itself
}
drag_move(model, x, y)
}
/// On a pointer move at `(x, y)` while a thumb is captured: recompute the value from the cursor
/// position (absolute, drift-free — the grab offset keeps the same thumb point under the cursor) and
/// store it. Returns `Some((frame id, new value))` if the value actually changed — the caller fires
/// `OnValueChanged` outside the model borrow — else `None` (no capture, no travel, or no change).
pub(super) fn drag_move(model: &mut Model, x: f32, y: f32) -> Option<(u32, f32)> {
let SliderDrag {
slider,
grab_offset,
} = *model.slider_drag.as_ref()?;
let r = model.resolved.get(&slider).copied()?;
let (min, max, vertical, thumb) = match model.arena.frame(slider).map(|f| &f.kind_state) {
Some(KindState::Slider(s)) => (s.min, s.max, s.vertical, s.thumb),
_ => return None, // slider destroyed mid-drag
};
let (tw, th) = thumb
.and_then(|rh| model.region_data.get(&rh))
.and_then(|d| d.size)
.unwrap_or((r.width(), r.height()));
let fraction = if vertical {
let travel = r.height() - th;
if travel <= 0.0 {
return None; // nothing to scroll
}
((r.top - (y - grab_offset)) / travel).clamp(0.0, 1.0)
} else {
let travel = r.width() - tw;
if travel <= 0.0 {
return None;
}
(((x - grab_offset) - r.left) / travel).clamp(0.0, 1.0)
};
let value = min + fraction * (max - min);
let changed = match model.arena.frame_mut(slider).map(|f| &mut f.kind_state) {
Some(KindState::Slider(s)) => s.store_value(value),
_ => None,
};
changed.map(|v| (model.frame_id(slider), v))
}
/// Release any in-flight thumb drag (LeftButton up, or the pointer leaving the window).
pub(super) fn end_drag(model: &mut Model) {
model.slider_drag = None;
}