From 6d312cc4c79d2a21941af40c4496ed5b3adb78c4 Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Thu, 3 Jul 2025 12:02:05 +0200 Subject: [PATCH] Add support for scrolling via accesskit / kittest (#7286) I need to scroll in a snapshot test in my app, and kittest had no utilities for this. Event::MouseWheel is error prone. This adds support for some accesskit scroll actions, and uses this in kittest to add helpers to scroll to a node / scroll the scroll area surrounding a node. The accesskit code says down/up/left/right `Scrolls by approximately one screen in a specific direction.`. Unfortunately it's difficult to get the size of a "screen" (I guess that would be the size of the containing scroll area)where I implemented the scrolling, so for now I've hardcoded it to 100px. I think scrolling a fixed amount is still better than not scrolling at all. --------- Co-authored-by: Emil Ernerfeldt --- crates/egui/src/context.rs | 47 +++++++++++++- crates/egui/src/input_state/mod.rs | 17 ++++++ crates/egui_kittest/src/lib.rs | 16 ++++- crates/egui_kittest/src/node.rs | 45 ++++++++++++++ .../tests/snapshots/test_scroll_initial.png | 3 + .../tests/snapshots/test_scroll_scrolled.png | 3 + crates/egui_kittest/tests/tests.rs | 61 ++++++++++++++++++- 7 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 crates/egui_kittest/tests/snapshots/test_scroll_initial.png create mode 100644 crates/egui_kittest/tests/snapshots/test_scroll_scrolled.png diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 80bcf570..abb03b1c 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -1149,7 +1149,7 @@ impl Context { ID clashes happens when things like Windows or CollapsingHeaders share names,\n\ or when things like Plot and Grid:s aren't given unique id_salt:s.\n\n\ Sometimes the solution is to use ui.push_id.", - if below { "above" } else { "below" }) + if below { "above" } else { "below" }), ); } } @@ -1216,6 +1216,51 @@ impl Context { self.accesskit_node_builder(w.id, |builder| res.fill_accesskit_node_common(builder)); } + #[cfg(feature = "accesskit")] + self.write(|ctx| { + use crate::{Align, pass_state::ScrollTarget, style::ScrollAnimation}; + let viewport = ctx.viewport_for(ctx.viewport_id()); + + viewport + .input + .consume_accesskit_action_requests(res.id, |request| { + // TODO(lucasmerlin): Correctly handle the scroll unit: + // https://github.com/AccessKit/accesskit/blob/e639c0e0d8ccbfd9dff302d972fa06f9766d608e/common/src/lib.rs#L2621 + const DISTANCE: f32 = 100.0; + + match &request.action { + accesskit::Action::ScrollIntoView => { + viewport.this_pass.scroll_target = [ + Some(ScrollTarget::new( + res.rect.x_range(), + Some(Align::Center), + ScrollAnimation::none(), + )), + Some(ScrollTarget::new( + res.rect.y_range(), + Some(Align::Center), + ScrollAnimation::none(), + )), + ]; + } + accesskit::Action::ScrollDown => { + viewport.this_pass.scroll_delta.0 += DISTANCE * Vec2::UP; + } + accesskit::Action::ScrollUp => { + viewport.this_pass.scroll_delta.0 += DISTANCE * Vec2::DOWN; + } + accesskit::Action::ScrollLeft => { + viewport.this_pass.scroll_delta.0 += DISTANCE * Vec2::LEFT; + } + accesskit::Action::ScrollRight => { + viewport.this_pass.scroll_delta.0 += DISTANCE * Vec2::RIGHT; + } + _ => return false, + }; + true + }); + }); + res } diff --git a/crates/egui/src/input_state/mod.rs b/crates/egui/src/input_state/mod.rs index d87bd566..fd3e78a2 100644 --- a/crates/egui/src/input_state/mod.rs +++ b/crates/egui/src/input_state/mod.rs @@ -824,6 +824,23 @@ impl InputState { }) } + #[cfg(feature = "accesskit")] + pub fn consume_accesskit_action_requests( + &mut self, + id: crate::Id, + mut consume: impl FnMut(&accesskit::ActionRequest) -> bool, + ) { + let accesskit_id = id.accesskit_id(); + self.events.retain(|event| { + if let Event::AccessKitActionRequest(request) = event { + if request.target == accesskit_id { + return !consume(request); + } + } + true + }); + } + #[cfg(feature = "accesskit")] pub fn has_accesskit_action_request(&self, id: crate::Id, action: accesskit::Action) -> bool { self.accesskit_action_requests(id, action).next().is_some() diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index 01aef326..6adefe53 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -28,6 +28,7 @@ pub use builder::*; pub use node::*; pub use renderer::*; +use egui::style::ScrollAnimation; use egui::{Key, Modifiers, Pos2, Rect, RepaintCause, Vec2, ViewportId}; use kittest::Queryable; @@ -55,6 +56,10 @@ impl Display for ExceededMaxStepsError { /// The [Harness] has a optional generic state that can be used to pass data to the app / ui closure. /// In _most cases_ it should be fine to just store the state in the closure itself. /// The state functions are useful if you need to access the state after the harness has been created. +/// +/// Some egui style options are changed from the defaults: +/// - The cursor blinking is disabled +/// - The scroll animation is disabled pub struct Harness<'a, State = ()> { pub ctx: egui::Context, input: egui::RawInput, @@ -96,8 +101,12 @@ impl<'a, State> Harness<'a, State> { let ctx = ctx.unwrap_or_default(); ctx.set_theme(theme); ctx.enable_accesskit(); - // Disable cursor blinking so it doesn't interfere with snapshots - ctx.all_styles_mut(|style| style.visuals.text_cursor.blink = false); + ctx.all_styles_mut(|style| { + // Disable cursor blinking so it doesn't interfere with snapshots + style.visuals.text_cursor.blink = false; + style.scroll_animation = ScrollAnimation::none(); + style.animation_time = 0.0; + }); let mut input = egui::RawInput { screen_rect: Some(screen_rect), ..Default::default() @@ -564,7 +573,8 @@ impl<'a, State> Harness<'a, State> { .expect("Missing root viewport") } - fn root(&self) -> Node<'_> { + /// The root node of the test harness. + pub fn root(&self) -> Node<'_> { Node { accesskit_node: self.kittest.root(), queue: &self.queued_events, diff --git a/crates/egui_kittest/src/node.rs b/crates/egui_kittest/src/node.rs index 51a0cc3a..94940fff 100644 --- a/crates/egui_kittest/src/node.rs +++ b/crates/egui_kittest/src/node.rs @@ -159,4 +159,49 @@ impl Node<'_> { pub fn is_focused(&self) -> bool { self.accesskit_node.is_focused() } + + /// Scroll the node into view. + pub fn scroll_to_me(&self) { + self.event(egui::Event::AccessKitActionRequest(ActionRequest { + action: accesskit::Action::ScrollIntoView, + target: self.accesskit_node.id(), + data: None, + })); + } + + /// Scroll the [`egui::ScrollArea`] containing this node down (100px). + pub fn scroll_down(&self) { + self.event(egui::Event::AccessKitActionRequest(ActionRequest { + action: accesskit::Action::ScrollDown, + target: self.accesskit_node.id(), + data: None, + })); + } + + /// Scroll the [`egui::ScrollArea`] containing this node up (100px). + pub fn scroll_up(&self) { + self.event(egui::Event::AccessKitActionRequest(ActionRequest { + action: accesskit::Action::ScrollUp, + target: self.accesskit_node.id(), + data: None, + })); + } + + /// Scroll the [`egui::ScrollArea`] containing this node left (100px). + pub fn scroll_left(&self) { + self.event(egui::Event::AccessKitActionRequest(ActionRequest { + action: accesskit::Action::ScrollLeft, + target: self.accesskit_node.id(), + data: None, + })); + } + + /// Scroll the [`egui::ScrollArea`] containing this node right (100px). + pub fn scroll_right(&self) { + self.event(egui::Event::AccessKitActionRequest(ActionRequest { + action: accesskit::Action::ScrollRight, + target: self.accesskit_node.id(), + data: None, + })); + } } diff --git a/crates/egui_kittest/tests/snapshots/test_scroll_initial.png b/crates/egui_kittest/tests/snapshots/test_scroll_initial.png new file mode 100644 index 00000000..32969d74 --- /dev/null +++ b/crates/egui_kittest/tests/snapshots/test_scroll_initial.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70d76e55327de17163bc9c7e128c28153f95db3229dec919352a024eb80544f1 +size 7399 diff --git a/crates/egui_kittest/tests/snapshots/test_scroll_scrolled.png b/crates/egui_kittest/tests/snapshots/test_scroll_scrolled.png new file mode 100644 index 00000000..361925d0 --- /dev/null +++ b/crates/egui_kittest/tests/snapshots/test_scroll_scrolled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4b7b3145401b7cf9815a652a0914b230892ffda3b5e23fea530dafee9c0c3d3 +size 8110 diff --git a/crates/egui_kittest/tests/tests.rs b/crates/egui_kittest/tests/tests.rs index b4e49642..6d66c5f5 100644 --- a/crates/egui_kittest/tests/tests.rs +++ b/crates/egui_kittest/tests/tests.rs @@ -1,5 +1,5 @@ -use egui::{Modifiers, Vec2, include_image}; -use egui_kittest::Harness; +use egui::{Modifiers, ScrollArea, Vec2, include_image}; +use egui_kittest::{Harness, SnapshotResults}; use kittest::Queryable as _; #[test] @@ -81,3 +81,60 @@ fn should_wait_for_images() { harness.snapshot("should_wait_for_images"); } + +fn test_scroll_harness() -> Harness<'static, bool> { + Harness::builder() + .with_size(Vec2::new(100.0, 200.0)) + .build_ui_state( + |ui, state| { + ScrollArea::vertical().show(ui, |ui| { + for i in 0..20 { + ui.label(format!("Item {i}")); + } + if ui.button("Hidden Button").clicked() { + *state = true; + }; + }); + }, + false, + ) +} + +#[test] +fn test_scroll_to_me() { + let mut harness = test_scroll_harness(); + let mut results = SnapshotResults::new(); + + results.add(harness.try_snapshot("test_scroll_initial")); + + harness.get_by_label("Hidden Button").scroll_to_me(); + + harness.run(); + results.add(harness.try_snapshot("test_scroll_scrolled")); + + harness.get_by_label("Hidden Button").click(); + harness.run(); + + assert!( + harness.state(), + "The button was not clicked after scrolling." + ); +} + +#[test] +fn test_scroll_down() { + let mut harness = test_scroll_harness(); + + let button = harness.get_by_label("Hidden Button"); + button.scroll_down(); + button.scroll_down(); + harness.run(); + + harness.get_by_label("Hidden Button").click(); + harness.run(); + + assert!( + harness.state(), + "The button was not clicked after scrolling down. (Probably not scrolled enough / at all)" + ); +}