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(
176 |press: On<Pointer<Press>>, mut commands: Commands| {
177 if press.event().button == PointerButton::Primary {
178 commands.entity(press.entity).despawn();
179 }
180 },
181 );
182 }
183 });
184 });
185
186 parent.spawn((
188 Node {
189 width: percent(100),
190 height: percent(100),
191 flex_direction: FlexDirection::Row,
192 justify_content: JustifyContent::SpaceBetween,
193 ..default()
194 },
195 children![
196 vertically_scrolling_list(asset_server.load("fonts/FiraSans-Bold.ttf")),
197 bidirectional_scrolling_list(asset_server.load("fonts/FiraSans-Bold.ttf")),
198 bidirectional_scrolling_list_with_sticky(
199 asset_server.load("fonts/FiraSans-Bold.ttf")
200 ),
201 nested_scrolling_list(asset_server.load("fonts/FiraSans-Bold.ttf")),
202 ],
203 ));
204 });
205}
206
207fn vertically_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
208 (
209 Node {
210 flex_direction: FlexDirection::Column,
211 justify_content: JustifyContent::Center,
212 align_items: AlignItems::Center,
213 width: px(200),
214 ..default()
215 },
216 children![
217 (
218 Text::new("Vertically Scrolling List"),
220 TextFont {
221 font: font_handle.clone().into(),
222 font_size: FONT_SIZE,
223 ..default()
224 },
225 Label,
226 ),
227 (
228 Node {
230 flex_direction: FlexDirection::Column,
231 align_self: AlignSelf::Stretch,
232 height: percent(50),
233 overflow: Overflow::scroll_y(), scrollbar_width: 20.,
235 ..default()
236 },
237 #[cfg(feature = "bevy_ui_debug")]
238 UiDebugOptions {
239 enabled: true,
240 outline_border_box: false,
241 outline_padding_box: false,
242 outline_content_box: false,
243 outline_scrollbars: true,
244 line_width: 2.,
245 line_color_override: None,
246 show_hidden: false,
247 show_clipped: true,
248 ignore_border_radius: true,
249 },
250 BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
251 Children::spawn(SpawnIter((0..25).map(move |i| {
252 (
253 Node {
254 min_height: px(LINE_HEIGHT),
255 max_height: px(LINE_HEIGHT),
256 ..default()
257 },
258 children![(
259 Text(format!("Item {i}")),
260 TextFont {
261 font: font_handle.clone().into(),
262 ..default()
263 },
264 Label,
265 AccessibilityNode(Accessible::new(Role::ListItem)),
266 )],
267 )
268 })))
269 ),
270 ],
271 )
272}
273
274fn bidirectional_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
275 (
276 Node {
277 flex_direction: FlexDirection::Column,
278 justify_content: JustifyContent::Center,
279 align_items: AlignItems::Center,
280 width: px(200),
281 ..default()
282 },
283 children![
284 (
285 Text::new("Bidirectionally Scrolling List"),
286 TextFont {
287 font: font_handle.clone().into(),
288 font_size: FONT_SIZE,
289 ..default()
290 },
291 Label,
292 ),
293 (
294 Node {
295 flex_direction: FlexDirection::Column,
296 align_self: AlignSelf::Stretch,
297 height: percent(50),
298 overflow: Overflow::scroll(), ..default()
300 },
301 BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
302 Children::spawn(SpawnIter((0..25).map(move |oi| {
303 (
304 Node {
305 flex_direction: FlexDirection::Row,
306 ..default()
307 },
308 Children::spawn(SpawnIter((0..10).map({
309 let value = font_handle.clone();
310 move |i| {
311 (
312 Text(format!("Item {}", (oi * 10) + i)),
313 TextFont::from(value.clone()),
314 Label,
315 AccessibilityNode(Accessible::new(Role::ListItem)),
316 )
317 }
318 }))),
319 )
320 })))
321 )
322 ],
323 )
324}
325
326fn bidirectional_scrolling_list_with_sticky(font_handle: Handle<Font>) -> impl Bundle {
327 (
328 Node {
329 flex_direction: FlexDirection::Column,
330 justify_content: JustifyContent::Center,
331 align_items: AlignItems::Center,
332 width: px(200),
333 ..default()
334 },
335 children![
336 (
337 Text::new("Bidirectionally Scrolling List With Sticky Nodes"),
338 TextFont {
339 font: font_handle.clone().into(),
340 font_size: FONT_SIZE,
341 ..default()
342 },
343 Label,
344 ),
345 (
346 Node {
347 display: Display::Grid,
348 align_self: AlignSelf::Stretch,
349 height: percent(50),
350 overflow: Overflow::scroll(), grid_template_columns: RepeatedGridTrack::auto(30),
352 ..default()
353 },
354 Children::spawn(SpawnIter(
355 (0..30)
356 .flat_map(|y| (0..30).map(move |x| (y, x)))
357 .map(move |(y, x)| {
358 let value = font_handle.clone();
359 let ignore_scroll = BVec2 {
363 x: x == 0,
364 y: y == 0,
365 };
366 let (z_index, background_color, role) = match (x == 0, y == 0) {
367 (true, true) => (2, RED, Role::RowHeader),
368 (true, false) => (1, BLUE, Role::RowHeader),
369 (false, true) => (1, BLUE, Role::ColumnHeader),
370 (false, false) => (0, BLACK, Role::Cell),
371 };
372 (
373 Text(format!("|{},{}|", y, x)),
374 TextFont::from(value.clone()),
375 TextLayout {
376 linebreak: LineBreak::NoWrap,
377 ..default()
378 },
379 Label,
380 AccessibilityNode(Accessible::new(role)),
381 IgnoreScroll(ignore_scroll),
382 ZIndex(z_index),
383 BackgroundColor(Color::Srgba(background_color)),
384 )
385 })
386 ))
387 )
388 ],
389 )
390}
391
392fn nested_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
393 (
394 Node {
395 flex_direction: FlexDirection::Column,
396 justify_content: JustifyContent::Center,
397 align_items: AlignItems::Center,
398 width: px(200),
399 ..default()
400 },
401 children![
402 (
403 Text::new("Nested Scrolling Lists"),
405 TextFont {
406 font: font_handle.clone().into(),
407 font_size: FONT_SIZE,
408 ..default()
409 },
410 Label,
411 ),
412 (
413 Node {
415 column_gap: px(20),
416 flex_direction: FlexDirection::Row,
417 align_self: AlignSelf::Stretch,
418 height: percent(50),
419 overflow: Overflow::scroll(),
420 ..default()
421 },
422 BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
423 Children::spawn(SpawnIter((0..5).map(move |oi| {
425 (
426 Node {
427 flex_direction: FlexDirection::Column,
428 align_self: AlignSelf::Stretch,
429 height: percent(200. / 5. * (oi as f32 + 1.)),
430 overflow: Overflow::scroll_y(),
431 ..default()
432 },
433 BackgroundColor(Color::srgb(0.05, 0.05, 0.05)),
434 Children::spawn(SpawnIter((0..20).map({
435 let value = font_handle.clone();
436 move |i| {
437 (
438 Text(format!("Item {}", (oi * 20) + i)),
439 TextFont::from(value.clone()),
440 Label,
441 AccessibilityNode(Accessible::new(Role::ListItem)),
442 )
443 }
444 }))),
445 )
446 })))
447 )
448 ],
449 )
450}