1use accesskit::{Node as Accessible, Role};
4use bevy::{
5 a11y::AccessibilityNode,
6 ecs::spawn::SpawnIter,
7 input::mouse::{MouseScrollUnit, MouseWheel},
8 picking::hover::HoverMap,
9 prelude::*,
10};
11
12fn main() {
13 let mut app = App::new();
14 app.add_plugins(DefaultPlugins)
15 .add_systems(Startup, setup)
16 .add_systems(Update, send_scroll_events)
17 .add_observer(on_scroll_handler);
18
19 app.run();
20}
21
22const LINE_HEIGHT: f32 = 21.;
23
24fn send_scroll_events(
26 mut mouse_wheel_reader: MessageReader<MouseWheel>,
27 hover_map: Res<HoverMap>,
28 keyboard_input: Res<ButtonInput<KeyCode>>,
29 mut commands: Commands,
30) {
31 for mouse_wheel in mouse_wheel_reader.read() {
32 let mut delta = -Vec2::new(mouse_wheel.x, mouse_wheel.y);
33
34 if mouse_wheel.unit == MouseScrollUnit::Line {
35 delta *= LINE_HEIGHT;
36 }
37
38 if keyboard_input.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]) {
39 std::mem::swap(&mut delta.x, &mut delta.y);
40 }
41
42 for pointer_map in hover_map.values() {
43 for entity in pointer_map.keys().copied() {
44 commands.trigger(Scroll { entity, delta });
45 }
46 }
47 }
48}
49
50#[derive(EntityEvent, Debug)]
52#[entity_event(propagate, auto_propagate)]
53struct Scroll {
54 entity: Entity,
55 delta: Vec2,
57}
58
59fn on_scroll_handler(
60 mut scroll: On<Scroll>,
61 mut query: Query<(&mut ScrollPosition, &Node, &ComputedNode)>,
62) {
63 let Ok((mut scroll_position, node, computed)) = query.get_mut(scroll.entity) else {
64 return;
65 };
66
67 let max_offset = (computed.content_size() - computed.size()) * computed.inverse_scale_factor();
68
69 let delta = &mut scroll.delta;
70 if node.overflow.x == OverflowAxis::Scroll && delta.x != 0. {
71 let max = if delta.x > 0. {
73 scroll_position.x >= max_offset.x
74 } else {
75 scroll_position.x <= 0.
76 };
77
78 if !max {
79 scroll_position.x += delta.x;
80 delta.x = 0.;
82 }
83 }
84
85 if node.overflow.y == OverflowAxis::Scroll && delta.y != 0. {
86 let max = if delta.y > 0. {
88 scroll_position.y >= max_offset.y
89 } else {
90 scroll_position.y <= 0.
91 };
92
93 if !max {
94 scroll_position.y += delta.y;
95 delta.y = 0.;
97 }
98 }
99
100 if *delta == Vec2::ZERO {
102 scroll.propagate(false);
103 }
104}
105
106const FONT_SIZE: f32 = 20.;
107
108fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
109 commands.spawn((Camera2d, IsDefaultUiCamera));
111
112 let font_handle = asset_server.load("fonts/FiraSans-Bold.ttf");
114
115 commands
117 .spawn(Node {
118 width: percent(100),
119 height: percent(100),
120 justify_content: JustifyContent::SpaceBetween,
121 flex_direction: FlexDirection::Column,
122 ..default()
123 })
124 .with_children(|parent| {
125 parent
127 .spawn(Node {
128 width: percent(100),
129 flex_direction: FlexDirection::Column,
130 ..default()
131 })
132 .with_children(|parent| {
133 parent.spawn((
135 Text::new("Horizontally Scrolling list (Ctrl + MouseWheel)"),
136 TextFont {
137 font: font_handle.clone(),
138 font_size: FONT_SIZE,
139 ..default()
140 },
141 Label,
142 ));
143
144 parent
146 .spawn((
147 Node {
148 width: percent(80),
149 margin: UiRect::all(px(10)),
150 flex_direction: FlexDirection::Row,
151 overflow: Overflow::scroll_x(), ..default()
153 },
154 BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
155 ))
156 .with_children(|parent| {
157 for i in 0..100 {
158 parent
159 .spawn((
160 Text(format!("Item {i}")),
161 TextFont {
162 font: font_handle.clone(),
163 ..default()
164 },
165 Label,
166 AccessibilityNode(Accessible::new(Role::ListItem)),
167 Node {
168 min_width: px(200),
169 align_content: AlignContent::Center,
170 ..default()
171 },
172 ))
173 .observe(
174 |press: On<Pointer<Press>>, mut commands: Commands| {
175 if press.event().button == PointerButton::Primary {
176 commands.entity(press.entity).despawn();
177 }
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 nested_scrolling_list(asset_server.load("fonts/FiraSans-Bold.ttf")),
197 ],
198 ));
199 });
200}
201
202fn vertically_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
203 (
204 Node {
205 flex_direction: FlexDirection::Column,
206 justify_content: JustifyContent::Center,
207 align_items: AlignItems::Center,
208 width: px(200),
209 ..default()
210 },
211 children![
212 (
213 Text::new("Vertically Scrolling List"),
215 TextFont {
216 font: font_handle.clone(),
217 font_size: FONT_SIZE,
218 ..default()
219 },
220 Label,
221 ),
222 (
223 Node {
225 flex_direction: FlexDirection::Column,
226 align_self: AlignSelf::Stretch,
227 height: percent(50),
228 overflow: Overflow::scroll_y(), ..default()
230 },
231 BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
232 Children::spawn(SpawnIter((0..25).map(move |i| {
233 (
234 Node {
235 min_height: px(LINE_HEIGHT),
236 max_height: px(LINE_HEIGHT),
237 ..default()
238 },
239 children![(
240 Text(format!("Item {i}")),
241 TextFont {
242 font: font_handle.clone(),
243 ..default()
244 },
245 Label,
246 AccessibilityNode(Accessible::new(Role::ListItem)),
247 )],
248 )
249 })))
250 ),
251 ],
252 )
253}
254
255fn bidirectional_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
256 (
257 Node {
258 flex_direction: FlexDirection::Column,
259 justify_content: JustifyContent::Center,
260 align_items: AlignItems::Center,
261 width: px(200),
262 ..default()
263 },
264 children![
265 (
266 Text::new("Bidirectionally Scrolling List"),
267 TextFont {
268 font: font_handle.clone(),
269 font_size: FONT_SIZE,
270 ..default()
271 },
272 Label,
273 ),
274 (
275 Node {
276 flex_direction: FlexDirection::Column,
277 align_self: AlignSelf::Stretch,
278 height: percent(50),
279 overflow: Overflow::scroll(), ..default()
281 },
282 BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
283 Children::spawn(SpawnIter((0..25).map(move |oi| {
284 (
285 Node {
286 flex_direction: FlexDirection::Row,
287 ..default()
288 },
289 Children::spawn(SpawnIter((0..10).map({
290 let value = font_handle.clone();
291 move |i| {
292 (
293 Text(format!("Item {}", (oi * 10) + i)),
294 TextFont {
295 font: value.clone(),
296 ..default()
297 },
298 Label,
299 AccessibilityNode(Accessible::new(Role::ListItem)),
300 )
301 }
302 }))),
303 )
304 })))
305 )
306 ],
307 )
308}
309
310fn nested_scrolling_list(font_handle: Handle<Font>) -> impl Bundle {
311 (
312 Node {
313 flex_direction: FlexDirection::Column,
314 justify_content: JustifyContent::Center,
315 align_items: AlignItems::Center,
316 width: px(200),
317 ..default()
318 },
319 children![
320 (
321 Text::new("Nested Scrolling Lists"),
323 TextFont {
324 font: font_handle.clone(),
325 font_size: FONT_SIZE,
326 ..default()
327 },
328 Label,
329 ),
330 (
331 Node {
333 column_gap: px(20),
334 flex_direction: FlexDirection::Row,
335 align_self: AlignSelf::Stretch,
336 height: percent(50),
337 overflow: Overflow::scroll(),
338 ..default()
339 },
340 BackgroundColor(Color::srgb(0.10, 0.10, 0.10)),
341 Children::spawn(SpawnIter((0..5).map(move |oi| {
343 (
344 Node {
345 flex_direction: FlexDirection::Column,
346 align_self: AlignSelf::Stretch,
347 height: percent(200. / 5. * (oi as f32 + 1.)),
348 overflow: Overflow::scroll_y(),
349 ..default()
350 },
351 BackgroundColor(Color::srgb(0.05, 0.05, 0.05)),
352 Children::spawn(SpawnIter((0..20).map({
353 let value = font_handle.clone();
354 move |i| {
355 (
356 Text(format!("Item {}", (oi * 20) + i)),
357 TextFont {
358 font: value.clone(),
359 ..default()
360 },
361 Label,
362 AccessibilityNode(Accessible::new(Role::ListItem)),
363 )
364 }
365 }))),
366 )
367 })))
368 )
369 ],
370 )
371}