addon-screen polish, and item glows return to the paper doll
- every glue-screen string renders its colour markup — the addon tooltip included, which used to print its |cff… codes raw - the addon list's scroll knob finally drags, on one shared slider law: press anywhere on the track and the knob warps under your cursor and follows, exactly as 1.12's slider behaves - item effects survive the character sheet and dressing room again — a booth bake is premultiplied by construction and is no longer premultiplied twice, which had been erasing every glow hanging in the space beside the model - the login one-shot announcements wait for the in-game UI to exist before they fire Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,8 @@
|
||||
//! coloured **gold when loadable, red when enabled-and-broken — except `DEP_DISABLED`, which reads
|
||||
//! grey** (AddonList.lua's own exception), and the status token (`getglobal("ADDON_"..reason)`) in
|
||||
//! `GlueFontNormalSmall` 30 units right of the title box. Titles render their `|cAARRGGBB` markup
|
||||
//! as colour, exactly as every FontString does ([`title_spans`]). Hovering a row raises
|
||||
//! as colour, exactly as every FontString does — as does every other string on this screen,
|
||||
//! the tooltip included ([`crate::glue::widgets::markup_spans`]). Hovering a row raises
|
||||
//! `AddonTooltip` (title + notes + `ADDON_DEPENDENCIES`) anchored to the row's left; hovering a
|
||||
//! MIXED checkbox raises `ENABLED_FOR_SOME`. **A row has no hover highlight and no row-click** —
|
||||
//! only the checkbox toggles (the entry template carries no `HighlightTexture` and no `OnClick`);
|
||||
@@ -43,14 +44,14 @@
|
||||
use bevy::prelude::*;
|
||||
use bevy::ui_render::ui_material::MaterialNode;
|
||||
|
||||
use benilla_ui::markup::{tokens, TokenKind};
|
||||
use benilla_ui::script::addon_gate::{can_load, GateRow, Verdict};
|
||||
use benilla_ui::script::UiScript;
|
||||
use benilla_ui::widget::{slider_fraction, slider_grab};
|
||||
|
||||
use crate::glue::art::{tc_rect, GlueArt, DIM, GOLD};
|
||||
use crate::glue::backdrop::{backdrop_border, tiled_bg_node};
|
||||
use crate::glue::widgets::{
|
||||
glue_button, outlined_spans, outlined_text, overlay, ArtSwap, GlueBtnKind, GlueText, Hilight,
|
||||
glue_button, outlined_text, overlay, ArtSwap, GlueBtnKind, GlueText, Hilight,
|
||||
};
|
||||
use crate::glue_strings::GlueStrings;
|
||||
use crate::sound::GlueSound;
|
||||
@@ -100,6 +101,12 @@ const FORCE_TOP: f32 = 42.0;
|
||||
const SCROLL_TOP: f32 = 73.0;
|
||||
const SCROLL_H: f32 = 390.0;
|
||||
const SCROLL_RIGHT: f32 = 49.0 + 510.0;
|
||||
/// The slider column between the two 16² arrows — the `<Slider>` the reference's
|
||||
/// `GlueScrollBarTemplate` authors, and the surface a drag measures itself against ([`ScrollBand`]).
|
||||
const BAR_TOP: f32 = SCROLL_TOP + 16.0;
|
||||
const BAR_H: f32 = SCROLL_H - 32.0;
|
||||
/// The knob's extent along the track (`UI-ScrollBar-Knob`, 16²).
|
||||
const KNOB: f32 = 16.0;
|
||||
/// The bottom row: 35 tall, 13 off the plate bottom.
|
||||
const BTN_TOP: f32 = BG_H - 13.0 - 35.0;
|
||||
|
||||
@@ -168,6 +175,15 @@ pub(super) struct AddonsPanel {
|
||||
version_check: bool,
|
||||
/// First visible row (`AddonList.offset`).
|
||||
offset: usize,
|
||||
/// The scroll bar's in-flight drag: the grab [`slider_grab`] returned at the press, as a
|
||||
/// fraction of the band's own height ([`ScrollBand`]).
|
||||
///
|
||||
/// Held here rather than read back off `Interaction::Pressed` because the panel **respawns
|
||||
/// its whole tree** on any repaint — and a drag repaints it every row it crosses. A fresh
|
||||
/// entity's `Interaction` starts at `None`, so a capture inferred from the widget would drop
|
||||
/// the moment the drag moved the list. The button's own held state (`ButtonInput`) is the
|
||||
/// only thing that survives a respawn, so that is what the drag rides on.
|
||||
drag: Option<f32>,
|
||||
/// The spawned tooltip, keyed by what it describes — **the only thing hover drives, and
|
||||
/// never a repaint trigger**. The old model set `dirty` on every hover change and respawned
|
||||
/// the whole tree, which reset the fresh entities' `Interaction` to `None`, which flipped
|
||||
@@ -197,6 +213,7 @@ impl Default for AddonsPanel {
|
||||
// for the frame before the drive system's first CVar read corrects it.
|
||||
version_check: true,
|
||||
offset: 0,
|
||||
drag: None,
|
||||
tip: None,
|
||||
root: None,
|
||||
spawned_s: 0.0,
|
||||
@@ -314,6 +331,30 @@ impl AddonsPanel {
|
||||
self.list.len().saturating_sub(MAX_ROWS)
|
||||
}
|
||||
|
||||
/// Where the knob sits, as a fraction of its travel — the value→pixel direction. Shared by the
|
||||
/// spawn (which draws the knob there) and the drag (which needs to know where it grabbed), so
|
||||
/// the two can never disagree about what the bar is showing.
|
||||
fn thumb_fraction(&self) -> f32 {
|
||||
match self.max_offset() {
|
||||
0 => 0.0,
|
||||
max => self.offset as f32 / max as f32,
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixel→value direction: seat the first visible row at `fraction` of the way down.
|
||||
///
|
||||
/// Rounded to a whole row because this list scrolls by ROWS, not pixels — the reference's
|
||||
/// AddonList is a faux scroll frame over 19 fixed slots, so the knob rides in row-sized
|
||||
/// detents. `dirty` only when the row actually changes: a drag inside one detent must not
|
||||
/// respawn the tree 60 times a second.
|
||||
fn scroll_to(&mut self, fraction: f32) {
|
||||
let next = (fraction * self.max_offset() as f32).round() as usize;
|
||||
if next != self.offset {
|
||||
self.offset = next;
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// One row's checkbox state for the current view. A single character's view is plain
|
||||
/// two-state over their column; the All view is the reference's tri-state — its
|
||||
/// `GetAddOnEnableState(nil, i)`: enabled for all / for some (the greyed check) / for none.
|
||||
@@ -388,7 +429,7 @@ impl AddonsPanel {
|
||||
/// which reads grey like a disabled row (the Lua's own `reason ~= "DEP_DISABLED"` guard: the
|
||||
/// player already made that choice one row up, and red would blame the dependent); grey
|
||||
/// otherwise. Any `|c` markup in the title overrides this base per-span, exactly as the
|
||||
/// reference FontString renders it ([`title_spans`]).
|
||||
/// reference FontString renders it ([`crate::glue::widgets::markup_spans`]).
|
||||
fn title_colour(&self, i: usize) -> Color {
|
||||
let verdict = self.verdict(i);
|
||||
if verdict.loadable() {
|
||||
@@ -471,53 +512,6 @@ fn status_label<'a>(strings: &'a GlueStrings, token: &'a str) -> &'a str {
|
||||
strings.get(&format!("ADDON_{token}")).unwrap_or(fallback)
|
||||
}
|
||||
|
||||
/// Split a title into its coloured spans — WoW's `|cAARRGGBB…|r` inline markup, decoded by the
|
||||
/// byte-verified grammar (`benilla_ui::markup`, RF-0087) instead of drawn literally. `base` is
|
||||
/// the row's own colour ([`AddonsPanel::title_colour`]); an escape overrides it until `|r`, and
|
||||
/// the escape's alpha byte is discarded exactly as the client discards it. Link escapes hide
|
||||
/// their payload and keep their visible text; `||` draws one `|`; a line break becomes a space
|
||||
/// (a title is one line).
|
||||
fn title_spans(text: &str, base: Color) -> Vec<(String, Color)> {
|
||||
let mut spans: Vec<(String, Color)> = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut colour = base;
|
||||
for (_, tok) in tokens(text) {
|
||||
let switch = match tok.kind {
|
||||
TokenKind::Color(rgba) => {
|
||||
let [r, g, b, _] = rgba.to_f32_at(1.0);
|
||||
Some(Color::srgb(r, g, b))
|
||||
}
|
||||
TokenKind::ColorReset => Some(base),
|
||||
TokenKind::EscapedPipe => {
|
||||
cur.push('|');
|
||||
None
|
||||
}
|
||||
TokenKind::LineBreak => {
|
||||
cur.push(' ');
|
||||
None
|
||||
}
|
||||
TokenKind::LinkOpen { .. } | TokenKind::LinkClose => None,
|
||||
TokenKind::Char(c) => {
|
||||
cur.push(c);
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(next) = switch {
|
||||
if !cur.is_empty() {
|
||||
spans.push((std::mem::take(&mut cur), colour));
|
||||
}
|
||||
colour = next;
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
spans.push((cur, colour));
|
||||
}
|
||||
if spans.is_empty() {
|
||||
spans.push((String::new(), base));
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
/// Flip the *Load out of date AddOns* box: the `checkAddonVersion` CVar **inverted** (1292 §2,
|
||||
/// byte-verified — ticked = `"0"`). Written through [`UiScript::set_cvar_engine`] so it rides the
|
||||
/// change queue like a Lua `SetCVar` and the host's sync persists it (the minimap-zoom pattern) —
|
||||
@@ -543,6 +537,10 @@ pub(super) enum AddonsAction {
|
||||
Cancel,
|
||||
ScrollUp,
|
||||
ScrollDown,
|
||||
/// The slider column — **one surface covering knob and track alike**, because the law has no
|
||||
/// thumb hit-test to gate capture on: a press anywhere on the bar captures, and where it
|
||||
/// *grabs* is [`slider_grab`]'s to say, from geometry rather than from picking.
|
||||
ScrollBar,
|
||||
/// The dropdown control — opens/closes the option list. Also carried by the open list's
|
||||
/// full-screen backdrop, so a click that misses every option closes it (click-away).
|
||||
DropdownToggle,
|
||||
@@ -556,6 +554,17 @@ pub(super) enum AddonsAction {
|
||||
#[derive(Component)]
|
||||
struct AddonsUi;
|
||||
|
||||
/// The scroll bar's travel band — the node spanning [`BAR_TOP`]`..`[`BAR_TOP`]`+`[`BAR_H`], with
|
||||
/// the knob riding inside it as a child.
|
||||
///
|
||||
/// The drag reads its `ComputedNode`/`UiGlobalTransform` rather than re-deriving where the bar
|
||||
/// landed on screen: this panel is centered by a flex parent at a window-dependent scale, so any
|
||||
/// hand-rolled screen-rect math here would be a second copy of the layout, wrong the first time
|
||||
/// the window resizes. Normalizing the cursor into this node's own box is scale-free by
|
||||
/// construction.
|
||||
#[derive(Component)]
|
||||
pub(super) struct ScrollBand;
|
||||
|
||||
/// A panel button whose child [`Hilight`] lights on hover (the checkboxes' `UI-CheckBox-Highlight`,
|
||||
/// the close X, the dropdown arrow, the open list's `UI-QuestTitleHighlight` rows, the scroll
|
||||
/// arrows) — driven per frame by [`drive_addons_panel`] with **no respawn**, which is the point.
|
||||
@@ -575,12 +584,14 @@ pub(super) fn drive_addons_panel(
|
||||
strings: Option<Res<GlueStrings>>,
|
||||
mut script: Option<NonSendMut<UiScript>>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mouse: Res<ButtonInput<MouseButton>>,
|
||||
mut wheel: MessageReader<bevy::input::mouse::MouseWheel>,
|
||||
mut sounds: MessageWriter<GlueSound>,
|
||||
presses: Query<(&AddonsAction, &Interaction), Changed<Interaction>>,
|
||||
hovers: Query<(Entity, &AddonsAction, &Interaction)>,
|
||||
lit: Query<(&Interaction, &Children), With<HoverLit>>,
|
||||
mut hilights: Query<&mut Visibility, With<Hilight>>,
|
||||
band: Query<(&ComputedNode, &UiGlobalTransform), With<ScrollBand>>,
|
||||
window: Query<&Window, With<bevy::window::PrimaryWindow>>,
|
||||
) {
|
||||
if !panel.open {
|
||||
@@ -588,6 +599,7 @@ pub(super) fn drive_addons_panel(
|
||||
commands.entity(root).despawn();
|
||||
}
|
||||
panel.tip = None;
|
||||
panel.drag = None;
|
||||
wheel.clear();
|
||||
return;
|
||||
}
|
||||
@@ -595,6 +607,7 @@ pub(super) fn drive_addons_panel(
|
||||
// ── the flows ─────────────────────────────────────────────────────────────────────────────
|
||||
let mut close_and_save = false;
|
||||
let mut close_and_discard = false;
|
||||
let mut bar_pressed = false;
|
||||
for (action, interaction) in &presses {
|
||||
if *interaction != Interaction::Pressed {
|
||||
continue;
|
||||
@@ -608,6 +621,7 @@ pub(super) fn drive_addons_panel(
|
||||
AddonsAction::Cancel => close_and_discard = true,
|
||||
AddonsAction::ScrollUp => scroll(&mut panel, -1),
|
||||
AddonsAction::ScrollDown => scroll(&mut panel, 1),
|
||||
AddonsAction::ScrollBar => bar_pressed = true,
|
||||
AddonsAction::DropdownToggle => {
|
||||
// The reference's dropdown button click (`ToggleDropDownMenu` +
|
||||
// `PlaySound("igMainMenuOptionCheckBoxOn")`).
|
||||
@@ -643,6 +657,41 @@ pub(super) fn drive_addons_panel(
|
||||
}
|
||||
}
|
||||
|
||||
// ── the scroll bar's drag (B274) ──────────────────────────────────────────────────────────
|
||||
// Benilla's one slider law, reached from the glue lane: the press grabs ([`slider_grab`] —
|
||||
// on the knob it keeps the grabbed point, off it the knob's centre warps under the cursor),
|
||||
// and press and every move after it share the one absolute cursor→value map
|
||||
// ([`slider_fraction`]). Absolute, so it cannot drift off the cursor over a long drag; and
|
||||
// the capture rides the held BUTTON, not the widget, so it survives the repaint that every
|
||||
// crossed row triggers and keeps tracking with the cursor off the bar entirely.
|
||||
let cursor = window
|
||||
.single()
|
||||
.ok()
|
||||
.and_then(|w| w.physical_cursor_position());
|
||||
if !mouse.pressed(MouseButton::Left) || cursor.is_none() {
|
||||
// Release, or the cursor left the window — the widget lane abandons a capture on the
|
||||
// same two conditions.
|
||||
panel.drag = None;
|
||||
}
|
||||
if let (Some(cursor), Ok((node, xf))) = (cursor, band.single()) {
|
||||
// Everything below is a fraction of the band's own height, which makes it free of the
|
||||
// window scale, the panel's centering, and the retina factor all at once.
|
||||
if let Some(local) = node.normalize_point(*xf, cursor) {
|
||||
let cursor_n = local.y + 0.5;
|
||||
let thumb_n = KNOB / BAR_H;
|
||||
if bar_pressed && panel.drag.is_none() {
|
||||
let lead_n = panel.thumb_fraction() * (1.0 - thumb_n);
|
||||
panel.drag = Some(slider_grab(cursor_n, lead_n, thumb_n));
|
||||
}
|
||||
if let Some(grab) = panel.drag {
|
||||
// `None` = a knob with nowhere to go; the bar is not spawned in that case.
|
||||
if let Some(f) = slider_fraction(cursor_n, grab, 1.0, thumb_n) {
|
||||
panel.scroll_to(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The live `checkAddonVersion` mirror (1293 §5): read per frame like the gate's own per-query
|
||||
// read (1292 §2.2), so the ForceLoad click above — or any other writer — repaints the
|
||||
// statuses this same frame with nothing rescanned. Absent VM or table = the registrar
|
||||
@@ -1177,9 +1226,10 @@ fn spawn_panel(
|
||||
))
|
||||
.with_children(|r| {
|
||||
// Title — `GlueFontNormal`, 220 wide, colour markup rendered
|
||||
// ([`title_spans`]); clipped at the authored box so a long title
|
||||
// ([`crate::glue::widgets::markup_spans`], run by every `GlueText`);
|
||||
// clipped at the authored box so a long title
|
||||
// cannot run into the status column.
|
||||
outlined_spans(
|
||||
outlined_text(
|
||||
r,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
@@ -1192,9 +1242,12 @@ fn spawn_panel(
|
||||
},
|
||||
(),
|
||||
(),
|
||||
&title_spans(addon.display_title(), colour),
|
||||
15.0, // GlueFontNormal
|
||||
false,
|
||||
GlueText {
|
||||
text: addon.display_title(),
|
||||
size: 15.0, // GlueFontNormal
|
||||
color: colour,
|
||||
wrap: false,
|
||||
},
|
||||
&font,
|
||||
s,
|
||||
);
|
||||
@@ -1278,8 +1331,8 @@ fn spawn_panel(
|
||||
}
|
||||
// The slider column: TOPLEFT at scrollframe TOPRIGHT +(6, −16).
|
||||
let bar_left = SCROLL_RIGHT + 6.0;
|
||||
let bar_top = SCROLL_TOP + 16.0;
|
||||
let bar_h = SCROLL_H - 32.0;
|
||||
let bar_top = BAR_TOP;
|
||||
let bar_h = BAR_H;
|
||||
if let Some(sc) = &art.scroll {
|
||||
for (action, up, top) in [
|
||||
(AddonsAction::ScrollUp, &sc.up_btn, bar_top - 16.0),
|
||||
@@ -1305,16 +1358,39 @@ fn spawn_panel(
|
||||
));
|
||||
});
|
||||
}
|
||||
let travel = bar_h - 16.0;
|
||||
let frac = panel.offset as f32 / panel.max_offset() as f32;
|
||||
// The band IS the slider: pressable end to end, with the knob a
|
||||
// decorative child riding at the value fraction. Splitting them into two
|
||||
// pickable surfaces would put a thumb hit-test back into the capture
|
||||
// decision, which the class does not have (`slider_grab`).
|
||||
b.spawn((
|
||||
ImageNode {
|
||||
image: sc.knob.0.clone(),
|
||||
rect: Some(tc_rect(sc.knob.1, crate::glue::art::SCROLL_BTN_TC)),
|
||||
..default()
|
||||
},
|
||||
abs(bar_left, bar_top + frac * travel, 16.0, 16.0),
|
||||
));
|
||||
AddonsAction::ScrollBar,
|
||||
ScrollBand,
|
||||
Button,
|
||||
abs(bar_left, bar_top, KNOB, bar_h),
|
||||
))
|
||||
.with_children(|band| {
|
||||
band.spawn((
|
||||
// The knob must NOT capture the press it sits under: a UI node
|
||||
// with no `FocusPolicy` defaults to `Block`, and a blocking knob
|
||||
// would swallow every press aimed at it — the one gesture this
|
||||
// whole path exists to serve — leaving only the bare track
|
||||
// draggable.
|
||||
bevy::ui::FocusPolicy::Pass,
|
||||
ImageNode {
|
||||
image: sc.knob.0.clone(),
|
||||
rect: Some(tc_rect(sc.knob.1, crate::glue::art::SCROLL_BTN_TC)),
|
||||
..default()
|
||||
},
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: px(0.0),
|
||||
top: px(panel.thumb_fraction() * (bar_h - KNOB)),
|
||||
width: px(KNOB),
|
||||
height: px(KNOB),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
});
|
||||
} else {
|
||||
for (action, caption, top) in [
|
||||
(AddonsAction::ScrollUp, "-", bar_top - 16.0),
|
||||
@@ -1787,47 +1863,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `|cAARRGGBB…|r` markup renders as colour, not as text — the MapCoords regression (its
|
||||
/// `## Title` is `|cff00ff00MapCoords 0.32`, which the screen printed literally, in full).
|
||||
/// The grammar is the byte-verified `benilla_ui::markup`; the alpha byte is discarded there,
|
||||
/// so only rgb reaches the span colour.
|
||||
#[test]
|
||||
fn colour_escapes_become_spans_not_text() {
|
||||
let base = GOLD;
|
||||
let green = Color::srgb(0.0, 1.0, 0.0);
|
||||
|
||||
assert_eq!(
|
||||
title_spans("|cff00ff00MapCoords 0.32", base),
|
||||
vec![("MapCoords 0.32".to_string(), green)],
|
||||
"the whole-title escape colours everything and prints nothing"
|
||||
);
|
||||
assert_eq!(
|
||||
title_spans("A |cff00ff00B|r C", base),
|
||||
vec![
|
||||
("A ".to_string(), base),
|
||||
("B".to_string(), green),
|
||||
(" C".to_string(), base)
|
||||
],
|
||||
"|r restores the row's base colour"
|
||||
);
|
||||
assert_eq!(
|
||||
title_spans("pipe || pipe", base),
|
||||
vec![("pipe | pipe".to_string(), base)],
|
||||
"|| draws one literal pipe"
|
||||
);
|
||||
assert_eq!(
|
||||
title_spans("|xnot an escape", base),
|
||||
vec![("|xnot an escape".to_string(), base)],
|
||||
"a | that opens nothing well-formed is an ordinary character (the grammar's \
|
||||
fall-through arm)"
|
||||
);
|
||||
assert_eq!(
|
||||
title_spans("", base),
|
||||
vec![(String::new(), base)],
|
||||
"an empty title still yields one span, so the text entity exists"
|
||||
);
|
||||
}
|
||||
|
||||
/// The All view's gate input is OUR carve (1293): "enabled = any staged character has it on".
|
||||
#[test]
|
||||
fn the_all_view_feeds_the_gate_any_character_on() {
|
||||
@@ -2011,6 +2046,121 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **B274** — the knob is draggable, and the drag is the client's own law.
|
||||
///
|
||||
/// This models the press/move arithmetic the drive system runs, in the same band-normalized
|
||||
/// units: `cursor_n` is the cursor as a fraction of the bar's height, `thumb_n` the knob's
|
||||
/// share of it. Only the cursor read and the `ComputedNode` lookup are left out — everything
|
||||
/// that decides where the list lands is here.
|
||||
///
|
||||
/// What it pins is the property the reporter is missing (a grab moves the list at all) plus
|
||||
/// the two that make it feel right: **offset-preserving** on the knob (grab its bottom edge,
|
||||
/// and the list does not jump before you have moved), and **absolute, not accumulated**, so a
|
||||
/// long drag cannot walk away from the cursor.
|
||||
#[test]
|
||||
fn the_scroll_knob_drags_the_list_under_the_cursor() {
|
||||
let thumb_n = KNOB / BAR_H;
|
||||
// 39 addons over 19 slots = 20 scroll positions.
|
||||
let mut p = panel(
|
||||
(0..MAX_ROWS * 2 + 1)
|
||||
.map(|i| addon(&format!("A{i}"), &[], 11200))
|
||||
.collect(),
|
||||
);
|
||||
let max = p.max_offset();
|
||||
assert_eq!(max, MAX_ROWS + 1);
|
||||
|
||||
// Press the knob at its BOTTOM edge, at rest (knob at the top of the track).
|
||||
let grab = slider_grab(thumb_n, 0.0, thumb_n);
|
||||
assert_eq!(grab, thumb_n, "grabbing the knob's bottom keeps that point");
|
||||
let f = slider_fraction(thumb_n, grab, 1.0, thumb_n).expect("the bar has travel");
|
||||
p.scroll_to(f);
|
||||
assert_eq!(p.offset, 0, "the press alone must not jump the list");
|
||||
|
||||
// Drag to the middle of the track; the grabbed point stays under the cursor, so the
|
||||
// knob's LEADING edge lands half a knob above it.
|
||||
let f = slider_fraction(0.5 + thumb_n * 0.5, grab, 1.0, thumb_n).unwrap();
|
||||
p.scroll_to(f);
|
||||
assert_eq!(p.offset, max / 2, "mid-track = mid-list");
|
||||
|
||||
// Past the bottom end: pinned, never past it (the clamp `SetValue` does in the client).
|
||||
let f = slider_fraction(4.0, grab, 1.0, thumb_n).unwrap();
|
||||
p.scroll_to(f);
|
||||
assert_eq!(p.offset, max);
|
||||
assert_eq!(p.visible().count(), MAX_ROWS, "the last page is still full");
|
||||
|
||||
// Absolute, not accumulated: returning the cursor to where it pressed returns the list
|
||||
// to where it started, however far it wandered in between.
|
||||
let f = slider_fraction(thumb_n, grab, 1.0, thumb_n).unwrap();
|
||||
p.scroll_to(f);
|
||||
assert_eq!(p.offset, 0);
|
||||
}
|
||||
|
||||
/// A press on the TRACK — off the knob — warps the knob's centre under the cursor and drags
|
||||
/// on from there, one gesture. Byte-verified 1.12 (wow-re `slider-mouse-law.md` §1, and the
|
||||
/// director's own requirement in 0989); the glue bar runs the same law as every in-game one.
|
||||
#[test]
|
||||
fn a_press_on_the_bare_track_warps_the_knob_under_the_cursor() {
|
||||
let thumb_n = KNOB / BAR_H;
|
||||
let mut p = panel(
|
||||
(0..MAX_ROWS * 2 + 1)
|
||||
.map(|i| addon(&format!("A{i}"), &[], 11200))
|
||||
.collect(),
|
||||
);
|
||||
let max = p.max_offset();
|
||||
|
||||
// Press well below the resting knob, three quarters down the bar.
|
||||
let grab = slider_grab(0.75, 0.0, thumb_n);
|
||||
assert_eq!(grab, thumb_n * 0.5, "off the knob = grab it by its centre");
|
||||
let f = slider_fraction(0.75, grab, 1.0, thumb_n).unwrap();
|
||||
p.scroll_to(f);
|
||||
assert_eq!(
|
||||
p.offset,
|
||||
((0.75 - thumb_n * 0.5) / (1.0 - thumb_n) * max as f32).round() as usize,
|
||||
"the press itself moves the list — the knob's centre goes to the cursor"
|
||||
);
|
||||
assert!(p.dirty, "and the move repaints");
|
||||
}
|
||||
|
||||
/// The two directions are inverses at every stop: draw the knob where the offset says
|
||||
/// ([`AddonsPanel::thumb_fraction`]), read the offset back out of that position
|
||||
/// ([`AddonsPanel::scroll_to`]), and land on the same row. A bar that fails this creeps a row
|
||||
/// per grab.
|
||||
#[test]
|
||||
fn the_knob_position_and_the_row_it_means_are_inverses() {
|
||||
let mut p = panel(
|
||||
(0..MAX_ROWS + 7)
|
||||
.map(|i| addon(&format!("A{i}"), &[], 11200))
|
||||
.collect(),
|
||||
);
|
||||
for row in 0..=p.max_offset() {
|
||||
p.offset = row;
|
||||
let drawn = p.thumb_fraction();
|
||||
p.offset = usize::MAX; // so a no-op `scroll_to` cannot pass by accident
|
||||
p.scroll_to(drawn);
|
||||
assert_eq!(p.offset, row, "knob at {drawn} must read back as row {row}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A list that fits has no bar at all, and nothing may divide by its zero travel.
|
||||
#[test]
|
||||
fn a_list_that_fits_has_no_scroll_positions() {
|
||||
let mut p = panel(
|
||||
(0..MAX_ROWS)
|
||||
.map(|i| addon(&format!("A{i}"), &[], 11200))
|
||||
.collect(),
|
||||
);
|
||||
assert_eq!(p.max_offset(), 0);
|
||||
assert_eq!(p.thumb_fraction(), 0.0);
|
||||
p.scroll_to(1.0);
|
||||
assert_eq!(p.offset, 0, "nowhere to scroll to");
|
||||
assert!(!p.dirty);
|
||||
assert_eq!(
|
||||
slider_fraction(0.5, 0.0, 1.0, 1.0),
|
||||
None,
|
||||
"a knob as long as its track reports no travel rather than dividing by zero"
|
||||
);
|
||||
}
|
||||
|
||||
/// Scrolling clamps at both ends and never moves a list that fits.
|
||||
#[test]
|
||||
fn the_offset_clamps_to_the_list() {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! component (`CreateAction`, `SelectAction`, …); each degrades to a plain-fill + text face
|
||||
//! without client art.
|
||||
|
||||
use benilla_ui::markup::{tokens, TokenKind};
|
||||
use benilla_ui::widget::EditBoxState;
|
||||
use bevy::prelude::*;
|
||||
use bevy::text::LineHeight;
|
||||
@@ -93,6 +94,10 @@ pub(crate) fn overlay() -> Node {
|
||||
|
||||
/// A glue string's look, before the outline treatment: content, authored size, color, and whether
|
||||
/// it may wrap (the ref FontStrings never break a label; only the info paragraphs wrap).
|
||||
///
|
||||
/// `text` is **markup**, not a literal: its `|cAARRGGBB…|r` escapes decode to colour and `color`
|
||||
/// is the base they override ([`markup_spans`]). Every glue string goes through that decode
|
||||
/// because every `CSimpleFontString` in 5875 does.
|
||||
pub(crate) struct GlueText<'a> {
|
||||
pub text: &'a str,
|
||||
pub size: f32,
|
||||
@@ -123,7 +128,7 @@ pub(crate) fn outlined_text<W: Bundle, T: Bundle>(
|
||||
node,
|
||||
wrapper_extra,
|
||||
text_extra,
|
||||
&[(spec.text.to_string(), spec.color)],
|
||||
&markup_spans(spec.text, spec.color, spec.wrap),
|
||||
spec.size,
|
||||
spec.wrap,
|
||||
font,
|
||||
@@ -131,13 +136,79 @@ pub(crate) fn outlined_text<W: Bundle, T: Bundle>(
|
||||
)
|
||||
}
|
||||
|
||||
/// [`outlined_text`] over **coloured spans** — WoW's `|cAARRGGBB…|r` inline markup rendered the
|
||||
/// way the reference FontString renders it: one string, colour switching mid-run (the escape
|
||||
/// grammar is `benilla_ui::markup`, byte-verified). The real text is a span tree (Bevy's
|
||||
/// `Text` root + `TextSpan` children); the eight outline copies carry the flattened plain string,
|
||||
/// which is identical geometry because the copies only exist to be a black ring.
|
||||
/// Split a glue string into its coloured spans — WoW's `|cAARRGGBB…|r` inline markup, decoded by
|
||||
/// the byte-verified grammar ([`benilla_ui::markup`], RF-0087) instead of drawn literally.
|
||||
///
|
||||
/// **This lives in the primitive on purpose.** It used to be one call site's private helper (the
|
||||
/// AddOns row title), which is exactly how the same `|cff0055FF…|r` came out coloured in a list
|
||||
/// row and literal in the tooltip an inch to its left (B273): a FontString property implemented
|
||||
/// per-caller is a property no caller reliably has. In the real client the decode is
|
||||
/// `CSimpleFontString`'s own and takes no opt-in — `0x5c2810` parses `|c`/`|r`/`|H`/`|h`/`||`
|
||||
/// unconditionally for every one of them (the flags word that could disable them has no writer
|
||||
/// anywhere in the image). So every `GlueText` decodes, and there is no way to spawn one that
|
||||
/// doesn't.
|
||||
///
|
||||
/// `base` is the string's own colour; an escape overrides it until `|r`, and the escape's alpha
|
||||
/// byte is discarded exactly as the client discards it (`0x5c2ab2`). Link escapes hide their
|
||||
/// payload and keep their visible text — the glue has no clickable links, so a `|H…|h` reads as
|
||||
/// its text. `||` draws one `|`.
|
||||
///
|
||||
/// A line break (`\n`, `|n`) follows the string's own `wrap`: a real break where the ref lets the
|
||||
/// string wrap (a tooltip paragraph), a space where it authored one line (a row title, a button
|
||||
/// caption) — the flag is our stand-in for the multi-line bit the client tests, and collapsing
|
||||
/// beats a label growing a second row inside a 20-unit slot.
|
||||
///
|
||||
/// Never empty: an empty string yields one empty span, so [`outlined_spans`] always has a root.
|
||||
pub(crate) fn markup_spans(text: &str, base: Color, wrap: bool) -> Vec<(String, Color)> {
|
||||
let mut spans: Vec<(String, Color)> = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut colour = base;
|
||||
for (_, tok) in tokens(text) {
|
||||
let switch = match tok.kind {
|
||||
TokenKind::Color(rgba) => {
|
||||
let [r, g, b, _] = rgba.to_f32_at(1.0);
|
||||
Some(Color::srgb(r, g, b))
|
||||
}
|
||||
TokenKind::ColorReset => Some(base),
|
||||
TokenKind::EscapedPipe => {
|
||||
cur.push('|');
|
||||
None
|
||||
}
|
||||
TokenKind::LineBreak => {
|
||||
cur.push(if wrap { '\n' } else { ' ' });
|
||||
None
|
||||
}
|
||||
TokenKind::LinkOpen { .. } | TokenKind::LinkClose => None,
|
||||
TokenKind::Char(c) => {
|
||||
cur.push(c);
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(next) = switch {
|
||||
if !cur.is_empty() {
|
||||
spans.push((std::mem::take(&mut cur), colour));
|
||||
}
|
||||
colour = next;
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
spans.push((cur, colour));
|
||||
}
|
||||
if spans.is_empty() {
|
||||
spans.push((String::new(), base));
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
/// [`outlined_text`]'s span-tree body — the decoded spans of [`markup_spans`] drawn as one string
|
||||
/// with the colour switching mid-run. The real text is Bevy's `Text` root + `TextSpan` children;
|
||||
/// the eight outline copies carry the flattened plain string, which is identical geometry because
|
||||
/// the copies only exist to be a black ring.
|
||||
///
|
||||
/// **Private**: [`outlined_text`] is the one door in, so no caller can hand-build spans and skip
|
||||
/// the markup decode (B273's shape — see [`markup_spans`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn outlined_spans<W: Bundle, T: Bundle>(
|
||||
fn outlined_spans<W: Bundle, T: Bundle>(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
node: Node,
|
||||
wrapper_extra: W,
|
||||
@@ -727,3 +798,90 @@ pub(crate) fn glue_button<A: Component>(
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `|cAARRGGBB…|r` markup renders as colour, not as text — for **every** glue string, which
|
||||
/// is the whole point of the decode living in the primitive.
|
||||
///
|
||||
/// Two regressions are pinned here. The MapCoords one (a `## Title` of
|
||||
/// `|cff00ff00MapCoords 0.32`, printed literally in the AddOns list), and B273: the same
|
||||
/// screen's tooltip printed `|cff0055FFDeadly Boss Mod API|r` raw while the row an inch to
|
||||
/// its left drew it blue, because only the row had opted into a decode. Nothing opts in now
|
||||
/// — [`outlined_text`] is the one door and it always decodes — so the case worth testing is
|
||||
/// the function, once, for both.
|
||||
///
|
||||
/// The grammar is the byte-verified [`benilla_ui::markup`]; the escape's alpha byte is
|
||||
/// discarded there, so only rgb reaches the span colour.
|
||||
#[test]
|
||||
fn colour_escapes_become_spans_not_text() {
|
||||
let base = GOLD;
|
||||
let green = Color::srgb(0.0, 1.0, 0.0);
|
||||
let blue = Color::srgb(0.0, 0x55 as f32 / 255.0, 1.0);
|
||||
|
||||
assert_eq!(
|
||||
markup_spans("|cff00ff00MapCoords 0.32", base, false),
|
||||
vec![("MapCoords 0.32".to_string(), green)],
|
||||
"the whole-title escape colours everything and prints nothing"
|
||||
);
|
||||
assert_eq!(
|
||||
markup_spans("|cff0055FFDeadly Boss Mod API|r", base, true),
|
||||
vec![("Deadly Boss Mod API".to_string(), blue)],
|
||||
"B273: the tooltip's title wraps, and wrapping does not exempt it from the decode"
|
||||
);
|
||||
assert_eq!(
|
||||
markup_spans("A |cff00ff00B|r C", base, false),
|
||||
vec![
|
||||
("A ".to_string(), base),
|
||||
("B".to_string(), green),
|
||||
(" C".to_string(), base)
|
||||
],
|
||||
"|r restores the string's base colour"
|
||||
);
|
||||
assert_eq!(
|
||||
markup_spans("pipe || pipe", base, false),
|
||||
vec![("pipe | pipe".to_string(), base)],
|
||||
"|| draws one literal pipe"
|
||||
);
|
||||
assert_eq!(
|
||||
markup_spans("|xnot an escape", base, false),
|
||||
vec![("|xnot an escape".to_string(), base)],
|
||||
"a | that opens nothing well-formed is an ordinary character (the grammar's \
|
||||
fall-through arm)"
|
||||
);
|
||||
assert_eq!(
|
||||
markup_spans("", base, false),
|
||||
vec![(String::new(), base)],
|
||||
"an empty string still yields one span, so the text entity exists"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `|H…|h` link keeps its visible text and drops its payload — the glue has no clickable
|
||||
/// links, so the alternative is an addon note printing `|Hitem:1234|h`.
|
||||
#[test]
|
||||
fn a_link_escape_keeps_its_text_and_hides_its_payload() {
|
||||
assert_eq!(
|
||||
markup_spans("see |Hitem:1234:0:0:0|h[Thunderfury]|h now", GOLD, true),
|
||||
vec![("see [Thunderfury] now".to_string(), GOLD)],
|
||||
);
|
||||
}
|
||||
|
||||
/// A line break follows the string's own `wrap`: a real break where the ref lets the string
|
||||
/// wrap, a space where it authored one line. A 20-unit row title growing a second row is the
|
||||
/// failure this avoids; a tooltip paragraph losing its author's break is the other.
|
||||
#[test]
|
||||
fn a_line_break_follows_the_strings_own_wrap_flag() {
|
||||
assert_eq!(
|
||||
markup_spans("one|ntwo", GOLD, true),
|
||||
vec![("one\ntwo".to_string(), GOLD)],
|
||||
"a wrapping string breaks where the author asked"
|
||||
);
|
||||
assert_eq!(
|
||||
markup_spans("one|ntwo", GOLD, false),
|
||||
vec![("one two".to_string(), GOLD)],
|
||||
"a one-line label collapses the break to a space"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,18 @@
|
||||
// The fold runs on the GAMMA byte (after `linear_to_srgb`) because the reference's UI is 8-bit
|
||||
// arithmetic end to end; greying a linearized texel would land a different byte than the client's.
|
||||
@group(2) @binding(7) var<uniform> desaturate: u32;
|
||||
// The sampled texture is already PREMULTIPLIED — a portrait/paper-doll/dressing-room booth bake,
|
||||
// and nothing else (see `UiQuad::premultiplied`).
|
||||
//
|
||||
// Every other texture this pass samples is authored STRAIGHT (a BLP's rgb means nothing where its
|
||||
// alpha is 0), so the premultiply below has to happen here. A booth render target is the opposite
|
||||
// by construction: its opaque geometry wrote `a = 1`, its alpha batches blended over that, and its
|
||||
// ADDITIVE particles added light while contributing NO coverage (`wow_effect.wgsl` returns
|
||||
// `(rgb·a, 0)`). Colour is emitted light, alpha is coverage — premultiplied. Weighting it by its own
|
||||
// alpha again multiplies exactly the emitted light that sits over EMPTY pane space by zero, which is
|
||||
// how a transparent-clear pane lost the R14 pauldrons' fire entirely and kept a weapon glow only
|
||||
// where it overlapped the model's own opaque pixels.
|
||||
@group(2) @binding(8) var<uniform> premultiplied: u32;
|
||||
|
||||
// ITU-R BT.601 luma — the `PARAM c[0]` of that shader, read as raw f32 words: `0x3E991687`,
|
||||
// `0x3F1645A2`, `0x3DE978D5`. Not `(0.3, 0.3, 0.3)`, not `(0.3, 0.59, 0.11)` (this file's own first
|
||||
@@ -101,21 +113,29 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
if desaturate != 0u {
|
||||
rgb = vec3<f32>(dot(texel, LUMA));
|
||||
}
|
||||
var a = t.a * c.a;
|
||||
// `k` is the COVERAGE the UI itself imposes — the vertex colour's alpha (`SetAlpha`, the frame's
|
||||
// inherited alpha) and the two masks. It is kept apart from the texel's OWN alpha `t.a` because
|
||||
// the two premultiply differently: `k` scales a premultiplied source's colour and alpha alike,
|
||||
// while `t.a` must weight the colour only when the source is straight.
|
||||
var k = c.a;
|
||||
if circular != 0u {
|
||||
// Soft ~2%-of-width edge: reads as the ref's stencil at portrait size, no jaggies.
|
||||
a *= 1.0 - smoothstep(0.48, 0.5, distance(in.uv, vec2<f32>(0.5)));
|
||||
k *= 1.0 - smoothstep(0.48, 0.5, distance(in.uv, vec2<f32>(0.5)));
|
||||
}
|
||||
if mask_rect.z > mask_rect.x {
|
||||
let muv = (in.position.xy - mask_rect.xy) / (mask_rect.zw - mask_rect.xy);
|
||||
let inside = f32(all(muv >= vec2<f32>(0.0)) && all(muv <= vec2<f32>(1.0)));
|
||||
let m = textureSampleLevel(mask_texture, mask_sampler, clamp(muv, vec2<f32>(0.0), vec2<f32>(1.0)), 0.0).a;
|
||||
a *= m * inside;
|
||||
k *= m * inside;
|
||||
}
|
||||
let a = t.a * k;
|
||||
// Premultiply in GAMMA (decision 0160's lesson, here for the UI): the hardware `SrcAlpha` factor
|
||||
// would weight a linearized colour and inflate every soft edge and every dim additive skirt.
|
||||
// An already-premultiplied source (a booth bake) takes `k` alone — folding in `t.a` a second
|
||||
// time is the double multiply that erased the panes' effects.
|
||||
let weight = select(a, k, premultiplied != 0u);
|
||||
if additive != 0u {
|
||||
return vec4<f32>(rgb * a, 0.0);
|
||||
return vec4<f32>(rgb * weight, 0.0);
|
||||
}
|
||||
return vec4<f32>(rgb * a, a);
|
||||
return vec4<f32>(rgb * weight, a);
|
||||
}
|
||||
|
||||
@@ -162,6 +162,27 @@ pub(crate) struct UiQuad {
|
||||
/// own `SetItemButtonDesaturated(button, 1, 0.65, 0.65, 0.65)` lands as greyscale *and* dim.
|
||||
/// Splits the run like `additive`/`circular`.
|
||||
pub desaturated: bool,
|
||||
/// The sampled texture already carries **premultiplied** colour, so the shader must not weight
|
||||
/// it by its own alpha a second time — the booth render targets ([`crate::portrait`]), and
|
||||
/// nothing else.
|
||||
///
|
||||
/// A booth that clears **transparent** (the `<PlayerModel>` body panes and the dressing room,
|
||||
/// which composite over the page's own art — decision 1083) builds its target the way any
|
||||
/// render-to-texture does: opaque geometry writes `a = 1`, alpha batches blend over it, and an
|
||||
/// **additive particle adds light while contributing no coverage** (`wow_effect.wgsl`'s
|
||||
/// `(rgb·a, 0)` under a premultiplied state). That buffer is premultiplied by construction —
|
||||
/// colour is emitted light, alpha is coverage. Sampling it as *straight* alpha and applying the
|
||||
/// usual `rgb·a` multiplies every effect hanging in EMPTY pane space by zero: the R14
|
||||
/// pauldrons' fire vanished outright, and a weapon glow survived only where it happened to
|
||||
/// overlap the model's own opaque pixels — chopped to a hard edge at the silhouette (the
|
||||
/// director's paper-doll/dressing-room report). The opaque-cleared round portraits hid it,
|
||||
/// because `a = 1` everywhere makes the extra multiply a no-op — exactly the way opaque panels
|
||||
/// hid the identical defect one level up, in this camera's own output blend (see
|
||||
/// [`spawn_player_ui_camera`]'s note on `rgb·a²`).
|
||||
///
|
||||
/// Splits the run like `additive`/`circular`; a booth target is its own texture anyway, so no
|
||||
/// batching is lost.
|
||||
pub premultiplied: bool,
|
||||
/// CPU-clip stand-in for a real scissor rect (see the module doc). `None` = unclipped.
|
||||
pub clip: Option<Rect>,
|
||||
/// Rotate the quad's corners by this many radians **clockwise on screen** about the rect's
|
||||
@@ -207,6 +228,7 @@ impl Default for UiQuad {
|
||||
additive: false,
|
||||
circular: false,
|
||||
desaturated: false,
|
||||
premultiplied: false,
|
||||
clip: None,
|
||||
rotation: 0.0,
|
||||
mask: None,
|
||||
@@ -287,6 +309,9 @@ pub(crate) struct UiQuadMaterial {
|
||||
/// Fold the texel to luminance — see [`UiQuad::desaturated`].
|
||||
#[uniform(7)]
|
||||
desaturate: u32,
|
||||
/// The texel is already premultiplied (a booth bake) — see [`UiQuad::premultiplied`].
|
||||
#[uniform(8)]
|
||||
premultiplied: u32,
|
||||
/// The screen-anchored mask span in **physical framebuffer px** (`min.xy, max.xy` — the
|
||||
/// fragment shader compares `@builtin(position)`, which is physical): [`UiQuadMask::rect`]
|
||||
/// scaled by the window's scale factor at mesh build. `z <= x` (degenerate) disables masking.
|
||||
@@ -534,6 +559,7 @@ struct Run {
|
||||
additive: bool,
|
||||
circular: bool,
|
||||
desaturated: bool,
|
||||
premultiplied: bool,
|
||||
mask: Option<UiQuadMask>,
|
||||
positions: Vec<[f32; 3]>,
|
||||
uvs: Vec<[f32; 2]>,
|
||||
@@ -547,6 +573,7 @@ impl Run {
|
||||
additive: bool,
|
||||
circular: bool,
|
||||
desaturated: bool,
|
||||
premultiplied: bool,
|
||||
mask: Option<UiQuadMask>,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -554,6 +581,7 @@ impl Run {
|
||||
additive,
|
||||
circular,
|
||||
desaturated,
|
||||
premultiplied,
|
||||
mask,
|
||||
positions: Vec::new(),
|
||||
uvs: Vec::new(),
|
||||
@@ -638,6 +666,7 @@ type MatKey = (
|
||||
bool,
|
||||
bool,
|
||||
bool,
|
||||
bool,
|
||||
Option<AssetId<Image>>,
|
||||
[u32; 4],
|
||||
);
|
||||
@@ -770,6 +799,7 @@ fn rebuild_ui_mesh(
|
||||
&& r.additive == q.additive
|
||||
&& r.circular == q.circular
|
||||
&& r.desaturated == q.desaturated
|
||||
&& r.premultiplied == q.premultiplied
|
||||
&& r.mask == q.mask
|
||||
});
|
||||
if !same_run {
|
||||
@@ -778,6 +808,7 @@ fn rebuild_ui_mesh(
|
||||
q.additive,
|
||||
q.circular,
|
||||
q.desaturated,
|
||||
q.premultiplied,
|
||||
q.mask.clone(),
|
||||
));
|
||||
}
|
||||
@@ -865,6 +896,7 @@ fn rebuild_ui_mesh(
|
||||
run.additive,
|
||||
run.circular,
|
||||
run.desaturated,
|
||||
run.premultiplied,
|
||||
mask.as_ref().map(bevy::asset::Handle::id),
|
||||
mask_rect.to_array().map(f32::to_bits),
|
||||
);
|
||||
@@ -877,6 +909,7 @@ fn rebuild_ui_mesh(
|
||||
texture: Some(run.texture),
|
||||
circular: u32::from(run.circular),
|
||||
desaturate: u32::from(run.desaturated),
|
||||
premultiplied: u32::from(run.premultiplied),
|
||||
mask_rect,
|
||||
mask,
|
||||
})
|
||||
|
||||
@@ -555,12 +555,19 @@ pub(super) fn drive_script(
|
||||
.0
|
||||
.insert(token.clone(), rect.width() / rect.height());
|
||||
}
|
||||
let handle = match booths.images.0.get(token) {
|
||||
Some(PortraitSource::Live(h)) => Some(h.clone()),
|
||||
Some(PortraitSource::File(p)) => assets
|
||||
.as_mut()
|
||||
.and_then(|a| a.sprite_texture(p, &mut images)),
|
||||
None => None,
|
||||
// The bake is a render target and carries PREMULTIPLIED colour; the 2D stand-in
|
||||
// is an ordinary straight-alpha BLP. The quad pass has to be told which, or it
|
||||
// premultiplies the bake a second time and erases every effect the pane draws
|
||||
// over empty space (see [`crate::ui_pass::UiQuad::premultiplied`]).
|
||||
let (handle, premultiplied) = match booths.images.0.get(token) {
|
||||
Some(PortraitSource::Live(h)) => (Some(h.clone()), true),
|
||||
Some(PortraitSource::File(p)) => (
|
||||
assets
|
||||
.as_mut()
|
||||
.and_then(|a| a.sprite_texture(p, &mut images)),
|
||||
false,
|
||||
),
|
||||
None => (None, false),
|
||||
};
|
||||
let Some(handle) = handle else {
|
||||
continue;
|
||||
@@ -587,6 +594,7 @@ pub(super) fn drive_script(
|
||||
// rounds the square stand-in art the same way); `BenillaSetBoothTexture`
|
||||
// (the paper-doll model pane, decision 0208 §5) samples the bake square.
|
||||
circular,
|
||||
premultiplied,
|
||||
clip,
|
||||
..default()
|
||||
});
|
||||
@@ -1149,4 +1157,47 @@ mod extract_gate_tests {
|
||||
"a moved frame must re-extract"
|
||||
);
|
||||
}
|
||||
|
||||
/// **A booth bake reaches the quad pass flagged PREMULTIPLIED, an ordinary region does not**
|
||||
/// (decision 1347). This is the wiring half of the paper-doll/dressing-room fix, and it is the
|
||||
/// half that can silently rot: the shader's `select(a, k, premultiplied)` is only correct if
|
||||
/// exactly the render-target quads carry the flag. Drop it and every additive effect the pane
|
||||
/// draws over EMPTY space is multiplied by its own zero alpha again — the R14 pauldrons' fire
|
||||
/// gone, a weapon glow chopped at the model's silhouette (Goudy, `#bugs` 2026-07-27).
|
||||
#[test]
|
||||
fn a_booth_bake_quad_is_flagged_premultiplied_and_a_plain_one_is_not() {
|
||||
let mut app = app_with_marker();
|
||||
// The paper doll's own binding — the square booth pane (`BenillaSetBoothTexture`).
|
||||
app.world_mut()
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.run("BenillaSetBoothTexture(marker, 'paperdoll')")
|
||||
.unwrap();
|
||||
// The booth publishes a live bake for that slot; without an entry the region draws nothing.
|
||||
let bake = app
|
||||
.world_mut()
|
||||
.resource_mut::<Assets<Image>>()
|
||||
.add(Image::default());
|
||||
app.world_mut().resource_mut::<PortraitImages>().0.insert(
|
||||
"paperdoll".to_string(),
|
||||
crate::portrait::PortraitSource::Live(bake.clone()),
|
||||
);
|
||||
app.update();
|
||||
|
||||
let quads = &app.world().resource::<UiQuads>().quads;
|
||||
let pane = quads
|
||||
.iter()
|
||||
.find(|q| q.texture.as_ref() == Some(&bake))
|
||||
.expect("the booth pane's quad reached the pass");
|
||||
assert!(
|
||||
pane.premultiplied,
|
||||
"a render-target bake carries premultiplied colour — the pass must not re-weight it"
|
||||
);
|
||||
assert!(
|
||||
quads
|
||||
.iter()
|
||||
.filter(|q| q.texture.as_ref() != Some(&bake))
|
||||
.all(|q| !q.premultiplied),
|
||||
"every straight-alpha region stays unflagged — the flag is the booth's alone"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ pub(super) fn emit(
|
||||
additive: false,
|
||||
circular: false,
|
||||
desaturated: false,
|
||||
premultiplied: false,
|
||||
clip: text_clip,
|
||||
rotation: 0.0,
|
||||
mask: None,
|
||||
@@ -338,6 +339,7 @@ pub(super) fn emit(
|
||||
additive: false,
|
||||
circular: false,
|
||||
desaturated: false,
|
||||
premultiplied: false,
|
||||
clip: text_clip,
|
||||
rotation: 0.0,
|
||||
mask: None,
|
||||
|
||||
@@ -133,6 +133,30 @@ pub(crate) fn arm_entry_ui_load(mut commands: Commands) {
|
||||
commands.insert_resource(PendingEntryUiLoad::default());
|
||||
}
|
||||
|
||||
/// **Is the in-game UI still owed for this world entry?** True from `OnEnter(InWorld)` until
|
||||
/// [`run_pending_entry_load`] has built the frame tree — the window in which the VM is still the
|
||||
/// *boot* VM: strings, emote tokens and fonts, and not one frame.
|
||||
///
|
||||
/// The run condition on [`crate::ui_unit::UnitFeed`], and the reason it exists (1348): the feeds
|
||||
/// fire the login **one-shots** — `PLAYER_ENTERING_WORLD`, the first `PLAYER_XP_UPDATE`, the first
|
||||
/// `UPDATE_EXHAUSTION` — and every one of them is latched by a [`super::VmMemo`] keyed on the VM's
|
||||
/// *session*, which the entry load does not change (it loads files ONTO this VM). So an event
|
||||
/// fired in this window is delivered to nobody and then never fires again for the whole session:
|
||||
/// the frames built moments later do their first paint with no first paint. That is not a
|
||||
/// hypothetical ordering — it is a RACE against the wire, which is why it took some logins and not
|
||||
/// others, and why the symptom moved between characters. The self descriptor arriving inside the
|
||||
/// [`ENTRY_LOAD_COVER_FRAMES`] deferral window is all it takes.
|
||||
///
|
||||
/// The reference has no such window: `UI_Init 0x48fbf0` loads all of FrameXML and *then* fires the
|
||||
/// world-enter cascade (`PLAYER_LOGIN` at `0x49094b`, `PLAYER_ENTERING_WORLD` at `0x490965`) from
|
||||
/// inside itself, so a UI-less client never sees a unit event at all. Gating the feed here is that
|
||||
/// same ordering, expressed against our deferred load: nothing is pushed and nothing is fired until
|
||||
/// there is a UI to receive it, and the very next frame's feed — running against a fresh, unlatched
|
||||
/// world — delivers the full set in order.
|
||||
pub(crate) fn ingame_ui_pending(pending: Option<Res<PendingEntryUiLoad>>) -> bool {
|
||||
pending.is_some()
|
||||
}
|
||||
|
||||
/// `PreUpdate` (chained after [`run_pending_reload`] — same exclusive slot, and a reload must
|
||||
/// not interleave an armed entry load): run the armed entry load once the cover has presented.
|
||||
///
|
||||
|
||||
@@ -364,8 +364,8 @@ fn arbitrate_pointer_over_ui(
|
||||
/// lives there; this module keeps the per-frame bridge. See its header.
|
||||
mod lifecycle;
|
||||
pub(crate) use lifecycle::{
|
||||
end_ui_session, run_pending_reload, setup_script, AddOnIdentity, PendingEntryUiLoad,
|
||||
ReloadUiPending,
|
||||
end_ui_session, ingame_ui_pending, run_pending_reload, setup_script, AddOnIdentity,
|
||||
PendingEntryUiLoad, ReloadUiPending,
|
||||
};
|
||||
// Consumed only from other modules' test code (the emote-table checks, the harness's UI-init
|
||||
// tail, the quit-once pin) — a plain re-export would warn unused in a non-test build.
|
||||
|
||||
@@ -675,3 +675,100 @@ fn leaving_inside_the_deferral_window_drops_the_load_and_writes_nothing() {
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
// ───────────── The login one-shots wait for the in-game UI (decision 1348) ─────────────
|
||||
|
||||
/// **The director's white XP bar, from the side that causes it.**
|
||||
///
|
||||
/// The world-entry UI load is deferred a few covered frames (0962/1051), and the unit feed is not:
|
||||
/// it fires `PLAYER_ENTERING_WORLD`, the first `PLAYER_XP_UPDATE` and the first `UPDATE_EXHAUSTION`
|
||||
/// the moment our own descriptor lands. When that lands *inside* the deferral window the events go
|
||||
/// to a VM with no frames, and because every one of them is latched by a [`super::VmMemo`] keyed on
|
||||
/// the VM's session — which the entry load does not change — they never fire again. The frames
|
||||
/// built moments later do their first paint with no first paint, which is why
|
||||
/// `ExhaustionTick_Update` had never run and `ExhaustionLevelFillBar` was still wearing its
|
||||
/// authored opaque white across the whole XP strip, with the tick parked at the strip's centre.
|
||||
///
|
||||
/// It is a RACE against the wire, so it took some logins and not others.
|
||||
///
|
||||
/// The probe is a plain frame in the boot VM registering the event the way FrameXML does — if the
|
||||
/// feed runs at all in the window, it sees it.
|
||||
#[test]
|
||||
fn the_login_one_shots_wait_for_the_in_game_ui() {
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (tmp, _c, _h) = hermetic_probe("oneshot");
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(crate::ui_unit::UiUnitPlugin);
|
||||
app.init_resource::<super::AddOnIdentity>();
|
||||
app.init_resource::<crate::minimap::MinimapZoom>();
|
||||
app.init_resource::<super::ReloadUiPending>();
|
||||
app.init_resource::<crate::target::Selection>();
|
||||
app.init_resource::<crate::names::NameCache>();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
app.insert_resource(crate::net::NetCommands(tx));
|
||||
app.init_resource::<crate::net::Reputations>();
|
||||
app.init_resource::<crate::ui_party::GroupState>();
|
||||
app.init_resource::<crate::ui_chat::ChatLog>();
|
||||
app.init_resource::<crate::ui_guild::GuildState>();
|
||||
app.add_message::<crate::creature_anim::SwingImpact>();
|
||||
super::setup_script(app.world_mut());
|
||||
|
||||
// The probe frame — FrameXML's own shape, in the VM that exists before the entry load.
|
||||
app.world()
|
||||
.non_send_resource::<benilla_ui::script::UiScript>()
|
||||
.run(
|
||||
"EnteringWorldSeen = 0 \
|
||||
local f = CreateFrame(\"Frame\") \
|
||||
f:RegisterEvent(\"PLAYER_ENTERING_WORLD\") \
|
||||
f:SetScript(\"OnEvent\", function() \
|
||||
EnteringWorldSeen = EnteringWorldSeen + 1 end)",
|
||||
)
|
||||
.expect("probe frame");
|
||||
|
||||
// Our own descriptor has landed — the condition the feed fires the one-shots on.
|
||||
app.world_mut().spawn((
|
||||
crate::net::SelfPlayer,
|
||||
crate::net::Guid(1),
|
||||
crate::net::ObjectStore(
|
||||
benilla_protocol::messages::ObjectFields::from_pairs(&[])
|
||||
.into_created(benilla_protocol::messages::ObjectType::Player),
|
||||
),
|
||||
));
|
||||
|
||||
let seen = |app: &App| -> i64 {
|
||||
app.world()
|
||||
.non_send_resource::<benilla_ui::script::UiScript>()
|
||||
.eval::<i64>("return EnteringWorldSeen")
|
||||
.expect("probe global")
|
||||
};
|
||||
|
||||
// …but the in-game UI is still owed. Three frames inside the deferral window.
|
||||
app.insert_resource(super::PendingEntryUiLoad::default());
|
||||
for frame in 1..=3 {
|
||||
app.update();
|
||||
assert_eq!(
|
||||
seen(&app),
|
||||
0,
|
||||
"frame {frame}: the feed must not spend PLAYER_ENTERING_WORLD on a UI-less VM"
|
||||
);
|
||||
}
|
||||
|
||||
// The entry load has run (its own tests cover the timing) — the latch is gone, and the very
|
||||
// next feed delivers the full set to the frames that now exist.
|
||||
app.world_mut()
|
||||
.remove_resource::<super::PendingEntryUiLoad>();
|
||||
app.update();
|
||||
assert_eq!(
|
||||
seen(&app),
|
||||
1,
|
||||
"with the UI up the one-shot fires — once, on the first feed after the load"
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(seen(&app), 1, "and exactly once per world entry");
|
||||
|
||||
drop(app);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
@@ -148,7 +148,15 @@ impl Plugin for UiUnitPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.configure_sets(
|
||||
Update,
|
||||
UnitFeed.after(benilla_world::schedule::WorldStage::Net), // the set's own doc: the why
|
||||
UnitFeed
|
||||
.after(benilla_world::schedule::WorldStage::Net) // the set's own doc: the why
|
||||
// …and never before the in-game UI exists (1348). The whole SET, not just
|
||||
// `feed_units`: every feed in it either fires a login one-shot or latches a
|
||||
// per-VM memo, and both are lost forever against the boot VM. The window and
|
||||
// the reference's own ordering: `ui_script::ingame_ui_pending`.
|
||||
.run_if(bevy::ecs::schedule::common_conditions::not(
|
||||
crate::ui_script::ingame_ui_pending,
|
||||
)),
|
||||
)
|
||||
.init_resource::<UnitFeedState>()
|
||||
.add_message::<UnitCombatFeedback>()
|
||||
|
||||
@@ -378,7 +378,19 @@ impl ObjectFields {
|
||||
/// (id, name, multiplier) triple: `1` = rested, `2` = normal (vmangos `REST_STATE_*`,
|
||||
/// `Player.h:673`). The server writes it with hysteresis off the rest pool — rested when the
|
||||
/// pool exceeds 10, normal when it falls to ≤1 (`SetRestBonus`) — so the byte, not the pool,
|
||||
/// is the state authority. `None` when never sent; a fresh descriptor reads absent as normal.
|
||||
/// is the state authority.
|
||||
///
|
||||
/// **Absent reads `0`, not "normal"** — the struct's own absent-field law, and it is the
|
||||
/// truth: vmangos always writes this word (`Player::Create` and `LoadFromDB` seed byte 3 with
|
||||
/// `REST_STATE_NORMAL` and byte 1 with `0xEE`, so the field is never zero and never masked out
|
||||
/// of a create block), so on a created store this IS the wire's byte. `0` is what the client's
|
||||
/// own zero-initialized descriptor holds *before* the create lands, and it is a state
|
||||
/// `Exhaustion.dbc` cannot map — `GetRestState()` answers the binary's `(nil, nil, nil)` fail
|
||||
/// path for it, which the reference's own unguarded `exhaustionStateID >= 3` cannot survive.
|
||||
/// The reference never meets that because FrameXML is up and the descriptor has landed before
|
||||
/// any handler runs; we hold the same guarantee at the feed's own gate
|
||||
/// (`ui_script::ingame_ui_pending`, 1348). Do not fabricate a `2` here — that hides an
|
||||
/// ordering bug behind a value.
|
||||
pub fn player_rest_state(&self) -> Option<u8> {
|
||||
self.player_bytes_2().map(|b| (b >> 24) as u8)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ 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};
|
||||
use crate::widget::{
|
||||
slider_fraction, slider_grab, 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";
|
||||
@@ -303,25 +305,26 @@ pub(super) fn thumb_rect(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// The in-flight thumb drag (decision 0250 §5): the slider being dragged + the grab offset
|
||||
/// [`slider_grab`] returned, 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`).
|
||||
/// The grab, in **distance from the track's leading edge** — the axis-neutral frame
|
||||
/// [`slider_grab`]/[`slider_fraction`] are stated in, so this arena's y-up rects and the glue
|
||||
/// screens' y-down nodes run the identical arithmetic.
|
||||
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`.
|
||||
/// Slider with a resolved rect, begin a drag capture (records [`Model::slider_drag`]). Where the
|
||||
/// press grabs is [`slider_grab`]'s to say — one law, shared with the glue lane's scrollbars, so
|
||||
/// an in-game bar and a character-screen bar cannot drift apart on feel.
|
||||
///
|
||||
/// 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>,
|
||||
@@ -342,27 +345,17 @@ pub(super) fn begin_drag(
|
||||
.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
|
||||
}
|
||||
// Everything below is stated as distance from the TRACK'S LEADING EDGE — the end the thumb
|
||||
// sits at when the value is `min`. Vertical tracks run downward from `r.top` in this y-up
|
||||
// arena, so that distance is `top − y`; horizontal ones run rightward from `r.left`.
|
||||
let (cursor, thumb_lead, thumb_extent) = if vertical {
|
||||
(r.top - y, r.top - trect.top, trect.top - trect.bottom)
|
||||
} 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
|
||||
}
|
||||
(x - r.left, trect.left - r.left, trect.right - trect.left)
|
||||
};
|
||||
model.slider_drag = Some(SliderDrag {
|
||||
slider: h,
|
||||
grab_offset,
|
||||
grab_offset: slider_grab(cursor, thumb_lead, thumb_extent),
|
||||
});
|
||||
if on_thumb {
|
||||
return None; // no value change from the grab itself
|
||||
@@ -371,9 +364,10 @@ pub(super) fn begin_drag(
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// position via [`slider_fraction`] (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,
|
||||
@@ -388,19 +382,14 @@ pub(super) fn drag_move(model: &mut Model, x: f32, y: f32) -> Option<(u32, f32)>
|
||||
.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)
|
||||
// The same leading-edge frame [`begin_drag`] stored the grab in.
|
||||
let (cursor, track_extent, thumb_extent) = if vertical {
|
||||
(r.top - y, r.height(), th)
|
||||
} else {
|
||||
let travel = r.width() - tw;
|
||||
if travel <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
(((x - grab_offset) - r.left) / travel).clamp(0.0, 1.0)
|
||||
(x - r.left, r.width(), tw)
|
||||
};
|
||||
// `None` = nothing to scroll (a thumb as long as its track).
|
||||
let fraction = slider_fraction(cursor, grab_offset, track_extent, thumb_extent)?;
|
||||
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),
|
||||
|
||||
@@ -602,6 +602,64 @@ impl Default for SliderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The slider drag law — the one owner, for every lane.**
|
||||
///
|
||||
/// Two pure functions, stated on a single axis in *distance from the track's leading edge* (the
|
||||
/// end the thumb sits at when the value is `min`). That framing is what makes them lane-neutral:
|
||||
/// the Lua widget arena measures y **up** and the Bevy-UI glue screens measure y **down**, and
|
||||
/// both reduce to "how far along the track is this", so neither has to restate the arithmetic.
|
||||
///
|
||||
/// Restating it is exactly what went wrong. The AddOns glue scrollbar shipped with a *decorative*
|
||||
/// knob and no drag at all (1297 named the gap; B273's reporter hit it), the char-create glue
|
||||
/// scrollbar grew its own accumulated-delta drag that drifts off the cursor, and the engine slider
|
||||
/// held the real formula — three surfaces, one widget, no shared line of code. These two functions
|
||||
/// are that shared line.
|
||||
///
|
||||
/// **The law is benilla's, and it diverges from 1.12 deliberately in one place.** wow-re's
|
||||
/// `system/ui/scratch/slider-mouse-law.md` (a §5 1v1, VERIFIED off the bytes of `0x789ba0` /
|
||||
/// `0x789ca0`) settled `CSimpleSlider`: there is **no thumb hit-test in the class at all** — every
|
||||
/// press, track or thumb, warps the value to seat the thumb's CENTER under the cursor and begins
|
||||
/// one continuous drag capture, button-agnostic, clamped by SetValue. We take all of that except
|
||||
/// the thumb press: ours grabs **offset-preserving**, so the point you grabbed stays under the
|
||||
/// finger instead of jumping to the thumb's middle (decision 0992 §6 — kept as the less surprising
|
||||
/// feel, and invisible on a reference-sized thumb either way). If that ever flips to byte-faithful,
|
||||
/// it flips here, once, for every surface at the same time.
|
||||
///
|
||||
/// [`slider_grab`] runs on the press, [`slider_fraction`] on the press and on every move after it.
|
||||
///
|
||||
/// Where a press grabs the thumb: the offset from the thumb's leading edge that stays under the
|
||||
/// cursor for the rest of the drag.
|
||||
///
|
||||
/// `cursor` and `thumb_lead` are distances from the track's leading edge; `thumb_extent` is the
|
||||
/// thumb's length along the axis. A press **on** the thumb keeps its grabbed point (0992 §6); a
|
||||
/// press **off** it — anywhere on the track — grabs the thumb by its center, which is what makes
|
||||
/// the value warp under the cursor and the drag continue as one gesture (1.12's whole law, and
|
||||
/// 0989's directed requirement, which converged with it).
|
||||
pub fn slider_grab(cursor: f32, thumb_lead: f32, thumb_extent: f32) -> f32 {
|
||||
if cursor >= thumb_lead && cursor <= thumb_lead + thumb_extent {
|
||||
cursor - thumb_lead
|
||||
} else {
|
||||
thumb_extent * 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// Cursor → fraction of travel, absolute and drift-free: the thumb's leading edge goes to
|
||||
/// `cursor − grab`, and that lands at `fraction × (track_extent − thumb_extent)`.
|
||||
///
|
||||
/// `None` when the travel is zero or negative — a thumb as long as its track has nowhere to go, so
|
||||
/// there is no value to compute and the caller must leave the slider alone rather than divide by
|
||||
/// zero. Otherwise clamped to `[0, 1]`: dragging past either end pins there, exactly as the
|
||||
/// client's own out-of-span presses pin through SetValue's clamp.
|
||||
pub fn slider_fraction(
|
||||
cursor: f32,
|
||||
grab: f32,
|
||||
track_extent: f32,
|
||||
thumb_extent: f32,
|
||||
) -> Option<f32> {
|
||||
let travel = track_extent - thumb_extent;
|
||||
(travel > 0.0).then(|| ((cursor - grab) / travel).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
impl SliderState {
|
||||
/// The value fraction `(value − min) / (max − min)`, clamped to `[0, 1]`; a degenerate range
|
||||
/// (`max <= min`, an unscrollable slider) is `0.0` — the thumb sits at the track's start.
|
||||
|
||||
@@ -173,11 +173,12 @@ impl<T> Arena<T> {
|
||||
|
||||
mod kinds;
|
||||
pub use kinds::{
|
||||
ButtonFont, ButtonState, ColorSelectState, CooldownState, EditAction, EditBoxState,
|
||||
EditOutcome, EditUnit, FrameKind, InsertMode, KindState, MessageFrameState, MessageLine,
|
||||
MinimapState, RegionKind, ScrollFrameState, ScrollingMessageState, SliderState, StatusBarState,
|
||||
TooltipState, COOLDOWN_FLASH_SECS, MINIMAP_DEFAULT_ZOOM, MINIMAP_ZOOM_LEVELS,
|
||||
TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS, TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH,
|
||||
slider_fraction, slider_grab, ButtonFont, ButtonState, ColorSelectState, CooldownState,
|
||||
EditAction, EditBoxState, EditOutcome, EditUnit, FrameKind, InsertMode, KindState,
|
||||
MessageFrameState, MessageLine, MinimapState, RegionKind, ScrollFrameState,
|
||||
ScrollingMessageState, SliderState, StatusBarState, TooltipState, COOLDOWN_FLASH_SECS,
|
||||
MINIMAP_DEFAULT_ZOOM, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS,
|
||||
TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user