ui: a Slider's thumb had no rect, so no scrollbar could be skinned or seen
"Half of the info on the right is out of the frame" was a scroll frame with 699 of content in a 451 window, a scroll range of 228, and a scrollbar with nothing on it. The thumb's rect was only ever computed inside the draw pass and never published: the region sweep skips anchor-less regions and a thumb is anchor-less by construction, because its slider places it. That is invisible until something anchors to the thumb, and pfUI does -- SkinScrollbar clears the stock knob and hangs its own off the thumb's two corners, so every pfUI-skinned scrollbar in the interface was a bare track, which is a scroll frame that silently cannot be scrolled. The thumb is on the layout roster now, survives the unanchored prune, and carries its slider's (vertical, fraction) in its node hash -- without the last one a named touch moved no hash and seeded nothing, which WOW_LAYOUT_VERIFY caught as an incremental pass disagreeing with a full derivation. The draw pass keeps computing its own rect: extract takes &self and cannot resolve, so a SetValue with no resolve behind it still has to draw where the value now is. map: the window has two layouts and the canvas only knew the maximized one Pressing maximize left the art a 1002x668 patch in a full-screen window and minimize could not put it back, because the container was anchored once at build to WorldMapPositioningGuide TOP -502,-69 -- 1.12's maximized anchor against a fixed 1024x768 guide. Read as insets instead (15/4/33/20 windowed, 10/12/69/31 maximized) the same numbers reproduce 1.12 at 1.12's sizes and fit any other window, which is also the left-edge bleed: -502 against a guide stretched to pfUI's 1017-wide window starts the art 6.5 px in, not 10. The zoom ladder's floor follows: it was the constant 1, true only while the container was the art's own size, and is the measured fit now. A view outside a rebuilt ladder is pulled onto its nearest end, since ZoomIn and ZoomOut walk it by value -- OpenMapClassic's whole ladder sits below ours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -586,14 +586,18 @@ function MapCanvasScrollControllerMixin:CreateZoomLevels()
|
||||
local layers = C_Map.GetMapArtLayers(mapID)
|
||||
local layer = layers and layers[1]
|
||||
if layer then
|
||||
-- A geometric ladder from the layer's own bounds. Scale 1 is the whole map in the
|
||||
-- container by construction — the canvas is sized to the layer and the container is the
|
||||
-- rect that layer used to fill — so a `minScale` below 1 would open the map zoomed
|
||||
-- *out* of its own window, which is how this was first wrong.
|
||||
-- A geometric ladder from the layer's own bounds, floored at the scale that fits the
|
||||
-- whole map in the container. That used to be the constant 1 and the comment here
|
||||
-- said why: the canvas is sized to the layer and the container *is* the rect that
|
||||
-- layer filled, so 1 was the whole map by construction and a `minScale` below 1 would
|
||||
-- open the map zoomed out of its own window. The container is the window's content
|
||||
-- area now (`SeatContainer`), which is 1002x668 only when the window is 1.12's own
|
||||
-- size, so the fit has to be measured. It still comes out exactly 1 when it is.
|
||||
local fit = self:GetFitScale()
|
||||
local lo = layer.minScale or 1
|
||||
local hi = layer.maxScale or 1
|
||||
if lo > 1 then
|
||||
lo = 1
|
||||
if lo > fit then
|
||||
lo = fit
|
||||
end
|
||||
if hi < lo then
|
||||
hi = lo
|
||||
@@ -614,10 +618,32 @@ function MapCanvasScrollControllerMixin:CreateZoomLevels()
|
||||
end
|
||||
end
|
||||
if table.getn(levels) == 0 then
|
||||
levels[1] = { scale = 1, layerIndex = 1 }
|
||||
-- No art layer to build a ladder from: one step, at the scale that fits the canvas in
|
||||
-- the window. 1 while the two are the same size, which is 1.12's own map.
|
||||
levels[1] = { scale = self:GetFitScale(), layerIndex = 1 }
|
||||
end
|
||||
self.zoomLevels = levels
|
||||
self.baseScale = levels[1].scale
|
||||
-- A view left outside the new ladder is pulled back onto its nearest end. `ZoomIn`/`ZoomOut`
|
||||
-- walk the ladder **by value** — the first step above or below the current scale — so a view
|
||||
-- outside it at either end cannot be climbed back onto by scrolling. Both ends happen: a
|
||||
-- window that grew raises the floor, and a canvas addon that replaces the ladder wholesale
|
||||
-- lowers the ceiling, which is OpenMapClassic's case (its stitched world is reached by
|
||||
-- zooming *out* far past this file's own scale 1, so its whole ladder sits below ours).
|
||||
local floor, ceil = levels[1].scale, levels[table.getn(levels)].scale
|
||||
local clamped = self.currentScale
|
||||
if clamped then
|
||||
if clamped < floor then
|
||||
clamped = floor
|
||||
elseif clamped > ceil then
|
||||
clamped = ceil
|
||||
end
|
||||
end
|
||||
if clamped and clamped ~= self.currentScale then
|
||||
self.currentScale = clamped
|
||||
self.targetScale = clamped
|
||||
self.zoomTarget = clamped
|
||||
end
|
||||
-- The scale itself is only seeded when there is none: `CreateZoomLevels` is called again on
|
||||
-- every map change and on every container resize, and a view the player has zoomed must not
|
||||
-- snap back because the window was dragged.
|
||||
@@ -628,6 +654,26 @@ function MapCanvasScrollControllerMixin:CreateZoomLevels()
|
||||
end
|
||||
end
|
||||
|
||||
-- The scale at which the whole canvas child fits inside the container, which is the zoom ladder's
|
||||
-- floor. 1 whenever the container is the art's own size, which is 1.12's own map.
|
||||
function MapCanvasScrollControllerMixin:GetFitScale()
|
||||
local child = self.Child
|
||||
if not child then
|
||||
return 1
|
||||
end
|
||||
self:GetLeft()
|
||||
local cw, ch = child:GetWidth(), child:GetHeight()
|
||||
local w, h = self:GetWidth(), self:GetHeight()
|
||||
if not cw or cw <= 0 or not ch or ch <= 0 or not w or w <= 0 or not h or h <= 0 then
|
||||
return 1
|
||||
end
|
||||
local fit = math.min(w / cw, h / ch)
|
||||
if fit <= 0 then
|
||||
return 1
|
||||
end
|
||||
return fit
|
||||
end
|
||||
|
||||
function MapCanvasScrollControllerMixin:GetScaleForMaxZoom()
|
||||
local n = table.getn(self.zoomLevels)
|
||||
if n == 0 then
|
||||
@@ -814,6 +860,68 @@ local function CaptureOverlays()
|
||||
ScaleDetailArt()
|
||||
end
|
||||
|
||||
-- The window's content area, the rect 1.12 gives its map art, in both of the two layouts the
|
||||
-- stock map has. Taken from `WorldMapFrame_Minimize`/`_Maximize` themselves, as insets rather
|
||||
-- than as the one size each of them happens to produce:
|
||||
--
|
||||
-- windowed `WorldMapDetailFrame:SetPoint("TOPLEFT", 15, -33)` at scale 0.7 inside a 720x521
|
||||
-- window — 701x468 of art, so 15 left, 33 top, 4 right, 20 bottom.
|
||||
-- maximized `TOPLEFT WorldMapPositioningGuide TOP -502,-69` at scale 1 — the guide is a fixed
|
||||
-- **1024x768** frame centred on the window, so 10 left, 69 top, 12 right, 31 bottom,
|
||||
-- and a maximized map that fills the screen still draws its sheet at 1024x768 with
|
||||
-- `BlackoutWorld` around it, which is 1.12's own full-screen map.
|
||||
--
|
||||
-- Read as insets, the same two numbers reproduce 1.12 exactly at 1.12's sizes and do the obvious
|
||||
-- thing at any other: pfUI resizes the window to `WorldMapButton`'s size plus its own chrome
|
||||
-- (`pfUI/modules/map.lua:111`), and the art then fills that instead of sitting at 70% in the
|
||||
-- corner of it. Before this the container kept the maximized anchor whatever the mode, so the
|
||||
-- maximize button left the art a 1002x668 patch in a full-screen window and the minimize button
|
||||
-- could not put it back.
|
||||
local WINDOWED_INSET = { l = 15, t = 33, r = 4, b = 20 }
|
||||
local MAXIMIZED_INSET = { l = 10, t = 69, r = 12, b = 31 }
|
||||
|
||||
-- `(host, inset)` for the current mode: the frame the content area is measured in, and how far
|
||||
-- inside it the art sits.
|
||||
local function ContentHost()
|
||||
if WORLDMAP_WINDOWED == 1 then
|
||||
return WorldMapFrame, WINDOWED_INSET
|
||||
end
|
||||
return WorldMapPositioningGuide or WorldMapFrame, MAXIMIZED_INSET
|
||||
end
|
||||
|
||||
-- Put the container on that content area. The size is written explicitly rather than left to a
|
||||
-- two-corner anchor, because this client fires `OnSizeChanged` only for an explicit
|
||||
-- `SetWidth`/`SetHeight` — and that event is what rebuilds the zoom ladder.
|
||||
local function SeatContainer()
|
||||
local sc = WorldMapFrame and WorldMapFrame.ScrollContainer
|
||||
local host, inset = ContentHost()
|
||||
if not sc or not host then
|
||||
return
|
||||
end
|
||||
host:GetLeft()
|
||||
local hw, hh = host:GetWidth(), host:GetHeight()
|
||||
if not hw or hw <= 0 or not hh or hh <= 0 then
|
||||
return
|
||||
end
|
||||
local w = hw - inset.l - inset.r
|
||||
local h = hh - inset.t - inset.b
|
||||
if w <= 0 or h <= 0 then
|
||||
return
|
||||
end
|
||||
sc:ClearAllPoints()
|
||||
sc:SetPoint("TOPLEFT", host, "TOPLEFT", inset.l, -inset.t)
|
||||
if math.abs((sc:GetWidth() or 0) - w) > 0.5 or math.abs((sc:GetHeight() or 0) - h) > 0.5 then
|
||||
sc:SetWidth(w)
|
||||
sc:SetHeight(h)
|
||||
-- Driven rather than waited for: a resize is what rebuilds the zoom ladder and the scroll
|
||||
-- extents, and this runs from inside `SeatOnCanvas`, which `OnCanvasSizeChanged` itself
|
||||
-- calls -- so leaving it to the `OnSizeChanged` script would land the rebuild after the
|
||||
-- ladder had already been built from the old rect. The size test above is the recursion
|
||||
-- guard: the re-entry finds the container already the right size and sets nothing.
|
||||
sc:OnCanvasSizeChanged()
|
||||
end
|
||||
end
|
||||
|
||||
-- The two frames the canvas took over, re-seated on it: both at scale 1, the art filling the
|
||||
-- canvas and the mouse surface covering the art. This is what keeps `WorldMapDetailFrame` at
|
||||
-- 1002x668 — see "1002x668 is a contract, not a size".
|
||||
@@ -830,6 +938,7 @@ local function SeatOnCanvas(w, h)
|
||||
if not canvas or not detail then
|
||||
return
|
||||
end
|
||||
SeatContainer()
|
||||
detail:SetScale(1)
|
||||
detail:ClearAllPoints()
|
||||
detail:SetAllPoints(canvas)
|
||||
@@ -870,10 +979,15 @@ function MapCanvasScrollControllerMixin:OnCanvasSizeChanged()
|
||||
w, h = layer.layerWidth, layer.layerHeight
|
||||
end
|
||||
end
|
||||
-- With no art layer (an instance map, a map the realm has no folder for) the canvas keeps the
|
||||
-- container's own size, so pins still place and the view is simply not zoomable.
|
||||
-- With no art layer (an instance map, a map the realm has no folder for) the canvas is still
|
||||
-- 1.12's own art rect, which is what `WorldMapDetailFrame` and everything pinned into it are
|
||||
-- placed in. It used to take the container's size instead, and that was only ever safe while
|
||||
-- the container *was* 1002x668: now that the container is the window's content area
|
||||
-- (`SeatContainer`), the fallback would hand the whole 1002x668 contract over to whatever
|
||||
-- size the player's map window happens to be. The view still fits, because the zoom ladder is
|
||||
-- floored at `GetFitScale` rather than at 1.
|
||||
if not w or w <= 0 or not h or h <= 0 then
|
||||
w, h = self:GetWidth(), self:GetHeight()
|
||||
w, h = DETAIL_BASE_W, DETAIL_BASE_H
|
||||
end
|
||||
child:SetWidth(w)
|
||||
child:SetHeight(h)
|
||||
@@ -1750,6 +1864,16 @@ local function BuildCanvas()
|
||||
self:OnCanvasSizeChanged()
|
||||
end)
|
||||
|
||||
-- The window resizing moves the content area, so the container has to follow it. pfUI's map
|
||||
-- module is the case that reaches a player: it sizes `WorldMapFrame` from
|
||||
-- `WorldMapButton:GetSize()` at `PLAYER_ENTERING_WORLD`, long after this ran. Same limit as
|
||||
-- the container's own hook — only an explicit `SetWidth`/`SetHeight` fires it, which is what
|
||||
-- `WorldMapFrame_Minimize` and pfUI both do; the maximize leg has no size of its own
|
||||
-- (`SetAllPoints`) and is covered by the `WorldMapFrame_Maximize` hook below.
|
||||
map:HookScript("OnSizeChanged", function()
|
||||
SeatOnCanvas()
|
||||
end)
|
||||
|
||||
-- Everything the button carries is map content except one frame: `WorldMapFrameAreaFrame`,
|
||||
-- the hovered-zone name. 1.12's XML hangs it off the mouse surface (TOP, 400x128) because
|
||||
-- that is what covers the art, but it is chrome — it names what the cursor is over and sits
|
||||
|
||||
@@ -433,3 +433,50 @@ fn the_windowed_scale_does_not_reach_the_detail_frames_own_units() {
|
||||
"a child pinned at 1.12's own (500, -300) landed at {got}, not {want}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 1.12 lays its map art out twice, once per window mode, and the canvas container has to be
|
||||
/// where that art would have been — `WorldMapFrame_Minimize` insets the detail frame 15,-33
|
||||
/// inside a 720x521 window at scale 0.7 (701x468 of art: 15 left, 4 right, 33 top, 20 bottom),
|
||||
/// `_Maximize` puts it at the fixed 1024x768 positioning guide's TOP -502,-69 (10/12/69/31).
|
||||
///
|
||||
/// Taking only the maximized anchor, whatever the mode, is what left grom's map a 1002x668 patch
|
||||
/// in a full-screen window the moment he pressed maximize, with no way back: minimize re-sized
|
||||
/// the window and the art stayed where the maximized sheet had put it.
|
||||
///
|
||||
/// The zoom ladder's floor moves with it. It used to be the constant 1, which was right only
|
||||
/// while the container was the art's own size; it is the fitted scale now, and that still comes
|
||||
/// out exactly 1 when the window is 1.12's own.
|
||||
#[test]
|
||||
fn the_canvas_container_is_the_windows_content_area_in_both_map_modes() {
|
||||
let Some(mut s) = harness() else { return };
|
||||
|
||||
for (call, want) in [
|
||||
("WorldMapFrame_Minimize()", (701.0, 468.0)),
|
||||
("WorldMapFrame_Maximize()", (1002.0, 668.0)),
|
||||
] {
|
||||
s.run(call).expect("the stock mode switch runs");
|
||||
s.resolve();
|
||||
let (w, h): (f64, f64) = s
|
||||
.eval(
|
||||
"WorldMapScrollContainer:GetLeft() \
|
||||
return WorldMapScrollContainer:GetWidth(), WorldMapScrollContainer:GetHeight()",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
(w - want.0).abs() < 1.0 && (h - want.1).abs() < 1.0,
|
||||
"{call}: the container must be 1.12's own art rect, wanted {want:?}, got {w}x{h}"
|
||||
);
|
||||
// And the whole map has to be reachable in it: the ladder's floor is the fit, which is
|
||||
// what `ResetZoom` and the right-click zoom-out both walk down to.
|
||||
let (fit, floor): (f64, f64) = s
|
||||
.eval(
|
||||
"local sc = WorldMapFrame.ScrollContainer \
|
||||
return sc:GetFitScale(), sc:GetScaleForMinZoom()",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
(fit - floor).abs() < 1e-3,
|
||||
"{call}: the zoom ladder must reach the fitted scale, fit {fit} floor {floor}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,8 +191,21 @@ 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>`.
|
||||
// Computed here from the live value rather than read back from the
|
||||
// layout's published rect, because `extract` takes `&self` and cannot
|
||||
// resolve: a `SetValue` with no resolve behind it still has to draw
|
||||
// the thumb where the value now is. The StatusBar fill above is the
|
||||
// same arrangement for the same reason.
|
||||
//
|
||||
// The layout publishes the same rect from the same inputs
|
||||
// (`script::layout`'s thumb branch) for the other half of the job —
|
||||
// `GetThumbTexture():GetLeft()` and, far more to the point, anchoring
|
||||
// to the thumb, which is how every pfUI-skinned scrollbar hangs its
|
||||
// own knob. Two call sites of one formula, not two formulas.
|
||||
//
|
||||
// `tsize` is 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);
|
||||
rect = rect
|
||||
.map(|r| slider::thumb_rect(r, tsize, sl.vertical, sl.fraction()));
|
||||
|
||||
@@ -97,8 +97,34 @@ pub(crate) fn frame_node_hash(input: &LayoutInput) -> u64 {
|
||||
|
||||
/// A region's per-node input hash: its anchors, explicit size and measured text extent.
|
||||
#[inline]
|
||||
fn region_node_hash(data: &RegionData) -> u64 {
|
||||
/// Where a Slider puts its thumb, for the one region whose rect that is: `(vertical, fraction)`,
|
||||
/// and `None` for every other region. A thumb has no anchors, so none of this hash's other
|
||||
/// inputs move when the slider's value does — and a named write whose hash does not move seeds
|
||||
/// nothing, which is how a `SetValue` left the published thumb rect where it was.
|
||||
fn thumb_placement(
|
||||
arena: &crate::widget::WidgetArena,
|
||||
rh: crate::widget::RegionHandle,
|
||||
) -> Option<(bool, f32)> {
|
||||
match arena
|
||||
.region(rh)
|
||||
.and_then(|r| arena.frame(r.owner))
|
||||
.map(|f| &f.kind_state)
|
||||
{
|
||||
Some(crate::widget::KindState::Slider(sl)) if sl.thumb == Some(rh) => {
|
||||
Some((sl.vertical, sl.fraction()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn region_node_hash(data: &RegionData, thumb: Option<(bool, f32)>) -> u64 {
|
||||
let mut node = InputFingerprint::default();
|
||||
// Only for a thumb, so no other region's hash moves and nothing re-resolves that did not
|
||||
// have to.
|
||||
if let Some((vertical, fraction)) = thumb {
|
||||
node.feed(u64::from(vertical));
|
||||
node.f32(fraction);
|
||||
}
|
||||
node.anchors(&data.anchors);
|
||||
match data.size {
|
||||
Some((w, h)) => {
|
||||
@@ -772,7 +798,7 @@ impl UiScript {
|
||||
let now = if n & REGION_TAG != 0 {
|
||||
let rh = scope.regions[(n & !REGION_TAG) as usize].rh;
|
||||
match region_data.get(&rh) {
|
||||
Some(d) => region_node_hash(d),
|
||||
Some(d) => region_node_hash(d, thumb_placement(arena, rh)),
|
||||
None => continue,
|
||||
}
|
||||
} else {
|
||||
@@ -1056,6 +1082,33 @@ impl UiScript {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A Slider's thumb is placed by its slider, not by anchors: it has none, so the
|
||||
// resolver below would drop it and leave it rectless forever. That was invisible
|
||||
// for as long as the only reader was the draw pass, which computes the same rect
|
||||
// itself — but `GetThumbTexture()` is a region like any other, and **addons anchor
|
||||
// to it**. pfUI's `SkinScrollbar` clears the stock knob and hangs its own texture
|
||||
// off the thumb's two corners (`pfUI/api/ui-widgets.lua:813-820`), so with no rect
|
||||
// here every pfUI-skinned scrollbar in the interface had a thumb that could not be
|
||||
// seen or grabbed — which is a scroll frame that silently cannot be scrolled.
|
||||
//
|
||||
// Into the solver as well as the map, so a region anchored to the thumb resolves
|
||||
// on a later round of this same fixpoint, as region-to-region anchors already do.
|
||||
let thumb_of = match arena.frame(owner).map(|f| &f.kind_state) {
|
||||
Some(crate::widget::KindState::Slider(sl)) if sl.thumb == Some(rh) => resolved
|
||||
.get(&owner)
|
||||
.map(|&track| (track, sl.vertical, sl.fraction())),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((track, vertical, fraction)) = thumb_of {
|
||||
let rect =
|
||||
super::slider::thumb_rect(track, (width, height), vertical, fraction);
|
||||
solver.set_external(region_id, rect);
|
||||
if region_resolved.get(&rh) != Some(&rect) {
|
||||
region_resolved.insert(rh, rect);
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
scratch.anchors.clear();
|
||||
scratch.anchors.extend_from_slice(&data.anchors);
|
||||
scratch.width = width;
|
||||
@@ -1215,8 +1268,20 @@ impl UiScript {
|
||||
// reference's resolving-flag bail. Every rect is recomputed from inputs and externals, so
|
||||
// last frame's rects are a legal seed: only dead or unanchored regions drop.
|
||||
region_resolved.retain(|rh, _| {
|
||||
arena.region(*rh).is_some()
|
||||
&& region_data.get(rh).is_some_and(|d| !d.anchors.is_empty())
|
||||
if arena.region(*rh).is_none() {
|
||||
return false;
|
||||
}
|
||||
// A Slider's thumb is placed by its slider and carries no anchors, so the
|
||||
// unanchored rule would drop the rect the sweep just gave it. See the thumb branch
|
||||
// in the region sweep.
|
||||
let is_slider_thumb = matches!(
|
||||
arena
|
||||
.region(*rh)
|
||||
.and_then(|r| arena.frame(r.owner))
|
||||
.map(|f| &f.kind_state),
|
||||
Some(crate::widget::KindState::Slider(sl)) if sl.thumb == Some(*rh)
|
||||
);
|
||||
is_slider_thumb || region_data.get(rh).is_some_and(|d| !d.anchors.is_empty())
|
||||
});
|
||||
pre.retain = pre.lap();
|
||||
|
||||
@@ -1261,7 +1326,20 @@ impl UiScript {
|
||||
for (&rh, data) in region_data.iter() {
|
||||
// Anchor-less entries are not layout inputs: hashing them would make a paint-only
|
||||
// setter that creates one (`SetTexture`'s `or_default()`) read as a layout change.
|
||||
if data.anchors.is_empty() {
|
||||
//
|
||||
// A Slider's thumb is the exception, and it is anchor-less by construction: its
|
||||
// slider places it. It has to be on the roster anyway, because its rect is what an
|
||||
// addon anchors to when it re-skins a scrollbar, and a region that never enters the
|
||||
// sweep never gets one. `Slider:SetValue` and friends mark it dirty themselves
|
||||
// (`touch_layout_region`), since the fraction that moves it is not a region input.
|
||||
let is_slider_thumb = matches!(
|
||||
arena
|
||||
.region(rh)
|
||||
.and_then(|r| arena.frame(r.owner))
|
||||
.map(|f| &f.kind_state),
|
||||
Some(crate::widget::KindState::Slider(sl)) if sl.thumb == Some(rh)
|
||||
);
|
||||
if data.anchors.is_empty() && !is_slider_thumb {
|
||||
continue;
|
||||
}
|
||||
fed_regions += 1;
|
||||
@@ -1297,8 +1375,15 @@ impl UiScript {
|
||||
};
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let idx = scope.regions.len() as u32 | REGION_TAG;
|
||||
let placement = is_slider_thumb
|
||||
.then(|| thumb_placement(arena, rh))
|
||||
.flatten();
|
||||
if let Some((vertical, fraction)) = placement {
|
||||
fp.feed(u64::from(vertical));
|
||||
fp.f32(fraction);
|
||||
}
|
||||
if let Some(slot) = scope.now.get_mut(id as usize) {
|
||||
*slot = region_node_hash(data);
|
||||
*slot = region_node_hash(data, placement);
|
||||
scope.node_of[id as usize] = idx;
|
||||
}
|
||||
// A region reads its anchor targets' rects and its owner's scale.
|
||||
|
||||
@@ -85,6 +85,24 @@ fn ensure_thumb(lua: &Lua, this: &Table, layer: Option<DrawLayer>) -> mlua::Resu
|
||||
Ok(model.region_id(rh))
|
||||
}
|
||||
|
||||
/// Mark the thumb's rect dirty after anything that moves it. The thumb is placed by the layout
|
||||
/// pass (`script::layout`), not by anchors, so the fraction that moves it is not one of the
|
||||
/// region inputs the change gate hashes — without this a `SetValue` leaves the thumb's published
|
||||
/// rect where it was, and an addon anchored to it (every pfUI-skinned scrollbar) follows the
|
||||
/// stale one.
|
||||
fn touch_thumb(lua: &Lua, this: &Table) -> mlua::Result<()> {
|
||||
let h = frame_handle_of(lua, this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let thumb = match model.arena.frame(h).map(|f| &f.kind_state) {
|
||||
Some(KindState::Slider(s)) => s.thumb,
|
||||
_ => None,
|
||||
};
|
||||
if let Some(rh) = thumb {
|
||||
model.touch_layout_region(rh);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let m = lua.create_table()?;
|
||||
|
||||
@@ -94,6 +112,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// A reversed pair is kept, not swapped (`0x789580`). A held value re-clamps and fires
|
||||
// on a move; a fresh slider with no value yet fires nothing (bit 2).
|
||||
let changed = with_slider(lua, &this, |s| s.set_min_max(min, max))?;
|
||||
touch_thumb(lua, &this)?;
|
||||
fire_value_changed(lua, &this, changed)
|
||||
})?,
|
||||
)?;
|
||||
@@ -105,6 +124,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
"SetValue",
|
||||
lua.create_function(|lua, (this, v): (Table, f32)| {
|
||||
let changed = with_slider(lua, &this, |s| s.store_value(v))?;
|
||||
touch_thumb(lua, &this)?;
|
||||
fire_value_changed(lua, &this, changed)
|
||||
})?,
|
||||
)?;
|
||||
@@ -118,6 +138,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// moved onto the new step fires `OnValueChanged`.
|
||||
lua.create_function(|lua, (this, step): (Table, f32)| {
|
||||
let changed = with_slider(lua, &this, |s| s.set_value_step(step))?;
|
||||
touch_thumb(lua, &this)?;
|
||||
fire_value_changed(lua, &this, changed)
|
||||
})?,
|
||||
)?;
|
||||
@@ -137,7 +158,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)))
|
||||
}
|
||||
};
|
||||
with_slider(lua, &this, |s| s.vertical = vertical)
|
||||
with_slider(lua, &this, |s| s.vertical = vertical)?;
|
||||
touch_thumb(lua, &this)
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
@@ -356,6 +378,13 @@ pub(super) fn drag_move(model: &mut Model, x: f32, y: f32) -> Option<(u32, f32)>
|
||||
Some(KindState::Slider(s)) => s.store_value(value),
|
||||
_ => None,
|
||||
};
|
||||
// As `touch_thumb` does for the Lua setters: the drag moves the thumb and nothing else the
|
||||
// change gate hashes moved with it.
|
||||
if changed.is_some() {
|
||||
if let Some(rh) = thumb {
|
||||
model.touch_layout_region(rh);
|
||||
}
|
||||
}
|
||||
changed.map(|v| (model.frame_id(slider), v))
|
||||
}
|
||||
|
||||
|
||||
@@ -2628,3 +2628,100 @@ anchor is `WorldMapFrame TOPLEFT 15,-33` at scale 0.7, which would fill 70% of h
|
||||
pfUI sizes that window from the **unscaled** `WorldMapButton` — it is written for the maximized
|
||||
map and he is running the windowed one. Flipping the map to maximized is the one-click test of
|
||||
that reading.
|
||||
|
||||
## No scrollbar in the interface had a thumb anything could anchor to
|
||||
|
||||
grom: "half of the info on the right is out of the frame" about the character panel. It was not
|
||||
out of the frame. `BCPPFUIStatsContent` is 699 tall inside a 451-tall scroll frame, with a
|
||||
scrollbar and a scroll range of 228 — the information was behind a scrollbar he could not see,
|
||||
could not grab, and had no reason to think was there.
|
||||
|
||||
A Slider's thumb texture had **no resolved rect**. Measured, with his own addons loaded:
|
||||
|
||||
```
|
||||
bar 16x419 L=487.0 B=881.0
|
||||
realthumb 16x16 L=nil B=nil
|
||||
pfUIthumb 8x8 L=nil B=nil <- pfUI's own knob, anchored to the thumb
|
||||
anchored-to-thumb 0x0 L=nil <- a fresh texture, same anchors, same answer
|
||||
```
|
||||
|
||||
The rect was only ever computed inside the **draw** pass (`extract`'s thumb branch, from
|
||||
`slider::thumb_rect`), never published, and the region sweep skipped the thumb outright: it
|
||||
carries no anchors, because its slider places it, and anchor-less regions are not layout inputs.
|
||||
A thumb that draws but has no rect is invisible for exactly as long as nobody anchors to it —
|
||||
|
||||
and pfUI anchors to it. `SkinScrollbar` clears the stock knob's texture and hangs its own off the
|
||||
thumb's two corners (`pfUI/api/ui-widgets.lua:813-820`). Stock knob blanked, replacement
|
||||
unplaceable: **every pfUI-skinned scrollbar in the interface was a bare track**, which is a scroll
|
||||
frame that silently cannot be scrolled. The character panel is where he noticed; the Cine settings
|
||||
page he reported two rounds ago is the same fault.
|
||||
|
||||
### What it took
|
||||
|
||||
Three things, because the thumb had fallen out of the layout at three separate points:
|
||||
|
||||
1. **The roster.** `if data.anchors.is_empty() { continue; }` in the region walk — the thumb is
|
||||
anchor-less by construction, so it never entered the sweep. It is on the roster now.
|
||||
2. **The prune.** `region_resolved.retain(…)` drops unanchored regions, which would have taken
|
||||
the rect back off the thumb the moment the sweep gave it one.
|
||||
3. **The node hash.** The incremental pass re-hashes only the ids a write *named*, and seeds only
|
||||
those whose hash moved — "a named write that did not move the hash seeds nothing". The thumb's
|
||||
hash is over its anchors, size and measured extent, and a `SetValue` moves none of them, so a
|
||||
named touch alone changed nothing. The slider's `(vertical, fraction)` is now part of that
|
||||
hash, for thumbs only, and `Slider:SetValue`/`SetMinMaxValues`/`SetValueStep`/`SetOrientation`
|
||||
and the drag name the thumb through `touch_layout_region`.
|
||||
|
||||
`WOW_LAYOUT_VERIFY=1` caught stage 3 outright — it panicked with "an INCREMENTAL layout pass
|
||||
disagreed with a full derivation" — which is exactly what that gate is for and the reason the
|
||||
first two stages alone looked like they had worked.
|
||||
|
||||
**The draw pass still computes its own rect, and that is deliberate.** `extract` takes `&self` and
|
||||
cannot resolve, so a `SetValue` with no resolve behind it must still draw the thumb where the
|
||||
value now is; the published rect is for `GetLeft` and for anchoring, and is refreshed on the next
|
||||
resolve, which the client runs every frame. Two call sites of one formula, like the StatusBar fill
|
||||
beside it. Reading the published rect in `extract` instead broke
|
||||
`slider_thumb_draws_at_value_fraction_along_the_track`, which is the test that says so.
|
||||
|
||||
Measured after, on the same scrollbar: thumb at the track top for value 0, and for value 200 of
|
||||
0..228, `thumbB=930.49` against 881 + (1 − 200/228) × (419 − 16) = 930.5 by hand, with pfUI's
|
||||
replacement knob 4 px inside it.
|
||||
|
||||
## The map window has two layouts, and the canvas only knew one
|
||||
|
||||
grom pressed the maximize button and "it got worse, doesn't go back to what it was". Both halves
|
||||
are one bug: the canvas container was anchored once, at build, to `WorldMapPositioningGuide TOP
|
||||
-502,-69` — 1.12's **maximized** anchor, against a guide that is a fixed 1024x768 frame.
|
||||
|
||||
1.12 lays the map art out twice and the two have nothing in common:
|
||||
|
||||
| | window | art anchor | art size |
|
||||
|---|---|---|---|
|
||||
| `WorldMapFrame_Minimize` | 720x521 | `WorldMapFrame TOPLEFT 15,-33` | 1002x668 at scale **0.7** |
|
||||
| `WorldMapFrame_Maximize` | the whole screen | guide `TOP -502,-69` | 1002x668 at scale 1, inside the fixed 1024x768 guide, `BlackoutWorld` around it |
|
||||
|
||||
So maximize left the art a 1002x668 patch in a full-screen window, and minimize put the window
|
||||
back at 720x521 with the art still where the maximized sheet had it.
|
||||
|
||||
Read as **insets** rather than as the one size each produces — 15/4/33/20 windowed, 10/12/69/31
|
||||
maximized — the same two numbers reproduce 1.12 exactly at 1.12's sizes and do the obvious thing
|
||||
at any other, which matters because pfUI resizes the window to `WorldMapButton`'s size plus its own
|
||||
chrome (`pfUI/modules/map.lua:111`). That is also the answer to the left-edge bleed left open in
|
||||
the previous section: it was the maximized `-502` being read against a guide stretched to a
|
||||
1017-wide window, so the art started 6.5 px in instead of 10.
|
||||
|
||||
Two consequences had to follow it:
|
||||
|
||||
- **The zoom ladder's floor was the constant 1**, and the comment there said why: the canvas is
|
||||
sized to the art layer and the container *was* the rect that layer filled, so 1 was the whole
|
||||
map by construction. The container is the window's content area now, so the floor is measured
|
||||
(`GetFitScale`) — and comes out exactly 1 when the window is 1.12's own size.
|
||||
- **A view outside the new ladder is pulled onto its nearest end.** `ZoomIn`/`ZoomOut` walk the
|
||||
ladder by value, so a scale outside it cannot be climbed back onto by scrolling. Both ends
|
||||
happen: a window that grew raises the floor, and a canvas addon that replaces the ladder
|
||||
wholesale lowers the ceiling — OpenMapClassic's stitched world is reached by zooming out far
|
||||
past this file's own scale 1, so its entire ladder sits below ours. Measured with OpenMap
|
||||
loaded: `minZoom=0.0125 maxZoom=0.899 current=1.0`, a view above its own ceiling.
|
||||
|
||||
The no-art-layer fallback changed with them: the canvas used to take the **container's** size when
|
||||
a map had no art layer, which was safe only while the container was 1002x668. It is 1.12's own art
|
||||
rect now in every case, and the fitted floor is what keeps it visible in a smaller window.
|
||||
|
||||
Reference in New Issue
Block a user