scripts/SCROLL_COORDINATE_ARCHITECTURE.md
This session fixed 5 related bugs in scroll container rendering. The root cause
across all of them is a single architectural weakness: the display list uses
absolute window-space coordinates everywhere, and the conversion to
scroll-frame-relative coordinates happens ad-hoc per display-list-item type in
the compositor. This means every new DisplayListItem variant must remember
to call apply_offset(), and forgetting creates a silent, hard-to-detect bug.
Can we permanently prevent these bugs? Yes, via one of two approaches:
Both are described below. Approach 2 is recommended as the pragmatic next step.
| # | Bug | Root Cause | File |
|---|---|---|---|
| 1 | Flex containers never got scrollbar_info | Taffy's flex algorithm bypasses Azul's scrollbar detection | taffy_bridge.rs |
| 2 | Text invisible inside scroll frames | DisplayListItem::Text missing apply_offset() | compositor2.rs |
| 3 | Image mispositioned in scroll frames | DisplayListItem::Image missing apply_offset() | compositor2.rs |
| 4 | IFrame scroll not detected by hit-test | Child pipeline occludes parent scroll nodes | wr_translate2.rs |
| 5 | IFrame display list ordering wrong | IFrame appended at end instead of placeholder position | window.rs |
Bug #1 is a layout/Taffy integration issue. Bugs #2-3 are the core coordinate-space bug. Bugs #4-5 are IFrame-specific integration issues.
Layout Engine → Display List → Compositor → WebRender
(Window) (Window) (convert) (ScrollFrame)
compositor2.rs) iterates items and pushes them to
WebRender. When inside a scroll frame, it must subtract the scroll frame's
origin from each item's coordinates via apply_offset().define_scroll_frame.The offset_stack in compositor2.rs tracks nested scroll frame origins:
let mut offset_stack: Vec<(f32, f32)> = vec![(0.0, 0.0)];
// PushScrollFrame: offset_stack.push(frame_origin)
// PopScrollFrame: offset_stack.pop()
// Items: rect = apply_offset(raw_rect, current_offset!())
Every DisplayListItem::* match arm must independently remember to:
scale_bounds_to_layout_rect)current_offset!())apply_offset(raw_rect, offset))There is no compiler enforcement. If step 2-3 are forgotten, the item
renders at its absolute window position instead of relative to the scroll frame.
This is invisible when offset_stack is [(0.0, 0.0)] (no scroll frames), so
it only manifests when scroll containers are actually used.
After this session's fixes, the status is:
| Item | apply_offset? | Notes |
|---|---|---|
| Rect | ✅ | Was already correct |
| SelectionRect | ✅ | Fixed this session |
| CursorRect | ✅ | Fixed this session |
| Border | ✅ | Fixed this session |
| ScrollBar | ✅ | Was already correct |
| ScrollBarStyled | ✅ | Was already correct |
| HitTestArea | ✅ | Fixed this session |
| Underline | ✅ | Fixed this session |
| Strikethrough | ✅ | Fixed this session |
| Overline | ✅ | Fixed this session |
| Text | ✅ | Fixed this session |
| Image | ✅ | Fixed this session |
| LinearGradient | ✅ | Was already correct |
| RadialGradient | ✅ | Was already correct |
| ConicGradient | ✅ | Was already correct |
| BoxShadow | ✅ | Was already correct |
| PushClip | ✅ | Fixed: apply_offset to clip rect and rounded clip bounds |
| PushScrollFrame | ✅ | Correctly applies offset to frame rect |
| PushStackingContext | ✅ | Fixed: subtract current_offset from scaled origin |
| PushReferenceFrame | N/A | Uses zero origin by design |
| PushFilter | ✅ | Fixed: subtract current_offset from origin |
| PushBackdropFilter | ✅ | Fixed: apply_offset to clip_rect |
| PushOpacity | ✅ | Fixed: subtract current_offset from origin |
| PushTextShadow | N/A | Shadow offset is text-relative |
| IFrame | N/A | Has own pipeline/coordinate system |
All items are now ✅ — the Push* context items were fixed by applying
current_offset to their origins/rects, ensuring correct positioning
inside scroll frames.
Replace LogicalRect in DisplayListItem with a newtype that carries the
coordinate space:
/// A rectangle in absolute window coordinates (layout output).
pub struct WindowRect(pub LogicalRect);
/// A rectangle relative to the current scroll/reference frame.
pub struct FrameRelativeRect(pub LogicalRect);
enum DisplayListItem {
Rect { bounds: WindowRect, ... },
Text { clip_rect: WindowRect, ... },
...
}
The compositor would then have:
fn to_frame_relative(rect: &WindowRect, offset: (f32, f32)) -> FrameRelativeRect {
FrameRelativeRect(apply_offset(rect.0, offset))
}
WebRender push functions would only accept FrameRelativeRect, making it a
compile error to pass unconverted coordinates.
Pros:
Cons:
f32 pairs, not rects — needs separate handlingRefactor the compositor loop to apply the offset once, centrally, rather than per-item:
// Before the match: extract bounds from any item that has spatial bounds
let offset = current_offset!();
// Centralized helper that ALL items use
let resolve_rect = |bounds: &LogicalRect| -> LayoutRect {
apply_offset(scale_bounds_to_layout_rect(bounds, dpi_scale), offset)
};
match item {
DisplayListItem::Rect { bounds, .. } => {
let rect = resolve_rect(bounds);
...
}
DisplayListItem::Text { clip_rect, .. } => {
let rect = resolve_rect(clip_rect);
...
}
}
Better yet, define a trait or method on DisplayListItem:
impl DisplayListItem {
/// Returns the spatial bounds of this item (if any).
/// Items that define coordinate contexts (PushClip, PushScrollFrame, etc.)
/// return None — they handle coordinates differently.
fn bounds(&self) -> Option<&LogicalRect> { ... }
}
Then the compositor does:
let resolved_bounds = item.bounds().map(|b| resolve_rect(b));
Pros:
bounds()#[cfg(debug_assertions)] assert that no raw bounds escapeCons:
Regardless of approach 1 or 2, add integration tests:
#[test]
fn test_scroll_frame_offset_all_items() {
// Create a display list with every item type inside a scroll frame
// Render to a mock WebRender builder that records coordinates
// Assert all coordinates are frame-relative, not window-absolute
}
This is complementary — it catches regressions but doesn't prevent the initial bug when adding a new variant.
Separate from coordinate spaces, the Taffy integration has a design tension:
Taffy owns flex/grid layout but Azul owns scrollbar detection. When
Taffy lays out a flex container, it doesn't know that overflow: auto should
constrain the container's size and trigger scrollbar creation. The fix
(compute_child_layout() in taffy_bridge.rs) runs Azul's scrollbar check
after Taffy returns, using the CSS-specified height vs. Taffy's content height.
This is inherently fragile because:
px heights (not %, vh, calc())compute_scrollbar_info() in cache.rsRecommendation: The scrollbar check in compute_child_layout() should be
unified with compute_scrollbar_info(). Both should use the same function. The
Taffy overflow property (Style::overflow = Scroll) is already set correctly
and tells Taffy to use min-size: 0 for the automatic minimum, but it doesn't
constrain the final size — that's by CSS spec design. The container size
constraint must come from known_dimensions passed by the parent flex
algorithm, which already works correctly for the PerformLayout pass.
Implemented: Approach 1 (Type-Level Enforcement).
WindowLogicalRect newtype wraps LogicalRect in all DisplayListItem
variants and ScrollbarDrawInfo fields. The compositor accesses the inner
rect via .inner() or .0, making every coordinate-space conversion explicit.
A centralized resolve_rect() helper combines DPI scaling + offset subtraction
in a single call.
Files changed:
layout/src/solver3/display_list.rs — WindowLogicalRect definition, enum, helpersdll/src/desktop/compositor2.rs — resolve_rect(), .inner() at all match armsdll/src/desktop/shell2/common/debug_server.rs — .0. field accesslayout/src/cpurender.rs — .inner() at function boundarieslayout/src/window.rs — .into() wrappingFuture work:
resolve_rect() instead of the two-step
scale_bounds_to_layout_rect + apply_offset pattern.