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:
2026-11-03 14:27:51 -07:00
co-authored by Claude Opus 5
parent 41512a04ef
commit abb4dc1dd3
6 changed files with 414 additions and 19 deletions
+134 -10
View File
@@ -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}"
);
}
}
+15 -2
View File
@@ -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()));
+91 -6
View File
@@ -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.
+30 -1
View File
@@ -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))
}
+97
View File
@@ -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.