Skip to content

Commit 9107b8b

Browse files
committed
fix(layout): reserve scrollbar width in BFC children containing block size
When a block container has overflow: auto/scroll, the children's containing block size must be reduced by the scrollbar width to prevent children from overlapping the vertical scrollbar. This fix proactively reduces children_containing_block_size in layout_bfc based on the overflow-y property, ensuring proper layout even before scrollbar_info is computed.
1 parent a6328c8 commit 9107b8b

File tree

5 files changed

+287
-5
lines changed

5 files changed

+287
-5
lines changed

layout/src/solver3/cache.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,39 @@ fn prepare_layout_context<'a, T: ParsedFontTrait>(
743743
// Height is explicit - use inner size (after padding/border)
744744
node.box_props.inner_size(final_used_size, writing_mode)
745745
};
746+
747+
// Proactively reserve space for scrollbars based on overflow properties.
748+
// If overflow-y is auto/scroll, we must reduce available width for children
749+
// to ensure they don't overlap with the scrollbar.
750+
// This is done BEFORE layout so children are sized correctly from the start.
751+
let scrollbar_reservation = match dom_id {
752+
Some(id) => {
753+
let styled_node_state = ctx
754+
.styled_dom
755+
.styled_nodes
756+
.as_container()
757+
.get(id)
758+
.map(|s| s.styled_node_state.clone())
759+
.unwrap_or_default();
760+
let overflow_y = get_overflow_y(ctx.styled_dom, id, &styled_node_state);
761+
use azul_css::props::layout::LayoutOverflow;
762+
match overflow_y.unwrap_or_default() {
763+
LayoutOverflow::Scroll | LayoutOverflow::Auto => fc::SCROLLBAR_WIDTH_PX,
764+
_ => 0.0,
765+
}
766+
}
767+
None => 0.0,
768+
};
769+
770+
// Reduce available width by scrollbar reservation (if any)
771+
let available_size_for_children = if scrollbar_reservation > 0.0 {
772+
LogicalSize {
773+
width: (available_size_for_children.width - scrollbar_reservation).max(0.0),
774+
height: available_size_for_children.height,
775+
}
776+
} else {
777+
available_size_for_children
778+
};
746779

747780
let constraints = LayoutConstraints {
748781
available_size: available_size_for_children,
@@ -789,9 +822,11 @@ fn compute_scrollbar_info<T: ParsedFontTrait>(
789822
let overflow_x = get_overflow_x(ctx.styled_dom, dom_id, styled_node_state);
790823
let overflow_y = get_overflow_y(ctx.styled_dom, dom_id, styled_node_state);
791824

825+
let container_size = box_props.inner_size(final_used_size, writing_mode);
826+
792827
fc::check_scrollbar_necessity(
793828
content_size,
794-
box_props.inner_size(final_used_size, writing_mode),
829+
container_size,
795830
to_overflow_behavior(overflow_x),
796831
to_overflow_behavior(overflow_y),
797832
)

layout/src/solver3/fc.rs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ use crate::{
9191

9292
/// Default scrollbar width in pixels (CSS Overflow Module Level 3).
9393
/// Used when `overflow: scroll` or `overflow: auto` triggers scrollbar display.
94-
const SCROLLBAR_WIDTH_PX: f32 = 16.0;
94+
pub const SCROLLBAR_WIDTH_PX: f32 = 16.0;
9595

9696
// Note: DEFAULT_FONT_SIZE and PT_TO_PX are imported from pixel
9797

@@ -704,7 +704,7 @@ fn layout_bfc<T: ParsedFontTrait>(
704704
// We use constraints.available_size directly as this already represents the
705705
// content-box available to this node (set by parent). For nodes with explicit
706706
// sizes, used_size contains the border-box which we convert to content-box.
707-
let children_containing_block_size = if let Some(used_size) = node.used_size {
707+
let mut children_containing_block_size = if let Some(used_size) = node.used_size {
708708
// Node has explicit used_size (border-box) - convert to content-box
709709
node.box_props.inner_size(used_size, writing_mode)
710710
} else {
@@ -713,6 +713,30 @@ fn layout_bfc<T: ParsedFontTrait>(
713713
constraints.available_size
714714
};
715715

716+
// Proactively reserve space for vertical scrollbar if overflow-y is auto/scroll.
717+
// This ensures children are laid out with the correct available width from the start,
718+
// preventing the "children overlap scrollbar" layout issue.
719+
let scrollbar_reservation = node.dom_node_id.map(|dom_id| {
720+
let styled_node_state = ctx
721+
.styled_dom
722+
.styled_nodes
723+
.as_container()
724+
.get(dom_id)
725+
.map(|s| s.styled_node_state.clone())
726+
.unwrap_or_default();
727+
let overflow_y = crate::solver3::getters::get_overflow_y(ctx.styled_dom, dom_id, &styled_node_state);
728+
use azul_css::props::layout::LayoutOverflow;
729+
match overflow_y.unwrap_or_default() {
730+
LayoutOverflow::Scroll | LayoutOverflow::Auto => SCROLLBAR_WIDTH_PX,
731+
_ => 0.0,
732+
}
733+
}).unwrap_or(0.0);
734+
735+
if scrollbar_reservation > 0.0 {
736+
children_containing_block_size.width =
737+
(children_containing_block_size.width - scrollbar_reservation).max(0.0);
738+
}
739+
716740
// Pass 1: Size all children (floats and normal flow)
717741
for &child_index in &node.children {
718742
let child_node = tree.get(child_index).ok_or(LayoutError::InvalidTree)?;

layout/src/solver3/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -536,7 +536,7 @@ pub fn layout_document<T: ParsedFontTrait + Sync + 'static>(
536536
);
537537

538538
if reflow_needed_for_scrollbars {
539-
ctx.debug_log("Scrollbars changed container size, starting full reflow...");
539+
ctx.debug_log(&format!("Scrollbars changed container size, starting full reflow (loop {})", loop_count));
540540
recon_result.layout_roots.clear();
541541
recon_result.layout_roots.insert(new_tree.root);
542542
recon_result.intrinsic_dirty = (0..new_tree.nodes.len()).collect();

layout/src/solver3/taffy_bridge.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1110,11 +1110,46 @@ impl<'a, 'b, T: ParsedFontTrait> TaffyBridge<'a, 'b, T> {
11101110
})
11111111
.unwrap_or(f32::INFINITY);
11121112

1113-
let available_size = LogicalSize {
1113+
let mut available_size = LogicalSize {
11141114
width: available_width,
11151115
height: available_height,
11161116
};
11171117

1118+
// Check if this node has overflow: auto/scroll and might need a vertical scrollbar.
1119+
// If so, reduce available width for children to reserve space for the scrollbar.
1120+
// This prevents the "children overlap scrollbar" layout issue.
1121+
let scrollbar_reservation = self
1122+
.tree
1123+
.get(node_idx)
1124+
.and_then(|node| node.dom_node_id)
1125+
.map(|dom_id| {
1126+
let styled_node_state = self
1127+
.ctx
1128+
.styled_dom
1129+
.styled_nodes
1130+
.as_container()
1131+
.get(dom_id)
1132+
.map(|s| s.styled_node_state.clone())
1133+
.unwrap_or_default();
1134+
let overflow_y = get_overflow_y(self.ctx.styled_dom, dom_id, &styled_node_state);
1135+
use azul_css::props::layout::LayoutOverflow;
1136+
let needs_scrollbar_space = matches!(
1137+
overflow_y.unwrap_or_default(),
1138+
LayoutOverflow::Scroll | LayoutOverflow::Auto
1139+
);
1140+
if needs_scrollbar_space {
1141+
crate::solver3::fc::SCROLLBAR_WIDTH_PX
1142+
} else {
1143+
0.0
1144+
}
1145+
})
1146+
.unwrap_or(0.0);
1147+
1148+
// Reduce available width by scrollbar reservation
1149+
if scrollbar_reservation > 0.0 && available_size.width.is_finite() {
1150+
available_size.width = (available_size.width - scrollbar_reservation).max(0.0);
1151+
}
1152+
11181153
// Convert Taffy's AvailableSpace to our Text3AvailableSpace for caching
11191154
let available_width_type = match inputs.available_space.width {
11201155
AvailableSpace::Definite(w) => crate::text3::cache::AvailableSpace::Definite(w),

scripts/debug_scrollbar_layout.sh

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
#!/bin/bash
2+
#
3+
# Debug script for scrollbar layout issues
4+
#
5+
# Usage:
6+
# 1. Start the application with debug server: AZUL_DEBUG=8765 cargo run --release --example scrolling
7+
# 2. Run this script: ./scripts/debug_scrollbar_layout.sh
8+
# 3. Check the output files in target/debug_output/
9+
#
10+
11+
set -e
12+
13+
PORT="${AZUL_DEBUG_PORT:-8765}"
14+
API="http://localhost:$PORT"
15+
OUTPUT_DIR="target/debug_output"
16+
17+
# Create output directory
18+
mkdir -p "$OUTPUT_DIR"
19+
20+
# Colors for output
21+
RED='\033[0;31m'
22+
GREEN='\033[0;32m'
23+
YELLOW='\033[1;33m'
24+
BLUE='\033[0;34m'
25+
NC='\033[0m' # No Color
26+
27+
echo -e "${BLUE}=== Azul Scrollbar Layout Debug Script ===${NC}"
28+
echo "API endpoint: $API"
29+
echo "Output directory: $OUTPUT_DIR"
30+
echo ""
31+
32+
# Check if server is running
33+
echo -e "${YELLOW}Checking debug server...${NC}"
34+
if ! curl -s "$API/" > /dev/null 2>&1; then
35+
echo -e "${RED}Error: Debug server not running on port $PORT${NC}"
36+
echo "Start your app with: AZUL_DEBUG=$PORT cargo run --release --example scrolling"
37+
exit 1
38+
fi
39+
echo -e "${GREEN}Debug server is running${NC}"
40+
echo ""
41+
42+
# Get display list
43+
echo -e "${YELLOW}Fetching display list...${NC}"
44+
curl -s -X POST "$API/" -d '{"op":"get_display_list"}' > "$OUTPUT_DIR/display_list.json"
45+
echo "Saved to $OUTPUT_DIR/display_list.json"
46+
47+
# Get layout tree
48+
echo -e "${YELLOW}Fetching layout tree...${NC}"
49+
curl -s -X POST "$API/" -d '{"op":"get_layout_tree"}' > "$OUTPUT_DIR/layout_tree.json"
50+
echo "Saved to $OUTPUT_DIR/layout_tree.json"
51+
52+
# Get scroll states
53+
echo -e "${YELLOW}Fetching scroll states...${NC}"
54+
curl -s -X POST "$API/" -d '{"op":"get_scroll_states"}' > "$OUTPUT_DIR/scroll_states.json"
55+
echo "Saved to $OUTPUT_DIR/scroll_states.json"
56+
57+
# Get scrollable nodes
58+
echo -e "${YELLOW}Fetching scrollable nodes...${NC}"
59+
curl -s -X POST "$API/" -d '{"op":"get_scrollable_nodes"}' > "$OUTPUT_DIR/scrollable_nodes.json"
60+
echo "Saved to $OUTPUT_DIR/scrollable_nodes.json"
61+
62+
# Get logs
63+
echo -e "${YELLOW}Fetching debug logs...${NC}"
64+
curl -s -X POST "$API/" -d '{"op":"get_logs"}' > "$OUTPUT_DIR/logs.json"
65+
echo "Saved to $OUTPUT_DIR/logs.json"
66+
67+
echo ""
68+
echo -e "${BLUE}=== Analysis ===${NC}"
69+
70+
# Analyze scroll frame clip
71+
echo ""
72+
echo -e "${YELLOW}Scroll Frame Clip:${NC}"
73+
cat "$OUTPUT_DIR/display_list.json" | python3 -c "
74+
import sys, json
75+
d = json.load(sys.stdin)
76+
analysis = d.get('data', {}).get('value', {}).get('clip_analysis', {})
77+
for op in analysis.get('operations', []):
78+
if op.get('op') == 'PushScrollFrame':
79+
b = op.get('bounds', {})
80+
cs = op.get('content_size', {})
81+
print(f' clip bounds: x={b.get(\"x\")}, y={b.get(\"y\")}, w={b.get(\"width\")}, h={b.get(\"height\")}')
82+
print(f' content_size: w={cs.get(\"width\")}, h={cs.get(\"height\")}')
83+
print(f' scroll_id: {op.get(\"scroll_id\")}')
84+
"
85+
86+
# Analyze child positions
87+
echo ""
88+
echo -e "${YELLOW}Child Rects (inside scroll frame):${NC}"
89+
cat "$OUTPUT_DIR/display_list.json" | python3 -c "
90+
import sys, json
91+
d = json.load(sys.stdin)
92+
items = d.get('data', {}).get('value', {}).get('items', [])
93+
for item in items:
94+
if item.get('scroll_depth') == 1 and item.get('type') == 'rect':
95+
print(f' x={item[\"x\"]:6.1f} y={item[\"y\"]:6.1f} w={item[\"width\"]:6.1f} h={item[\"height\"]:6.1f} {item[\"color\"]}')
96+
"
97+
98+
# Analyze scrollbars
99+
echo ""
100+
echo -e "${YELLOW}Scrollbars:${NC}"
101+
cat "$OUTPUT_DIR/display_list.json" | python3 -c "
102+
import sys, json
103+
d = json.load(sys.stdin)
104+
items = d.get('data', {}).get('value', {}).get('items', [])
105+
for item in items:
106+
t = item.get('type', '')
107+
if 'scrollbar' in t:
108+
print(f' {t}:')
109+
print(f' position: x={item.get(\"x\")}, y={item.get(\"y\")}')
110+
print(f' size: w={item.get(\"width\")}, h={item.get(\"height\")}')
111+
"
112+
113+
# Check for layout issues
114+
echo ""
115+
echo -e "${YELLOW}Layout Issue Check:${NC}"
116+
cat "$OUTPUT_DIR/display_list.json" | python3 -c "
117+
import sys, json
118+
d = json.load(sys.stdin)
119+
items = d.get('data', {}).get('value', {}).get('items', [])
120+
analysis = d.get('data', {}).get('value', {}).get('clip_analysis', {})
121+
122+
# Find scroll frame clip
123+
clip_x, clip_y, clip_w, clip_h = 0, 0, 0, 0
124+
for op in analysis.get('operations', []):
125+
if op.get('op') == 'PushScrollFrame':
126+
b = op.get('bounds', {})
127+
clip_x, clip_y = b.get('x', 0), b.get('y', 0)
128+
clip_w, clip_h = b.get('width', 0), b.get('height', 0)
129+
130+
# Find vertical scrollbar
131+
scrollbar_x = None
132+
for item in items:
133+
if 'scrollbar_vertical' in item.get('type', ''):
134+
scrollbar_x = item.get('x', 0)
135+
136+
# Check child positions
137+
issues = []
138+
for item in items:
139+
if item.get('scroll_depth') == 1 and item.get('type') == 'rect':
140+
child_x = item.get('x', 0)
141+
child_w = item.get('width', 0)
142+
child_end_x = child_x + child_w
143+
clip_end_x = clip_x + clip_w
144+
145+
if scrollbar_x and child_end_x > scrollbar_x:
146+
overlap = child_end_x - scrollbar_x
147+
issues.append(f'Child rect overlaps scrollbar by {overlap:.1f}px (child ends at x={child_end_x:.1f}, scrollbar at x={scrollbar_x:.1f})')
148+
break
149+
elif child_end_x > clip_end_x:
150+
overflow = child_end_x - clip_end_x
151+
issues.append(f'Child rect overflows clip by {overflow:.1f}px (child ends at x={child_end_x:.1f}, clip ends at x={clip_end_x:.1f})')
152+
break
153+
154+
if issues:
155+
print(' ⚠️ Issues found:')
156+
for issue in issues:
157+
print(f' - {issue}')
158+
else:
159+
print(' ✅ No layout issues detected')
160+
"
161+
162+
# Filter logs for scrollbar-related entries
163+
echo ""
164+
echo -e "${YELLOW}Scrollbar-related logs:${NC}"
165+
cat "$OUTPUT_DIR/logs.json" | python3 -c "
166+
import sys, json
167+
d = json.load(sys.stdin)
168+
logs = d.get('data', {}).get('logs', [])
169+
scrollbar_logs = [l for l in logs if 'scroll' in l.get('message', '').lower() or 'reflow' in l.get('message', '').lower()]
170+
if scrollbar_logs:
171+
for log in scrollbar_logs[-10:]:
172+
print(f' [{log.get(\"level\", \"\")}] {log.get(\"message\", \"\")}')
173+
else:
174+
print(' (no scrollbar-related logs found)')
175+
" 2>/dev/null || echo " (logs not available or empty)"
176+
177+
echo ""
178+
echo -e "${GREEN}Debug data saved to $OUTPUT_DIR/${NC}"
179+
echo ""
180+
echo "Useful commands:"
181+
echo " # View full display list:"
182+
echo " cat $OUTPUT_DIR/display_list.json | python3 -m json.tool | less"
183+
echo ""
184+
echo " # Filter for specific item types:"
185+
echo " cat $OUTPUT_DIR/display_list.json | jq '.data.value.items[] | select(.type | contains(\"scroll\"))'"
186+
echo ""
187+
echo " # View layout tree:"
188+
echo " cat $OUTPUT_DIR/layout_tree.json | python3 -m json.tool | less"

0 commit comments

Comments
 (0)