1use accesskit::{Node as Accessible, Role};
4use bevy::{
5 a11y::AccessibilityNode,
6 color::palettes::css::{BLACK, BLUE, RED},
7 ecs::spawn::SpawnIter,
8 input::mouse::{MouseScrollUnit, MouseWheel},
9 picking::hover::HoverMap,
10 prelude::*,
11};
12
13fn main() {
14 let mut app = App::new();
15
16 app.add_plugins(DefaultPlugins)
17 .add_systems(Startup, setup)
18 .add_systems(Update, send_scroll_events)
19 .add_observer(on_scroll_handler);
20
21 app.run();
22}
23
24const LINE_HEIGHT: f32 = 21.;
25
26fn send_scroll_events(
28 mut mouse_wheel_reader: MessageReader<MouseWheel>,
29 hover_map: Res<HoverMap>,
30 keyboard_input: Res<ButtonInput<KeyCode>>,
31 mut commands: Commands,
32) {
33 for mouse_wheel in mouse_wheel_reader.read() {
34 let mut delta = -Vec2::new(mouse_wheel.x, mouse_wheel.y);
35
36 if mouse_wheel.unit == MouseScrollUnit::Line {
37 delta *= LINE_HEIGHT;
38 }
39
40 if keyboard_input.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]) {
41 std::mem::swap(&mut delta.x, &mut delta.y);
42 }
43
44 for pointer_map in hover_map.values() {
45 for entity in pointer_map.keys().copied() {
46 commands.trigger(Scroll { entity, delta });
47 }
48 }
49 }
50}
51
52#[derive(EntityEvent, Debug)]
54#[entity_event(propagate, auto_propagate)]
55struct Scroll {
56 entity: Entity,
57 delta: Vec2,
59}
60
61fn on_scroll_handler(
62 mut scroll: On<Scroll>,
63 mut query: Query<(&mut ScrollPosition, &Node, &ComputedNode)>,
64) {
65 let Ok((mut scroll_position, node, computed)) = query.get_mut(scroll.entity) else {
66 return;
67 };
68
69 let max_offset = (computed.content_size() - computed.size()) * computed.inverse_scale_factor();
70
71 let delta = &mut scroll.delta;
72 if node.overflow.x == OverflowAxis::Scroll && delta.x != 0. {
73 let max = if delta.x > 0. {
75 scroll_position.x >= max_offset.x
76 } else {
77 scroll_position.x <= 0.
78 };
79
80 if !max {
81 scroll_position.x += delta.x;
82 delta.x = 0.;
84 }
85 }
86
87 if node.overflow.y == OverflowAxis::Scroll && delta.y != 0. {
88 let max = if delta.y > 0. {
90 scroll_position.y >= max_offset.y
91 } else {
92 scroll_position.y <= 0.
93 };
94
95 if !max {
96 scroll_position.y += delta.y;
97 delta.y = 0.;
99 }
100 }
101
102 if *delta == Vec2::ZERO {
104 scroll.propagate(false);
105 }
106}
107
108const FONT_SIZE: FontSize = FontSize::Px(20.);
109
110fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
111 commands.spawn((Camera2d, IsDefaultUiCamera));
113
114 let font_handle = asset_server.load("fonts/FiraSans-Bold.ttf");
116
117 commands
119 .spawn(Node {
120 width: percent(100),
121 height: percent(100),
122 justify_content: JustifyContent::SpaceBetween,
123 flex_direction: FlexDirection::Column,
124 ..default()
125 })
126 .with_children(|parent| {
127 parent
129 .spawn(Node {
130 width: percent(100),
131 flex_direction: FlexDirection::Column,
132 ..default()
133 })
134 .with_children(|parent| {
135 parent.spawn((
137 Text::new("Horizontally Scrolling list (Ctrl + MouseWheel)"),
138 TextFont {
139 font: font_handle.clone().into(),
140 font_size: FONT_SIZE,
141 ..default()
142 },
143 Label,
144 ));
145
146 parent
148 .spawn((
149 Node {
150 width: percent(80),
151 margin: UiRect::all(px(10)),
152 flex_direction: FlexDirection::Row,
153 overflow: Overflow::scroll_x(), ..default()
155 },
156 BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
157 ))
158 .with_children(|parent| {
159 for i in 0..100 {
160 parent
161 .spawn((
162 Text(format!("Item {i}")),
163 TextFont {
164 font: font_handle.clone().into(),
165 ..default()
166 },
167 Label,
168 AccessibilityNode(Accessible::new(Role::ListItem)),
169 Node {
170 min_width: px(200),
171 align_content: AlignContent::Center,
172 ..default()
173 },
174 ))
175 .observe(|press: On<PointerPress>, mut commands: Commands| {
176 if press.event().button == PointerButton::Primary {
177 commands.entity(press.entity).despawn();
178 }
179 });
180 }
181 });
182 });
183
184 parent.spawn((
186 Node {
187 width: percent(100),
188 height: percent(100),
189 flex_direction: FlexDirection::Row,
190 justify_content: JustifyContent::SpaceBetween,
191 ..default()
192 },
193 children![
194 vertically_scrolling_list(asset_server.load("fonts/FiraSans-Bold.ttf")),
195 bidirectional_scrolling_list(asset_server.load("fonts/FiraSans-Bold.ttf")),
196 bidirectional_scrolling_list_with_sticky(
197 asset_server.load("fonts/FiraSans-Bold.ttf")
198 ),
199 nested_scrolling_list(asset_server.load("fonts/FiraSans-Bold.ttf")),
200 ],
201 ));
202 });
203}
204
205fn vertically_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
206 (
207 Node {
208 flex_direction: FlexDirection::Column,
209 justify_content: JustifyContent::Center,
210 align_items: AlignItems::Center,
211 width: px(200),
212 ..default()
213 },
214 children![
215 (
216 Text::new("Vertically Scrolling List"),
218 TextFont {
219 font: font_handle.clone().into(),
220 font_size: FONT_SIZE,
221 ..default()
222 },
223 Label,
224 ),
225 (
226 Node {
228 flex_direction: FlexDirection::Column,
229 align_self: AlignSelf::Stretch,
230 height: percent(50),
231 overflow: Overflow::scroll_y(), scrollbar_width: 20.,
233 ..default()
234 },
235 #[cfg(feature = "bevy_ui_debug")]
236 UiDebugOptions {
237 enabled: true,
238 outline_border_box: false,
239 outline_padding_box: false,
240 outline_content_box: false,
241 outline_scrollbars: true,
242 line_width: 2.,
243 line_color_override: None,
244 show_hidden: false,
245 show_clipped: true,
246 ignore_border_radius: true,
247 },
248 BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
249 Children::spawn(SpawnIter((0..25).map(move |i| {
250 (
251 Node {
252 min_height: px(LINE_HEIGHT),
253 max_height: px(LINE_HEIGHT),
254 ..default()
255 },
256 children![(
257 Text(format!("Item {i}")),
258 TextFont {
259 font: font_handle.clone().into(),
260 ..default()
261 },
262 Label,
263 AccessibilityNode(Accessible::new(Role::ListItem)),
264 )],
265 )
266 })))
267 ),
268 ],
269 )
270}
271
272fn bidirectional_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
273 (
274 Node {
275 flex_direction: FlexDirection::Column,
276 justify_content: JustifyContent::Center,
277 align_items: AlignItems::Center,
278 width: px(200),
279 ..default()
280 },
281 children![
282 (
283 Text::new("Bidirectionally Scrolling List"),
284 TextFont {
285 font: font_handle.clone().into(),
286 font_size: FONT_SIZE,
287 ..default()
288 },
289 Label,
290 ),
291 (
292 Node {
293 flex_direction: FlexDirection::Column,
294 align_self: AlignSelf::Stretch,
295 height: percent(50),
296 overflow: Overflow::scroll(), ..default()
298 },
299 BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
300 Children::spawn(SpawnIter((0..25).map(move |oi| {
301 (
302 Node {
303 flex_direction: FlexDirection::Row,
304 ..default()
305 },
306 Children::spawn(SpawnIter((0..10).map({
307 let value = font_handle.clone();
308 move |i| {
309 (
310 Text(format!("Item {}", (oi * 10) + i)),
311 TextFont::from(value.clone()),
312 Label,
313 AccessibilityNode(Accessible::new(Role::ListItem)),
314 )
315 }
316 }))),
317 )
318 })))
319 )
320 ],
321 )
322}
323
324fn bidirectional_scrolling_list_with_sticky(font_handle: Handle<Font>) -> impl Bundle {
325 (
326 Node {
327 flex_direction: FlexDirection::Column,
328 justify_content: JustifyContent::Center,
329 align_items: AlignItems::Center,
330 width: px(200),
331 ..default()
332 },
333 children![
334 (
335 Text::new("Bidirectionally Scrolling List With Sticky Nodes"),
336 TextFont {
337 font: font_handle.clone().into(),
338 font_size: FONT_SIZE,
339 ..default()
340 },
341 Label,
342 ),
343 (
344 Node {
345 display: Display::Grid,
346 align_self: AlignSelf::Stretch,
347 height: percent(50),
348 overflow: Overflow::scroll(), grid_template_columns: RepeatedGridTrack::auto(30),
350 ..default()
351 },
352 Children::spawn(SpawnIter(
353 (0..30)
354 .flat_map(|y| (0..30).map(move |x| (y, x)))
355 .map(move |(y, x)| {
356 let value = font_handle.clone();
357 let ignore_scroll = BVec2 {
361 x: x == 0,
362 y: y == 0,
363 };
364 let (z_index, background_color, role) = match (x == 0, y == 0) {
365 (true, true) => (2, RED, Role::RowHeader),
366 (true, false) => (1, BLUE, Role::RowHeader),
367 (false, true) => (1, BLUE, Role::ColumnHeader),
368 (false, false) => (0, BLACK, Role::Cell),
369 };
370 (
371 Text(format!("|{},{}|", y, x)),
372 TextFont::from(value.clone()),
373 TextLayout {
374 linebreak: LineBreak::NoWrap,
375 ..default()
376 },
377 Label,
378 AccessibilityNode(Accessible::new(role)),
379 IgnoreScroll(ignore_scroll),
380 ZIndex(z_index),
381 BackgroundColor(Color::Srgba(background_color)),
382 )
383 })
384 ))
385 )
386 ],
387 )
388}
389
390fn nested_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
391 (
392 Node {
393 flex_direction: FlexDirection::Column,
394 justify_content: JustifyContent::Center,
395 align_items: AlignItems::Center,
396 width: px(200),
397 ..default()
398 },
399 children![
400 (
401 Text::new("Nested Scrolling Lists"),
403 TextFont {
404 font: font_handle.clone().into(),
405 font_size: FONT_SIZE,
406 ..default()
407 },
408 Label,
409 ),
410 (
411 Node {
413 column_gap: px(20),
414 flex_direction: FlexDirection::Row,
415 align_self: AlignSelf::Stretch,
416 height: percent(50),
417 overflow: Overflow::scroll(),
418 ..default()
419 },
420 BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
421 Children::spawn(SpawnIter((0..5).map(move |oi| {
423 (
424 Node {
425 flex_direction: FlexDirection::Column,
426 align_self: AlignSelf::Stretch,
427 height: percent(200. / 5. * (oi as f32 + 1.)),
428 overflow: Overflow::scroll_y(),
429 ..default()
430 },
431 BackgroundColor(Color::srgb(0.05, 0.05, 0.05)),
432 Children::spawn(SpawnIter((0..20).map({
433 let value = font_handle.clone();
434 move |i| {
435 (
436 Text(format!("Item {}", (oi * 20) + i)),
437 TextFont::from(value.clone()),
438 Label,
439 AccessibilityNode(Accessible::new(Role::ListItem)),
440 )
441 }
442 }))),
443 )
444 })))
445 )
446 ],
447 )
448}