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 <emil.ernerfeldt@gmail.com>
This commit is contained in:
Lucas Meurer 2025-07-03 12:02:05 +02:00 committed by GitHub
parent 378e22e6ec
commit 6d312cc4c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 186 additions and 6 deletions

View File

@ -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
}

View File

@ -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()

View File

@ -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,

View File

@ -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,
}));
}
}

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:70d76e55327de17163bc9c7e128c28153f95db3229dec919352a024eb80544f1
size 7399

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b4b7b3145401b7cf9815a652a0914b230892ffda3b5e23fea530dafee9c0c3d3
size 8110

View File

@ -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)"
);
}