Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecb0a01d5c |
@@ -110,7 +110,8 @@
|
||||
BenillaOptionsFrame:SetScale(k)
|
||||
end
|
||||
|
||||
-- The era tab width, label + 40, measured after OnShow sets the scale.
|
||||
-- The era tab width, label + 40, measured after the window's OnShow sets the scale:
|
||||
-- the tabs' own OnShows run before it, children first.
|
||||
function BenillaOptionsTab_FitWidth(tab)
|
||||
tab:SetWidth(getglobal(tab:GetName() .. "Text"):GetStringWidth() + 40)
|
||||
end
|
||||
@@ -1341,9 +1342,6 @@
|
||||
<NormalFont inherits="GameFontHighlightSmall"/>
|
||||
<HighlightFont inherits="GameFontHighlightSmall"/>
|
||||
<DisabledFont inherits="GameFontHighlightSmall"/>
|
||||
<Scripts>
|
||||
<OnShow>BenillaOptionsTab_FitWidth(this)</OnShow>
|
||||
</Scripts>
|
||||
</Button>
|
||||
<Button name="$parentAddOnsTab">
|
||||
<Size><AbsDimension x="87" y="37"/></Size>
|
||||
@@ -1371,7 +1369,6 @@
|
||||
<!-- No addon list behind it: disabled, in the era's unselected gold. -->
|
||||
<Scripts>
|
||||
<OnLoad>this:Disable()</OnLoad>
|
||||
<OnShow>BenillaOptionsTab_FitWidth(this)</OnShow>
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
@@ -3068,6 +3065,8 @@
|
||||
<!-- Controls by default; selection persists and the scale re-fits, as the era's. -->
|
||||
<OnShow>
|
||||
BenillaOptionsFrame_UpdateScale()
|
||||
BenillaOptionsTab_FitWidth(BenillaOptionsFrameGameTab)
|
||||
BenillaOptionsTab_FitWidth(BenillaOptionsFrameAddOnsTab)
|
||||
BenillaOptionsFrame_SelectCategory(this.selectedCategory or "Controls")
|
||||
if UpdateMicroButtons then UpdateMicroButtons() end
|
||||
</OnShow>
|
||||
|
||||
@@ -232,8 +232,9 @@
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<Frame name="BenillaScriptLogFrame" hidden="true">
|
||||
<!-- The standard left-slot panel size. No <Anchors>: ShowUIPanel seats it. -->
|
||||
<Frame name="BenillaScriptLogFrame" parent="UIParent" hidden="true">
|
||||
<!-- The standard left-slot panel size. No <Anchors>: ShowUIPanel seats it. Under UIParent,
|
||||
as the stock panels are, so it takes the UI scale with them. -->
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
|
||||
@@ -1358,15 +1358,23 @@ pub(super) fn seed_ui_fixture(
|
||||
warn!("capture: ui-chat-tabhover select failed: {e}");
|
||||
}
|
||||
script.resolve();
|
||||
// Each in the root's units the cursor is fed in: the frame's own times its scale.
|
||||
let expr = match mode.as_str() {
|
||||
"2" | "3" => "return ChatFrame2Tab:GetCenter()",
|
||||
"2" | "3" => {
|
||||
"local x, y = ChatFrame2Tab:GetCenter() \
|
||||
local k = ChatFrame2Tab:GetEffectiveScale() return x * k, y * k"
|
||||
}
|
||||
"0" => {
|
||||
"return (ChatFrame2:GetLeft() + ChatFrame2:GetRight()) / 2, \
|
||||
(ChatFrame2:GetBottom() + ChatFrame2:GetTop()) / 2"
|
||||
"local k = ChatFrame2:GetEffectiveScale() \
|
||||
return (ChatFrame2:GetLeft() + ChatFrame2:GetRight()) / 2 * k, \
|
||||
(ChatFrame2:GetBottom() + ChatFrame2:GetTop()) / 2 * k"
|
||||
}
|
||||
// 9: park far away, so the dock conceals itself.
|
||||
"9" => "return 2000, 2000",
|
||||
_ => "return ChatFrame1Tab:GetCenter()",
|
||||
_ => {
|
||||
"local x, y = ChatFrame1Tab:GetCenter() \
|
||||
local k = ChatFrame1Tab:GetEffectiveScale() return x * k, y * k"
|
||||
}
|
||||
};
|
||||
let centre: Result<(f32, f32), _> = script.eval(expr);
|
||||
match centre {
|
||||
|
||||
@@ -550,13 +550,13 @@ struct ProbeDrag {
|
||||
to: (f32, f32),
|
||||
}
|
||||
|
||||
/// A frame's centre from its live `GetLeft`/`GetRight`/`GetTop`/`GetBottom`, in the space
|
||||
/// [`crate::ui_script::input`] feeds the cursor in.
|
||||
/// A frame's centre from its live `GetLeft`/`GetRight`/`GetTop`/`GetBottom`, times its effective
|
||||
/// scale: the edges answer in the frame's own units, the cursor is fed in the root's.
|
||||
fn frame_centre(script: &benilla_ui::script::UiScript, name: &str) -> Option<(f32, f32)> {
|
||||
let read = |edge: &str| {
|
||||
script
|
||||
.eval::<f32>(&format!(
|
||||
"local f = getglobal(\"{name}\") return f and f:Get{edge}()"
|
||||
"local f = getglobal(\"{name}\") return f and f:Get{edge}() * f:GetEffectiveScale()"
|
||||
))
|
||||
.ok()
|
||||
};
|
||||
|
||||
@@ -553,7 +553,7 @@ fn draw_bubble(
|
||||
border_color: [1.0; 4],
|
||||
};
|
||||
let up = GxRect::new(-frame.max.y, frame.min.x, -frame.min.y, frame.max.x);
|
||||
for p in pieces(up, &bd) {
|
||||
for p in pieces(up, &bd, 1.0) {
|
||||
// Equal insets keep every piece axis-aligned: a y-down rect from its TL and BR corners.
|
||||
let rect = Rect::new(
|
||||
p.corners[0][0],
|
||||
|
||||
@@ -160,7 +160,6 @@ pub(super) type BlipInputs<'w, 's> = (
|
||||
Query<'w, 's, &'static GlobalTransform, With<NetEntity>>,
|
||||
Query<'w, 's, &'static Window, With<PrimaryWindow>>,
|
||||
ResMut<'w, MinimapBlipHover>,
|
||||
Res<'w, crate::ui_script::UiScaleCvar>,
|
||||
Res<'w, crate::ui_party::GroupState>,
|
||||
TrackedCandidates<'w, 's>,
|
||||
Query<'w, 's, (&'static ObjectStore, &'static Guid), With<SelfPlayer>>,
|
||||
@@ -200,11 +199,11 @@ pub(super) struct BlipCtx {
|
||||
pub(super) wz: f32,
|
||||
/// The cursor in the quads' y-down logical px.
|
||||
pub(super) cursor: Option<Vec2>,
|
||||
/// The cursor in y-up UI space; the reference seats the blip tooltip at the cursor, its exact
|
||||
/// offset untraced.
|
||||
/// The cursor in the y-up root units; the reference seats the blip tooltip at the cursor, its
|
||||
/// exact offset untraced.
|
||||
pub(super) cursor_ui: Option<Vec2>,
|
||||
/// Window px per UI unit: everything else here is window px, while Lua
|
||||
/// ([`Minimap:PingLocation`](super::ping)) speaks UI units.
|
||||
/// Window px per root unit: everything else here is window px, while Lua
|
||||
/// ([`Minimap:PingLocation`](super::ping)) hands root units.
|
||||
pub(super) seam: f32,
|
||||
}
|
||||
|
||||
|
||||
@@ -351,7 +351,6 @@ fn emit_minimap(
|
||||
unit_pos,
|
||||
window,
|
||||
mut blip_hover,
|
||||
ui_scale,
|
||||
group,
|
||||
tracked,
|
||||
self_store,
|
||||
@@ -621,7 +620,7 @@ fn emit_minimap(
|
||||
}
|
||||
let win = window.iter().next();
|
||||
let cursor = win.and_then(|w| w.cursor_position());
|
||||
let seam = win.map_or(1.0, |w| crate::ui_script::seam_scale(w.height(), ui_scale.0));
|
||||
let seam = win.map_or(1.0, |w| crate::ui_script::seam_scale(w.height()));
|
||||
// The pan origin snapped to a half-logical-pixel grid: every blip offset and rim bearing
|
||||
// derives from `wx`/`wy`, so the layer steps together instead of rewriting the batch mesh
|
||||
// every frame while walking. Blip world positions stay exact.
|
||||
@@ -637,11 +636,11 @@ fn emit_minimap(
|
||||
wy: (wy / q).round() * q,
|
||||
wz: wow[2],
|
||||
cursor,
|
||||
// The cursor in UI space (y-up, ÷ the seam scale): the tooltip anchor resolves in
|
||||
// 768-virtual units, not window px.
|
||||
// The cursor in the root's units (y-up, ÷ the seam scale): the tooltip anchor resolves
|
||||
// in them, not window px.
|
||||
cursor_ui: cursor
|
||||
.zip(win)
|
||||
.map(|(c, w)| Vec2::new(c.x / seam, (w.height() - c.y) / seam)),
|
||||
.map(|(c, w)| crate::ui_script::window_to_ui(w.height(), c)),
|
||||
seam,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
//! [`drive_minimap_ping`] republishes against the live view radius, so the marker follows the pan
|
||||
//! and the zoom with no second copy to fall out of step.
|
||||
//!
|
||||
//! - In: the stock `Minimap_OnClick` calls `Minimap:PingLocation(dx, dy)` in UI units from the
|
||||
//! centre; the renderer converts it with the geometry of the frame it draws: UI units × the seam
|
||||
//! scale = window px, ÷ `px_per_yd` = yards.
|
||||
//! - In: the stock `Minimap_OnClick` calls `Minimap:PingLocation(dx, dy)` in the minimap's units
|
||||
//! from the centre, which the engine hands over in root units; the renderer converts them with
|
||||
//! the geometry of the frame it draws: root units × the seam scale = window px, ÷ `px_per_yd` =
|
||||
//! yards.
|
||||
//! - Across: our ping sends `MSG_MINIMAP_PING` (raw world floats, relayed to the rest of the group
|
||||
//! only, `GroupHandler.cpp:384-391`); a member's seats the same way. A ping seats locally at
|
||||
//! click time, so a solo ping works.
|
||||
@@ -59,8 +60,8 @@ impl MinimapPing {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Minimap:PingLocation(x, y)` click to the world point it names: `ui` is centre-relative UI
|
||||
/// units (x right, y up), `seam` window px per UI unit ([`crate::ui_script::seam_scale`]), `ctx`
|
||||
/// A `Minimap:PingLocation(x, y)` click to the world point it names: `ui` is centre-relative root
|
||||
/// units (x right, y up), `seam` window px per root unit ([`crate::ui_script::seam_scale`]), `ctx`
|
||||
/// the map as drawn this frame. The inverse of [`BlipCtx::offset`]: screen right is −WoW y, screen
|
||||
/// up +WoW x. `None` outside the disc, the stock test (`Minimap.lua:135`) in yards.
|
||||
fn click_to_world(ctx: &BlipCtx, ui: (f32, f32), seam: f32) -> Option<(f32, f32)> {
|
||||
@@ -193,13 +194,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The click is in UI units and `px_per_yd` in window px: at 0.9 uiScale on a 1080p window the
|
||||
/// seam is ≈1.27, and a conversion that skips it seats every ping ≈27 % too far out.
|
||||
/// The click is in root units and `px_per_yd` in window px: on a 1080p window the seam is
|
||||
/// ≈1.41, and a conversion that skips it seats every ping ≈41 % too far out.
|
||||
#[test]
|
||||
fn a_click_converts_through_the_seam_scale() {
|
||||
let c = ctx();
|
||||
let seam = 1080.0 / 768.0 * 0.9; // the shipped default at 1080p
|
||||
// 20 UI units right → 20·seam px → yards west (−y).
|
||||
let seam = 1080.0 / 768.0;
|
||||
// 20 root units right → 20·seam px → yards west (−y).
|
||||
let (x, y) = click_to_world(&c, (20.0, 0.0), seam).expect("inside the disc");
|
||||
let expect_yd = 20.0 * seam / c.px_per_yd;
|
||||
assert!((x - 0.0).abs() < 1e-3, "no northing from a due-east click");
|
||||
|
||||
@@ -113,9 +113,9 @@ pub(super) fn refresh_hud_snapshot(
|
||||
/// clear). The always-up world-state readout (`WorldStateAlwaysUpFrame`, stock
|
||||
/// `WorldStateFrame.xml`) shares the band, and the dev pill is the one that yields.
|
||||
///
|
||||
/// The layout answers in UI units, a screen `768/uiScale` units tall whatever the window, while
|
||||
/// the pill draws in window px, so the chunk returns a fraction of the screen and the caller
|
||||
/// scales it by the window height. Reading an edge settles the layout, one graph solve at most.
|
||||
/// The layout answers in `UIParent`'s units, a screen `768/uiScale` units tall whatever the
|
||||
/// window, while the pill draws in window px, so the chunk returns a fraction of the screen and the
|
||||
/// caller scales it by the window height. Reading an edge settles the layout, one graph solve at most.
|
||||
pub(crate) fn top_centre_claimed(script: &UiScript, win_h: f32) -> f32 {
|
||||
// `WorldStateAlwaysUpFrame` is always shown; the readout occupies its `AlwaysUpFrame<n>` rows,
|
||||
// built on demand and hidden when empty, so the claim is the lowest shown row's bottom.
|
||||
|
||||
@@ -444,8 +444,8 @@ pub(super) fn auto_join_zone_channels(
|
||||
walk.at = Some(zone_id);
|
||||
}
|
||||
|
||||
/// Feed the VM the zone-less catalog before any interface file runs: world entry runs FrameXML,
|
||||
/// the addons and `PLAYER_LOGIN` in one call, ahead of the walk's first tick.
|
||||
/// Feed the VM the zone-less catalog before any interface file runs: world entry runs FrameXML
|
||||
/// and the addons in one call, ahead of the walk's first tick.
|
||||
pub(crate) fn seed_zone_channel_catalog(
|
||||
world: &mut World,
|
||||
script: &mut benilla_ui::script::UiScript,
|
||||
|
||||
@@ -623,8 +623,9 @@ pub(crate) fn fold_dying_vm_chat_cache(world: &mut World) {
|
||||
|
||||
pub(super) fn plugin(app: &mut App) {
|
||||
app.init_resource::<ChatWindowFile>()
|
||||
// The restore runs from the world-entry UI load, not here: its events must precede the
|
||||
// first chat line and `PLAYER_LOGIN`, which no `Update` ordering guarantees.
|
||||
// The restore runs from the world-entry UI load, not here: its events must follow
|
||||
// `VARIABLES_LOADED` inside the load, where the reference registers its reader
|
||||
// (`0x4900d6`), and precede the first chat line and `PLAYER_LOGIN`.
|
||||
.add_systems(
|
||||
Update,
|
||||
// After the tick: every write it watches is Lua's.
|
||||
|
||||
@@ -2397,8 +2397,9 @@ fn the_free_professions_line_is_printed_once() {
|
||||
// ───────────── the chat cache restores inside the login ─────────
|
||||
|
||||
/// `UPDATE_CHAT_WINDOWS` registers the chat frames for `CHAT_MSG_*`, and the `UPDATE_CHAT_COLOR`
|
||||
/// burst repaints id-0 lines through `ChatTypeInfo["REPLY"]`, so both come before `PLAYER_LOGIN`.
|
||||
/// The probe is a loose addon, because the entry load builds the VM it runs on.
|
||||
/// burst repaints id-0 lines through `ChatTypeInfo["REPLY"]`, so both come before `PLAYER_LOGIN`,
|
||||
/// which the load arms and the world-enter cascade fires. The probe is a loose addon, because the
|
||||
/// entry load builds the VM it runs on.
|
||||
#[test]
|
||||
fn the_chat_cache_restore_is_finished_before_player_login() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
@@ -2485,6 +2486,10 @@ fn the_chat_cache_restore_is_finished_before_player_login() {
|
||||
1,
|
||||
));
|
||||
crate::ui_script::load_ingame_ui_on_world_entry(&mut world);
|
||||
// The cascade, as the unit feed runs it once the player is in the world.
|
||||
world
|
||||
.non_send_resource_mut::<benilla_ui::script::UiScript>()
|
||||
.fire_world_enter();
|
||||
|
||||
let read = |expr: &str| -> String {
|
||||
world
|
||||
@@ -2512,7 +2517,7 @@ fn the_chat_cache_restore_is_finished_before_player_login() {
|
||||
read("ChatOrderProbe.loginRegistered")
|
||||
);
|
||||
// The reference's login order: `ADDON_LOADED` (`0x4900a3`), `VARIABLES_LOADED` (`0x4900b2`),
|
||||
// the chat-cache burst (`0x4900d6`), `PLAYER_LOGIN` (`0x490959`).
|
||||
// the chat-cache burst (`0x4900d6`), the arm (`0x49011d`), `PLAYER_LOGIN` (`0x490959`).
|
||||
assert_eq!(
|
||||
read("ChatOrderProbe.order"),
|
||||
"VARIABLES_LOADED UPDATE_CHAT_WINDOWS UPDATE_CHAT_COLOR PLAYER_LOGIN ",
|
||||
|
||||
@@ -1692,9 +1692,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `UI_Init 0x48fbf0` loads the addons at `0x4900a3` (each `ADDON_LOADED` at `0x51f5ad`) and
|
||||
/// fires `VARIABLES_LOADED` at `0x4900b2`; `PLAYER_LOGIN` comes after, on a fresh login from
|
||||
/// the player's create (`0x5deb60` → `0x4908c0`), on a `/reload` from `0x490168`.
|
||||
/// `UI_Init 0x48fbf0` loads the addons at `0x4900a3` (each `ADDON_LOADED` at `0x51f5ad`),
|
||||
/// fires `VARIABLES_LOADED` at `0x4900b2` and arms `PLAYER_LOGIN` (`0x49011d`), which the
|
||||
/// world-enter cascade fires: on a fresh login from the player's create (`0x5deb60` →
|
||||
/// `0x4908c0`), on a `/reload` from `0x490168`.
|
||||
#[test]
|
||||
fn the_ui_init_events_fire_in_the_reference_order() {
|
||||
let _l = crate::local_state::test_env::ENV_LOCK
|
||||
@@ -1728,6 +1729,12 @@ mod tests {
|
||||
let failures = load_third_party(&mut script, None, &[], true);
|
||||
assert!(failures.is_empty(), "load errors: {failures:?}");
|
||||
crate::ui_script::finish_ui_load(&mut script);
|
||||
assert_eq!(
|
||||
event_log(&script),
|
||||
vec!["ADDON_LOADED:EventProbe", "VARIABLES_LOADED:nil"],
|
||||
"the load only arms PLAYER_LOGIN"
|
||||
);
|
||||
script.fire_world_enter();
|
||||
|
||||
assert_eq!(
|
||||
event_log(&script),
|
||||
|
||||
@@ -425,7 +425,11 @@ pub(super) fn tick_script(
|
||||
mut last_seam: Local<f32>,
|
||||
// The `scale_factor` measures were last answered under; 0 until the first frame.
|
||||
mut last_dpi: Local<f32>,
|
||||
ui_scale: Res<super::UiScaleCvar>,
|
||||
// The dial, `UIParent`'s scale in the VM, and the automatic scale's last display.
|
||||
(ui_scale, mut auto_cache): (
|
||||
Res<super::UiScaleCvar>,
|
||||
Option<ResMut<super::UiAutoScaleCache>>,
|
||||
),
|
||||
mut ui_cost: ResMut<super::UiFrameCost>,
|
||||
mut pass: ResMut<super::UiPassState>,
|
||||
) {
|
||||
@@ -442,9 +446,9 @@ pub(super) fn tick_script(
|
||||
};
|
||||
let (w, h) = (window.width(), window.height());
|
||||
// The client's UI space is 768 units tall at every aspect (`f(screenH) = 768`, `0x41ad10`; the
|
||||
// caret's `H_px/192`, `0x77b8c0`), `768/uiScale` with the dial. The VM lives in it: quads
|
||||
// scale ×s out, and the mouse and the measures ÷s in.
|
||||
let s = super::seam_scale(h, ui_scale.0);
|
||||
// caret's `H_px/192`, `0x77b8c0`), the dial being `UIParent`'s scale inside it. The VM lives in
|
||||
// it: quads scale ×s out, and the mouse and the measures ÷s in.
|
||||
let s = super::seam_scale(h);
|
||||
// A moved seam scale or DPI stales every text metric the engine caches: integer-stepped
|
||||
// advances do not rescale, and the size snap moves a string's width by several percent,
|
||||
// enough for the ellipsis to eat fitting text. A monitor hop moves the DPI alone.
|
||||
@@ -486,8 +490,17 @@ pub(super) fn tick_script(
|
||||
{
|
||||
let _span = bevy::log::info_span!("ui_script: tick").entered();
|
||||
let resized = script.set_screen_size(w / s, if h > 0.0 { h / s } else { 768.0 });
|
||||
// A resize re-runs what the interface computed from the old screen size.
|
||||
// A display change re-applies the UI scale (`0x492e90`), whose narrow-aspect cap follows
|
||||
// the window; a moved dial applies once, as its CVar callback does (`0x490770`).
|
||||
if resized {
|
||||
let display = (w.round() as u32, h.round() as u32);
|
||||
let mut none = None;
|
||||
let cache = auto_cache.as_deref_mut().map_or(&mut none, |c| &mut c.0);
|
||||
script.apply_ui_scale(display, cache);
|
||||
}
|
||||
let rescaled = script.set_ui_scale(ui_scale.0);
|
||||
// Either re-runs what the interface computed from the old `GetScreenHeight()`.
|
||||
if resized || rescaled {
|
||||
super::manifest::on_screen_resized(&script);
|
||||
}
|
||||
script.tick(time.delta_secs());
|
||||
@@ -1713,9 +1726,10 @@ mod clip_plumb_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// At dial 0.5 the VM sees a 2048×1536 screen, and every window-px rect halves.
|
||||
/// The dial is `UIParent`'s scale, so a ScrollFrame with no parent keeps its px rect and clip
|
||||
/// at dial 0.5: the root stays 768 units tall.
|
||||
#[test]
|
||||
fn the_uiscale_dial_scales_the_extracted_rects() {
|
||||
fn the_uiscale_dial_leaves_a_parentless_frame_alone() {
|
||||
let mut app = app_with_scrolled_marker();
|
||||
app.insert_resource(crate::ui_script::UiScaleCvar(0.5));
|
||||
app.update();
|
||||
@@ -1726,12 +1740,10 @@ mod clip_plumb_tests {
|
||||
.find(|q| q.color[0] == 1.0 && q.color[1] == 0.0 && q.color[2] == 0.0)
|
||||
.expect("the colored marker quad extracted");
|
||||
|
||||
// WoW space: top 1536 − 100 = 1436, bottom 1236, right 300; ×0.5 is y-up px 618..718,
|
||||
// and y-down through the 768 window the top is 50 and the bottom 150.
|
||||
assert_eq!(
|
||||
marker.clip,
|
||||
Some(Rect::new(0.0, 50.0, 150.0, 150.0)),
|
||||
"dial 0.5 halves the px rect and re-hangs the frame from the taller virtual top"
|
||||
Some(Rect::new(0.0, 100.0, 300.0, 300.0)),
|
||||
"dial 0.5 moves nothing outside UIParent"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,10 +40,11 @@ pub(super) struct TextHost<'a> {
|
||||
/// The focused editbox's text UI, unfiltered: [`emit`] matches it to this quad by `target`.
|
||||
pub ebox: Option<&'a EditBoxTextUi>,
|
||||
pub screen_h: f32,
|
||||
/// The seam scale `windowH/768 × uiScale`, for the unit-space inputs; rects arrive in px.
|
||||
/// The seam scale `windowH/768`, for the root-unit inputs; rects arrive in px.
|
||||
pub scale: f32,
|
||||
/// The frame's effective scale, already in the rect: glyph size and shadow offset ride it, as
|
||||
/// the 1.12 client's text rides `SetScale`; the editbox x-offsets arrive in screen UI units.
|
||||
/// the 1.12 client's text rides `SetScale` and the UI scale; the editbox x-offsets arrive in
|
||||
/// root units.
|
||||
pub font_scale: f32,
|
||||
/// Captures pin the caret on for deterministic pixels; live, the engine's blink decides.
|
||||
pub caret_pinned: bool,
|
||||
@@ -387,7 +388,7 @@ mod tests {
|
||||
assert_eq!(band_clip_slack(Some(2.0)), 3.0);
|
||||
}
|
||||
|
||||
/// `1.0546875` is the seam scale of a 1600×900 window at uiScale 0.9.
|
||||
/// `1.0546875` is a 1600×900 window's seam times the 0.9 UI scale.
|
||||
#[test]
|
||||
fn a_shadow_offset_is_a_whole_pixel_and_never_vanishes() {
|
||||
assert_eq!(shadow_offset_px(1.0), 1.0);
|
||||
|
||||
@@ -94,8 +94,6 @@ pub(super) fn feed_ui_input(
|
||||
),
|
||||
// The characters the active layout makes, which name a key for a keyboard frame.
|
||||
layout: Res<crate::bindings::LayoutNames>,
|
||||
// The uiScale dial folded into the seam scale.
|
||||
ui_scale: Res<super::UiScaleCvar>,
|
||||
) {
|
||||
let (keyboard, keys, capture, clipboard) = (&mut kbd.0, &kbd.1, &mut kbd.2, &mut kbd.3);
|
||||
let world_pick = pointer.world_pick();
|
||||
@@ -139,10 +137,8 @@ pub(super) fn feed_ui_input(
|
||||
.or_else(crate::target::hover_probe_point)
|
||||
.filter(|_| !synthetic)
|
||||
{
|
||||
// The window cursor is logical px, y-down from the top left; the UI is y-up in 768-high
|
||||
// units under uiScale: flip through the window height, then undo the extract seam's scale.
|
||||
let s = super::seam_scale(window.height(), ui_scale.0);
|
||||
let (x, y) = (cursor.x / s, (window.height() - cursor.y) / s);
|
||||
// The window cursor in the root's y-up 768-high units, as `GetCursorPosition` answers it.
|
||||
let Vec2 { x, y } = super::window_to_ui(window.height(), cursor);
|
||||
// `WOW_HIT_COST=1` meters this call's per-frame hit-test rebuild, one line a second.
|
||||
static HIT_COST: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
let metering =
|
||||
|
||||
@@ -492,16 +492,19 @@ fn toggle_inspect_reopens_after_a_close_and_raises_like_the_reference() {
|
||||
s.eval::<bool>("return InspectFrame:IsVisible()").unwrap(),
|
||||
"the window still shows — Show() runs before OnShow raises"
|
||||
);
|
||||
// Two raises: `InspectFrame` and `InspectPaperDollFrame` each run their own `OnShow` under
|
||||
// their own pcall, the first dying at the portrait's gate, the second at `UnitLevel`'s.
|
||||
// Two raises: `InspectPaperDollFrame` and `InspectFrame` each run their own `OnShow` under
|
||||
// their own pcall, the page first, since the show cascade notifies children before their
|
||||
// parent (`0x76aed5`, then `0x76aef5`): the page dies at `UnitLevel`'s gate, the window at
|
||||
// the portrait's.
|
||||
let errors = s.errors();
|
||||
assert_eq!(errors.len(), 2, "one raise per OnShow handler: {errors:?}");
|
||||
assert!(
|
||||
errors[0].contains(r#"Usage: SetPortraitTexture(texture, "unit")"#),
|
||||
"the portrait's gate, before the name line is ever reached: {errors:?}"
|
||||
errors[0].contains(r#"Usage: UnitLevel("unit")"#),
|
||||
"the paper doll's own handler, dying at its first getter: {errors:?}"
|
||||
);
|
||||
assert!(
|
||||
errors[1].contains(r#"Usage: UnitLevel("unit")"#),
|
||||
"and the paper doll's own handler, dying at its first getter: {errors:?}"
|
||||
errors[1].contains(r#"Usage: SetPortraitTexture(texture, "unit")"#),
|
||||
"then the window's, at the portrait's gate, before the name line is ever reached: \
|
||||
{errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -230,26 +230,22 @@ pub(crate) fn run_pending_entry_load(world: &mut World) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Seat the VM's screen size and text measurer for the load, under the current window and
|
||||
/// `uiScale`: the load-edge half of [`super::extract::tick_script`]'s per-frame pair. A fresh VM
|
||||
/// answers 1024×768 until then, and a value computed once at load keeps it: stock
|
||||
/// `WorldMapFrame_OnLoad` sizes the full-screen `BlackoutWorld` from `GetScreenWidth()` and
|
||||
/// `GetScreenHeight()` and never again (`WorldMapFrame.lua:19-28`). With no atlas yet (it bakes on
|
||||
/// the first `Update`) the per-frame pass seats the measurer.
|
||||
/// Seat the VM's screen size and text measurer for the load, under the current window: the
|
||||
/// load-edge half of [`super::extract::tick_script`]'s per-frame pair. A fresh VM answers 1024×768
|
||||
/// until then, and a value computed once at load keeps it: stock `WorldMapFrame_OnLoad` sizes the
|
||||
/// full-screen `BlackoutWorld` from `GetScreenWidth()` and `GetScreenHeight()` and never again
|
||||
/// (`WorldMapFrame.lua:19-28`), which answer the root while `UIParent` is still at scale 1. With no
|
||||
/// atlas yet (it bakes on the first `Update`) the per-frame pass seats the measurer.
|
||||
fn seat_raster_seam_for_load(world: &mut World, script: &mut UiScript) {
|
||||
let ui_scale = world
|
||||
.get_resource::<super::UiScaleCvar>()
|
||||
.map_or(1.0, |c| c.0);
|
||||
let (w, h) = {
|
||||
let mut q = world.query_filtered::<&Window, With<bevy::window::PrimaryWindow>>();
|
||||
q.single(world)
|
||||
.map_or((0.0, 0.0), |win| (win.width(), win.height()))
|
||||
};
|
||||
let s = super::seam_scale(h, ui_scale);
|
||||
let s = super::seam_scale(h);
|
||||
if w > 0.0 && h > 0.0 {
|
||||
// The VM lives in 768-tall virtual space, so logical size over the seam scale is its
|
||||
// screen. No `UIParent_ManageFramePositions` here, unlike `tick_script`: nothing is laid
|
||||
// out yet.
|
||||
// The VM lives in a 768-tall root, so logical size over the seam scale is its screen. No
|
||||
// `UIParent_ManageFramePositions` here, unlike `tick_script`: nothing is laid out yet.
|
||||
script.set_screen_size(w / s, h / s);
|
||||
}
|
||||
let Some(atlas) = world.get_resource::<crate::ui_text::UiFontAtlas>() else {
|
||||
@@ -258,12 +254,29 @@ fn seat_raster_seam_for_load(world: &mut World, script: &mut UiScript) {
|
||||
super::extract::seat_text_measurer(script, atlas, s);
|
||||
}
|
||||
|
||||
/// Which `"player"` a UI load runs its files under, by the edge that runs it.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum LoadSeat {
|
||||
/// World entry: `UI_Init` runs before `CMSG_PLAYER_LOGIN` goes out (the load at `0x46c236`,
|
||||
/// the send in a later step at `0x46c272`), so no player object exists and the roster's
|
||||
/// record stands in ([`seat_from_roster`]).
|
||||
Roster,
|
||||
/// `/reload`: the player object stays in the world through the teardown and the load, so the
|
||||
/// live one is seated ([`crate::ui_unit::live_player_seat`]), the roster's if none exists.
|
||||
Live,
|
||||
}
|
||||
|
||||
/// Load the in-game UI for this session onto a VM built for it, once per world entry. The
|
||||
/// reference builds the UI in `UI_Init` (`0x48fbf0`, re-making the Lua state at `0x48fe97`) and
|
||||
/// destroys it at `0x490bd0`, so every login runs every addon's file scope afresh. Safe on the
|
||||
/// state edge because the initial transition runs after `PostStartup`: a capture booting straight
|
||||
/// into `InWorld` still loads after [`benilla_assets::AssetSet::Open`].
|
||||
pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
load_ingame_ui_seated(world, LoadSeat::Roster);
|
||||
}
|
||||
|
||||
/// [`load_ingame_ui_on_world_entry`] under the `"player"` its edge has.
|
||||
fn load_ingame_ui_seated(world: &mut World, seat: LoadSeat) {
|
||||
// The parked VM goes back into the world, where the CVar fold reads it; a reload has none
|
||||
// parked and uses the live one.
|
||||
unpark_boot_vm(world);
|
||||
@@ -322,19 +335,31 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
}
|
||||
// The player too, before the addon walk. The record first: the reference's
|
||||
// `UnitName`/`UnitRace`/`UnitClass`/`UnitSex` read only a copy of the char-enum row made at the
|
||||
// Enter World commit (`0x5abd9e`) and never cleared, and each rebuilt VM is told once. The
|
||||
// snapshot stands in for the descriptor until that streams in.
|
||||
// Enter World commit (`0x5abd9e`) and never cleared, and each rebuilt VM is told once. Then
|
||||
// the unit: the live player on a `/reload`, else the roster's stand-in until the descriptor
|
||||
// streams in.
|
||||
if let Some(record) = world
|
||||
.get_resource::<crate::char_select::Roster>()
|
||||
.and_then(record_from_roster)
|
||||
{
|
||||
script.set_player_record(record);
|
||||
}
|
||||
if let Some(seat) = world
|
||||
.get_resource::<crate::char_select::Roster>()
|
||||
.and_then(seat_from_roster)
|
||||
match (seat == LoadSeat::Live)
|
||||
.then(|| live_player_seat(world))
|
||||
.flatten()
|
||||
{
|
||||
script.set_unit("player", Some(seat));
|
||||
Some(live) => {
|
||||
script.set_unit("player", Some(live.state));
|
||||
script.set_player_xp(live.xp.0, live.xp.1);
|
||||
}
|
||||
None => {
|
||||
if let Some(seat) = world
|
||||
.get_resource::<crate::char_select::Roster>()
|
||||
.and_then(seat_from_roster)
|
||||
{
|
||||
script.set_unit("player", Some(seat));
|
||||
}
|
||||
}
|
||||
}
|
||||
// The addon version gate, read from the persisted base the fold above just made current, so a
|
||||
// character-screen "Load out of date AddOns" click counts. A bare test world is check on.
|
||||
@@ -358,6 +383,9 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
// text width, `UIPanelTemplates.xml:370-373`), which answers 0 with no measurer.
|
||||
seat_raster_seam_for_load(world, &mut script);
|
||||
// Read out of the world first: the bracket below borrows `world` for the chat-cache restore.
|
||||
let ui_scale = world
|
||||
.get_resource::<super::UiScaleCvar>()
|
||||
.map_or(1.0, |c| c.0);
|
||||
let zoom = {
|
||||
let z = world.resource::<crate::minimap::MinimapZoom>();
|
||||
(z.outdoor, z.inside)
|
||||
@@ -392,6 +420,21 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
|script| crate::vplates::seat_legacy_settings(script, &legacy_plates),
|
||||
|script| {
|
||||
crate::ui_chat::restore_chat_looks(world, script);
|
||||
// The UI scale lands on `UIParent` once the files, the addons and
|
||||
// `VARIABLES_LOADED` are done, after the chat reader (`0x4900d6`) and before the
|
||||
// `PLAYER_LOGIN` arm (`0x49011d`), through the display handler (`0x49010e` →
|
||||
// `0x492e90`). So every `OnLoad` sees `UIParent` at scale 1 and the root's size.
|
||||
let display = {
|
||||
let mut q =
|
||||
world.query_filtered::<&Window, With<bevy::window::PrimaryWindow>>();
|
||||
q.single(world).map_or((0, 0), |w| {
|
||||
(w.width().round() as u32, w.height().round() as u32)
|
||||
})
|
||||
};
|
||||
let mut none = None;
|
||||
let mut cache = world.get_resource_mut::<super::UiAutoScaleCache>();
|
||||
let cache = cache.as_deref_mut().map_or(&mut none, |c| &mut c.0);
|
||||
script.seat_ui_scale(ui_scale, display, cache);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -418,6 +461,17 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The live player's seat off the world, through the unit feed's own builder; `None` before our
|
||||
/// own create. A world without the feed's resources (a bare test world) has none to read.
|
||||
fn live_player_seat(world: &mut World) -> Option<crate::ui_unit::LivePlayerSeat> {
|
||||
world
|
||||
.run_system_cached(crate::ui_unit::live_player_seat)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("ui_script: no live player to seat for the load ({e}); the roster stands in");
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
/// The wire's gender byte (0 male, 1 female) on `UnitSex`'s 2/3 scale, as `ui_unit::snapshot`
|
||||
/// maps the descriptor's; the record and the seat share it.
|
||||
fn roster_sex(gender: u8) -> u8 {
|
||||
@@ -648,11 +702,13 @@ pub(crate) fn end_ui_session(world: &mut World) {
|
||||
pub(crate) struct ReloadUiPending(pub(crate) bool);
|
||||
|
||||
/// Run a pending `ReloadUI()`: the reference's teardown and rebuild (`0x495664 call 0x490bd0`,
|
||||
/// `0x495669 call 0x48fbf0`), for us [`end_ui_session`] then [`load_ingame_ui_on_world_entry`]
|
||||
/// without leaving the world, so a `DisableAddOn` staged in the dying VM reaches `AddOns.txt`
|
||||
/// before the rebuild reads it. `PLAYER_ENTERING_WORLD` then refires from [`crate::ui_unit`]'s
|
||||
/// feed on the new VM. In-world only, as the reference's gate (`0x494a50(0xa)`) is; elsewhere the
|
||||
/// request is dropped.
|
||||
/// `0x495669 call 0x48fbf0`), for us [`end_ui_session`] then the load without leaving the world,
|
||||
/// so a `DisableAddOn` staged in the dying VM reaches `AddOns.txt` before the rebuild reads it.
|
||||
/// The load runs under the live player, which never left. `UI_Init` then enters the world-enter
|
||||
/// cascade itself (`0x490168`), firing the `PLAYER_LOGIN` it armed and `PLAYER_ENTERING_WORLD`;
|
||||
/// ours fire from [`crate::ui_unit`]'s feed on the new VM's first `Update`, once it has re-seated
|
||||
/// the player. In-world only, as the reference's gate (`0x494a50(0xa)`) is; elsewhere the request
|
||||
/// is dropped.
|
||||
pub(crate) fn run_pending_reload(world: &mut World) {
|
||||
if !std::mem::take(&mut world.resource_mut::<ReloadUiPending>().0) {
|
||||
return;
|
||||
@@ -667,7 +723,7 @@ pub(crate) fn run_pending_reload(world: &mut World) {
|
||||
}
|
||||
info!("ui_script: ReloadUI — ending the UI session and building a new one");
|
||||
end_ui_session(world);
|
||||
load_ingame_ui_on_world_entry(world);
|
||||
load_ingame_ui_seated(world, LoadSeat::Live);
|
||||
// `UI_Init` ends by re-entering the world-enter cascade (`0x490168`), its sends included, but
|
||||
// only with the active player set (`0x490166 je`): our own player's entity exists. Not
|
||||
// `SetActiveMover`, which only the player's create runs.
|
||||
@@ -710,13 +766,13 @@ pub(crate) fn shutdown_on_exit(
|
||||
/// saved file and fires `VARIABLES_LOADED` (`0x4900b2` → `0x4913b0`), and arms `PLAYER_LOGIN`
|
||||
/// (`[0xb4e260]`, set at `0x49011d`). The world-enter cascade `0x4908c0` fires `PLAYER_LOGIN` if
|
||||
/// armed (read at `0x49094b`, fired at `0x490959`, disarmed at `0x49095e`), then
|
||||
/// `PLAYER_ENTERING_WORLD` (`0x49096a`).
|
||||
/// `PLAYER_ENTERING_WORLD` (`0x49096a`): [`UiScript::fire_world_enter`].
|
||||
///
|
||||
/// On a `/reload`, `UI_Init` enters the cascade itself (`0x490168`). On a fresh login that call is
|
||||
/// skipped (`0x490166 je`, the active-player GUID still 0) and the cascade runs from the local
|
||||
/// player's own create (`0x5deb60 call 0x4908c0`). This fires `PLAYER_LOGIN` at the end of the
|
||||
/// load on both, the `/reload` shape; `PLAYER_ENTERING_WORLD` fires from [`crate::ui_unit`] once
|
||||
/// the self descriptor lands.
|
||||
/// player's own create (`0x5deb60 call 0x4908c0`). Either way the player object exists when it
|
||||
/// runs, so this only arms, and [`crate::ui_unit`]'s feed fires both once it has seated the
|
||||
/// player.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn finish_ui_load(script: &mut UiScript) {
|
||||
finish_ui_load_with(script, |_| {}, |_| {});
|
||||
@@ -724,10 +780,10 @@ pub(crate) fn finish_ui_load(script: &mut UiScript) {
|
||||
|
||||
/// [`finish_ui_load`] with its two host seams. `host_settings` runs between the saved-variables
|
||||
/// chunk and `VARIABLES_LOADED`, carrying over a setting `config.toml` held before it moved into
|
||||
/// the saved variables. `between`
|
||||
/// is the chat-cache restore's `UPDATE_CHAT_WINDOWS` + `UPDATE_CHAT_COLOR` burst, after
|
||||
/// `VARIABLES_LOADED` and before `PLAYER_LOGIN`, where the reference registers its reader
|
||||
/// (`0x4900d6` → `0x498a20`). On a fresh login the reference's cache (`0x5afe50`) defers that
|
||||
/// the saved variables. `between` runs after `VARIABLES_LOADED` and before the `PLAYER_LOGIN` arm
|
||||
/// (so before the event too): the chat-cache restore's `UPDATE_CHAT_WINDOWS` +
|
||||
/// `UPDATE_CHAT_COLOR` burst, where the reference registers its reader (`0x4900d6` → `0x498a20`),
|
||||
/// then the UI scale (`0x49010e`). On a fresh login the reference's cache (`0x5afe50`) defers that
|
||||
/// reader until the server's account data reconciles (`0x5afa42`/`0x5afd01`), after `UI_Init` has
|
||||
/// returned. Either way a `VARIABLES_LOADED` handler sees the boot chat colours, not the file's.
|
||||
pub(crate) fn finish_ui_load_with(
|
||||
@@ -736,11 +792,12 @@ pub(crate) fn finish_ui_load_with(
|
||||
between: impl FnOnce(&mut UiScript),
|
||||
) {
|
||||
// Still the load edge, so still bounded: re-armed so the saved variables and every
|
||||
// `PLAYER_LOGIN` handler get the full allowance; the entry edge disarms it after.
|
||||
// `VARIABLES_LOADED` handler get the full allowance; the entry edge disarms it after. The
|
||||
// world-enter events fire later, unbounded, as every event the feeds fire is.
|
||||
script.set_instruction_budget(addons::LOAD_INSTRUCTION_BUDGET);
|
||||
crate::ui_saved::load_saved_variables(script, host_settings);
|
||||
between(script);
|
||||
script.fire_event("PLAYER_LOGIN", vec![]);
|
||||
script.arm_player_login();
|
||||
}
|
||||
|
||||
/// Run the patch chain's `Interface\FrameXML\GlobalStrings.lua`, the first file in the reference's
|
||||
|
||||
@@ -68,13 +68,13 @@ pub(super) fn apply_buff_durations(script: &UiScript) -> Result<(), String> {
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// What a screen-size change re-runs, from `extract::tick_script`'s resize arm: anchors follow the
|
||||
/// screen, but not a size or seat computed from the old one. The managed pass re-wraps the open
|
||||
/// bags, whose columns `updateContainerFrameAnchors` lays out by `GetScreenHeight()`.
|
||||
/// What a screen-size or UI-scale change re-runs, from `extract::tick_script`: anchors follow the
|
||||
/// screen, but not a size or seat computed from the old `GetScreenHeight()`. The managed pass
|
||||
/// re-wraps the open bags, whose columns `updateContainerFrameAnchors` lays out by it.
|
||||
/// Deviation: [`FULLSCREEN_QUADS_RESEAT`] re-sizes the full-screen quads, which the stock files
|
||||
/// size only in their OnLoads, because a resize or `uiScale` change here keeps the loaded UI and
|
||||
/// the blackout would stop short of the new screen. Host-side, not in the layer: it adapts the
|
||||
/// engine's resizable window, and changes nothing the stock UI does at a fixed size.
|
||||
/// size only in their OnLoads, because a resize here keeps the loaded UI and the blackout would
|
||||
/// stop short of the new screen. Host-side, not in the layer: it adapts the engine's resizable
|
||||
/// window, and changes nothing the stock UI does at a fixed size.
|
||||
pub(super) fn on_screen_resized(script: &UiScript) {
|
||||
let _ = script.run("if UIParent_ManageFramePositions then UIParent_ManageFramePositions() end");
|
||||
if let Err(e) = script.run(FULLSCREEN_QUADS_RESEAT) {
|
||||
@@ -85,11 +85,14 @@ pub(super) fn on_screen_resized(script: &UiScript) {
|
||||
/// The sizing arithmetic of `WorldMapFrame_OnLoad` (`WorldMapFrame.lua:19-28`) and
|
||||
/// `CinematicFrame_OnLoad` (`CinematicFrame.lua:6-21`), each existence-guarded: the glue screens
|
||||
/// have neither. Below 4:3 the bars go back to `CinematicFrame.xml`'s declared 1024 x 128, where
|
||||
/// the stock OnLoad leaves them.
|
||||
/// the stock OnLoad leaves them. The OnLoads read the screen root's size, `UIParent` being still at
|
||||
/// scale 1 then, and their frames hang off the root, so this re-reads the root:
|
||||
/// `GetScreenWidth()` times `UIParent`'s scale.
|
||||
const FULLSCREEN_QUADS_RESEAT: &str = r#"
|
||||
local rootScale = UIParent and UIParent:GetEffectiveScale() or 1
|
||||
if BlackoutWorld then
|
||||
local width = GetScreenWidth()
|
||||
local height = GetScreenHeight()
|
||||
local width = GetScreenWidth() * rootScale
|
||||
local height = GetScreenHeight() * rootScale
|
||||
if ( width / height < 4 / 3 ) then
|
||||
width = width * 1.25
|
||||
height = height * 1.25
|
||||
@@ -98,8 +101,8 @@ if BlackoutWorld then
|
||||
BlackoutWorld:SetHeight( height )
|
||||
end
|
||||
if UpperBlackBar and LowerBlackBar then
|
||||
local width = GetScreenWidth()
|
||||
local height = GetScreenHeight()
|
||||
local width = GetScreenWidth() * rootScale
|
||||
local height = GetScreenHeight() * rootScale
|
||||
local barWidth, blackBarHeight = 1024, 128
|
||||
if ( width / height > 4 / 3 ) then
|
||||
local desiredHeight = width / 2
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! The UI-engine bridge: hosts [`benilla_ui::script::UiScript`] and feeds its
|
||||
//! [`extract`](benilla_ui::script::UiScript::extract) output into the quad pass
|
||||
//! ([`crate::ui_pass::UiQuads`]) every frame. The script side is WoW UI space (y-up, origin
|
||||
//! bottom-left, a screen `768/uiScale` units tall); [`seam_scale`] carries quads ×s out and the
|
||||
//! mouse ÷s in, and extraction flips to y-down window px.
|
||||
//! bottom-left, a screen root 768 units tall at scale 1); [`seam_scale`] carries quads ×s out and
|
||||
//! the mouse ÷s in, and extraction flips to y-down window px. The UI scale is `UIParent`'s own
|
||||
//! frame scale inside the VM ([`UiScaleCvar`]), never part of the seam.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -199,8 +200,10 @@ pub(crate) fn run_or_warn(script: &benilla_ui::script::UiScript, chunk: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference's `uiScale` CVar: the VM's screen is `768/uiScale` units tall, so
|
||||
/// `uiScale = 768/screenH` is pixel-perfect. Tests pin the `Default` 1.0.
|
||||
/// The reference's `uiScale` CVar, which the VM makes `UIParent`'s own scale
|
||||
/// ([`UiScript::set_ui_scale`]): the screen root stays 768 units tall, so `uiScale = 768/screenH`
|
||||
/// is pixel-perfect inside `UIParent`, and a frame outside it is not scaled. Tests pin the
|
||||
/// `Default` 1.0.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct UiScaleCvar(pub(crate) f32);
|
||||
|
||||
@@ -213,8 +216,17 @@ impl Default for UiScaleCvar {
|
||||
/// The shipped `uiScale`. Deviation: a flat 0.9, because 1.0 reads oversized. With `useUiScale`
|
||||
/// off, its default (`0x8430c0`), the reference sets `max(768/H, 0.9)` above 768 px tall and 1.0
|
||||
/// at or below (`0x492f70`, on a mode set and from `0x4908ad`), so the two agree from ~853 px up.
|
||||
/// benilla never reads `useUiScale`: this CVar always applies to `UIParent` alone, as with it on.
|
||||
/// A change goes through the CVar callback (`0x490770`: the narrow-aspect cap, the 0.64 floor);
|
||||
/// the load and a display change through the display handler's ON leg (`0x492e90`: the cap, and
|
||||
/// below 0.64 the automatic scale `0x492f70` instead of the floor).
|
||||
pub(crate) const DEFAULT_UI_SCALE: f32 = 0.9;
|
||||
|
||||
/// The display the reference's automatic UI scale last ran for (`0x492f70`'s `[0xb4e304]` and
|
||||
/// `[0xb4e308]`), which outlives every UI: the display handler skips a display it already ran for.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct UiAutoScaleCache(pub(crate) Option<(u32, u32)>);
|
||||
|
||||
/// `WOW_UI_SCALE=` if set, clamped to the dial's range, else [`DEFAULT_UI_SCALE`].
|
||||
fn default_ui_scale() -> f32 {
|
||||
std::env::var("WOW_UI_SCALE")
|
||||
@@ -224,16 +236,24 @@ fn default_ui_scale() -> f32 {
|
||||
.unwrap_or(DEFAULT_UI_SCALE)
|
||||
}
|
||||
|
||||
/// The seam scale `s`, window px per UI unit (`windowH/768 × uiScale`), used at every crossing of
|
||||
/// the VM boundary; identity for a degenerate window (h ≤ 0, before winit).
|
||||
pub(crate) fn seam_scale(window_h: f32, ui_scale: f32) -> f32 {
|
||||
/// The seam scale `s`, window px per screen-root unit (`windowH/768`: the root is 768 units tall at
|
||||
/// every aspect, `0x41ad10`), used at every crossing of the VM boundary; identity for a degenerate
|
||||
/// window (h ≤ 0, before winit). The UI scale is not in it: it is `UIParent`'s, inside the VM.
|
||||
pub(crate) fn seam_scale(window_h: f32) -> f32 {
|
||||
if window_h > 0.0 {
|
||||
window_h / 768.0 * ui_scale
|
||||
window_h / 768.0
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A window cursor (logical px, y-down from the top left) in the VM's root units, y up: what the
|
||||
/// pointer feed hands the engine and `GetCursorPosition` answers (`0x48b820`, no scale divided out).
|
||||
pub(crate) fn window_to_ui(window_h: f32, cursor: Vec2) -> Vec2 {
|
||||
let s = seam_scale(window_h);
|
||||
Vec2::new(cursor.x / s, (window_h - cursor.y) / s)
|
||||
}
|
||||
|
||||
/// The Lua UI host (a `NonSend` resource: an mlua VM is `!Send`) and its per-frame passes.
|
||||
pub(crate) struct UiScriptPlugin;
|
||||
|
||||
@@ -263,6 +283,7 @@ impl Plugin for UiScriptPlugin {
|
||||
// The quit root runs in `Last`: the close button's `AppExit` is written in `PostUpdate`.
|
||||
crate::shutdown::on_app_exit(app, shutdown_on_exit.into_configs());
|
||||
app.insert_resource(UiScaleCvar(default_ui_scale()))
|
||||
.init_resource::<UiAutoScaleCache>()
|
||||
.init_resource::<UiFrameCost>()
|
||||
.init_resource::<crate::bindings::WheelNotches>()
|
||||
.init_resource::<UiCostWanted>()
|
||||
@@ -600,9 +621,9 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<VmMemo<
|
||||
},
|
||||
]);
|
||||
script.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
|
||||
// 70% XP, set before `PLAYER_ENTERING_WORLD` so the bar's first update reads it.
|
||||
// 70% XP, set before the world-enter events so the bar's first update reads it.
|
||||
script.set_player_xp(4200, 6000);
|
||||
script.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||||
script.fire_world_enter();
|
||||
// The bottom multibars ship off and a capture has no toggle byte, so raise them as the
|
||||
// Options rows do; `WOW_DEMO_BOTTOM_BARS=0` leaves them down for the stance shelf art,
|
||||
// which `ShapeshiftBar_UpdatePosition` hides under the bottom-left bar.
|
||||
@@ -706,6 +727,9 @@ mod chat_resize_tests;
|
||||
#[cfg(test)]
|
||||
mod dropdown_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod ui_scale_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod action_bar_tests;
|
||||
|
||||
@@ -986,19 +1010,26 @@ mod world_map_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod seam_scale_tests {
|
||||
use super::seam_scale;
|
||||
use super::{seam_scale, window_to_ui};
|
||||
use bevy::math::Vec2;
|
||||
|
||||
#[test]
|
||||
fn seam_scale_is_the_768_base_times_the_dial() {
|
||||
fn seam_scale_is_the_768_tall_root() {
|
||||
// Identity at the design height, proportional elsewhere.
|
||||
assert_eq!(seam_scale(768.0, 1.0), 1.0);
|
||||
assert_eq!(seam_scale(1536.0, 1.0), 2.0);
|
||||
// The dial multiplies it.
|
||||
assert_eq!(seam_scale(768.0, 0.9), 0.9);
|
||||
// The reference's pixel-perfect setting: uiScale = 768/screenH → 1 px per UI unit.
|
||||
assert!((seam_scale(1080.0, 768.0 / 1080.0) - 1.0).abs() < 1e-6);
|
||||
assert_eq!(seam_scale(768.0), 1.0);
|
||||
assert_eq!(seam_scale(1536.0), 2.0);
|
||||
// A degenerate (pre-winit) window is identity, never a division blow-up.
|
||||
assert_eq!(seam_scale(0.0, 0.9), 1.0);
|
||||
assert_eq!(seam_scale(0.0), 1.0);
|
||||
}
|
||||
|
||||
/// `GetCursorPosition` answers root units whatever the dial: the bottom of a 1080-tall window
|
||||
/// is 0 and its top 768, its middle column `768·a/2`.
|
||||
#[test]
|
||||
fn the_cursor_reaches_the_vm_in_root_units() {
|
||||
let at = window_to_ui(1080.0, Vec2::new(960.0, 108.0));
|
||||
assert!((at.x - 960.0 * 768.0 / 1080.0).abs() < 1e-3, "{at}");
|
||||
assert!((at.y - 972.0 * 768.0 / 1080.0).abs() < 1e-3, "{at}");
|
||||
assert_eq!(window_to_ui(1080.0, Vec2::new(0.0, 1080.0)), Vec2::ZERO);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -660,6 +660,8 @@ pub(super) const BAG_UI: &[&str] = &[
|
||||
"Interface\\FrameXML\\UIDropDownMenu.xml",
|
||||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||||
// `CharacterFrame.xml`'s tab template, for the hidden parent `PaperDollFrame` needs below.
|
||||
r"Interface\FrameXML\CharacterFrameTemplates.xml",
|
||||
// The dialog engine, after the `UIPanelCloseButton` it inherits.
|
||||
r"Interface\FrameXML\StaticPopup.xml",
|
||||
// `ContainerFrameItemButton_OnClick` hides `StackSplitFrame` on every plain click
|
||||
@@ -683,7 +685,9 @@ pub(super) const BAG_UI: &[&str] = &[
|
||||
"Interface\\FrameXML\\ItemButtonTemplate.xml",
|
||||
// `BagSlotButtonTemplate` inherits `PaperDollItemSlotButtonTemplate`, and its OnLoad
|
||||
// (`PaperDollItemSlotButton_OnLoad`) gives each bag button its inventory-slot id, 20..23.
|
||||
// `CharacterFrame.xml` stays out: a missing `parent=` only warns.
|
||||
// Its hidden parent `CharacterFrame` comes first: a parentless `PaperDollFrame` would be
|
||||
// visible at the end of its load and run its OnShow, which reaches for the Honor tab.
|
||||
"Interface\\FrameXML\\CharacterFrame.xml",
|
||||
"Interface\\FrameXML\\PaperDollFrame.xml",
|
||||
// The stock bag bar, with `BagSlotButtonTemplate` and `KEYRING_CONTAINER`.
|
||||
"Interface\\FrameXML\\MainMenuBarBagButtons.xml",
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! The UI scale through the real UI pass: `uiScale` is `UIParent`'s own frame scale (`0x494550`
|
||||
//! calls `0x76ac10` on the frame named `"UIParent"`), and the screen root under it keeps scale 1
|
||||
//! and is 768 units tall, so a frame outside `UIParent` is not scaled by it.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
use super::extract::{paint_script, tick_script};
|
||||
use crate::portrait::PortraitImages;
|
||||
use crate::ui_pass::{UiQuad, UiQuads};
|
||||
|
||||
/// A `UIParent` filling the root with a red 100-unit square inside it, 10 units in from its top
|
||||
/// left, and a green 100-unit square at the root's bottom left with no parent.
|
||||
const TREE: &str = r#"
|
||||
local ui = CreateFrame("Frame", "UIParent")
|
||||
ui:SetPoint("BOTTOMLEFT", 0, 0)
|
||||
ui:SetPoint("TOPRIGHT", 0, 0)
|
||||
local inside = CreateFrame("Frame", "Inside", UIParent)
|
||||
inside:SetPoint("TOPLEFT", 10, -10)
|
||||
inside:SetWidth(100); inside:SetHeight(100)
|
||||
local red = inside:CreateTexture(nil, "ARTWORK")
|
||||
red:SetTexture(1, 0, 0)
|
||||
red:SetAllPoints()
|
||||
local outside = CreateFrame("Frame", "Outside")
|
||||
outside:SetPoint("BOTTOMLEFT", 0, 0)
|
||||
outside:SetWidth(100); outside:SetHeight(100)
|
||||
local green = outside:CreateTexture(nil, "ARTWORK")
|
||||
green:SetTexture(0, 1, 0)
|
||||
green:SetAllPoints()
|
||||
"#;
|
||||
|
||||
/// Both halves of the UI pass over [`TREE`] on a `w`×`h` window, the dial at `ui_scale`.
|
||||
fn app(w: u32, h: u32, ui_scale: f32) -> App {
|
||||
let script = UiScript::new().unwrap();
|
||||
script.run(TREE).unwrap();
|
||||
let mut app = App::new();
|
||||
app.insert_non_send_resource(script);
|
||||
app.init_resource::<UiQuads>();
|
||||
app.init_resource::<super::UiPassState>();
|
||||
app.init_resource::<Assets<Image>>();
|
||||
app.init_resource::<PortraitImages>();
|
||||
app.init_resource::<crate::portrait::BoothPanes>();
|
||||
app.init_resource::<crate::ui_models::UiModelTiles>();
|
||||
app.init_resource::<crate::minimap::MinimapWidget>();
|
||||
app.init_resource::<super::UiFrameCost>();
|
||||
app.init_resource::<super::UiCostWanted>();
|
||||
app.init_resource::<Time>();
|
||||
app.init_resource::<Time<Real>>();
|
||||
app.init_resource::<super::UiClock>();
|
||||
app.insert_resource(super::UiScaleCvar(ui_scale));
|
||||
app.world_mut().spawn((
|
||||
Window {
|
||||
resolution: UVec2::new(w, h).into(),
|
||||
..default()
|
||||
},
|
||||
PrimaryWindow,
|
||||
));
|
||||
app.add_systems(Update, (tick_script, paint_script).chain());
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn eval(app: &App, chunk: &str) -> f64 {
|
||||
app.world()
|
||||
.non_send_resource::<UiScript>()
|
||||
.eval::<f64>(chunk)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The window-px rect of the quad tinted `rgb`, y-down.
|
||||
fn quad(app: &App, rgb: [f32; 3]) -> Rect {
|
||||
let quads = &app.world().resource::<UiQuads>().quads;
|
||||
let q: &UiQuad = quads
|
||||
.iter()
|
||||
.find(|q| q.color[..3] == rgb)
|
||||
.expect("the marker quad extracted");
|
||||
q.rect
|
||||
}
|
||||
|
||||
fn close(a: f32, b: f32) -> bool {
|
||||
(a - b).abs() < 1e-3
|
||||
}
|
||||
|
||||
fn rect_close(got: Rect, want: Rect) -> bool {
|
||||
close(got.min.x, want.min.x)
|
||||
&& close(got.min.y, want.min.y)
|
||||
&& close(got.max.x, want.max.x)
|
||||
&& close(got.max.y, want.max.y)
|
||||
}
|
||||
|
||||
/// `UIParent:GetScale()` and `GetEffectiveScale()` answer the UI scale: it is `UIParent`'s own
|
||||
/// scale, set through the worker Lua's `SetScale` reaches (`0x494550` → `0x76ac10`).
|
||||
#[test]
|
||||
fn uiparent_answers_the_ui_scale() {
|
||||
let app = app(1024, 768, 0.9);
|
||||
assert!(close(eval(&app, "return UIParent:GetScale()") as f32, 0.9));
|
||||
assert!(close(
|
||||
eval(&app, "return UIParent:GetEffectiveScale()") as f32,
|
||||
0.9
|
||||
));
|
||||
assert!(close(
|
||||
eval(&app, "return Inside:GetEffectiveScale()") as f32,
|
||||
0.9
|
||||
));
|
||||
assert_eq!(eval(&app, "return Outside:GetEffectiveScale()"), 1.0);
|
||||
}
|
||||
|
||||
/// `GetScreenWidth`/`GetScreenHeight` (`0x48b480`/`0x48b4d0`) divide the 768-tall root by
|
||||
/// `UIParent`'s scale (`0x494590`), so they answer in `UIParent`'s units.
|
||||
#[test]
|
||||
fn the_screen_size_answers_in_uiparent_units() {
|
||||
let app = app(1024, 768, 0.9);
|
||||
assert!(close(
|
||||
eval(&app, "return GetScreenHeight()") as f32,
|
||||
768.0 / 0.9
|
||||
));
|
||||
assert!(close(
|
||||
eval(&app, "return GetScreenWidth()") as f32,
|
||||
1024.0 / 0.9
|
||||
));
|
||||
}
|
||||
|
||||
/// A frame with no parent hangs off the root, which keeps scale 1: one unit is `windowH/768` px
|
||||
/// whatever the dial says.
|
||||
#[test]
|
||||
fn a_parentless_frame_is_not_scaled_by_the_ui_scale() {
|
||||
let app = app(1280, 1024, 0.9);
|
||||
let s = 1024.0 / 768.0;
|
||||
let got = quad(&app, [0.0, 1.0, 0.0]);
|
||||
assert!(
|
||||
rect_close(got, Rect::new(0.0, 1024.0 - 100.0 * s, 100.0 * s, 1024.0)),
|
||||
"got {got:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Inside `UIParent` nothing moves: the dial scales the subtree exactly as it scaled the seam.
|
||||
#[test]
|
||||
fn a_frame_inside_uiparent_lands_where_the_dial_put_it() {
|
||||
let app = app(1280, 1024, 0.9);
|
||||
let s = 1024.0 / 768.0 * 0.9;
|
||||
let got = quad(&app, [1.0, 0.0, 0.0]);
|
||||
assert!(
|
||||
rect_close(got, Rect::new(10.0 * s, 10.0 * s, 110.0 * s, 110.0 * s)),
|
||||
"got {got:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether `frame` is `UIParent` or hangs below it.
|
||||
fn under_ui_parent(s: &UiScript, frame: benilla_ui::widget::FrameHandle) -> bool {
|
||||
let mut at = Some(frame);
|
||||
while let Some(h) = at {
|
||||
if s.frame_name(h).as_deref() == Some("UIParent") {
|
||||
return true;
|
||||
}
|
||||
at = s.frame_parent(h);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The whole stock interface, every frame shown: scaling `UIParent` by `S` in the 768-tall root
|
||||
/// must place every quad of its subtree, and every hit, exactly where enlarging the root by `1/S`
|
||||
/// with `UIParent` at scale 1 placed them, the seam having been the old home of the UI scale. A
|
||||
/// frame-local length the engine forgets to scale (a border, an inset, a thumb) shows up here.
|
||||
#[test]
|
||||
fn the_stock_interface_inside_uiparent_is_unmoved_by_where_the_scale_lives() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
const S: f32 = 0.9;
|
||||
let (w, h) = (768.0 * 16.0 / 9.0, 768.0);
|
||||
let mut script = UiScript::new().expect("VM");
|
||||
script.set_screen_size(w, h);
|
||||
let failures = super::load_default_ui(&script);
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"default UI failed to load: {failures:?}"
|
||||
);
|
||||
// Every frame shown, its parents first, so the most geometry the stock files declare draws.
|
||||
script
|
||||
.run(
|
||||
r#"
|
||||
local f = EnumerateFrames()
|
||||
while f do
|
||||
pcall(f.Show, f)
|
||||
f = EnumerateFrames(f)
|
||||
end
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = script.take_errors();
|
||||
|
||||
// The old home: a root `1/S` larger, `UIParent` at scale 1.
|
||||
script.set_screen_size(w / S, h / S);
|
||||
script.run("UIParent:SetScale(1)").unwrap();
|
||||
script.resolve();
|
||||
let old = script.extract();
|
||||
let grid: Vec<(f32, f32)> = (0..=40)
|
||||
.flat_map(|i| (0..=24).map(move |j| (w * i as f32 / 40.0, h * j as f32 / 24.0)))
|
||||
.collect();
|
||||
let old_hits: Vec<_> = grid
|
||||
.iter()
|
||||
.map(|&(x, y)| script.hit_test_frame(x / S, y / S))
|
||||
.collect();
|
||||
|
||||
// The reference's: a 768-tall root, `UIParent` at scale `S`.
|
||||
script.set_screen_size(w, h);
|
||||
assert!(script.set_ui_scale(S));
|
||||
script.resolve();
|
||||
let new = script.extract();
|
||||
let new_hits: Vec<_> = grid
|
||||
.iter()
|
||||
.map(|&(x, y)| script.hit_test_frame(x, y))
|
||||
.collect();
|
||||
|
||||
assert_eq!(old.len(), new.len(), "the same quads draw");
|
||||
let close = |a: f32, b: f32| (a - b).abs() < 0.02;
|
||||
let mut moved = Vec::new();
|
||||
for (a, b) in old.iter().zip(&new) {
|
||||
assert_eq!(a.target, b.target, "the same paint order");
|
||||
let Some(frame) = script.target_frame(b.target) else {
|
||||
continue;
|
||||
};
|
||||
if !under_ui_parent(&script, frame) {
|
||||
continue;
|
||||
}
|
||||
let same_rect = match (a.rect, b.rect) {
|
||||
(Some(ra), Some(rb)) => {
|
||||
close(ra.left * S, rb.left)
|
||||
&& close(ra.right * S, rb.right)
|
||||
&& close(ra.top * S, rb.top)
|
||||
&& close(ra.bottom * S, rb.bottom)
|
||||
}
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !same_rect || !close(a.scale * S, b.scale) {
|
||||
moved.push(format!(
|
||||
"{:?} {:?}: {:?}×{S} vs {:?}",
|
||||
script.target_owner_name(b.target),
|
||||
std::mem::discriminant(&b.content),
|
||||
a.rect,
|
||||
b.rect
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
moved.is_empty(),
|
||||
"{} quads inside UIParent moved, first: {:#?}",
|
||||
moved.len(),
|
||||
&moved[..moved.len().min(20)]
|
||||
);
|
||||
|
||||
let inside = |hit: Option<benilla_ui::widget::FrameHandle>| {
|
||||
hit.is_none_or(|h| under_ui_parent(&script, h))
|
||||
};
|
||||
let mut rehit = Vec::new();
|
||||
for ((&at, a), b) in grid.iter().zip(&old_hits).zip(&new_hits) {
|
||||
if inside(*a) && inside(*b) && a != b {
|
||||
rehit.push(format!(
|
||||
"{at:?}: {:?} vs {:?}",
|
||||
a.and_then(|h| script.frame_name(h)),
|
||||
b.and_then(|h| script.frame_name(h))
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
rehit.is_empty(),
|
||||
"{} hits inside UIParent changed, first: {:#?}",
|
||||
rehit.len(),
|
||||
&rehit[..rehit.len().min(20)]
|
||||
);
|
||||
}
|
||||
@@ -773,6 +773,28 @@ fn leaving_inside_the_deferral_window_drops_the_load_and_writes_nothing() {
|
||||
|
||||
// ───────────── The login one-shots wait for the in-game UI ─────────────
|
||||
|
||||
/// An app running the unit feed after `Startup`, with what the feed reads; the receiver is the
|
||||
/// wire, kept alive beside it.
|
||||
fn unit_feed_app() -> (App, crossbeam_channel::Receiver<crate::net::ClientCommand>) {
|
||||
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>();
|
||||
app.add_message::<crate::net::WorldEnterCascadeMessage>();
|
||||
super::setup_script(app.world_mut());
|
||||
(app, rx)
|
||||
}
|
||||
|
||||
/// The unit feed's login one-shots (`PLAYER_ENTERING_WORLD`, the first `PLAYER_XP_UPDATE` and
|
||||
/// `UPDATE_EXHAUSTION`) wait for the in-game UI instead of reaching a VM with no frames.
|
||||
#[test]
|
||||
@@ -781,22 +803,7 @@ fn the_login_one_shots_wait_for_the_in_game_ui() {
|
||||
.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());
|
||||
let (mut app, _rx) = unit_feed_app();
|
||||
|
||||
// A frame registered as FrameXML registers, in the VM that exists before the entry load.
|
||||
app.world()
|
||||
@@ -1576,3 +1583,143 @@ fn a_login_and_a_reload_load_without_the_lost_target_sound() {
|
||||
drop(world);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
// ───────────── PLAYER_LOGIN fires from the world-enter cascade ─────────────
|
||||
|
||||
/// Records what file scope and the two cascade events see of `"player"`, in order.
|
||||
const LOGIN_PROBE_LUA: &str = "\
|
||||
LoginProbe = { order = \"\", fileExists = tostring(UnitExists(\"player\")),
|
||||
fileLevel = UnitLevel(\"player\") }
|
||||
local f = CreateFrame(\"Frame\")
|
||||
f:RegisterEvent(\"PLAYER_LOGIN\")
|
||||
f:RegisterEvent(\"PLAYER_ENTERING_WORLD\")
|
||||
f:SetScript(\"OnEvent\", function()
|
||||
if event == \"PLAYER_LOGIN\" then
|
||||
LoginProbe.order = LoginProbe.order .. \"LOGIN:\" .. tostring(UnitExists(\"player\"))
|
||||
.. \":\" .. UnitLevel(\"player\") .. \":\" .. UnitXP(\"player\") .. \" \"
|
||||
else
|
||||
LoginProbe.order = LoginProbe.order .. \"PEW \"
|
||||
end
|
||||
end)
|
||||
";
|
||||
|
||||
const LOGIN_PROBE_TOC: &str = "## Interface: 11200\nLoginProbe.lua\n";
|
||||
|
||||
/// Our own player as its create lands: level 17, 1234 of 5000 XP.
|
||||
fn spawn_self_player(world: &mut World) -> Entity {
|
||||
use benilla_protocol::messages::{ObjectFields, ObjectType};
|
||||
// `PLAYER_XP` and `PLAYER_NEXT_LEVEL_XP`, 716 and 717 in the 1.12 descriptor.
|
||||
let fields = ObjectFields::from_pairs(&[
|
||||
(benilla_protocol::field::FIELD_UNIT_LEVEL, 17),
|
||||
(716, 1234),
|
||||
(717, 5000),
|
||||
])
|
||||
.into_created(ObjectType::Player);
|
||||
world
|
||||
.spawn((
|
||||
crate::net::SelfPlayer,
|
||||
crate::net::Guid(1),
|
||||
crate::net::ObjectStore(fields),
|
||||
))
|
||||
.id()
|
||||
}
|
||||
|
||||
/// A probe global off the live VM, as a string.
|
||||
fn login_probe(app: &App, field: &str) -> String {
|
||||
app.world()
|
||||
.non_send_resource::<benilla_ui::script::UiScript>()
|
||||
.eval::<Option<String>>(&format!("return tostring(LoginProbe.{field})"))
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `PLAYER_LOGIN` has one fire site, `0x490959` in the world-enter cascade `0x4908c0`, which the
|
||||
/// UI load only arms (`0x49011d`). A fresh login skips the load's own entry (`0x490166 je`) and
|
||||
/// runs the cascade from the player's create (`0x5deb60`), so its handlers see the player, and
|
||||
/// `PLAYER_ENTERING_WORLD` follows at once (`0x49096a`). A worldport loads no UI, so its cascade
|
||||
/// finds the arm spent and fires 272 alone.
|
||||
#[test]
|
||||
fn player_login_fires_from_the_world_entry_with_the_player_seated() {
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (tmp, _c, _h) = hermetic_addon("login", "LoginProbe", LOGIN_PROBE_TOC, LOGIN_PROBE_LUA);
|
||||
let (mut app, _rx) = unit_feed_app();
|
||||
app.insert_resource(State::new(crate::char_select::ClientState::InWorld));
|
||||
app.insert_resource(roster_named("Probelogin", 1));
|
||||
super::load_ingame_ui_on_world_entry(app.world_mut());
|
||||
|
||||
assert_eq!(
|
||||
login_probe(&app, "fileExists"),
|
||||
"nil",
|
||||
"a fresh login's file scope runs before the player object exists"
|
||||
);
|
||||
assert_eq!(
|
||||
login_probe(&app, "order"),
|
||||
"",
|
||||
"the load arms PLAYER_LOGIN; nothing fires it before the player is in the world"
|
||||
);
|
||||
|
||||
let me = spawn_self_player(app.world_mut());
|
||||
app.update();
|
||||
assert_eq!(
|
||||
login_probe(&app, "order"),
|
||||
"LOGIN:1:17:1234 PEW ",
|
||||
"PLAYER_LOGIN sees the player's own unit, then PLAYER_ENTERING_WORLD fires at once"
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(login_probe(&app, "order"), "LOGIN:1:17:1234 PEW ");
|
||||
|
||||
// A cross-map worldport: the player object is destroyed and created again.
|
||||
app.world_mut().despawn(me);
|
||||
app.update();
|
||||
spawn_self_player(app.world_mut());
|
||||
app.update();
|
||||
assert_eq!(
|
||||
login_probe(&app, "order"),
|
||||
"LOGIN:1:17:1234 PEW PEW ",
|
||||
"a worldport re-fires PLAYER_ENTERING_WORLD and not PLAYER_LOGIN"
|
||||
);
|
||||
|
||||
drop(app);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// On a `/reload` the player object stays in the world: `UI_Init` runs its file scope against it
|
||||
/// and enters the cascade itself (`0x490168`), so `PLAYER_LOGIN` then `PLAYER_ENTERING_WORLD` fire
|
||||
/// once each on the new VM, both seeing the player.
|
||||
#[test]
|
||||
fn a_reload_fires_player_login_once_against_the_live_player() {
|
||||
let _l = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (tmp, _c, _h) = hermetic_addon("relogin", "LoginProbe", LOGIN_PROBE_TOC, LOGIN_PROBE_LUA);
|
||||
let (mut app, _rx) = unit_feed_app();
|
||||
app.insert_resource(State::new(crate::char_select::ClientState::InWorld));
|
||||
app.insert_resource(roster_named("Probelogin", 1));
|
||||
super::load_ingame_ui_on_world_entry(app.world_mut());
|
||||
spawn_self_player(app.world_mut());
|
||||
app.update();
|
||||
assert_eq!(login_probe(&app, "order"), "LOGIN:1:17:1234 PEW ");
|
||||
|
||||
reload(app.world_mut(), crate::char_select::ClientState::InWorld);
|
||||
assert_eq!(
|
||||
(
|
||||
login_probe(&app, "fileExists"),
|
||||
login_probe(&app, "fileLevel")
|
||||
),
|
||||
("1".to_string(), "17".to_string()),
|
||||
"the reloaded file scope runs against the live player"
|
||||
);
|
||||
app.update();
|
||||
app.update();
|
||||
assert_eq!(
|
||||
login_probe(&app, "order"),
|
||||
"LOGIN:1:17:1234 PEW ",
|
||||
"the new VM gets PLAYER_LOGIN then PLAYER_ENTERING_WORLD, once each"
|
||||
);
|
||||
|
||||
drop(app);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
@@ -52,11 +52,12 @@ pub(crate) fn fontstring_em(height: Option<f32>) -> Option<f32> {
|
||||
height.map(|h| h.min(FONTSTRING_EM_CAP))
|
||||
}
|
||||
|
||||
/// The drawn pixel height of a UI FontString times the seam scale `s = windowH/768 × uiScale`
|
||||
/// (`crate::ui_script::seam_scale`), under the reference's two size regimes (getter `0x7727b0`):
|
||||
/// one-to-one, the default, draws the raster size ([`FONTSTRING_EM_CAP`] applied in units), and a
|
||||
/// `SetTextHeight` size (`text_height`) draws uncapped, the reference magnifying the raster to it
|
||||
/// (`0x771600` clears bit `0x200`; the drawn em is `round(size/768·deviceH)`).
|
||||
/// The drawn pixel height of a UI FontString at `s`, the seam scale `windowH/768`
|
||||
/// (`crate::ui_script::seam_scale`) times the owner's effective scale, the UI scale included,
|
||||
/// under the reference's two size regimes (getter `0x7727b0`): one-to-one, the default, draws the
|
||||
/// raster size ([`FONTSTRING_EM_CAP`] applied in units), and a `SetTextHeight` size
|
||||
/// (`text_height`) draws uncapped, the reference magnifying the raster to it (`0x771600` clears bit
|
||||
/// `0x200`; the drawn em is `round(size/768·deviceH)`).
|
||||
pub(crate) fn drawn_px(font_height: Option<f32>, text_height: Option<f32>, s: f32) -> Option<f32> {
|
||||
match text_height {
|
||||
Some(t) => Some(t * s),
|
||||
|
||||
@@ -114,8 +114,6 @@ fn drive_mouseover_tooltip(
|
||||
go_inputs: crate::target::lock::GoLockInputs,
|
||||
// The known-spell set the resolver's SKILL arm scans.
|
||||
player_actions: Res<crate::ui_action::PlayerActions>,
|
||||
// The cursor seat crosses the VM seam: the anchor below is UI units, not px.
|
||||
ui_scale: Res<crate::ui_script::UiScaleCvar>,
|
||||
mut memo: HoverMemo,
|
||||
// `ChrClasses.dbc` field 16, `UnitHasRelicSlot`'s input, and the form table the creature type
|
||||
// reads.
|
||||
@@ -229,15 +227,17 @@ fn drive_mouseover_tooltip(
|
||||
.templates
|
||||
.get(guid)
|
||||
.is_some_and(|t| t.floating_tooltip);
|
||||
// Window px to the VM's y-up UI units, as the input seam converts.
|
||||
// Window px to the VM's y-up root units, as the input seam converts.
|
||||
let cursor_ui = cursor_seated
|
||||
.then(|| {
|
||||
window.iter().next().and_then(|w| {
|
||||
let s = crate::ui_script::seam_scale(w.height(), ui_scale.0);
|
||||
// The hover probe's aim stands in for a missing cursor; a real pointer wins.
|
||||
w.cursor_position()
|
||||
.or_else(crate::target::hover_probe_point)
|
||||
.map(|c| (c.x / s, (w.height() - c.y) / s))
|
||||
.map(|c| {
|
||||
let at = crate::ui_script::window_to_ui(w.height(), c);
|
||||
(at.x, at.y)
|
||||
})
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
|
||||
@@ -882,7 +882,6 @@ fn mouseover_app() -> (App, Entity, Entity) {
|
||||
let mut app = App::new();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
app.insert_resource(NetCommands(tx))
|
||||
.insert_resource(crate::ui_script::UiScaleCvar(1.0))
|
||||
.init_resource::<Hovered>()
|
||||
.init_resource::<HoveredObject>()
|
||||
.init_resource::<NameCache>()
|
||||
|
||||
@@ -101,7 +101,7 @@ struct UnitFeedMemo {
|
||||
/// The lazy caches' landing counters: their per-frame `&mut` misses would trip `is_changed`.
|
||||
names_generation: gate::Watch,
|
||||
guild_generation: gate::Watch,
|
||||
/// Whether `PLAYER_ENTERING_WORLD` has fired for this world entry.
|
||||
/// Whether the world-enter events have fired for this world entry.
|
||||
entered_world: bool,
|
||||
/// Per token, the last snapshot pushed.
|
||||
last: HashMap<String, UnitState>,
|
||||
@@ -1194,6 +1194,75 @@ pub(crate) fn fire_transitions(
|
||||
}
|
||||
}
|
||||
|
||||
/// `"player"`'s snapshot off our own descriptor: no reaction (the unit is ours), the raid icon,
|
||||
/// both faction-group spellings and the guild. One builder for [`feed_units`] and for the seat a
|
||||
/// `/reload`'s load runs under ([`live_player_seat`]).
|
||||
fn player_snapshot(
|
||||
store: &ObjectStore,
|
||||
guid: u64,
|
||||
tables: &SnapshotTables,
|
||||
names: &NameCache,
|
||||
commands: &NetCommands,
|
||||
group: &crate::ui_party::GroupState,
|
||||
factions: Option<&Factions>,
|
||||
guild: &mut crate::ui_guild::GuildState,
|
||||
) -> UnitState {
|
||||
let name = names
|
||||
.resolve_unit(guid, Some(store), commands)
|
||||
.map(str::to_string);
|
||||
let mut s = snapshot(store, guid, name, 0, tables.classes(), tables.types(names));
|
||||
s.is_player = true;
|
||||
s.raid_target = group.raid_target_index(guid);
|
||||
s.faction_group = faction_group(store, factions);
|
||||
s.faction_group_localized = faction_group_localized(store, factions);
|
||||
// `GetGuildInfo`: the public guild fields (191/192) joined against the lazy guild cache.
|
||||
s.guild = crate::ui_guild::unit_guild(&store.0, guild, commands);
|
||||
s
|
||||
}
|
||||
|
||||
/// `UnitXP("player")` and `UnitXPMax("player")` off our own descriptor, zero while unsent.
|
||||
fn xp_pair(store: &ObjectStore) -> (u32, u32) {
|
||||
(
|
||||
store.0.player_xp().unwrap_or(0),
|
||||
store.0.player_next_level_xp().unwrap_or(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// The live player a UI load seats before any file runs: its snapshot and XP pair, as
|
||||
/// [`feed_units`] pushes them.
|
||||
pub(crate) struct LivePlayerSeat {
|
||||
pub(crate) state: UnitState,
|
||||
pub(crate) xp: (u32, u32),
|
||||
}
|
||||
|
||||
/// [`LivePlayerSeat`] for an exclusive edge, `None` before our own create. A `/reload` loads with
|
||||
/// the player object still in the world, so `UI_Init`'s file scope, `ADDON_LOADED` and
|
||||
/// `VARIABLES_LOADED` read it there as `PLAYER_LOGIN` does (`0x490168`).
|
||||
pub(crate) fn live_player_seat(
|
||||
tables: SnapshotTables,
|
||||
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
group: Res<crate::ui_party::GroupState>,
|
||||
factions: Option<Res<Factions>>,
|
||||
mut guild: ResMut<crate::ui_guild::GuildState>,
|
||||
) -> Option<LivePlayerSeat> {
|
||||
let (store, guid) = self_q.iter().next()?;
|
||||
Some(LivePlayerSeat {
|
||||
state: player_snapshot(
|
||||
store,
|
||||
guid.0,
|
||||
&tables,
|
||||
&names,
|
||||
&commands,
|
||||
&group,
|
||||
factions.as_deref(),
|
||||
&mut guild,
|
||||
),
|
||||
xp: xp_pair(store),
|
||||
})
|
||||
}
|
||||
|
||||
fn feed_units(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
tables: SnapshotTables,
|
||||
@@ -1280,17 +1349,16 @@ fn feed_units(
|
||||
// A missing unit is `None`, which `set_unit` clears; a name miss lands on a later frame.
|
||||
let self_pair = self_q.iter().next();
|
||||
let player = self_pair.map(|(store, guid)| {
|
||||
let name = names
|
||||
.resolve_unit(guid.0, Some(store), &commands)
|
||||
.map(str::to_string);
|
||||
let mut s = snapshot(store, guid.0, name, 0, chr, types);
|
||||
s.is_player = true;
|
||||
s.raid_target = group.raid_target_index(guid.0);
|
||||
s.faction_group = faction_group(store, factions.as_deref());
|
||||
s.faction_group_localized = faction_group_localized(store, factions.as_deref());
|
||||
// `GetGuildInfo`: the public guild fields (191/192) joined against the lazy guild cache.
|
||||
s.guild = crate::ui_guild::unit_guild(&store.0, &mut guild, &commands);
|
||||
s
|
||||
player_snapshot(
|
||||
store,
|
||||
guid.0,
|
||||
&tables,
|
||||
&names,
|
||||
&commands,
|
||||
&group,
|
||||
factions.as_deref(),
|
||||
&mut guild,
|
||||
)
|
||||
});
|
||||
// Log once when our template names no side, almost always GM mode (vmangos forces template
|
||||
// 35, group mask 0): every `UnitFactionGroup` surface loses its side, as in the reference.
|
||||
@@ -1390,12 +1458,11 @@ fn feed_units(
|
||||
}
|
||||
}
|
||||
|
||||
// The XP pair and `PLAYER_XP_UPDATE`, ahead of the `PLAYER_ENTERING_WORLD` fire so its
|
||||
// handlers read real values. The event fires on first sight too, at login and after a
|
||||
// `/reload`, where the reference's field watchers stay silent.
|
||||
// The XP pair and `PLAYER_XP_UPDATE`, ahead of the world-enter events so `PLAYER_LOGIN` and
|
||||
// `PLAYER_ENTERING_WORLD` handlers read real values. The event fires on first sight too, at
|
||||
// login and after a `/reload`, where the reference's field watchers stay silent.
|
||||
if let Some((store, _)) = self_q.iter().next() {
|
||||
let xp = store.0.player_xp().unwrap_or(0);
|
||||
let next = store.0.player_next_level_xp().unwrap_or(0);
|
||||
let (xp, next) = xp_pair(store);
|
||||
if memo.last_xp != Some((xp, next)) {
|
||||
gate.audit("feed_units", "the XP pair");
|
||||
memo.last_xp = Some((xp, next));
|
||||
@@ -1404,7 +1471,7 @@ fn feed_units(
|
||||
}
|
||||
}
|
||||
|
||||
// The rest snapshot (rest-state byte, pool, `PLAYER_FLAGS`), ahead of the entering-world fire
|
||||
// The rest snapshot (rest-state byte, pool, `PLAYER_FLAGS`), ahead of the world-enter events
|
||||
// as the reference's descriptor is. Below `0x5ee990`'s local-GUID gate (`0x5eea93`) each arm
|
||||
// tests its own bits: `PLAYER_UPDATE_RESTING` on either edge of `0x20` (`0x5eead0`, fire
|
||||
// `0x5eeaf2`), `PLAYTIME_CHANGED` on `0x3000` (`0x5eeb65`, fire `0x5eeb6f`).
|
||||
@@ -1495,12 +1562,16 @@ fn feed_units(
|
||||
}
|
||||
}
|
||||
|
||||
// `PLAYER_ENTERING_WORLD` once per world entry, after our descriptor lands, as the reference's.
|
||||
// At world exit, re-arm it and forget the player-global memos so the next character seeds them.
|
||||
// The world-enter cascade's events once per world entry, after our descriptor lands and the
|
||||
// pushes above seat it: the reference runs the cascade from the local player's own create
|
||||
// (`0x5deb60 call 0x4908c0`) and, on a `/reload`, from `UI_Init` (`0x490168`), the player in
|
||||
// the world both times. `PLAYER_LOGIN` only when this VM's load armed it, so a worldport's
|
||||
// re-entry fires `PLAYER_ENTERING_WORLD` alone. At world exit, re-arm the entry and forget the
|
||||
// player-global memos so the next character seeds them.
|
||||
if self_pair.is_some() {
|
||||
if !memo.entered_world {
|
||||
gate.audit("feed_units", "the PLAYER_ENTERING_WORLD arm");
|
||||
script.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||||
gate.audit("feed_units", "the world-enter events");
|
||||
script.fire_world_enter();
|
||||
memo.entered_world = true;
|
||||
}
|
||||
} else if memo.entered_world {
|
||||
|
||||
@@ -710,7 +710,6 @@ fn dev_map_jump(
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
|
||||
ui_scale: Res<crate::ui_script::UiScaleCvar>,
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
data: Option<Res<WorldMapUiData>>,
|
||||
net: Res<crate::net::NetCommands>,
|
||||
@@ -729,10 +728,9 @@ fn dev_map_jump(
|
||||
let Some(cursor) = window.cursor_position() else {
|
||||
return;
|
||||
};
|
||||
// Window px (y-down) to the VM's y-up 768-unit space, as the pointer feed converts them.
|
||||
let s = crate::ui_script::seam_scale(window.height(), ui_scale.0);
|
||||
let Some((u, v)) = script.world_map_uv_at(cursor.x / s, (window.height() - cursor.y) / s)
|
||||
else {
|
||||
// Window px (y-down) to the VM's y-up root units, as the pointer feed converts them.
|
||||
let at = crate::ui_script::window_to_ui(window.height(), cursor);
|
||||
let Some((u, v)) = script.world_map_uv_at(at.x, at.y) else {
|
||||
return;
|
||||
};
|
||||
let (c, z, direct) = script.world_map_selection();
|
||||
|
||||
@@ -308,7 +308,6 @@ fn drive_vplates(
|
||||
net_commands: Res<NetCommands>,
|
||||
// `None` in a run with no UI VM.
|
||||
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
ui_scale: Res<crate::ui_script::UiScaleCvar>,
|
||||
anchor_q: (
|
||||
Query<&BoneAttach>,
|
||||
Query<&benilla_world::rig_anim::RigPose>,
|
||||
@@ -374,10 +373,8 @@ fn drive_vplates(
|
||||
|
||||
let (pw, ph) = (gx(PLATE_W), gx(PLATE_H));
|
||||
let scale = window.map_or(1.0, |w| w.scale_factor());
|
||||
// Everything below is in window px, divided by this once for the widget layer's units.
|
||||
let seam = window.map_or(1.0, |w| {
|
||||
crate::ui_script::seam_scale(w.height(), ui_scale.0)
|
||||
});
|
||||
// Everything below is in window px, divided by this once for the widget layer's root units.
|
||||
let seam = window.map_or(1.0, |w| crate::ui_script::seam_scale(w.height()));
|
||||
let mut states: Vec<PlateState> = Vec::new();
|
||||
|
||||
// Plates seat in squared-distance order from the gx point (0.4, 0.3), Y mirrored from the
|
||||
@@ -541,9 +538,8 @@ fn drive_vplates(
|
||||
}
|
||||
// ── The plate's state, handed to the widget layer ──
|
||||
//
|
||||
// The reference's plates sit outside the uiScale cascade, but benilla folds uiScale into
|
||||
// one global seam, so dividing by it here keeps plate pixels uiScale-blind.
|
||||
// The raid-target index is 1-based here, 0-based for the atlas cell.
|
||||
// The plates hang off `WorldFrame`, outside `UIParent`'s scale, so root units are their
|
||||
// own. The raid-target index is 1-based here, 0-based for the atlas cell.
|
||||
let mark = group.raid_target_index(guid.0);
|
||||
let x_units = (plate.min.x + plate.max.x) * 0.5 / seam;
|
||||
let y_units = (viewport.y - plate.min.y) / seam;
|
||||
|
||||
@@ -374,10 +374,9 @@ fn only_the_bids_pane_gets_its_page_without_being_opened() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// The reference's show cascade `0x76ae10` is post-order: a frame marks itself visible
|
||||
/// (`0x76ae7b`), walks its children re-reading live links, and fires its own `OnShow` last
|
||||
/// (`0x76aef5`). benilla's is pre-order, a gap in the engine; for this window both orders reach
|
||||
/// the same state, so only the `ShowOrder` assertions are benilla's order.
|
||||
/// The show cascade `0x76ae10` is post-order: a frame marks itself visible (`0x76ae7b`), walks its
|
||||
/// children re-reading live links, and fires its own `OnShow` last (`0x76aef5`), so the pane the
|
||||
/// XML leaves shown is notified before the window.
|
||||
#[test]
|
||||
fn the_show_cascade_notifies_the_bids_pane_once_and_keeps_its_page() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
@@ -408,11 +407,7 @@ fn the_show_cascade_notifies_the_bids_pane_once_and_keeps_its_page() {
|
||||
};
|
||||
|
||||
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
|
||||
assert_eq!(
|
||||
order(&s),
|
||||
"window,pane",
|
||||
"OURS, and a known deviation: the reference's cascade is post-order, \"pane,window\""
|
||||
);
|
||||
assert_eq!(order(&s), "pane,window", "children first, the window last");
|
||||
assert_eq!(
|
||||
page(&s),
|
||||
"0",
|
||||
@@ -431,7 +426,7 @@ fn the_show_cascade_notifies_the_bids_pane_once_and_keeps_its_page() {
|
||||
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
|
||||
assert_eq!(
|
||||
order(&s),
|
||||
"window,pane,window",
|
||||
"pane,window,window",
|
||||
"the reopen notifies the window and not the pane it left hidden"
|
||||
);
|
||||
assert_eq!(page(&s), "0", "kept from the first open, not re-assigned");
|
||||
|
||||
@@ -315,8 +315,10 @@ fn element_from_node(node: roxmltree::Node) -> Element {
|
||||
/// attributes override or extend the template's (case-insensitively). The reference merges
|
||||
/// nothing: `LoadXML` re-enters itself on the one object against each template, the most distant
|
||||
/// first, then the instance (`0x76985c`; `<Frames>` repeat the walk, `0x76a060`), and reads `name`
|
||||
/// and `id` from the instance alone (`PreLoadXML 0x769770`). Here `name` and `virtual` merge like
|
||||
/// any attribute. A cycle is skipped with a warning.
|
||||
/// and `id` from the instance alone (`PreLoadXML 0x769770`; a region's factory reads its `name`
|
||||
/// once, outside the walk, `0x6f2751`/`0x6f27e1`). So here `name` and `id` come from the element
|
||||
/// alone, and an unnamed instance stays unnamed; `virtual` merges like any attribute, read only at
|
||||
/// registration. A cycle is skipped with a warning.
|
||||
pub fn expand(
|
||||
element: &Element,
|
||||
templates: &HashMap<&str, &Element>,
|
||||
@@ -390,9 +392,15 @@ fn expand_inner(
|
||||
}
|
||||
}
|
||||
|
||||
/// [`expand`]'s merge; `over`'s tag wins, and its body when it has one.
|
||||
/// [`expand`]'s merge; `over`'s tag wins, and its body when it has one. `base`'s `name` and `id`
|
||||
/// are dropped: they belong to the node they are written on.
|
||||
fn merge(base: &Element, over: &Element) -> Element {
|
||||
let mut attrs = base.attrs.clone();
|
||||
let mut attrs: Vec<(String, String)> = base
|
||||
.attrs
|
||||
.iter()
|
||||
.filter(|(k, _)| !k.eq_ignore_ascii_case("name") && !k.eq_ignore_ascii_case("id"))
|
||||
.cloned()
|
||||
.collect();
|
||||
for (k, v) in &over.attrs {
|
||||
if let Some(slot) = attrs.iter_mut().find(|(ek, _)| ek.eq_ignore_ascii_case(k)) {
|
||||
slot.1 = v.clone();
|
||||
|
||||
@@ -642,7 +642,7 @@ impl Loader<'_> {
|
||||
}
|
||||
|
||||
/// Everything a frame element does to an existing frame, from `LoadXML` attributes to its
|
||||
/// `OnLoad` (last, bottom-up, `0x76a060`); shared by [`Self::materialize`] and
|
||||
/// `OnLoad` (bottom-up, `0x76a060`) and the post-load OnShow; shared by [`Self::materialize`] and
|
||||
/// [`apply_template`]. `$parent` is `self_name` in this frame's contents and `parent_name` in
|
||||
/// its own anchors (`0x76c5b0`).
|
||||
pub(super) fn decorate(
|
||||
@@ -655,6 +655,9 @@ impl Loader<'_> {
|
||||
) {
|
||||
// This frame's deferred anchors drain before its OnLoad; a nested frame drains its own.
|
||||
let deferred_mark = self.deferred_anchors.len();
|
||||
// 1c · the pre-load hook opens the frame's load window (`0x7697c2`): until the post-load
|
||||
// hook, its own OnShow and OnHide fire nothing.
|
||||
crate::script::visibility::set_load_window(self.lua(), wrapper, true);
|
||||
// 2 · LoadXML attributes (`0x769820`).
|
||||
self.apply_attrs(el, wrapper, dbg_name);
|
||||
// 3 · <Size> and 4 · <Anchors> (the CLayoutFrame geometry base, `0x767800`).
|
||||
@@ -681,7 +684,9 @@ impl Loader<'_> {
|
||||
// 6 · <Scripts> handlers (`0x769ef0`); OnLoad is captured to fire bottom-up below.
|
||||
let onload = self.apply_scripts(el, wrapper, dbg_name);
|
||||
|
||||
// 7 · nested <Frames>, whose OnLoads fire before ours (`0x76a060`).
|
||||
// 7 · the post-load hook (`0x76a2f0`): close the window (`0x76a2ff`), then the nested
|
||||
// <Frames>, whose OnLoads fire before ours (`0x76a060`).
|
||||
crate::script::visibility::set_load_window(self.lua(), wrapper, false);
|
||||
for frames_el in children_named(el, "Frames") {
|
||||
for child in &frames_el.children {
|
||||
let expanded = self.expand(child);
|
||||
@@ -717,6 +722,8 @@ impl Loader<'_> {
|
||||
if let Some(func) = onload {
|
||||
self.fire_onload(wrapper, &func, dbg_name);
|
||||
}
|
||||
// 9 · and its OnShow, if it is visible now (`0x76a3b3`).
|
||||
crate::script::visibility::post_load_show(self.lua(), wrapper);
|
||||
}
|
||||
|
||||
/// Apply one anchor, deferring it (when `may_defer`) while its named `relativeTo` is unbuilt.
|
||||
@@ -898,5 +905,7 @@ pub(super) fn font_object_from_element(el: &Element) -> FontObject {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod template_name_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! `name=` and `id=` belong to the node they are written on: `PreLoadXML 0x769770` reads them from
|
||||
//! the instance before `LoadXML` walks its templates (`0x76985c`), and the region factories read
|
||||
//! `name` once, outside that walk (`0x6f2751`, `0x6f27e1`).
|
||||
|
||||
use crate::framexml;
|
||||
use crate::loader::load;
|
||||
use crate::script::UiScript;
|
||||
|
||||
const DOC: &str = r#"<Ui>
|
||||
<Button name="TmpCloseTemplate" id="3" virtual="true">
|
||||
<Size><AbsDimension x="32" y="32"/></Size>
|
||||
</Button>
|
||||
<Texture name="TmpTexTemplate" virtual="true">
|
||||
<Color r="1" g="0" b="0" a="1"/>
|
||||
</Texture>
|
||||
<Frame name="Holder">
|
||||
<Size><AbsDimension x="64" y="64"/></Size>
|
||||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||||
<Layers>
|
||||
<Layer level="ARTWORK">
|
||||
<Texture inherits="TmpTexTemplate"/>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<Button inherits="TmpCloseTemplate"/>
|
||||
<Button name="$parentClose" inherits="TmpCloseTemplate"/>
|
||||
</Frames>
|
||||
</Frame>
|
||||
</Ui>"#;
|
||||
|
||||
fn loaded() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
let report = load(&s, &framexml::parse(DOC).unwrap(), &|_| None);
|
||||
assert!(report.errors.is_empty(), "{:?}", report.errors);
|
||||
s
|
||||
}
|
||||
|
||||
/// Stock `UIOptionsFrame.xml:1477`'s unnamed `<Button inherits="UIPanelCloseButton">` is the
|
||||
/// shape: in 1.12.1 the template's name is no global and the button answers no name.
|
||||
#[test]
|
||||
fn an_unnamed_instance_takes_neither_its_templates_name_nor_its_id() {
|
||||
let s = loaded();
|
||||
assert!(
|
||||
s.eval::<bool>("return TmpCloseTemplate == nil").unwrap(),
|
||||
"the unnamed instance published the template's name as a global"
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>("return ({Holder:GetChildren()})[1]:GetName() == nil")
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return ({Holder:GetChildren()})[1]:GetID()")
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_instance_keeps_its_own_name_and_takes_no_template_id() {
|
||||
let s = loaded();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return HolderClose:GetName()").unwrap(),
|
||||
"HolderClose"
|
||||
);
|
||||
assert_eq!(s.eval::<i64>("return HolderClose:GetID()").unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unnamed_region_takes_no_name_from_its_template() {
|
||||
let s = loaded();
|
||||
assert!(s.eval::<bool>("return TmpTexTemplate == nil").unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("return ({Holder:GetRegions()})[1]:GetName() == nil")
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// `CreateFrame` builds its node with the `name` it was given (`0x70622d`), nil included.
|
||||
#[test]
|
||||
fn create_frame_with_no_name_stays_unnamed_under_a_named_template() {
|
||||
let s = loaded();
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local b = CreateFrame('Button', nil, Holder, 'TmpCloseTemplate') \
|
||||
return b:GetName() == nil and TmpCloseTemplate == nil"
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
@@ -2470,6 +2470,112 @@ mod chunk_name_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The post-load hook's OnShow (`0x76a2f0`), for a frame visible at the end of its load.
|
||||
mod post_load_show_tests {
|
||||
use crate::framexml;
|
||||
use crate::loader::*;
|
||||
use crate::script::UiScript;
|
||||
|
||||
fn no_files(_: &str) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn parse(text: &str) -> framexml::ParsedDocument {
|
||||
framexml::parse(text).expect("valid FrameXML")
|
||||
}
|
||||
|
||||
/// The post-load hook `0x76a2f0` builds the `<Frames>` children (`0x76a36f`, their own hooks
|
||||
/// inside), fires OnLoad (`0x76a387`), then fires OnShow (`0x76a3b3`) when the frame is visible
|
||||
/// (`0x76a38c`) and has the handler (`0x76a396`): a frame visible at the end of its load gets
|
||||
/// one OnShow, after its OnLoad and its children's.
|
||||
#[test]
|
||||
fn a_frame_visible_at_the_end_of_its_load_gets_one_onshow_after_its_onload() {
|
||||
let s = UiScript::new().unwrap();
|
||||
s.run("LOG = {} function Log(tag) table.insert(LOG, tag) end")
|
||||
.unwrap();
|
||||
let doc = parse(
|
||||
r#"<Ui>
|
||||
<Frame name="Born">
|
||||
<Scripts>
|
||||
<OnLoad>Log("load")</OnLoad>
|
||||
<OnShow>Log("show")</OnShow>
|
||||
</Scripts>
|
||||
<Frames>
|
||||
<Frame name="BornChild">
|
||||
<Scripts>
|
||||
<OnLoad>Log("child-load")</OnLoad>
|
||||
<OnShow>Log("child-show")</OnShow>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
</Frames>
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
);
|
||||
let report = load_in(&s, &doc, "Test.xml", &no_files);
|
||||
assert!(report.errors.is_empty(), "{:?}", report.errors);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
|
||||
"child-load,child-show,load,show"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The post-load OnShow is keyed on the frame's visibility at `0x76a38c`, and nothing records
|
||||
/// an OnShow already given: a frame first shown by its own OnLoad gets that `Show()`'s OnShow
|
||||
/// and then the hook's, while a frame still hidden gets none.
|
||||
#[test]
|
||||
fn a_frame_shown_by_its_onload_gets_two_onshows_and_a_hidden_one_none() {
|
||||
let s = UiScript::new().unwrap();
|
||||
s.run("LOG = {} function Log(tag) table.insert(LOG, tag) end")
|
||||
.unwrap();
|
||||
let doc = parse(
|
||||
r#"<Ui>
|
||||
<Frame name="ShownByLoad" hidden="true">
|
||||
<Scripts>
|
||||
<OnLoad>Log("a-load") this:Show() Log("a-load-end")</OnLoad>
|
||||
<OnShow>Log("a-show")</OnShow>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<Frame name="StaysHidden" hidden="true">
|
||||
<Scripts>
|
||||
<OnLoad>Log("b-load")</OnLoad>
|
||||
<OnShow>Log("b-show")</OnShow>
|
||||
<OnHide>Log("b-hide")</OnHide>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
);
|
||||
let report = load_in(&s, &doc, "Test.xml", &no_files);
|
||||
assert!(report.errors.is_empty(), "{:?}", report.errors);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
|
||||
"a-load,a-show,a-load-end,a-show,b-load"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The post-load OnShow is the script alone (`0x76a3b3` calls the runner `0x702690`, not the
|
||||
/// notify slot `+0x30`), so an `autoFocus` EditBox born visible runs its OnShow and still takes
|
||||
/// no keyboard: the focus is the EditBox notify's (`0x77a750`), which only a transition reaches.
|
||||
#[test]
|
||||
fn the_post_load_onshow_runs_the_script_without_the_editbox_autofocus() {
|
||||
let s = UiScript::new().unwrap();
|
||||
let doc = parse(
|
||||
r#"<Ui>
|
||||
<EditBox name="BornBox" autoFocus="true">
|
||||
<Size><AbsDimension x="100" y="20"/></Size>
|
||||
<Scripts><OnShow>BOX_SHOWN = true</OnShow></Scripts>
|
||||
</EditBox>
|
||||
</Ui>"#,
|
||||
);
|
||||
let report = load_in(&s, &doc, "Test.xml", &no_files);
|
||||
assert!(report.errors.is_empty(), "{:?}", report.errors);
|
||||
assert!(s.eval::<bool>("return BOX_SHOWN == true").unwrap());
|
||||
assert_eq!(s.focused_editbox_name(), None);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_ref_resolves_relative_framexml_paths() {
|
||||
use super::join_ref;
|
||||
|
||||
@@ -123,18 +123,25 @@ fn aa_corners(x0: f32, x1: f32, y_bottom: f32, y_top: f32) -> [[f32; 2]; 4] {
|
||||
|
||||
/// The drawable pieces for a frame's resolved rect (y-up), in paint order: the background (layer
|
||||
/// `BACKGROUND`), then the 8 border pieces (layer `BORDER`). Geometry `0x77e8d0`, UVs `0x77f0c0`.
|
||||
pub fn pieces(frame: Rect, bd: &Backdrop) -> Vec<BackdropPiece> {
|
||||
/// The pieces are textures anchored and sized in the frame's own units, so the layout multiplies
|
||||
/// their geometry by the frame's effective `scale` (`0x76ac90`), as it does any region's. The
|
||||
/// texcoord repeats are not: `0x77f0c0` divides the scaled rect by the stored, unscaled
|
||||
/// `edgeSize` and `tileSize` (`0x77f101 fdiv [esi+0x48]`, `0x77f195`), so a scaled frame's edge
|
||||
/// and background repeat the art more or less often than at scale 1.
|
||||
pub fn pieces(frame: Rect, bd: &Backdrop, scale: f32) -> Vec<BackdropPiece> {
|
||||
let mut out = Vec::with_capacity(9);
|
||||
let e = bd.edge_size;
|
||||
let e = bd.edge_size * scale;
|
||||
// The texcoord divisor, unscaled.
|
||||
let e_uv = bd.edge_size;
|
||||
let (l, r, b, t) = (frame.left, frame.right, frame.bottom, frame.top);
|
||||
|
||||
// ── The background piece ──
|
||||
if bd.has_bg() {
|
||||
let (il, ir, it, ib) = (
|
||||
bd.insets.left,
|
||||
bd.insets.right,
|
||||
bd.insets.top,
|
||||
bd.insets.bottom,
|
||||
bd.insets.left * scale,
|
||||
bd.insets.right * scale,
|
||||
bd.insets.top * scale,
|
||||
bd.insets.bottom * scale,
|
||||
);
|
||||
// The four anchors, y-up. Bottom-right takes the top inset, as the reference does
|
||||
// (`0x77e9ae` loads `+0x50`, top, not `+0x54`); invisible when the two are equal.
|
||||
@@ -146,7 +153,11 @@ pub fn pieces(frame: Rect, bd: &Backdrop) -> Vec<BackdropPiece> {
|
||||
];
|
||||
// Untiled, no `SetTexCoord` runs and the background stretches over `[0,1]` (`0x77f0c0`).
|
||||
let uvs = if bd.tile {
|
||||
let period = if bd.tile_size != 0.0 { bd.tile_size } else { e };
|
||||
let period = if bd.tile_size != 0.0 {
|
||||
bd.tile_size
|
||||
} else {
|
||||
e_uv
|
||||
};
|
||||
let bg_w = (r - ir) - (l + il);
|
||||
let bg_h = (t - it) - (b + ib);
|
||||
let (wt, ht) = (bg_w / period, bg_h / period);
|
||||
@@ -165,8 +176,8 @@ pub fn pieces(frame: Rect, bd: &Backdrop) -> Vec<BackdropPiece> {
|
||||
// ── The 8 border pieces ──
|
||||
if bd.has_border() {
|
||||
// Edge strips tile `run` periods between the corners, 0 below two edges (`0x77f0c0`).
|
||||
let w_run = ((r - l) / e - 2.0).max(0.0);
|
||||
let h_run = ((t - b) / e - 2.0).max(0.0);
|
||||
let w_run = ((r - l) / e_uv - 2.0).max(0.0);
|
||||
let h_run = ((t - b) / e_uv - 2.0).max(0.0);
|
||||
|
||||
let mut edge = |x0: f32, x1: f32, y0: f32, y1: f32, uvs: [[f32; 2]; 4]| {
|
||||
out.push(BackdropPiece {
|
||||
@@ -311,7 +322,7 @@ mod tests {
|
||||
fn piece_geometry_200x100_edge16_inset5() {
|
||||
let frame = Rect::new(0.0, 0.0, 100.0, 200.0);
|
||||
let bd = tooltip_backdrop(16.0, 5.0);
|
||||
let ps = pieces(frame, &bd);
|
||||
let ps = pieces(frame, &bd, 1.0);
|
||||
assert_eq!(ps.len(), 9);
|
||||
|
||||
// Symmetric insets hide the bottom-right quirk.
|
||||
@@ -365,7 +376,7 @@ mod tests {
|
||||
},
|
||||
..tooltip_backdrop(16.0, 0.0)
|
||||
};
|
||||
let bg = pieces(frame, &bd)[0];
|
||||
let bg = pieces(frame, &bd, 1.0)[0];
|
||||
assert_eq!(bg.corners[3], [3.0, 9.0]);
|
||||
// BR takes the top inset, 7, not the bottom's 9.
|
||||
assert_eq!(bg.corners[2], [196.0, 7.0]);
|
||||
@@ -378,7 +389,7 @@ mod tests {
|
||||
// 20/16 - 2 = -0.75.
|
||||
let frame = Rect::new(0.0, 0.0, 20.0, 20.0);
|
||||
let bd = tooltip_backdrop(16.0, 5.0);
|
||||
let ps = pieces(frame, &bd);
|
||||
let ps = pieces(frame, &bd, 1.0);
|
||||
let left = ps[1]; // LEFT edge
|
||||
assert_eq!(left.uvs[3][1], 0.0, "h_run clamped to 0"); // BL v
|
||||
let top = ps[3]; // TOP edge
|
||||
@@ -388,16 +399,34 @@ mod tests {
|
||||
#[test]
|
||||
fn presence_gates_pieces() {
|
||||
let frame = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
assert!(pieces(frame, &Backdrop::default()).is_empty());
|
||||
assert!(pieces(frame, &Backdrop::default(), 1.0).is_empty());
|
||||
let bg_only = Backdrop {
|
||||
bg_file: Some("bg".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(pieces(frame, &bg_only).len(), 1);
|
||||
assert_eq!(pieces(frame, &bg_only, 1.0).len(), 1);
|
||||
let border_only = Backdrop {
|
||||
edge_file: Some("edge".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(pieces(frame, &border_only).len(), 8);
|
||||
assert_eq!(pieces(frame, &border_only, 1.0).len(), 8);
|
||||
}
|
||||
|
||||
/// A half-scale frame: the geometry halves, but `0x77f0c0` divides the scaled rect by the
|
||||
/// unscaled sizes, so a 100-wide (on screen) edge repeats `100/16 - 2` times and the 96-wide
|
||||
/// background `96/16` times.
|
||||
#[test]
|
||||
fn a_scaled_frames_texcoords_repeat_by_the_unscaled_sizes() {
|
||||
let frame = Rect::new(0.0, 0.0, 50.0, 100.0);
|
||||
let bd = tooltip_backdrop(16.0, 4.0);
|
||||
let ps = pieces(frame, &bd, 0.5);
|
||||
let bg = ps[0];
|
||||
assert_eq!(bg.corners[0], [2.0, 48.0], "inset 4 units, 2 on screen");
|
||||
assert_eq!(bg.uvs[2], [6.0, 46.0 / 16.0]);
|
||||
// TOP, slice 2, rotated: `u` holds the run.
|
||||
let top = ps[3];
|
||||
assert_eq!(top.corners[0][1], 50.0);
|
||||
assert_eq!(top.corners[2][1], 42.0, "8 thick on screen");
|
||||
assert_eq!(top.uvs[0][1], 100.0 / 16.0 - 2.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,6 +798,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The markers are textures of the picker, sized in its units, so a half-scale picker draws
|
||||
/// them at half size: 5 and 24 on screen.
|
||||
#[test]
|
||||
fn the_markers_scale_with_the_picker() {
|
||||
let mut s = picker();
|
||||
s.run("TestPicker:SetScale(0.5); TestPicker:SetColorRGB(1, 0, 0)")
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let wheel = marker_rect(&s, 5.0).expect("the wheel marker, 10 units at half scale");
|
||||
assert!((wheel.top - wheel.bottom - 5.0).abs() < 0.01, "{wheel:?}");
|
||||
let value = marker_rect(&s, 24.0).expect("the value marker, 48 units at half scale");
|
||||
assert!((value.top - value.bottom - 7.0).abs() < 0.01, "{value:?}");
|
||||
}
|
||||
|
||||
/// The extracted rect of the marker `width` wide: the two share one BLP and differ by size, 10
|
||||
/// for the wheel's and 48 for the strip's.
|
||||
fn marker_rect(s: &UiScript, width: f32) -> Option<crate::layout::Rect> {
|
||||
|
||||
@@ -64,7 +64,7 @@ fn caret(lua: &Lua, h: FrameHandle) -> bool {
|
||||
placed
|
||||
}
|
||||
|
||||
/// `OnCursorChanged(x, y, w, h)` (`0x77de1b`, in UI units via `0x77dd5f`), `x` the caret's advance
|
||||
/// `OnCursorChanged(x, y, w, h)` (`0x77de1b`, in the box's own units via `0x77dd5f`), `x` the caret's advance
|
||||
/// along its line, `y` minus the row index times the row pitch, `w` the constant 4.0 and `h` the
|
||||
/// line height. The arguments are computed only for a box with the script (`0x77dd8c`), from the
|
||||
/// box's advance table, which the installed font engine measures here when the text or font
|
||||
@@ -103,6 +103,14 @@ fn cursor_changed(lua: &Lua, h: FrameHandle) -> bool {
|
||||
let Some((x, y, pitch)) = args else {
|
||||
return false;
|
||||
};
|
||||
// The advance table is in root units (measured at the box's scale, divided by the seam
|
||||
// alone); the handler gets the box's own, the reference dividing by its effective scale
|
||||
// (`0x77db23 fdiv [esi+0x7c]`).
|
||||
let k = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
crate::script::object::eff_scale(&model, h)
|
||||
};
|
||||
let (x, y, pitch) = (x / k, y / k, pitch / k);
|
||||
if let Err(e) = event::fire_widget_handler(
|
||||
lua,
|
||||
id,
|
||||
|
||||
@@ -509,3 +509,49 @@ fn on_cursor_changed_reports_the_first_row_at_plus_zero() {
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// [`Mono`] as the host answers a scaled box: glyphs 7 and lines 14 in the box's own units, the
|
||||
/// table in root units (measured at the box's scale, divided by the seam alone).
|
||||
struct ScaledMono;
|
||||
|
||||
impl TextMeasure for ScaledMono {
|
||||
fn measure(&mut self, req: &MeasureRequest) -> (f32, f32, f32) {
|
||||
Mono.measure(req)
|
||||
}
|
||||
|
||||
fn editbox_advances(
|
||||
&mut self,
|
||||
req: &EditBoxAdvanceRequest,
|
||||
) -> Option<(Vec<f32>, Vec<usize>, f32)> {
|
||||
let (cum, rows, pitch) = Mono.editbox_advances(req)?;
|
||||
let k = req.scale;
|
||||
Some((cum.into_iter().map(|a| a * k).collect(), rows, pitch * k))
|
||||
}
|
||||
}
|
||||
|
||||
/// `OnCursorChanged` answers in the box's own units: the reference divides by its effective scale
|
||||
/// (`0x77db23 fdiv [esi+0x7c]`), so a half-scale box reports what a full-scale one does.
|
||||
#[test]
|
||||
fn on_cursor_changed_answers_in_the_boxs_own_units() {
|
||||
let mut s = script();
|
||||
s.set_text_measurer(Box::new(ScaledMono));
|
||||
logged_box(&mut s);
|
||||
s.run(
|
||||
r#"
|
||||
E:SetScale(0.5)
|
||||
E:SetScript("OnCursorChanged", function()
|
||||
table.insert(LOG, format("cursor@%g,%g,%g", arg1, arg2, arg4))
|
||||
end)
|
||||
E:SetMultiLine(true)
|
||||
E:SetText("abc\nd")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.tick(0.016);
|
||||
assert_eq!(
|
||||
take_log(&s),
|
||||
"early update cursor@7,-14,14 text:abc\nd late",
|
||||
"one glyph along the second row, a row pitch down, one pitch tall"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ use mlua::{Function, Lua, Table, Value};
|
||||
|
||||
use super::{Model, ScriptValue, REG_SCRIPTS};
|
||||
use crate::script::object::frame_wrapper;
|
||||
use crate::widget::{ButtonState, FrameHandle};
|
||||
|
||||
/// `UiScript::fire_event` for engine code that holds only the Lua context.
|
||||
pub(super) fn fire_global(lua: &Lua, event: &str, args: &[ScriptValue]) {
|
||||
@@ -108,75 +107,6 @@ pub(super) fn fire_size_changes(lua: &Lua) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire `OnShow`/`OnHide` for the frames whose effective visibility just changed, recording
|
||||
/// errors in [`Model::errors`]. A shown `toplevel` frame raises here, after the subtree has
|
||||
/// propagated and before its `OnShow` (`0x76ae10` at `0x76aee0`), so a show that does not come
|
||||
/// through Lua `Show` raises too.
|
||||
pub(super) fn fire_visibility_changes(lua: &Lua, changed: Vec<FrameHandle>) {
|
||||
// Resolve (handle, id, now-visible?) under one short borrow, then fire with no borrow held.
|
||||
let items: Vec<(FrameHandle, u32, bool)> = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
changed
|
||||
.into_iter()
|
||||
.filter_map(|h| {
|
||||
let vis = model.arena.frame(h)?.effective_visible;
|
||||
Some((h, model.frame_id(h), vis))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// Hiding the hovered frame, directly or by an ancestor, fires its `OnLeave` inside the hide and
|
||||
// before its `OnHide` (`0x764ba0`'s tail in `0x76ad50`, the leave at `0x764cce`), clears the
|
||||
// hover and the drag-arm (`+0x100`/`+0x104`) and arms the re-pick; a show arms it too
|
||||
// (`0x764b8d`). This leave is how a tooltip closes with its window; the reference has no other.
|
||||
let left: Option<(FrameHandle, u32)> = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let hidden_hover = model
|
||||
.mouseover
|
||||
.filter(|&m| items.iter().any(|&(h, _, vis)| h == m && !vis));
|
||||
// The leave is a virtual call (`0x764cce`, `[vtable+0x50]`): a disabled Button's own
|
||||
// `0x7794e0` skips its `OnLeave`, while the hover and the drag-arm still clear.
|
||||
let notified = super::button::hover_notify_runs(&model, hidden_hover);
|
||||
if let Some(m) = hidden_hover {
|
||||
model.mouseover = None;
|
||||
if model.drag.as_ref().is_some_and(|d| d.source == m) {
|
||||
model.drag = None;
|
||||
}
|
||||
}
|
||||
model.hover_repick |= hidden_hover.is_some() || items.iter().any(|&(_, _, vis)| vis);
|
||||
hidden_hover
|
||||
.filter(|_| notified)
|
||||
.map(|m| (m, model.frame_id(m)))
|
||||
};
|
||||
if let Some((_, oid)) = left {
|
||||
if let Err(e) = fire_widget_handler(lua, oid, "OnLeave", vec![Value::Boolean(true)]) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model")
|
||||
.record_script_error(e.to_string());
|
||||
}
|
||||
}
|
||||
for (h, id, visible) in items {
|
||||
if visible {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
super::object::toplevel::raise_on_show(&mut model, h);
|
||||
} else {
|
||||
// `CSimpleButton`'s hide notify (`+0x34`, `0x7791e0`) un-presses it, unless disabled
|
||||
// or locked, before the base notify, so a button hidden while held comes back unpushed.
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
super::button::edge(&mut model, h, ButtonState::on_hide);
|
||||
}
|
||||
let name = if visible { "OnShow" } else { "OnHide" };
|
||||
if let Err(e) = fire(lua, id, name, None, Vec::new()) {
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model")
|
||||
.record_script_error(e.to_string());
|
||||
}
|
||||
// The EditBox show/hide overrides (`0x81c910` `+0x30`/`+0x34`) call the base notify first,
|
||||
// so this runs after the `fire`: an `autoFocus` box takes a free focus, a hidden box
|
||||
// releases its own.
|
||||
super::editbox::visibility_focus(lua, h, visible);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a handler is bound under `script`, without firing it: the keyboard walk consumes on
|
||||
/// the slot's presence (`0x76b7d0`). A failed lookup reads absent, so the gate never raises.
|
||||
pub(super) fn has_widget_handler(lua: &Lua, id: u32, script: &str) -> bool {
|
||||
|
||||
@@ -130,9 +130,10 @@ impl UiScript {
|
||||
owner_frame.map(|f| &f.kind_state)
|
||||
{
|
||||
if sl.thumb == Some(rh) {
|
||||
// The thumb's own `CSimpleTexture::GetWidth`/`GetHeight`, as the drag
|
||||
// reads them (`slider::thumb_extent`), not its authored `<Size>`.
|
||||
let tsize = super::region::virtual_span(&model, rh);
|
||||
// The thumb's own `CSimpleTexture::GetWidth`/`GetHeight` at the
|
||||
// slider's scale, as the drag reads them (`slider::thumb_extent`), not
|
||||
// its authored `<Size>`.
|
||||
let tsize = super::region::screen_span(&model, rh);
|
||||
rect = rect
|
||||
.map(|r| slider::thumb_rect(r, tsize, sl.vertical, sl.fraction()));
|
||||
thumb_fill = true;
|
||||
@@ -145,7 +146,13 @@ impl UiScript {
|
||||
if let Some(crate::widget::KindState::ColorSelect(cs)) =
|
||||
owner_frame.map(|f| &f.kind_state)
|
||||
{
|
||||
let tsize = model.region_data.get(&rh).and_then(|d| d.size);
|
||||
// The markers' authored size, in the picker's units.
|
||||
let scale = owner_frame.map_or(1.0, |f| f.effective_scale);
|
||||
let tsize = model
|
||||
.region_data
|
||||
.get(&rh)
|
||||
.and_then(|d| d.size)
|
||||
.map(|(w, h)| (w * scale, h * scale));
|
||||
if cs.wheel_thumb == Some(rh) {
|
||||
rect = cs
|
||||
.wheel
|
||||
|
||||
@@ -1239,7 +1239,8 @@ impl UiScript {
|
||||
let Some(bd) = model.backdrops.get(&fh) else {
|
||||
return;
|
||||
};
|
||||
for piece in backdrop::pieces(fr, bd) {
|
||||
let scale = crate::script::object::eff_scale(model, fh);
|
||||
for piece in backdrop::pieces(fr, bd, scale) {
|
||||
// The piece's screen bounding box: the render is axis-aligned, so the reference's slant
|
||||
// under unequal top and bottom insets is not drawn (stock insets are symmetric).
|
||||
let xs = piece.corners.map(|c| c[0]);
|
||||
|
||||
@@ -114,13 +114,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
m.set(
|
||||
"PingLocation",
|
||||
// Centre-relative offsets in UI units, x right and y up, parked for the app to resolve in
|
||||
// the frame it draws the map; one pending click, not one per widget.
|
||||
// Centre-relative offsets in the minimap's own units, x right and y up, which the
|
||||
// reference reads against the widget's own size (`0x4eeca0`); parked in screen units, its
|
||||
// effective scale applied, for the app to resolve in the frame it draws the map. One
|
||||
// pending click, not one per widget.
|
||||
lua.create_function(|lua, (this, x, y): (Table, f32, f32)| {
|
||||
with_minimap(lua, &this, |_| ())?;
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.minimap_ping_request = Some((x, y));
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let s = super::object::eff_scale(&model, h);
|
||||
model.minimap_ping_request = Some((x * s, y * s));
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -130,6 +130,7 @@ mod tooltip_item;
|
||||
mod tooltip_spell;
|
||||
mod tooltip_unit;
|
||||
mod ui_errors;
|
||||
mod ui_scale;
|
||||
pub use tooltip_unit::TooltipTint;
|
||||
mod trade;
|
||||
mod tradeskill;
|
||||
@@ -137,6 +138,7 @@ mod trainer;
|
||||
mod types;
|
||||
mod unit;
|
||||
mod video_pairs;
|
||||
pub(crate) mod visibility;
|
||||
mod weapon_enchant;
|
||||
mod who_sort;
|
||||
mod worldmap;
|
||||
@@ -724,7 +726,8 @@ impl UiScript {
|
||||
self.model_mut().minimap_ping = ping;
|
||||
}
|
||||
|
||||
/// Drain a `Minimap:PingLocation(x, y)`: centre-relative offsets in UI units, x right, y up.
|
||||
/// Drain a `Minimap:PingLocation(x, y)`: centre-relative offsets in the screen root's units,
|
||||
/// x right, y up.
|
||||
pub fn take_minimap_ping_request(&mut self) -> Option<(f32, f32)> {
|
||||
self.model_mut().minimap_ping_request.take()
|
||||
}
|
||||
|
||||
@@ -228,8 +228,11 @@ pub(crate) struct Model {
|
||||
pub(crate) diagnostics: super::diagnostics::DiagnosticLog,
|
||||
/// Non-fatal warnings for the host, such as a dropped `inherits=` template.
|
||||
pub(crate) warnings: Vec<String>,
|
||||
/// The screen-root rect (`[bottom, left, top, right]`), the anchor base for top-level frames.
|
||||
/// The screen-root rect (`[bottom, left, top, right]`), the anchor base for top-level frames:
|
||||
/// 768 tall at scale 1, whatever the UI scale ([`super::ui_scale`]).
|
||||
pub(crate) screen: Rect,
|
||||
/// The host's `uiScale`, which [`super::UiScript::apply_ui_scale`] makes `UIParent`'s scale.
|
||||
pub(crate) ui_scale_request: Option<f32>,
|
||||
|
||||
/// Each unit token's state as pushed this frame, keyed lowercased because 1.12's resolver
|
||||
/// (`0x515970`) matches with `SStrCmpI`: read it only through `unit`, where the fold lives.
|
||||
@@ -346,6 +349,9 @@ pub(crate) struct Model {
|
||||
pub(crate) camera_view_requests: Vec<camera_view::CameraViewRequest>,
|
||||
/// `Logout`, `Quit`, `CancelLogout`, `ForceQuit` calls; Lua sees the countdown only as events.
|
||||
pub(crate) session_requests: Vec<session::SessionRequest>,
|
||||
/// `PLAYER_LOGIN`'s one-shot, the reference's `[0xb4e260]`: armed by the UI load, spent by
|
||||
/// the next world-enter cascade ([`super::UiScript::fire_world_enter`]).
|
||||
pub(crate) player_login_armed: bool,
|
||||
/// `TogglePVP` calls, a count because `CMSG_TOGGLE_PVP` has no body.
|
||||
pub(crate) pvp_toggles: u32,
|
||||
/// The player's private honor fields; before the first push the six self getters read zeros.
|
||||
@@ -916,8 +922,9 @@ pub(crate) struct Model {
|
||||
/// The last cursor position in UI space (logical px, y-up), behind `GetCursorPosition()`.
|
||||
pub(crate) cursor_pos: (f32, f32),
|
||||
|
||||
/// A `Minimap:PingLocation(x, y)`, drained the same frame: centre-relative offsets in UI units,
|
||||
/// `GetCursorPosition`'s space, not the window pixels of the app's minimap geometry.
|
||||
/// A `Minimap:PingLocation(x, y)`, drained the same frame: centre-relative offsets in the screen
|
||||
/// root's units, `GetCursorPosition`'s space, not the window pixels of the app's minimap
|
||||
/// geometry.
|
||||
pub(crate) minimap_ping_request: Option<(f32, f32)>,
|
||||
/// The ping's offsets as fractions of the minimap's side, updated by the app every frame.
|
||||
/// `GetPingPosition()` always answers both, from statics never cleared (`0x4eefd0`).
|
||||
@@ -1103,6 +1110,7 @@ impl Model {
|
||||
warnings: Vec::new(),
|
||||
// 1024x768 until the host calls `set_screen_size`; y-up `[bottom, left, top, right]`.
|
||||
screen: Rect::new(0.0, 0.0, 768.0, 1024.0),
|
||||
ui_scale_request: None,
|
||||
units_by_lower: HashMap::new(),
|
||||
units_by_guid: HashMap::new(),
|
||||
player_auras: Vec::new(),
|
||||
@@ -1159,6 +1167,7 @@ impl Model {
|
||||
follow_requests: Vec::new(),
|
||||
camera_view_requests: Vec::new(),
|
||||
session_requests: Vec::new(),
|
||||
player_login_armed: false,
|
||||
pvp_toggles: 0,
|
||||
honor: None,
|
||||
inspect_honor: None,
|
||||
@@ -1494,6 +1503,19 @@ impl Model {
|
||||
id
|
||||
}
|
||||
|
||||
/// `CSimpleFrame::SetScale` (`0x76ac10`): store `h`'s own scale, cascade the effective scale
|
||||
/// down its subtree, and re-lay out and re-measure on a change, since the owner's effective
|
||||
/// scale is in every descendant FontString's measure key. Lua `SetScale` and the UI scale
|
||||
/// ([`super::ui_scale`]) both come here, as both reach `0x76ac10` in the reference.
|
||||
pub(crate) fn set_frame_scale(&mut self, h: FrameHandle, scale: f32) {
|
||||
let changed = self.arena.frame(h).is_some_and(|f| f.scale != scale);
|
||||
self.arena.set_scale(h, scale);
|
||||
if changed {
|
||||
self.touch_layout();
|
||||
self.touch_measure_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// A layout write that names no node. The resolve's tooltip pre-pass must not call this, or
|
||||
/// the gate never closes; a missed call is caught by `WOW_LAYOUT_VERIFY`.
|
||||
pub(crate) fn touch_layout(&mut self) {
|
||||
|
||||
@@ -154,11 +154,12 @@ pub struct NamePlateClick {
|
||||
pub button: String,
|
||||
}
|
||||
|
||||
/// What a sync fires outside the model borrow: `OnShow`/`OnHide` for frames whose visibility
|
||||
/// flipped and `OnValueChanged` for moved bars (pfUI hooks it, `nameplates.lua:393`).
|
||||
/// What a sync runs outside the model borrow: the plates' `Show`/`Hide`, whose transitions fire
|
||||
/// handlers, and `OnValueChanged` for moved bars (pfUI hooks it, `nameplates.lua:393`).
|
||||
#[derive(Default)]
|
||||
struct SyncEffects {
|
||||
visibility: Vec<FrameHandle>,
|
||||
/// `(plate, shown)` in the order the sync issued them.
|
||||
visibility: Vec<(FrameHandle, bool)>,
|
||||
values: Vec<(FrameHandle, f32)>,
|
||||
}
|
||||
|
||||
@@ -247,7 +248,7 @@ impl UiScript {
|
||||
};
|
||||
sync(&mut model, world, geometry, states)
|
||||
};
|
||||
super::event::fire_visibility_changes(lua, effects.visibility);
|
||||
apply_visibility(lua, effects.visibility);
|
||||
for (bar, value) in effects.values {
|
||||
super::statusbar::fire_engine_value_changed(lua, bar, value);
|
||||
}
|
||||
@@ -304,7 +305,7 @@ impl UiScript {
|
||||
}
|
||||
effects
|
||||
};
|
||||
super::event::fire_visibility_changes(lua, effects.visibility);
|
||||
apply_visibility(lua, effects.visibility);
|
||||
}
|
||||
|
||||
/// How many plate widgets the pool holds.
|
||||
@@ -315,6 +316,13 @@ impl UiScript {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the plates' `Show`/`Hide` a sync issued, each a full transition with its handlers.
|
||||
fn apply_visibility(lua: &mlua::Lua, verbs: Vec<(FrameHandle, bool)>) {
|
||||
for (frame, shown) in verbs {
|
||||
super::visibility::set_shown(lua, frame, shown);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a click on `id` if it is a live plate; every button click comes here.
|
||||
pub(super) fn note_click(lua: &mlua::Lua, id: u32, button: &str) {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
@@ -368,7 +376,7 @@ fn sync(
|
||||
let free = (0..model.nameplates.plates.len())
|
||||
.find(|i| model.nameplates.plates[*i].last.is_none());
|
||||
let i = free.unwrap_or_else(|| {
|
||||
let plate = Plate::create(model, world, &mut effects);
|
||||
let plate = Plate::create(model, world);
|
||||
model.nameplates.plates.push(plate);
|
||||
model.nameplates.plates.len() - 1
|
||||
});
|
||||
@@ -396,7 +404,7 @@ fn sync(
|
||||
impl Plate {
|
||||
/// Build one plate: the Button, its six regions in the reference's order, and the health bar.
|
||||
/// Written once, so a region an addon blanks stays blank.
|
||||
fn create(model: &mut Model, world: FrameHandle, effects: &mut SyncEffects) -> Plate {
|
||||
fn create(model: &mut Model, world: FrameHandle) -> Plate {
|
||||
// Anonymous, as the reference's name is zeroed (`0x76c50b`): plate addons reject a named
|
||||
// `WorldFrame` child.
|
||||
let frame = model.arena.create(FrameKind::Button, None, Some(world));
|
||||
@@ -479,9 +487,8 @@ impl Plate {
|
||||
}
|
||||
|
||||
// Born retired and hidden: addons tell a live plate from a pooled one by `IsShown()`.
|
||||
effects
|
||||
.visibility
|
||||
.extend(model.arena.set_shown(frame, false));
|
||||
// Silent, since nothing can have set a handler on a frame not yet handed out.
|
||||
model.arena.set_shown(frame, false);
|
||||
|
||||
model
|
||||
.nameplates
|
||||
@@ -625,9 +632,7 @@ impl Plate {
|
||||
}
|
||||
|
||||
if last.is_none() {
|
||||
effects
|
||||
.visibility
|
||||
.extend(model.arena.set_shown(frame, true));
|
||||
effects.visibility.push((frame, true));
|
||||
}
|
||||
if last.as_ref().is_none_or(|l| l.alpha != state.alpha) {
|
||||
model.arena.set_alpha(frame, state.alpha);
|
||||
@@ -722,9 +727,7 @@ impl Plate {
|
||||
return;
|
||||
}
|
||||
let frame = model.nameplates.plates[i].frame;
|
||||
effects
|
||||
.visibility
|
||||
.extend(model.arena.set_shown(frame, false));
|
||||
effects.visibility.push((frame, false));
|
||||
model.nameplates.plates[i].last = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,19 +352,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let create_frame = lua.create_function(create_frame)?;
|
||||
lua.globals().set("CreateFrame", create_frame)?;
|
||||
|
||||
// `GetScreenWidth`/`GetScreenHeight`: the screen root's size in UI units.
|
||||
// `GetScreenWidth`/`GetScreenHeight` (`0x48b480`/`0x48b4d0`): the screen root's size divided by
|
||||
// `UIParent`'s scale (`GetUiScale`, `0x494590`), so they answer in `UIParent`'s units.
|
||||
lua.globals().set(
|
||||
"GetScreenWidth",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(model.screen.width())
|
||||
Ok(model.screen.width() / super::ui_scale::ui_parent_scale(&model))
|
||||
})?,
|
||||
)?;
|
||||
lua.globals().set(
|
||||
"GetScreenHeight",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(model.screen.height())
|
||||
Ok(model.screen.height() / super::ui_scale::ui_parent_scale(&model))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -396,16 +397,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetCursorPosition()`: the last cursor position the host fed, in screen UI units, y up, as
|
||||
// the reference returns it; a caller in a scaled frame divides by its `GetEffectiveScale()`.
|
||||
lua.globals().set(
|
||||
"GetCursorPosition",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(model.cursor_pos)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// The modifier keys the app feeds each frame before any mouse event
|
||||
// ([`UiScript::set_modifiers`]), answered 1 or nil as in 1.12, never a boolean: an addon
|
||||
// testing `IsShiftKeyDown() == 1` (`ColorPickerPlus.lua:121`) reads `true` as not held.
|
||||
|
||||
@@ -5,7 +5,7 @@ use mlua::{Lua, Table, Value};
|
||||
use crate::script::region_map::{set_shared, Side};
|
||||
|
||||
use crate::order::Strata;
|
||||
use crate::script::{event, Backdrop, Insets, Model};
|
||||
use crate::script::{visibility, Backdrop, Insets, Model};
|
||||
use crate::widget::FrameKind;
|
||||
|
||||
use super::{decode_id, draw_layer_from_str, frame_handle_of, frame_wrapper, strata_from_str};
|
||||
@@ -248,8 +248,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
)?;
|
||||
// `SetParent` (`0x7a1550`): a frame, a name (`0x76c760`) or an explicit nil; an absent argument
|
||||
// raises like an unknown name (`0x6f3400` gives -1), and so does a cycle (`0x87cb14`). As in
|
||||
// the reference, hide fires before show, between `reparent_begin` and `reparent_finish`,
|
||||
// since `fire_visibility_changes` reads each frame's live state for the direction.
|
||||
// the reference, the hide transition runs between `reparent_begin` and `reparent_finish`, and
|
||||
// the show transition after.
|
||||
set_shared(
|
||||
lua,
|
||||
m,
|
||||
@@ -330,24 +330,28 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
)));
|
||||
}
|
||||
}
|
||||
let hidden = {
|
||||
let was_visible = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
model.arena.reparent_begin(h, new_parent)
|
||||
};
|
||||
// None: the same parent, a total no-op (`0x76ab20`), not even a layout touch.
|
||||
let Some(hidden) = hidden else { return Ok(()) };
|
||||
let was_visible = !hidden.is_empty();
|
||||
event::fire_visibility_changes(lua, hidden);
|
||||
let shown = {
|
||||
let Some(was_visible) = was_visible else {
|
||||
return Ok(());
|
||||
};
|
||||
if was_visible {
|
||||
visibility::hide(lua, h);
|
||||
}
|
||||
{
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let shown = model.arena.reparent_finish(h, new_parent, was_visible);
|
||||
model.arena.reparent_finish(h, new_parent);
|
||||
// A reparent moves the subtree's effective scale, a layout input and part of every
|
||||
// descendant FontString's measure key.
|
||||
model.touch_layout_reparent(h);
|
||||
model.touch_measure_all();
|
||||
shown
|
||||
};
|
||||
event::fire_visibility_changes(lua, shown);
|
||||
}
|
||||
if was_visible {
|
||||
visibility::show(lua, h);
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)?;
|
||||
@@ -414,14 +418,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
"SetScale",
|
||||
lua.create_function(|lua, (this, scale): (Table, f32)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let changed = model.arena.frame(h).is_some_and(|f| f.scale != scale);
|
||||
model.arena.set_scale(h, scale);
|
||||
if changed {
|
||||
model.touch_layout();
|
||||
// The owner's effective scale is in every descendant FontString's measure key.
|
||||
model.touch_measure_all();
|
||||
}
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model")
|
||||
.set_frame_scale(h, scale);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -789,11 +788,7 @@ fn strata_name(s: Strata) -> &'static str {
|
||||
|
||||
fn set_shown(lua: &Lua, this: &Table, shown: bool) -> mlua::Result<()> {
|
||||
let h = frame_handle_of(lua, this)?;
|
||||
let changed = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
model.arena.set_shown(h, shown)
|
||||
};
|
||||
event::fire_visibility_changes(lua, changed);
|
||||
visibility::set_shown(lua, h, shown);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -197,11 +197,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
})
|
||||
})?;
|
||||
|
||||
// GetEffectiveScale(): parent scale times own scale, from a root of 1: `uiScale` is applied at
|
||||
// the raster seam (a screen `768/uiScale` units tall), so every coordinate Lua sees,
|
||||
// `GetCursorPosition()` included, is already in UI units. The reference instead makes
|
||||
// `uiScale` `UIParent`'s own scale (`0x494550` calls `SetScale`), so there
|
||||
// `UIParent:GetEffectiveScale()` answers it.
|
||||
// GetEffectiveScale(): parent scale times own scale (`0x76ac90`), from a root of 1. The UI
|
||||
// scale is `UIParent`'s own (`0x494550`), so `UIParent:GetEffectiveScale()` answers it and a
|
||||
// frame outside `UIParent` does not carry it.
|
||||
m.set(
|
||||
"GetEffectiveScale",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
|
||||
@@ -22,8 +22,9 @@ use super::{button, cursor, editbox, event, Model, UiScript};
|
||||
/// each release only has to hit the frame.
|
||||
pub(super) const DOUBLE_CLICK_SECS: f64 = 0.300;
|
||||
|
||||
/// Install `GetCursorPosition()` and `GetMouseFocus()`. The cursor is in UI units, as 1.12
|
||||
/// answers it; the engine's y-up UI space is 1:1 logical pixels, so no scale factor applies.
|
||||
/// Install `GetCursorPosition()` and `GetMouseFocus()`. The cursor (`0x48b820`) answers in the
|
||||
/// screen root's units, y up, with no scale divided out: a caller divides by its frame's
|
||||
/// `GetEffectiveScale()`, which carries the UI scale inside `UIParent`.
|
||||
pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
|
||||
lua.globals().set(
|
||||
"GetCursorPosition",
|
||||
@@ -107,7 +108,7 @@ impl UiScript {
|
||||
// tested, so the reticle is placed through it.
|
||||
&& !model.nameplates.vetoes(fh)
|
||||
&& model.resolved.get(&fh).is_some_and(|r| {
|
||||
point_in_rect(inset_rect(*r, model.arena.hit_rect_insets(fh)), x, y)
|
||||
point_in_rect(hit_rect(&model, fh, *r), x, y)
|
||||
})
|
||||
&& effective_clip(&model, &scroll_sources, fh)
|
||||
.is_none_or(|c| point_in_rect(c, x, y))
|
||||
@@ -124,9 +125,10 @@ impl UiScript {
|
||||
// The wheel flag alone: the wheel index is separate from the mouse's, so the wheel
|
||||
// passes a frame that only takes the mouse, as a scroll pane's chrome does.
|
||||
model.arena.is_mouse_wheel_enabled(fh)
|
||||
&& model.resolved.get(&fh).is_some_and(|r| {
|
||||
point_in_rect(inset_rect(*r, model.arena.hit_rect_insets(fh)), x, y)
|
||||
})
|
||||
&& model
|
||||
.resolved
|
||||
.get(&fh)
|
||||
.is_some_and(|r| point_in_rect(hit_rect(&model, fh, *r), x, y))
|
||||
&& effective_clip(&model, &scroll_sources, fh)
|
||||
.is_none_or(|c| point_in_rect(c, x, y))
|
||||
})?;
|
||||
@@ -628,9 +630,12 @@ pub(super) fn point_in_rect(r: Rect, x: f32, y: f32) -> bool {
|
||||
x >= r.left && x <= r.right && y >= r.bottom && y <= r.top
|
||||
}
|
||||
|
||||
/// A frame's mouse rect: its resolved rect shrunk by its `[left, right, top, bottom]` hit-rect
|
||||
/// insets (y-up); an over-shrunk rect collapses to empty rather than inverting.
|
||||
fn inset_rect(r: Rect, [left, right, top, bottom]: [f32; 4]) -> Rect {
|
||||
/// A frame's mouse rect (`0x76b580`): its resolved rect shrunk by its `[left, right, top, bottom]`
|
||||
/// hit-rect insets times its effective scale (y-up), the insets being in the frame's own units; an
|
||||
/// over-shrunk rect collapses to empty rather than inverting.
|
||||
fn hit_rect(model: &Model, fh: FrameHandle, r: Rect) -> Rect {
|
||||
let s = super::object::eff_scale(model, fh);
|
||||
let [left, right, top, bottom] = model.arena.hit_rect_insets(fh).map(|i| i * s);
|
||||
Rect {
|
||||
left: r.left + left,
|
||||
right: (r.right - right).max(r.left + left),
|
||||
|
||||
@@ -684,3 +684,15 @@ pub(super) fn virtual_span(model: &Model, rh: RegionHandle) -> (f32, f32) {
|
||||
_ => (aw, ah),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`virtual_span`] on screen: the region's own units times its owner's effective scale, as the
|
||||
/// layout sizes a region (`0x76ac90`). For the parts the engine seats itself, with no anchors: a
|
||||
/// slider's thumb, a colour picker's markers.
|
||||
pub(super) fn screen_span(model: &Model, rh: RegionHandle) -> (f32, f32) {
|
||||
let (w, h) = virtual_span(model, rh);
|
||||
let s = model
|
||||
.arena
|
||||
.region(rh)
|
||||
.map_or(1.0, |r| super::object::eff_scale(model, r.owner));
|
||||
(w * s, h * s)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The session verbs: the game menu's Logout and Exit Game, their dialogs' answers, `ReloadUI`
|
||||
//! and the cinematic pair. Each queues a [`SessionRequest`] the app turns into a packet or an
|
||||
//! exit; the server decides whether a logout is instant or a 20-second countdown.
|
||||
//! exit; the server decides whether a logout is instant or a 20-second countdown. Beside them,
|
||||
//! the world-enter cascade's two events and the `PLAYER_LOGIN` arm a UI load sets.
|
||||
|
||||
use mlua::Lua;
|
||||
|
||||
@@ -41,6 +42,28 @@ impl super::UiScript {
|
||||
pub fn queue_session_request(&mut self, request: SessionRequest) {
|
||||
self.model_mut().session_requests.push(request);
|
||||
}
|
||||
|
||||
/// Arm `PLAYER_LOGIN`, the UI load's last step: `UI_Init` sets the one-shot `[0xb4e260]` at
|
||||
/// `0x49011d`, unconditionally, and [`Self::fire_world_enter`] spends it. The flag lives in
|
||||
/// this VM, so each load arms its own and a worldport, which loads nothing, re-arms nothing.
|
||||
pub fn arm_player_login(&mut self) {
|
||||
self.model_mut().player_login_armed = true;
|
||||
}
|
||||
|
||||
/// The world-enter cascade's events (`0x4908c0`): `PLAYER_LOGIN` (270, `0x490959`) only if a
|
||||
/// load armed it (read at `0x49094b`, cleared at `0x49095e`), then `PLAYER_ENTERING_WORLD`
|
||||
/// (272, `0x49096a`) every time. The cascade runs with the player object in the world, so its
|
||||
/// caller seats `"player"` first, and inside its own sound-suppression bracket (`0x4908d5` to
|
||||
/// `0x490a56`), so a handler's `PlaySound` is dropped.
|
||||
pub fn fire_world_enter(&mut self) {
|
||||
let armed = std::mem::take(&mut self.model_mut().player_login_armed);
|
||||
self.push_sound_suppression();
|
||||
if armed {
|
||||
self.fire_event("PLAYER_LOGIN", Vec::new());
|
||||
}
|
||||
self.fire_event("PLAYER_ENTERING_WORLD", Vec::new());
|
||||
self.pop_sound_suppression();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
@@ -263,8 +263,9 @@ pub(super) fn thumb_rect(r: Rect, thumb_size: (f32, f32), vertical: bool, fracti
|
||||
|
||||
/// The thumb region's own width and height, as `0x789ba0` reads them through the texture's
|
||||
/// geometry slots (`[vtable+0x1c]`/`+0x20`, `0x770720`/`0x770790`): the authored span, else the
|
||||
/// art's texel span, else 0. `None` without a thumb region: `0x789ba0` gates the value math on
|
||||
/// `+0x328`, so a thumbless slider captures a press but never moves.
|
||||
/// art's texel span, else 0, in the slider's units, unscaled, though the track it is subtracted
|
||||
/// from is the scaled rect (`0x789bd4`–`0x789bfe`). `None` without a thumb region: `0x789ba0`
|
||||
/// gates the value math on `+0x328`, so a thumbless slider captures a press but never moves.
|
||||
fn thumb_extent(model: &Model, thumb: Option<crate::widget::RegionHandle>) -> Option<(f32, f32)> {
|
||||
Some(super::region::virtual_span(model, thumb?))
|
||||
}
|
||||
@@ -303,18 +304,36 @@ pub(super) fn begin_drag(
|
||||
});
|
||||
return None;
|
||||
};
|
||||
let trect = thumb_rect(r, size, vertical, fraction);
|
||||
// The thumb as drawn, at the slider's scale, decides whether the press is on it.
|
||||
let drawn = thumb.map_or(size, |t| super::region::screen_span(model, t));
|
||||
let trect = thumb_rect(r, drawn, vertical, fraction);
|
||||
let on_thumb = point_in_rect(trect, x, y);
|
||||
// Distances from the track's leading edge, where the thumb sits at `min`: `top - y` on a
|
||||
// vertical track in this y-up arena, `x - left` on a horizontal one.
|
||||
let (cursor, thumb_lead, thumb_len) = if vertical {
|
||||
(r.top - y, r.top - trect.top, trect.top - trect.bottom)
|
||||
let (cursor, thumb_lead, drawn_len, own_len) = if vertical {
|
||||
(
|
||||
r.top - y,
|
||||
r.top - trect.top,
|
||||
trect.top - trect.bottom,
|
||||
size.1,
|
||||
)
|
||||
} else {
|
||||
(x - r.left, trect.left - r.left, trect.right - trect.left)
|
||||
(
|
||||
x - r.left,
|
||||
trect.left - r.left,
|
||||
trect.right - trect.left,
|
||||
size.0,
|
||||
)
|
||||
};
|
||||
// Off the thumb the cursor takes its centre, half its own unscaled length (`0x789bf5`).
|
||||
let grab_offset = if on_thumb {
|
||||
slider_grab(cursor, thumb_lead, drawn_len)
|
||||
} else {
|
||||
own_len * 0.5
|
||||
};
|
||||
model.slider_drag = Some(SliderDrag {
|
||||
slider: h,
|
||||
grab_offset: slider_grab(cursor, thumb_lead, thumb_len),
|
||||
grab_offset,
|
||||
});
|
||||
if on_thumb {
|
||||
return None; // no value change from the grab itself
|
||||
|
||||
@@ -216,3 +216,48 @@ fn backdrop_colors_coerce_rgb_but_default_alpha_opaque() {
|
||||
// Stored as a byte: 0.5 reads back as 128/255.
|
||||
assert_eq!(a, 128.0 / 255.0);
|
||||
}
|
||||
|
||||
/// The pieces are textures anchored and sized in the frame's own units (`0x77e8d0`), so the layout
|
||||
/// scales them by its effective scale like any region: a half-scale frame's border is half as
|
||||
/// thick and its background inset half as deep.
|
||||
#[test]
|
||||
fn a_scaled_frames_backdrop_scales_with_it() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
local f = CreateFrame("Frame", "Half")
|
||||
f:SetScale(0.5)
|
||||
f:SetPoint("TOPLEFT", nil, "TOPLEFT", 100, -100)
|
||||
f:SetWidth(200); f:SetHeight(100)
|
||||
f:SetBackdrop({
|
||||
bgFile = "bg", edgeFile = "edge", tile = false, edgeSize = 16,
|
||||
insets = { left = 4, right = 4, top = 4, bottom = 4 },
|
||||
})
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let rects: Vec<crate::layout::Rect> = s
|
||||
.extract()
|
||||
.into_iter()
|
||||
.filter(|q| matches!(q.content, QuadContent::Backdrop { .. }))
|
||||
.map(|q| q.rect.expect("piece rect"))
|
||||
.collect();
|
||||
assert_eq!(rects.len(), 9);
|
||||
// The frame on screen: left 50, top 550, 100 × 50.
|
||||
let bg = rects[0];
|
||||
assert_eq!(
|
||||
(bg.left, bg.right, bg.bottom, bg.top),
|
||||
(52.0, 148.0, 502.0, 548.0),
|
||||
"the insets are 4 units of a half-scale frame"
|
||||
);
|
||||
// Every border piece is 16 units, 8 on screen, thick.
|
||||
for r in &rects[1..] {
|
||||
assert_eq!(
|
||||
r.width().min(r.height()),
|
||||
8.0,
|
||||
"a border piece {r:?} is edgeSize units of a half-scale frame"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,3 +430,37 @@ fn an_empty_scroll_child_is_reported() {
|
||||
);
|
||||
assert!(s.eval::<bool>("return Hollow ~= nil").unwrap());
|
||||
}
|
||||
|
||||
/// `CreateFrame` builds through the same instantiator as the XML, `0x6ee280` (called at
|
||||
/// `0x70628b`), so its post-load hook `0x76a2f0` fires a visible frame's template OnShow once,
|
||||
/// after its OnLoad (`0x76a3b3`); one created under a hidden parent gets none.
|
||||
#[test]
|
||||
fn a_runtime_template_fires_its_onshow_after_its_onload_when_visible() {
|
||||
let s = script();
|
||||
register(
|
||||
&s,
|
||||
r#"<Ui>
|
||||
<Frame name="ShowProbeTemplate" virtual="true">
|
||||
<Scripts>
|
||||
<OnLoad>table.insert(LOG, this:GetName() .. ":load")</OnLoad>
|
||||
<OnShow>table.insert(LOG, this:GetName() .. ":show")</OnShow>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
LOG = {}
|
||||
CreateFrame("Frame", "Seen", nil, "ShowProbeTemplate")
|
||||
local hidden = CreateFrame("Frame", "HiddenHost")
|
||||
hidden:Hide()
|
||||
CreateFrame("Frame", "Unseen", hidden, "ShowProbeTemplate")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
|
||||
"Seen:load,Seen:show,Unseen:load"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -330,3 +330,49 @@ fn an_all_events_listener_runs_after_the_events_own() {
|
||||
assert_eq!(s.eval::<String>("return order[2]").unwrap(), "all");
|
||||
assert_eq!(s.eval::<i64>("return table.getn(order)").unwrap(), 2);
|
||||
}
|
||||
|
||||
/// The world-enter cascade (`0x4908c0`) fires `PLAYER_LOGIN` (`0x490959`) only while a load's arm
|
||||
/// (`0x49011d`) is unspent, clearing it (`0x49095e`), then `PLAYER_ENTERING_WORLD` (`0x49096a`)
|
||||
/// every time, inside its own sound bracket (`0x4908d5` to `0x490a56`).
|
||||
#[test]
|
||||
fn the_world_enter_cascade_spends_the_login_arm_once_and_plays_nothing() {
|
||||
let mut s = script();
|
||||
s.run(
|
||||
r#"
|
||||
order = ""
|
||||
local f = CreateFrame("Frame")
|
||||
f:RegisterEvent("PLAYER_LOGIN")
|
||||
f:RegisterEvent("PLAYER_ENTERING_WORLD")
|
||||
f:SetScript("OnEvent", function()
|
||||
order = order .. event .. " "
|
||||
PlaySound("igMainMenuOpen")
|
||||
end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.fire_world_enter();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return order").unwrap(),
|
||||
"PLAYER_ENTERING_WORLD ",
|
||||
"no load armed it"
|
||||
);
|
||||
s.run(r#"order = """#).unwrap();
|
||||
s.arm_player_login();
|
||||
s.fire_world_enter();
|
||||
s.fire_world_enter();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return order").unwrap(),
|
||||
"PLAYER_LOGIN PLAYER_ENTERING_WORLD PLAYER_ENTERING_WORLD ",
|
||||
"the arm is one-shot"
|
||||
);
|
||||
assert!(
|
||||
s.take_sounds().is_empty(),
|
||||
"the cascade's handlers are silent"
|
||||
);
|
||||
s.run(r#"PlaySound("igMainMenuOpen")"#).unwrap();
|
||||
assert_eq!(
|
||||
s.take_sounds().len(),
|
||||
1,
|
||||
"and the bracket closed behind them"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -253,6 +253,27 @@ fn hit_rect_insets_shrink_the_mouse_rect_only() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The hit rect is the rect less each inset times the frame's effective scale (`0x76b580`), so a
|
||||
/// half-scale frame's 20-unit header is 10 on screen.
|
||||
#[test]
|
||||
fn hit_rect_insets_scale_with_the_frame() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
local a = CreateFrame("Frame", "HalfHit")
|
||||
a:SetScale(0.5)
|
||||
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(100); a:SetHeight(100); a:EnableMouse(true)
|
||||
a:SetHitRectInsets(0, 0, 20, 0)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
// On screen the frame is 50 × 50 from the origin, its header the top 10.
|
||||
assert!(s.hit_test(25.0, 45.0).is_none(), "inside the scaled header");
|
||||
assert!(s.hit_test(25.0, 38.0).is_some(), "below the scaled header");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_rect_insets_default_to_zero() {
|
||||
let mut s = script();
|
||||
|
||||
@@ -282,3 +282,18 @@ fn frame_effective_alpha_reads_the_shown_frames_alpha() {
|
||||
"hidden through the parent"
|
||||
);
|
||||
}
|
||||
|
||||
/// `Minimap_OnClick` divides the cursor by the minimap's effective scale and hands
|
||||
/// `PingLocation` offsets in the minimap's own units; the reference reads them against the
|
||||
/// widget's own size (`0x4eeca0`), so they reach the host scaled back to screen units.
|
||||
#[test]
|
||||
fn ping_location_reaches_the_host_in_screen_units() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"m = CreateFrame("Minimap", "HalfMinimap")
|
||||
m:SetScale(0.5)
|
||||
m:PingLocation(10, -20)"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.take_minimap_ping_request(), Some((5.0, -10.0)));
|
||||
}
|
||||
|
||||
@@ -92,8 +92,9 @@ fn a_visible_reparent_refires_onhide_then_onshow() {
|
||||
let log: Vec<String> = s.eval("return log").unwrap();
|
||||
assert_eq!(
|
||||
log,
|
||||
vec!["hide:RTOld", "show:RTNew", "kidshow"],
|
||||
"OnHide observes the OLD parent, OnShow the new, and the refire walks the subtree"
|
||||
vec!["hide:RTOld", "kidshow", "show:RTNew"],
|
||||
"OnHide observes the OLD parent, OnShow the new, and the refire walks the subtree, the \
|
||||
child notified before its parent (`0x76aed5`, then `0x76aef5`)"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return RTMover:GetFrameLevel()").unwrap(),
|
||||
|
||||
@@ -628,3 +628,37 @@ fn a_step_before_any_range_is_the_whole_call() {
|
||||
let (min, max): (f32, f32) = s.eval("return SlNoRange:GetMinMaxValues()").unwrap();
|
||||
assert_eq!((min, max), (0.0, 0.0), "no range was pushed");
|
||||
}
|
||||
|
||||
/// The thumb is a texture of the slider, sized in its units, so it draws at the slider's
|
||||
/// effective scale; the drag reads its unscaled size against the scaled track, as the bytes do.
|
||||
#[test]
|
||||
fn a_scaled_sliders_thumb_scales_with_it() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run(
|
||||
r#"
|
||||
local sl = CreateFrame("Slider", "SlHalf")
|
||||
sl:SetScale(0.5)
|
||||
-- The render test's scrollbar at half scale: on screen 8 wide, 50 tall, from (50, 50).
|
||||
sl:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
|
||||
sl:SetWidth(16); sl:SetHeight(100)
|
||||
sl:SetThumbTexture("Interface\\Buttons\\UI-ScrollBar-Knob")
|
||||
local t = sl:GetThumbTexture()
|
||||
t:SetWidth(16); t:SetHeight(16)
|
||||
sl:SetMinMaxValues(0, 100)
|
||||
sl:SetValue(0)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
thumb_rect(&s.extract(), "Knob"),
|
||||
(50.0, 58.0, 92.0, 100.0),
|
||||
"a 16-unit thumb of a half-scale slider is 8 on screen, flush at the track top"
|
||||
);
|
||||
// The drag (`0x789ba0`) takes the thumb's own unscaled 16 from the scaled 50-tall track: a
|
||||
// press 40 below the track top is (40 - 8) of the 34 travel.
|
||||
s.mouse_button(54.0, 60.0, "LeftButton", true);
|
||||
let v = s.eval::<f64>("return SlHalf:GetValue()").unwrap();
|
||||
assert!((v - 100.0 * 32.0 / 34.0).abs() < 1e-3, "value {v}");
|
||||
}
|
||||
|
||||
@@ -679,3 +679,48 @@ fn minimap_blip_tooltip_shows_and_fades() {
|
||||
.unwrap();
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// A cursor-seated plate sits on the cursor under the UI scale: the cursor is in root units and
|
||||
/// the anchor offset in the tooltip's own, so the cursor arm divides by its effective scale
|
||||
/// (`0x530b20`).
|
||||
#[test]
|
||||
fn a_cursor_seated_plate_lands_on_the_cursor_under_the_ui_scale() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
local up = CreateFrame("Frame", "UIParent")
|
||||
up:SetPoint("BOTTOMLEFT", 0, 0); up:SetPoint("TOPRIGHT", 0, 0)
|
||||
CreateFrame("GameTooltip", "GameTooltip", UIParent)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.set_ui_scale(0.9));
|
||||
let at = |s: &mut crate::script::UiScript| -> (f64, f64) {
|
||||
let answers: Vec<(u32, f32, f32, u64)> = s
|
||||
.fontstrings_needing_measure()
|
||||
.iter()
|
||||
.map(|r| (r.id, 80.0, 10.0, r.key))
|
||||
.collect();
|
||||
s.set_measured_text_unwrapped(&answers);
|
||||
s.resolve();
|
||||
s.eval::<(f64, f64)>(
|
||||
"local k = GameTooltip:GetEffectiveScale() \
|
||||
local x = GameTooltip:GetCenter() \
|
||||
return x * k, GameTooltip:GetBottom() * k",
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert!(s.minimap_tooltip("Stormwind", 400.0, 300.0, false));
|
||||
let (x, y) = at(&mut s);
|
||||
assert!(
|
||||
(x - 400.0).abs() < 1e-3 && (y - 300.0).abs() < 1e-3,
|
||||
"({x}, {y})"
|
||||
);
|
||||
s.world_tooltip_move(200.0, 100.0);
|
||||
let (x, y) = at(&mut s);
|
||||
assert!(
|
||||
(x - 200.0).abs() < 1e-3 && (y - 100.0).abs() < 1e-3,
|
||||
"({x}, {y})"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,3 +64,151 @@ fn tick_runs_onupdate_only_when_effectively_visible() {
|
||||
s.tick(0.25); // visible again → fires
|
||||
assert_eq!(s.eval::<i64>("return ticks").unwrap(), 2);
|
||||
}
|
||||
|
||||
/// The show cascade `0x76ae10` walks its children (`0x76aed5`) before its own notify
|
||||
/// (`0x76aef5`), and the hide cascade `0x76ad50` likewise (`0x76adee`, then `0x76adfd`): both are
|
||||
/// post-order, depth-first in child-link order.
|
||||
#[test]
|
||||
fn a_cascade_notifies_children_before_their_parent() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
LOG = {}
|
||||
local function log(tag) return function() table.insert(LOG, tag) end end
|
||||
local p = CreateFrame("Frame", "PostP")
|
||||
local c1 = CreateFrame("Frame", "PostC1", p)
|
||||
local g = CreateFrame("Frame", "PostG", c1)
|
||||
local c2 = CreateFrame("Frame", "PostC2", p)
|
||||
for f, tag in pairs({ [p] = "p", [c1] = "c1", [g] = "g", [c2] = "c2" }) do
|
||||
f:SetScript("OnShow", log(tag .. "+"))
|
||||
f:SetScript("OnHide", log(tag .. "-"))
|
||||
end
|
||||
p:Hide()
|
||||
p:Show()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
|
||||
"g-,c1-,c2-,p-,g+,c1+,c2+,p+"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The child walk follows live links and each child's show re-checks its own gate when the walk
|
||||
/// reaches it (`0x76ae1d`): a sibling an earlier sibling's OnShow hides gets no OnShow, and, never
|
||||
/// having become visible, no OnHide either.
|
||||
#[test]
|
||||
fn a_sibling_hidden_by_an_earlier_siblings_onshow_gets_no_onshow() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
LOG = {}
|
||||
local p = CreateFrame("Frame", "LiveP")
|
||||
local c1 = CreateFrame("Frame", "LiveC1", p)
|
||||
local c2 = CreateFrame("Frame", "LiveC2", p)
|
||||
p:Hide()
|
||||
c1:SetScript("OnShow", function() table.insert(LOG, "c1+") LiveC2:Hide() end)
|
||||
c2:SetScript("OnShow", function() table.insert(LOG, "c2+") end)
|
||||
c2:SetScript("OnHide", function() table.insert(LOG, "c2-") end)
|
||||
p:SetScript("OnShow", function() table.insert(LOG, "p+") end)
|
||||
p:Show()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
|
||||
"c1+,p+"
|
||||
);
|
||||
assert!(!s.eval::<bool>("return LiveC2:IsVisible() == 1").unwrap());
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A sibling an earlier sibling's OnHide hides runs its whole hide, OnHide included, nested in that
|
||||
/// handler (`0x7758bb`); when the parent's walk reaches it, it is already invisible and the hide
|
||||
/// gate (`0x76ad5b`) returns without a second OnHide. The parent's own OnHide comes last.
|
||||
#[test]
|
||||
fn a_sibling_hidden_by_an_earlier_siblings_onhide_is_notified_once_inside_it() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
LOG = {}
|
||||
local p = CreateFrame("Frame", "NestP")
|
||||
local c1 = CreateFrame("Frame", "NestC1", p)
|
||||
local c2 = CreateFrame("Frame", "NestC2", p)
|
||||
c1:SetScript("OnHide", function()
|
||||
table.insert(LOG, "c1-")
|
||||
NestC2:Hide()
|
||||
table.insert(LOG, "c1-end")
|
||||
end)
|
||||
c2:SetScript("OnHide", function() table.insert(LOG, "c2-") end)
|
||||
p:SetScript("OnHide", function() table.insert(LOG, "p-") end)
|
||||
p:Hide()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
|
||||
"c1-,c2-,c1-end,p-"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A child's OnShow that hides the parent: the parent's hide cascade runs nested (the child's
|
||||
/// OnHide, then the parent's), the next sibling then fails its parent gate (`0x76ae35`) and is
|
||||
/// never shown, and the parent's own OnShow still fires last, since nothing between the walk and
|
||||
/// `0x76aef5` re-checks.
|
||||
#[test]
|
||||
fn a_parent_hidden_by_a_childs_onshow_stops_the_walk_but_still_gets_its_onshow() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
LOG = {}
|
||||
local p = CreateFrame("Frame", "StopP")
|
||||
local c1 = CreateFrame("Frame", "StopC1", p)
|
||||
local c2 = CreateFrame("Frame", "StopC2", p)
|
||||
p:Hide()
|
||||
c1:SetScript("OnShow", function() table.insert(LOG, "c1+") StopP:Hide() end)
|
||||
c1:SetScript("OnHide", function() table.insert(LOG, "c1-") end)
|
||||
c2:SetScript("OnShow", function() table.insert(LOG, "c2+") end)
|
||||
p:SetScript("OnShow", function() table.insert(LOG, "p+") end)
|
||||
p:SetScript("OnHide", function() table.insert(LOG, "p-") end)
|
||||
p:Show()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return table.concat(LOG, ',')").unwrap(),
|
||||
"c1+,c1-,p-,p+"
|
||||
);
|
||||
assert!(!s.eval::<bool>("return StopC2:IsVisible() == 1").unwrap());
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The Lua `Show` writes the shown bit and dispatches the cascade unconditionally (`0x7757f1`,
|
||||
/// `0x7757fb`), so a frame whose bit is already set but which is not visible (a `SetParent` from a
|
||||
/// hidden parent to a shown one skips the show half, `0x76abe3`) becomes visible on `Show()`.
|
||||
#[test]
|
||||
fn show_on_a_shown_but_invisible_frame_makes_it_visible() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
SHOWS = 0
|
||||
local hidden = CreateFrame("Frame", "StaleHidden")
|
||||
hidden:Hide()
|
||||
local shown = CreateFrame("Frame", "StaleShown")
|
||||
local f = CreateFrame("Frame", "Stale", hidden)
|
||||
f:SetScript("OnShow", function() SHOWS = SHOWS + 1 end)
|
||||
f:SetParent(shown)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!s.eval::<bool>("return Stale:IsVisible() == 1").unwrap(),
|
||||
"the reparent from an invisible parent runs no show half"
|
||||
);
|
||||
s.run("Stale:Show()").unwrap();
|
||||
assert!(s.eval::<bool>("return Stale:IsVisible() == 1").unwrap());
|
||||
assert_eq!(s.eval::<i64>("return SHOWS").unwrap(), 1);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -355,13 +355,9 @@ fn now(lua: &Lua) -> f64 {
|
||||
crate::script::clock::now(lua)
|
||||
}
|
||||
|
||||
/// Shows or hides through the arena and fires the visibility events.
|
||||
/// Shows or hides as the Lua verbs do, handlers included.
|
||||
pub(super) fn set_shown(lua: &Lua, h: FrameHandle, shown: bool) {
|
||||
let changed = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.arena.set_shown(h, shown)
|
||||
};
|
||||
event::fire_visibility_changes(lua, changed);
|
||||
crate::script::visibility::set_shown(lua, h, shown);
|
||||
}
|
||||
|
||||
/// The default line colour, gold `0xffffd200` (stored at `0xc0d3e8` by `0x528e50`). The zone
|
||||
|
||||
@@ -350,8 +350,8 @@ impl super::UiScript {
|
||||
if let Ok(t) = tip_mut(&mut model, h) {
|
||||
t.owner = Some(root);
|
||||
}
|
||||
let new = cursor_anchor_at(&model, h, root_id, ui_x, ui_y);
|
||||
let input = model.layout_inputs.entry(h).or_default();
|
||||
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
|
||||
let same = input.anchors.len() == 1
|
||||
&& super::object::anchor_bits_eq(&input.anchors[0], &new);
|
||||
if !same {
|
||||
@@ -440,10 +440,10 @@ impl super::UiScript {
|
||||
{
|
||||
let mut model = self.model_mut();
|
||||
clear_content(&mut model, h);
|
||||
let input = model.layout_inputs.entry(h).or_default();
|
||||
// Anchor only: the frame's own clamp flag (`Frame::clamped_to_screen`, G flags bit 4)
|
||||
// slides a plate near the window edge back on screen.
|
||||
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
|
||||
let new = cursor_anchor_at(&model, h, root_id, ui_x, ui_y);
|
||||
let input = model.layout_inputs.entry(h).or_default();
|
||||
// Compare-then-touch, so a still cursor does not dirty the layout.
|
||||
let same =
|
||||
input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new);
|
||||
@@ -497,8 +497,8 @@ impl super::UiScript {
|
||||
return;
|
||||
}
|
||||
let root_id = model.frame_id(root);
|
||||
let new = cursor_anchor_at(&model, h, root_id, ui_x, ui_y);
|
||||
let input = model.layout_inputs.entry(h).or_default();
|
||||
let new = Anchor::new(Point::Bottom, root_id, Point::BottomLeft, ui_x, ui_y);
|
||||
// Compare-then-touch: this runs on every pointer event.
|
||||
let same =
|
||||
input.anchors.len() == 1 && super::object::anchor_bits_eq(&input.anchors[0], &new);
|
||||
@@ -559,3 +559,17 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The plate's BOTTOM on the cursor: `(ui_x, ui_y)` is in the screen root's units, and an anchor
|
||||
/// offset is in the tooltip's own, so it is divided by the tooltip's effective scale, the UI scale
|
||||
/// included, as the cursor arm `0x530b20` does ([`super::tooltip`]'s `cursor_anchor`).
|
||||
fn cursor_anchor_at(model: &Model, h: FrameHandle, root_id: u32, ui_x: f32, ui_y: f32) -> Anchor {
|
||||
let s = super::object::eff_scale(model, h);
|
||||
Anchor::new(
|
||||
Point::Bottom,
|
||||
root_id,
|
||||
Point::BottomLeft,
|
||||
ui_x / s,
|
||||
ui_y / s,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! The UI scale: `UIParent`'s own frame scale, never the screen's. The screen root keeps scale 1
|
||||
//! and is 768 units tall at every window size; `uiScale` reaches only `UIParent`'s subtree,
|
||||
//! through the worker Lua's `SetScale` calls (`0x494550` → `0x76ac10` on the frame named
|
||||
//! `"UIParent"`, looked up at `0x49006a`), so a frame with no parent, or under `WorldFrame`, draws
|
||||
//! at scale 1.
|
||||
|
||||
use super::{Model, UiScript};
|
||||
|
||||
/// `SetUiScale`'s floor (`0x494550`: `[0x804584]`, below which `0x494563` stores 0.64).
|
||||
pub(crate) const UI_SCALE_FLOOR: f32 = 0.64;
|
||||
|
||||
/// `GetUiScale` (`0x494590`): `UIParent`'s effective scale (`[UIParent+0x7c]`), else 1.0 when there
|
||||
/// is no `UIParent`. `GetScreenWidth`/`GetScreenHeight` (`0x48b480`/`0x48b4d0`) divide the root by
|
||||
/// it, and nothing else reads it. A zero scale reads 1, as the reference's writer `0x768360`
|
||||
/// refuses one.
|
||||
pub(crate) fn ui_parent_scale(model: &Model) -> f32 {
|
||||
model
|
||||
.arena
|
||||
.lookup("UIParent")
|
||||
.map_or(1.0, |h| super::object::eff_scale(model, h))
|
||||
}
|
||||
|
||||
/// The cap both `uiScale` legs apply before the sink (`0x490770`, and the display handler's ON leg
|
||||
/// `0x492e90`): below a 4:3 aspect `a` the value is capped at `0.75·a` (`a·3 ≥ 4` skips the cap, so
|
||||
/// 4:3 itself is never capped).
|
||||
pub(crate) fn capped_ui_scale(ui_scale: f32, aspect: f32) -> f32 {
|
||||
if aspect * 3.0 < 4.0 {
|
||||
ui_scale.min(aspect * 0.75)
|
||||
} else {
|
||||
ui_scale
|
||||
}
|
||||
}
|
||||
|
||||
/// `SetUiScale`'s floor (`0x494550`): above [`UI_SCALE_FLOOR`] the value stands, else (NaN
|
||||
/// included) the floor.
|
||||
pub(crate) fn floored_ui_scale(v: f32) -> f32 {
|
||||
if v > UI_SCALE_FLOOR {
|
||||
v
|
||||
} else {
|
||||
UI_SCALE_FLOOR
|
||||
}
|
||||
}
|
||||
|
||||
/// What the CVar callback applies (`0x490770` → `0x494550`): capped, then floored.
|
||||
pub(crate) fn effective_ui_scale(ui_scale: f32, aspect: f32) -> f32 {
|
||||
floored_ui_scale(capped_ui_scale(ui_scale, aspect))
|
||||
}
|
||||
|
||||
/// The automatic scale `0x492f70` computes for a `w`×`h` display: 1.0, or `768/h` above 768 tall
|
||||
/// (`0x492fbc cmp esi,0x300`), under the same narrow-aspect cap, then raised to 0.9 unless above it
|
||||
/// (`0x493005`–`0x493012`).
|
||||
pub(crate) fn auto_ui_scale(w: u32, h: u32) -> f32 {
|
||||
let mut v = if h > 768 { 768.0 / h as f32 } else { 1.0 };
|
||||
if h > 0 {
|
||||
v = capped_ui_scale(v, w as f32 / h as f32);
|
||||
}
|
||||
if v > 0.9 {
|
||||
v
|
||||
} else {
|
||||
0.9
|
||||
}
|
||||
}
|
||||
|
||||
impl UiScript {
|
||||
/// The host's `uiScale`, compared first like [`Self::set_screen_size`]: a new value applies at
|
||||
/// once through the CVar's change callback (`0x490770`: capped, floored at 0.64), and `true`
|
||||
/// says it reached a `UIParent`. The same value again does nothing, so an addon's own
|
||||
/// `UIParent:SetScale` holds until the next change.
|
||||
pub fn set_ui_scale(&mut self, ui_scale: f32) -> bool {
|
||||
let applied = {
|
||||
let mut model = self.model_mut();
|
||||
if model.ui_scale_request == Some(ui_scale) {
|
||||
return false;
|
||||
}
|
||||
model.ui_scale_request = Some(ui_scale);
|
||||
let aspect = model.screen.width() / model.screen.height();
|
||||
model
|
||||
.arena
|
||||
.lookup("UIParent")
|
||||
.map(|h| (h, effective_ui_scale(ui_scale, aspect)))
|
||||
};
|
||||
let Some((ui_parent, v)) = applied else {
|
||||
return false;
|
||||
};
|
||||
self.model_mut().set_frame_scale(ui_parent, v);
|
||||
true
|
||||
}
|
||||
|
||||
/// Record `ui_scale` without applying it, then apply it through the display handler, as the UI
|
||||
/// load does once its files and `VARIABLES_LOADED` are done (`0x49010e` → `0x492e90`): the
|
||||
/// CVar's own callback found no `UIParent` when it fired at registration.
|
||||
pub fn seat_ui_scale(
|
||||
&mut self,
|
||||
ui_scale: f32,
|
||||
display: (u32, u32),
|
||||
auto_cache: &mut Option<(u32, u32)>,
|
||||
) -> bool {
|
||||
self.model_mut().ui_scale_request = Some(ui_scale);
|
||||
self.apply_ui_scale(display, auto_cache)
|
||||
}
|
||||
|
||||
/// The display handler `0x492e90` on the last [`Self::set_ui_scale`], for the load and a
|
||||
/// display change. Its ON leg caps the value and hands it to the sink only at 0.64 or more
|
||||
/// (`0x492efd fcomp [0x804584]; test ah,1; jne 0x492f4a`); below, it falls to the automatic
|
||||
/// scale `0x492f70(w, h, force = 0)` ([`auto_ui_scale`]), which does nothing at all when the
|
||||
/// display is the one it last ran for (`auto_cache`, the reference's `[0xb4e304]`/`[0xb4e308]`,
|
||||
/// which outlive the UI). `display` is the window in logical px, the host's unit for the
|
||||
/// reference's device size. `true` when a scale reached `UIParent`.
|
||||
pub fn apply_ui_scale(
|
||||
&mut self,
|
||||
display: (u32, u32),
|
||||
auto_cache: &mut Option<(u32, u32)>,
|
||||
) -> bool {
|
||||
let applied = {
|
||||
let model = self.model_ref();
|
||||
let Some(ui_scale) = model.ui_scale_request else {
|
||||
return false;
|
||||
};
|
||||
let aspect = model.screen.width() / model.screen.height();
|
||||
let v = capped_ui_scale(ui_scale, aspect);
|
||||
let v = if v >= UI_SCALE_FLOOR {
|
||||
v
|
||||
} else {
|
||||
let (w, h) = display;
|
||||
if w > 0 && h > 0 && *auto_cache == Some(display) {
|
||||
return false;
|
||||
}
|
||||
*auto_cache = Some(display);
|
||||
floored_ui_scale(auto_ui_scale(w, h))
|
||||
};
|
||||
model.arena.lookup("UIParent").map(|h| (h, v))
|
||||
};
|
||||
let Some((ui_parent, v)) = applied else {
|
||||
return false;
|
||||
};
|
||||
self.model_mut().set_frame_scale(ui_parent, v);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_callback_ladder_caps_below_four_thirds_and_floors_at_the_sink() {
|
||||
// 4:3 and wider: the dial as set.
|
||||
assert_eq!(effective_ui_scale(0.9, 4.0 / 3.0), 0.9);
|
||||
assert_eq!(effective_ui_scale(0.9, 16.0 / 9.0), 0.9);
|
||||
assert_eq!(effective_ui_scale(1.2, 16.0 / 9.0), 1.2);
|
||||
// 5:4 caps a full dial at 0.9375 and leaves 0.9 alone.
|
||||
assert_eq!(effective_ui_scale(1.0, 1.25), 0.9375);
|
||||
assert_eq!(effective_ui_scale(0.9, 1.25), 0.9);
|
||||
// The floor, on both sides of it.
|
||||
assert_eq!(effective_ui_scale(0.5, 16.0 / 9.0), UI_SCALE_FLOOR);
|
||||
assert_eq!(effective_ui_scale(0.65, 16.0 / 9.0), 0.65);
|
||||
// A square window caps at 0.75, a narrow one bottoms out at the floor.
|
||||
assert_eq!(effective_ui_scale(0.9, 1.0), 0.75);
|
||||
assert_eq!(effective_ui_scale(0.9, 0.5), UI_SCALE_FLOOR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scale_lands_on_uiparent_alone_and_only_on_a_change() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
assert!(!s.set_ui_scale(0.9), "no UIParent yet: nothing to scale");
|
||||
s.run(r#"CreateFrame("Frame", "UIParent") CreateFrame("Frame", "Loose")"#)
|
||||
.unwrap();
|
||||
assert!(!s.set_ui_scale(0.9), "the same value is no change");
|
||||
assert!(
|
||||
s.apply_ui_scale((1024, 768), &mut None),
|
||||
"the load edge applies what was set"
|
||||
);
|
||||
assert_eq!(s.eval::<f32>("return UIParent:GetScale()").unwrap(), 0.9);
|
||||
assert_eq!(s.eval::<f32>("return Loose:GetScale()").unwrap(), 1.0);
|
||||
// An addon's own scale holds until the dial moves.
|
||||
s.run("UIParent:SetScale(0.7)").unwrap();
|
||||
assert!(!s.set_ui_scale(0.9));
|
||||
assert_eq!(s.eval::<f32>("return UIParent:GetScale()").unwrap(), 0.7);
|
||||
assert!(s.set_ui_scale(0.8));
|
||||
assert_eq!(s.eval::<f32>("return UIParent:GetScale()").unwrap(), 0.8);
|
||||
}
|
||||
|
||||
/// The automatic scale (`0x492f70`): 1.0 up to 768 tall, `768/h` above it, never under 0.9.
|
||||
#[test]
|
||||
fn the_auto_scale_follows_the_height_and_stops_at_nine_tenths() {
|
||||
assert_eq!(auto_ui_scale(1024, 768), 1.0);
|
||||
assert_eq!(auto_ui_scale(1280, 800), 0.96);
|
||||
assert_eq!(auto_ui_scale(1920, 1080), 0.9);
|
||||
// A tall narrow display: capped, then raised to 0.9.
|
||||
assert_eq!(auto_ui_scale(600, 700), 0.9);
|
||||
}
|
||||
|
||||
/// The display handler (`0x492e90`) never floors: a dial capped below 0.64 falls to the
|
||||
/// automatic scale, which skips a display it already ran for; the CVar callback (`0x490770`)
|
||||
/// floors instead.
|
||||
#[test]
|
||||
fn below_the_floor_the_display_handler_falls_to_the_auto_scale() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run(r#"CreateFrame("Frame", "UIParent")"#).unwrap();
|
||||
let scale = |s: &UiScript| s.eval::<f32>("return UIParent:GetScale()").unwrap();
|
||||
let mut cache = None;
|
||||
assert!(s.seat_ui_scale(0.5, (1280, 800), &mut cache));
|
||||
assert_eq!(scale(&s), 0.96, "the load took the auto scale for 1280×800");
|
||||
assert_eq!(cache, Some((1280, 800)));
|
||||
// The same display again: the auto path does nothing at all.
|
||||
s.run("UIParent:SetScale(0.7)").unwrap();
|
||||
assert!(!s.apply_ui_scale((1280, 800), &mut cache));
|
||||
assert_eq!(scale(&s), 0.7);
|
||||
// A new display runs it.
|
||||
assert!(s.apply_ui_scale((1920, 1080), &mut cache));
|
||||
assert_eq!(scale(&s), 0.9);
|
||||
// The CVar callback floors.
|
||||
assert!(s.set_ui_scale(0.55));
|
||||
assert_eq!(scale(&s), UI_SCALE_FLOOR);
|
||||
// At the floor or above, the display handler hands the value to the sink.
|
||||
assert!(s.seat_ui_scale(0.8, (1920, 1080), &mut cache));
|
||||
assert_eq!(scale(&s), 0.8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! The show and hide transitions with their notifies (`0x76ae10`, `0x76ad50`). Each marks its
|
||||
//! frame before anything runs, walks the live child list re-gating each child as the walk reaches
|
||||
//! it, and fires its own notify last, so handlers run inside the walk, children before their
|
||||
//! parent, and a frame an earlier handler hid is never notified.
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
|
||||
use super::{event, Model};
|
||||
use crate::widget::{ButtonState, ChildWalk, FrameHandle};
|
||||
|
||||
/// The Lua `Show`/`Hide` (`0x775750`/`0x775810`): write the shown bit (`0x7757f1`/`0x7758b1`),
|
||||
/// then dispatch the transition (`0x7757fb`/`0x7758bb`), which gates itself, so `Show()` on a
|
||||
/// frame already shown but not visible still runs.
|
||||
pub(super) fn set_shown(lua: &Lua, h: FrameHandle, shown: bool) {
|
||||
match model_mut(lua).arena.frame_mut(h) {
|
||||
Some(f) => f.shown = shown,
|
||||
None => return,
|
||||
}
|
||||
if shown {
|
||||
show(lua, h);
|
||||
} else {
|
||||
hide(lua, h);
|
||||
}
|
||||
}
|
||||
|
||||
/// The show transition (`0x76ae10`, slot `+0x88`): the frame's own step, its children's
|
||||
/// transitions in link order, the toplevel raise (`0x76aeec`), then its own OnShow (`0x76aef5`).
|
||||
pub(super) fn show(lua: &Lua, h: FrameHandle) {
|
||||
{
|
||||
let mut model = model_mut(lua);
|
||||
if !model.arena.show_step(h) {
|
||||
return;
|
||||
}
|
||||
// Entering the mouse index schedules a hover re-pick (`0x764b8d`).
|
||||
model.hover_repick = true;
|
||||
}
|
||||
walk_children(lua, h, show);
|
||||
super::object::toplevel::raise_on_show(&mut model_mut(lua), h);
|
||||
notify(lua, h, true);
|
||||
}
|
||||
|
||||
/// The hide transition (`0x76ad50`, slot `+0x84`): the frame's own step with its hover leave,
|
||||
/// its children's transitions in link order, then its own OnHide (`0x76adfd`).
|
||||
pub(super) fn hide(lua: &Lua, h: FrameHandle) {
|
||||
let left = {
|
||||
let mut model = model_mut(lua);
|
||||
if !model.arena.hide_step(h) {
|
||||
return;
|
||||
}
|
||||
leave_if_hovered(&mut model, h)
|
||||
};
|
||||
if let Some(id) = left {
|
||||
let fired = event::fire_widget_handler(lua, id, "OnLeave", vec![Value::Boolean(true)]);
|
||||
record(lua, fired);
|
||||
}
|
||||
walk_children(lua, h, hide);
|
||||
// `CSimpleButton`'s hide notify (`+0x34`, `0x7791e0`) un-presses it, unless disabled or
|
||||
// locked, before the base notify, so a button hidden while held comes back unpushed.
|
||||
super::button::edge(&mut model_mut(lua), h, ButtonState::on_hide);
|
||||
notify(lua, h, false);
|
||||
}
|
||||
|
||||
/// Run `step` on each child of `parent` as a live walk reaches it
|
||||
/// ([`crate::widget::WidgetArena::next_child`]), with no borrow held across the step.
|
||||
fn walk_children(lua: &Lua, parent: FrameHandle, step: fn(&Lua, FrameHandle)) {
|
||||
let mut walk = ChildWalk::default();
|
||||
loop {
|
||||
let next = lua
|
||||
.app_data_ref::<Model>()
|
||||
.expect("model app_data")
|
||||
.arena
|
||||
.next_child(parent, &mut walk);
|
||||
let Some(child) = next else { break };
|
||||
step(lua, child);
|
||||
}
|
||||
}
|
||||
|
||||
/// The hide's de-registration (`0x764920`, at `0x76ad9d`, before the child walk) ends a hover on
|
||||
/// this frame: the removal tail (`0x764ba0`) clears the hover, schedules a re-pick (`0x764cbb`)
|
||||
/// and calls the leave notify with 1 (`0x764cce`), whose base `0x76b6f0` clears the drag-arm
|
||||
/// (`+0x100`/`+0x104`), so the hovered frame's OnLeave runs before its own OnHide and its
|
||||
/// ancestors'.
|
||||
/// The leave is a virtual call: a disabled Button's own `0x7794e0` skips its OnLeave. This leave is
|
||||
/// how a tooltip closes with its window; the reference has no other. The id whose OnLeave fires.
|
||||
fn leave_if_hovered(model: &mut Model, h: FrameHandle) -> Option<u32> {
|
||||
if model.mouseover != Some(h) {
|
||||
return None;
|
||||
}
|
||||
let notified = super::button::hover_notify_runs(model, Some(h));
|
||||
model.mouseover = None;
|
||||
if model.drag.as_ref().is_some_and(|d| d.source == h) {
|
||||
model.drag = None;
|
||||
}
|
||||
model.hover_repick = true;
|
||||
notified.then(|| model.frame_id(h))
|
||||
}
|
||||
|
||||
/// The notify slot (`+0x30` OnShow, `+0x34` OnHide): the base fires the script unless the frame is
|
||||
/// in its load window (`0x76b260`/`0x76b290`), and the EditBox's overrides then take or drop the
|
||||
/// focus (`0x77a750`/`0x77a780`, base first).
|
||||
fn notify(lua: &Lua, h: FrameHandle, visible: bool) {
|
||||
let id = {
|
||||
let mut model = model_mut(lua);
|
||||
match model.arena.frame(h) {
|
||||
Some(f) if f.load_window => None,
|
||||
Some(_) => Some(model.frame_id(h)),
|
||||
None => return,
|
||||
}
|
||||
};
|
||||
if let Some(id) = id {
|
||||
let script = if visible { "OnShow" } else { "OnHide" };
|
||||
let fired = event::fire_widget_handler(lua, id, script, Vec::new());
|
||||
record(lua, fired);
|
||||
}
|
||||
super::editbox::visibility_focus(lua, h, visible);
|
||||
}
|
||||
|
||||
/// Open or close a frame's load window: the pre-load hook's `+0x114 = 1` (`0x7697c2`) and the
|
||||
/// post-load hook's first write, `+0x114 = 0` (`0x76a2ff`).
|
||||
pub(crate) fn set_load_window(lua: &Lua, wrapper: &Table, open: bool) {
|
||||
let Ok(h) = super::object::frame_handle_of(lua, wrapper) else {
|
||||
return;
|
||||
};
|
||||
if let Some(f) = model_mut(lua).arena.frame_mut(h) {
|
||||
f.load_window = open;
|
||||
}
|
||||
}
|
||||
|
||||
/// The post-load hook's OnShow (`0x76a38c`–`0x76a3b3`), after the children and the OnLoad: the
|
||||
/// script alone, through the runner and not the notify slot, so no raise and no EditBox focus,
|
||||
/// when the frame is visible, has the handler and is out of its load window. It has no OnHide
|
||||
/// counterpart, and it fires whatever OnShows the frame already had.
|
||||
pub(crate) fn post_load_show(lua: &Lua, wrapper: &Table) {
|
||||
let Ok(h) = super::object::frame_handle_of(lua, wrapper) else {
|
||||
return;
|
||||
};
|
||||
let id = {
|
||||
let mut model = model_mut(lua);
|
||||
match model.arena.frame(h) {
|
||||
Some(f) if f.effective_visible && !f.load_window => model.frame_id(h),
|
||||
_ => return,
|
||||
}
|
||||
};
|
||||
let fired = event::fire_widget_handler(lua, id, "OnShow", Vec::new());
|
||||
record(lua, fired);
|
||||
}
|
||||
|
||||
fn model_mut(lua: &Lua) -> mlua::AppDataRefMut<'_, Model> {
|
||||
lua.app_data_mut::<Model>().expect("model app_data")
|
||||
}
|
||||
|
||||
/// A handler error is recorded in [`Model::errors`], never propagated to the verb.
|
||||
fn record(lua: &Lua, fired: mlua::Result<()>) {
|
||||
if let Err(e) = fired {
|
||||
model_mut(lua).record_script_error(e.to_string());
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,10 @@ pub struct Frame {
|
||||
pub shown: bool,
|
||||
/// `shown` and every ancestor effectively visible (`+0xd4`).
|
||||
pub effective_visible: bool,
|
||||
/// The frame's own load window (`+0x114`): set by the pre-load hook (`0x7697c2`), cleared as
|
||||
/// the post-load hook starts (`0x76a2ff`). While it is set the frame's own OnShow and OnHide
|
||||
/// fire nothing (`0x76b270`/`0x76b2a0`); it still transitions and walks its children.
|
||||
pub load_window: bool,
|
||||
/// `EnableMouse`: the mouse bit (bit 2) of the input mask `[frame+0xcc]`.
|
||||
pub mouse_enabled: bool,
|
||||
/// `EnableMouseWheel`, or an `<OnMouseWheel>` script at XML load: the mask's wheel bit (3).
|
||||
@@ -228,6 +232,14 @@ pub struct Region {
|
||||
pub detached: bool,
|
||||
}
|
||||
|
||||
/// A cursor over a frame's child list for [`WidgetArena::next_child`]: the child last returned and
|
||||
/// where it stood, so a walk that a handler interrupts resumes from the list as it is now.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct ChildWalk {
|
||||
at: usize,
|
||||
last: Option<FrameHandle>,
|
||||
}
|
||||
|
||||
// ── The arena ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The frame arena: frames, regions, the name registry and the propagation mutators. The screen
|
||||
@@ -424,6 +436,7 @@ impl WidgetArena {
|
||||
effective_alpha,
|
||||
shown,
|
||||
effective_visible: shown && parent_visible,
|
||||
load_window: false,
|
||||
mouse_enabled: mouse_enabled_by_ctor(kind),
|
||||
// The WorldFrame ctor takes the wheel (`0x481b1f`); the reference's ScrollFrame and
|
||||
// ScrollingMessageFrame ctors do not, and a stock one takes it from `<OnMouseWheel>`.
|
||||
|
||||
@@ -2,51 +2,88 @@
|
||||
|
||||
use crate::order::Strata;
|
||||
|
||||
use super::{FrameHandle, RegionHandle, WidgetArena, SCALE_EPS};
|
||||
use super::{ChildWalk, FrameHandle, RegionHandle, WidgetArena, SCALE_EPS};
|
||||
|
||||
impl WidgetArena {
|
||||
// ── Visibility ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Set `shown` and propagate effective visibility down the subtree (show `0x76ae10`, hide
|
||||
/// `0x76ad50`), returning the frames that changed, a node before its descendants, for the
|
||||
/// caller to fire `OnShow`/`OnHide`. The reference notifies post-order (`0x76aef5`) and
|
||||
/// re-reads live links, so a frame a handler hides mid-cascade fails its gate (`0x76ae1d`) and
|
||||
/// is never notified; this list is a snapshot that still holds it.
|
||||
/// The show transition's own step (`0x76ae10` up to its child walk): nothing unless the frame
|
||||
/// is shown (`0x76ae1d`), its parent is visible or absent (`0x76ae35`) and it is not visible
|
||||
/// yet (`0x76ae43`); otherwise it marks itself visible (`0x76ae7b`), before any child or
|
||||
/// handler runs, and relinks at its bucket's tail (`0x764780`). Whether it transitioned.
|
||||
pub fn show_step(&mut self, h: FrameHandle) -> bool {
|
||||
match self.frame(h) {
|
||||
Some(f) if f.shown && !f.effective_visible => {}
|
||||
_ => return false,
|
||||
}
|
||||
if !self.parent_visible(h) {
|
||||
return false;
|
||||
}
|
||||
self.frame_mut(h).unwrap().effective_visible = true;
|
||||
self.resequence_to_tail(h);
|
||||
true
|
||||
}
|
||||
|
||||
/// The hide transition's own step (`0x76ad50` up to its child walk): nothing unless the frame
|
||||
/// is visible (`0x76ad5b`); otherwise it marks itself invisible (`0x76ad93`). The shown bit is
|
||||
/// not consulted. Whether it transitioned.
|
||||
pub fn hide_step(&mut self, h: FrameHandle) -> bool {
|
||||
match self.frame_mut(h) {
|
||||
Some(f) if f.effective_visible => {
|
||||
f.effective_visible = false;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The next child of `parent` in a live walk of its child list (`0x76aec7`, `0x76ade0`). The
|
||||
/// reference reads the next link from the current node after the call returns, so a child a
|
||||
/// handler adds or removes later in the list is seen. A walked child that was itself moved
|
||||
/// out leaves the walk at the child now in its place; the reference reads a stale link there.
|
||||
pub fn next_child(&self, parent: FrameHandle, walk: &mut ChildWalk) -> Option<FrameHandle> {
|
||||
let children = &self.frame(parent)?.children;
|
||||
let at = match walk.last {
|
||||
None => 0,
|
||||
Some(last) if children.get(walk.at) == Some(&last) => walk.at + 1,
|
||||
Some(last) => children
|
||||
.iter()
|
||||
.position(|&c| c == last)
|
||||
.map_or(walk.at, |i| i + 1),
|
||||
};
|
||||
let next = *children.get(at)?;
|
||||
walk.at = at;
|
||||
walk.last = Some(next);
|
||||
Some(next)
|
||||
}
|
||||
|
||||
/// The Lua `Show`/`Hide` (`0x7757f1`/`0x7757fb`, `0x7758b1`/`0x7758bb`) with no handler to
|
||||
/// fire: write the shown bit, then run the transition, which gates itself. Returns the frames
|
||||
/// that changed in the order their notifies run, children before their parent. A caller with
|
||||
/// handlers walks with [`Self::show_step`], [`Self::hide_step`] and [`Self::next_child`].
|
||||
pub fn set_shown(&mut self, h: FrameHandle, shown: bool) -> Vec<FrameHandle> {
|
||||
let mut changed = Vec::new();
|
||||
match self.frame_mut(h) {
|
||||
Some(f) if f.shown != shown => f.shown = shown,
|
||||
_ => return changed,
|
||||
if let Some(f) = self.frame_mut(h) {
|
||||
f.shown = shown;
|
||||
self.cascade_silent(h, shown, &mut changed);
|
||||
}
|
||||
let parent_visible = self.parent_visible(h);
|
||||
self.propagate_visible(h, parent_visible, &mut changed);
|
||||
changed
|
||||
}
|
||||
|
||||
fn propagate_visible(
|
||||
&mut self,
|
||||
h: FrameHandle,
|
||||
parent_visible: bool,
|
||||
changed: &mut Vec<FrameHandle>,
|
||||
) {
|
||||
let (shown, old_ev, children) = {
|
||||
let f = self.frame(h).expect("live node in propagation");
|
||||
(f.shown, f.effective_visible, f.children.clone())
|
||||
fn cascade_silent(&mut self, h: FrameHandle, show: bool, changed: &mut Vec<FrameHandle>) {
|
||||
let stepped = if show {
|
||||
self.show_step(h)
|
||||
} else {
|
||||
self.hide_step(h)
|
||||
};
|
||||
let new_ev = shown && parent_visible;
|
||||
if new_ev == old_ev {
|
||||
// No change here means none below: prune, as the client's recursion does.
|
||||
if !stepped {
|
||||
return;
|
||||
}
|
||||
self.frame_mut(h).unwrap().effective_visible = new_ev;
|
||||
if new_ev {
|
||||
// Re-added at its bucket's tail, as the client does, and each descendant after it.
|
||||
self.resequence_to_tail(h);
|
||||
let mut walk = ChildWalk::default();
|
||||
while let Some(c) = self.next_child(h, &mut walk) {
|
||||
self.cascade_silent(c, show, changed);
|
||||
}
|
||||
changed.push(h);
|
||||
for c in children {
|
||||
self.propagate_visible(c, new_ev, changed);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Strata ───────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -202,18 +239,20 @@ impl WidgetArena {
|
||||
|
||||
// ── Reparenting ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Phase 1 of `SetParent` (`0x76ab10`): the guards and the hide half. `None` when the parent
|
||||
/// is unchanged (`0x76ab20` skips everything), the frame is dead, or the move would cycle (the
|
||||
/// Lua binding raises first, `0x7a177f`); otherwise the frames that lost effective visibility,
|
||||
/// in pre-order, whose `OnHide` runs under the old parent (`0x76ab41`).
|
||||
/// Phase 1 of `SetParent` (`0x76ab10`): the guards. `None` when the parent is unchanged
|
||||
/// (`0x76ab20` skips everything), the frame is dead, or the move would cycle (the Lua binding
|
||||
/// raises first, `0x7a177f`); otherwise whether the frame is visible (`0x76ab2b`), in which
|
||||
/// case the caller runs its hide transition under the old parent (`0x76ab41`) before
|
||||
/// [`Self::reparent_finish`], and its show transition after it (`0x76abfd`).
|
||||
///
|
||||
/// Deviation: the frame is still in the old parent's child list during `OnHide`, unlike the
|
||||
/// reference, because a handler that itself reparents must not find the lists half-spliced.
|
||||
/// reference (`0x76ab34` unlinks first), because a handler that itself reparents must not find
|
||||
/// the lists half-spliced.
|
||||
pub fn reparent_begin(
|
||||
&mut self,
|
||||
h: FrameHandle,
|
||||
new_parent: Option<FrameHandle>,
|
||||
) -> Option<Vec<FrameHandle>> {
|
||||
) -> Option<bool> {
|
||||
self.frame(h)?;
|
||||
let new_parent = new_parent.filter(|&p| self.frame(p).is_some());
|
||||
if let Some(np) = new_parent {
|
||||
@@ -221,32 +260,22 @@ impl WidgetArena {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if self.frame(h).unwrap().parent == new_parent {
|
||||
let f = self.frame(h).unwrap();
|
||||
if f.parent == new_parent {
|
||||
return None;
|
||||
}
|
||||
let mut hidden = Vec::new();
|
||||
if self.frame(h).unwrap().effective_visible {
|
||||
self.propagate_visible(h, false, &mut hidden);
|
||||
}
|
||||
Some(hidden)
|
||||
Some(f.effective_visible)
|
||||
}
|
||||
|
||||
/// Phase 2 of `SetParent` (`0x76ab10`): relink, strata := the parent's (`0x76ab5a`, MEDIUM for
|
||||
/// none), level := the parent's + 1 without propagating (`0x76ab65`, 0 for none), scale
|
||||
/// re-inherited, then the show half, returning the frames that became visible; alpha is
|
||||
/// untouched. `h`'s own children keep their levels, so one can end up below `h`.
|
||||
/// `was_visible` is whether phase 1 hid anything (the reference's `ebx` at `0x76ab2b`); the
|
||||
/// show half runs only then (`0x76abfd`), so a hidden frame moved under a visible parent keeps
|
||||
/// a stale effective visibility until something shows it.
|
||||
pub fn reparent_finish(
|
||||
&mut self,
|
||||
h: FrameHandle,
|
||||
new_parent: Option<FrameHandle>,
|
||||
was_visible: bool,
|
||||
) -> Vec<FrameHandle> {
|
||||
let mut shown = Vec::new();
|
||||
/// re-inherited; alpha is untouched. `h`'s own children keep their levels, so one can end up
|
||||
/// below `h`. The show half is the caller's, and runs only when phase 1 found the frame
|
||||
/// visible (`0x76abe3`), so a hidden frame moved under a visible parent keeps a stale
|
||||
/// effective visibility until something shows it.
|
||||
pub fn reparent_finish(&mut self, h: FrameHandle, new_parent: Option<FrameHandle>) {
|
||||
if self.frame(h).is_none() {
|
||||
return shown; // died inside an OnHide: nothing to move
|
||||
return; // died inside an OnHide: nothing to move
|
||||
}
|
||||
let new_parent = new_parent.filter(|&p| self.frame(p).is_some());
|
||||
// Relink from the current parent: an `OnHide` may have reparented it, and the reference's
|
||||
@@ -274,25 +303,27 @@ impl WidgetArena {
|
||||
self.set_frame_level(h, plevel, false);
|
||||
let ps = self.parent_effective_scale(h);
|
||||
self.propagate_scale(h, ps);
|
||||
|
||||
if was_visible && self.parent_visible(h) {
|
||||
self.propagate_visible(h, true, &mut shown);
|
||||
}
|
||||
shown
|
||||
}
|
||||
|
||||
/// Both reparent phases in one call, hide changes first, for a caller with no `OnHide` to fire.
|
||||
/// `SetParent` whole, for a caller with no handler to fire: the frames that lost visibility,
|
||||
/// then those that gained it, each half in notify order.
|
||||
pub fn set_parent(
|
||||
&mut self,
|
||||
h: FrameHandle,
|
||||
new_parent: Option<FrameHandle>,
|
||||
) -> Vec<FrameHandle> {
|
||||
let Some(hidden) = self.reparent_begin(h, new_parent) else {
|
||||
let Some(was_visible) = self.reparent_begin(h, new_parent) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let was_visible = !hidden.is_empty();
|
||||
let shown = self.reparent_finish(h, new_parent, was_visible);
|
||||
[hidden, shown].concat()
|
||||
let mut changed = Vec::new();
|
||||
if was_visible {
|
||||
self.cascade_silent(h, false, &mut changed);
|
||||
}
|
||||
self.reparent_finish(h, new_parent);
|
||||
if was_visible {
|
||||
self.cascade_silent(h, true, &mut changed);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// `Region:SetParent` for a texture or font string; `None` detaches. The client re-links in
|
||||
|
||||
@@ -34,7 +34,8 @@ fn mid_tree_hide_blocks_a_shown_grandchild() {
|
||||
assert!(a.frame(grand).unwrap().effective_visible);
|
||||
|
||||
let changed = a.set_shown(child, false);
|
||||
assert_eq!(changed, vec![child, grand]); // pre-order
|
||||
// Notify order: post-order, children before their parent (`0x76adee`, then `0x76adfd`).
|
||||
assert_eq!(changed, vec![grand, child]);
|
||||
assert!(a.frame(root).unwrap().effective_visible);
|
||||
assert!(!a.frame(child).unwrap().effective_visible);
|
||||
assert!(!a.frame(grand).unwrap().effective_visible);
|
||||
@@ -44,7 +45,7 @@ fn mid_tree_hide_blocks_a_shown_grandchild() {
|
||||
);
|
||||
|
||||
let changed = a.set_shown(child, true);
|
||||
assert_eq!(changed, vec![child, grand]);
|
||||
assert_eq!(changed, vec![grand, child]);
|
||||
assert!(a.frame(grand).unwrap().effective_visible);
|
||||
}
|
||||
|
||||
@@ -58,10 +59,10 @@ fn hidden_grandchild_stays_hidden_when_ancestor_reshows() {
|
||||
a.set_shown(grand, false);
|
||||
let changed = a.set_shown(root, false);
|
||||
// grand is already invisible, so the hide does not report it.
|
||||
assert_eq!(changed, vec![root, child]);
|
||||
assert_eq!(changed, vec![child, root]);
|
||||
|
||||
let changed = a.set_shown(root, true);
|
||||
assert_eq!(changed, vec![root, child]);
|
||||
assert_eq!(changed, vec![child, root]);
|
||||
assert!(a.frame(child).unwrap().effective_visible);
|
||||
assert!(!a.frame(grand).unwrap().effective_visible);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user