Skip to content

Commit 1d9b74c

Browse files
authored
feat!: Implement refactored ScrollIntoView action across desktop platforms (#594)
1 parent 70b534b commit 1d9b74c

File tree

8 files changed

+165
-43
lines changed

8 files changed

+165
-43
lines changed

common/src/lib.rs

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -313,9 +313,8 @@ pub enum Action {
313313
/// Scroll up by the specified unit.
314314
ScrollUp,
315315

316-
/// Scroll any scrollable containers to make the target object visible
317-
/// on the screen. Optionally set [`ActionRequest::data`] to
318-
/// [`ActionData::ScrollTargetRect`].
316+
/// Scroll any scrollable containers to make the target node visible.
317+
/// Optionally set [`ActionRequest::data`] to [`ActionData::ScrollHint`].
319318
ScrollIntoView,
320319

321320
/// Scroll the given object to a specified point in the tree's container
@@ -2658,6 +2657,26 @@ pub enum ScrollUnit {
26582657
Page,
26592658
}
26602659

2660+
/// A suggestion about where the node being scrolled into view should be
2661+
/// positioned relative to the edges of the scrollable container.
2662+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2663+
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
2664+
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
2665+
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
2666+
#[cfg_attr(
2667+
feature = "pyo3",
2668+
pyclass(module = "accesskit", rename_all = "SCREAMING_SNAKE_CASE", eq)
2669+
)]
2670+
#[repr(u8)]
2671+
pub enum ScrollHint {
2672+
TopLeft,
2673+
BottomRight,
2674+
TopEdge,
2675+
BottomEdge,
2676+
LeftEdge,
2677+
RightEdge,
2678+
}
2679+
26612680
#[derive(Clone, Debug, PartialEq)]
26622681
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
26632682
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
@@ -2668,9 +2687,10 @@ pub enum ActionData {
26682687
Value(Box<str>),
26692688
NumericValue(f64),
26702689
ScrollUnit(ScrollUnit),
2671-
/// Optional target rectangle for [`Action::ScrollIntoView`], in
2672-
/// the coordinate space of the action's target node.
2673-
ScrollTargetRect(Rect),
2690+
/// Optional suggestion for [`ActionData::ScrollIntoView`], specifying
2691+
/// the preferred position of the target node relative to the scrollable
2692+
/// container's viewport.
2693+
ScrollHint(ScrollHint),
26742694
/// Target for [`Action::ScrollToPoint`], in platform-native coordinates
26752695
/// relative to the origin of the tree's container (e.g. window).
26762696
ScrollToPoint(Point),

platforms/atspi-common/src/node.rs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,15 @@ impl PlatformNode {
10221022
Ok(true)
10231023
}
10241024

1025+
pub fn scroll_to(&self, scroll_type: ScrollType) -> Result<bool> {
1026+
self.do_action_internal(|_, _| ActionRequest {
1027+
action: Action::ScrollIntoView,
1028+
target: self.id,
1029+
data: atspi_scroll_type_to_scroll_hint(scroll_type).map(ActionData::ScrollHint),
1030+
})?;
1031+
Ok(true)
1032+
}
1033+
10251034
pub fn scroll_to_point(&self, coord_type: CoordType, x: i32, y: i32) -> Result<bool> {
10261035
self.resolve_with_context(|node, context| {
10271036
let window_bounds = context.read_root_window_bounds();
@@ -1378,14 +1387,22 @@ impl PlatformNode {
13781387
&self,
13791388
start_offset: i32,
13801389
end_offset: i32,
1381-
_: ScrollType,
1390+
scroll_type: ScrollType,
13821391
) -> Result<bool> {
13831392
self.resolve_for_text_with_context(|node, context| {
1384-
if let Some(rect) = text_range_bounds_from_offsets(&node, start_offset, end_offset) {
1393+
if let Some(range) = text_range_from_offsets(&node, start_offset, end_offset) {
1394+
let position = if matches!(
1395+
scroll_type,
1396+
ScrollType::BottomRight | ScrollType::BottomEdge | ScrollType::RightEdge
1397+
) {
1398+
range.end()
1399+
} else {
1400+
range.start()
1401+
};
13851402
context.do_action(ActionRequest {
13861403
action: Action::ScrollIntoView,
1387-
target: node.id(),
1388-
data: Some(ActionData::ScrollTargetRect(rect)),
1404+
target: position.inner_node().id(),
1405+
data: atspi_scroll_type_to_scroll_hint(scroll_type).map(ActionData::ScrollHint),
13891406
});
13901407
Ok(true)
13911408
} else {

platforms/atspi-common/src/simplified.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,13 @@ impl Accessible {
224224
}
225225
}
226226

227+
pub fn scroll_to(&self, scroll_type: ScrollType) -> Result<bool> {
228+
match self {
229+
Self::Node(node) => node.scroll_to(scroll_type),
230+
Self::Root(_) => Err(Error::UnsupportedInterface),
231+
}
232+
}
233+
227234
pub fn scroll_to_point(&self, coord_type: CoordType, x: i32, y: i32) -> Result<bool> {
228235
match self {
229236
Self::Node(node) => node.scroll_to_point(coord_type, x, y),

platforms/atspi-common/src/util.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
// the LICENSE-APACHE file) or the MIT license (found in
44
// the LICENSE-MIT file), at your option.
55

6-
use accesskit::{Point, Rect};
6+
use accesskit::{Point, Rect, ScrollHint};
77
use accesskit_consumer::{Node, TextPosition, TextRange};
8-
use atspi_common::{CoordType, Granularity};
8+
use atspi_common::{CoordType, Granularity, ScrollType};
99

1010
use crate::Error;
1111

@@ -120,3 +120,15 @@ pub(crate) fn text_range_bounds_from_offsets(
120120
.into_iter()
121121
.reduce(|rect1, rect2| rect1.union(rect2))
122122
}
123+
124+
pub(crate) fn atspi_scroll_type_to_scroll_hint(scroll_type: ScrollType) -> Option<ScrollHint> {
125+
match scroll_type {
126+
ScrollType::TopLeft => Some(ScrollHint::TopLeft),
127+
ScrollType::BottomRight => Some(ScrollHint::BottomRight),
128+
ScrollType::TopEdge => Some(ScrollHint::TopEdge),
129+
ScrollType::BottomEdge => Some(ScrollHint::BottomEdge),
130+
ScrollType::LeftEdge => Some(ScrollHint::LeftEdge),
131+
ScrollType::RightEdge => Some(ScrollHint::RightEdge),
132+
ScrollType::Anywhere => None,
133+
}
134+
}

platforms/macos/src/node.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ use std::rc::{Rc, Weak};
3030

3131
use crate::{context::Context, filters::filter, util::*};
3232

33+
const SCROLL_TO_VISIBLE_ACTION: &str = "AXScrollToVisible";
34+
3335
fn ns_role(node: &Node) -> &'static NSAccessibilityRole {
3436
let role = node.role();
3537
// TODO: Handle special cases.
@@ -961,6 +963,37 @@ declare_class!(
961963
.flatten()
962964
}
963965

966+
// We discovered through experimentation that when mixing the newer
967+
// NSAccessibility protocols with the older informal protocol,
968+
// the platform uses both protocols to discover which actions are
969+
// available and then perform actions. That means our implementation
970+
// of the legacy methods below only needs to cover actions not already
971+
// handled by the newer methods.
972+
973+
#[method_id(accessibilityActionNames)]
974+
fn action_names(&self) -> Id<NSArray<NSString>> {
975+
let mut result = vec![];
976+
self.resolve(|node| {
977+
if node.supports_action(Action::ScrollIntoView, &filter) {
978+
result.push(ns_string!(SCROLL_TO_VISIBLE_ACTION).copy());
979+
}
980+
});
981+
NSArray::from_vec(result)
982+
}
983+
984+
#[method(accessibilityPerformAction:)]
985+
fn perform_action(&self, action: &NSString) {
986+
self.resolve_with_context(|node, context| {
987+
if action == ns_string!(SCROLL_TO_VISIBLE_ACTION) {
988+
context.do_action(ActionRequest {
989+
action: Action::ScrollIntoView,
990+
target: node.id(),
991+
data: None,
992+
});
993+
}
994+
});
995+
}
996+
964997
#[method(isAccessibilitySelectorAllowed:)]
965998
fn is_selector_allowed(&self, selector: Sel) -> bool {
966999
self.resolve(|node| {
@@ -1043,6 +1076,8 @@ declare_class!(
10431076
|| selector == sel!(isAccessibilityFocused)
10441077
|| selector == sel!(accessibilityNotifiesWhenDestroyed)
10451078
|| selector == sel!(isAccessibilitySelectorAllowed:)
1079+
|| selector == sel!(accessibilityActionNames)
1080+
|| selector == sel!(accessibilityPerformAction:)
10461081
})
10471082
.unwrap_or(false)
10481083
}

platforms/unix/src/atspi/interfaces/component.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// the LICENSE-MIT file), at your option.
55

66
use accesskit_atspi_common::{PlatformNode, Rect};
7-
use atspi::{CoordType, Layer};
7+
use atspi::{CoordType, Layer, ScrollType};
88
use zbus::{fdo, interface, names::OwnedUniqueName};
99

1010
use crate::atspi::{ObjectId, OwnedObjectAddress};
@@ -64,6 +64,10 @@ impl ComponentInterface {
6464
self.node.grab_focus().map_err(self.map_error())
6565
}
6666

67+
fn scroll_to(&self, scroll_type: ScrollType) -> fdo::Result<bool> {
68+
self.node.scroll_to(scroll_type).map_err(self.map_error())
69+
}
70+
6771
fn scroll_to_point(&self, coord_type: CoordType, x: i32, y: i32) -> fdo::Result<bool> {
6872
self.node
6973
.scroll_to_point(coord_type, x, y)

platforms/windows/src/node.rs

Lines changed: 50 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,10 @@ impl NodeWrapper<'_> {
381381
self.0.is_required()
382382
}
383383

384+
fn is_scroll_item_pattern_supported(&self) -> bool {
385+
self.0.supports_action(Action::ScrollIntoView, &filter)
386+
}
387+
384388
pub(crate) fn is_selection_item_pattern_supported(&self) -> bool {
385389
match self.0.role() {
386390
// TODO: tables (#29)
@@ -499,6 +503,7 @@ impl NodeWrapper<'_> {
499503
IInvokeProvider,
500504
IValueProvider,
501505
IRangeValueProvider,
506+
IScrollItemProvider,
502507
ISelectionItemProvider,
503508
ISelectionProvider,
504509
ITextProvider
@@ -603,52 +608,59 @@ impl PlatformNode {
603608
self.resolve_with_context_for_text_pattern(|node, _| f(node))
604609
}
605610

606-
fn do_action<F>(&self, f: F) -> Result<()>
611+
fn do_complex_action<F>(&self, f: F) -> Result<()>
607612
where
608-
F: FnOnce() -> (Action, Option<ActionData>),
613+
for<'a> F: FnOnce(Node<'a>) -> Result<Option<ActionRequest>>,
609614
{
610615
let context = self.upgrade_context()?;
611616
if context.is_placeholder.load(Ordering::SeqCst) {
612-
return Ok(());
617+
return Err(element_not_enabled());
613618
}
614619
let tree = context.read_tree();
615-
let node_id = if let Some(id) = self.node_id {
616-
if !tree.state().has_node(id) {
617-
return Err(element_not_available());
618-
}
619-
id
620-
} else {
621-
tree.state().root_id()
622-
};
623-
drop(tree);
624-
let (action, data) = f();
625-
let request = ActionRequest {
626-
target: node_id,
627-
action,
628-
data,
629-
};
630-
context.do_action(request);
620+
let state = tree.state();
621+
let node = self.node(state)?;
622+
if let Some(request) = f(node)? {
623+
drop(tree);
624+
context.do_action(request);
625+
}
631626
Ok(())
632627
}
633628

629+
fn do_action<F>(&self, f: F) -> Result<()>
630+
where
631+
F: FnOnce() -> (Action, Option<ActionData>),
632+
{
633+
self.do_complex_action(|node| {
634+
if node.is_disabled() {
635+
return Err(element_not_enabled());
636+
}
637+
let (action, data) = f();
638+
Ok(Some(ActionRequest {
639+
target: node.id(),
640+
action,
641+
data,
642+
}))
643+
})
644+
}
645+
634646
fn click(&self) -> Result<()> {
635647
self.do_action(|| (Action::Click, None))
636648
}
637649

638650
fn set_selected(&self, selected: bool) -> Result<()> {
639-
self.resolve_with_context(|node, context| {
651+
self.do_complex_action(|node| {
640652
if node.is_disabled() {
641653
return Err(element_not_enabled());
642654
}
643655
let wrapper = NodeWrapper(&node);
644-
if selected != wrapper.is_selected() {
645-
context.do_action(ActionRequest {
646-
action: Action::Click,
647-
target: node.id(),
648-
data: None,
649-
});
656+
if selected == wrapper.is_selected() {
657+
return Ok(None);
650658
}
651-
Ok(())
659+
Ok(Some(ActionRequest {
660+
action: Action::Click,
661+
target: node.id(),
662+
data: None,
663+
}))
652664
})
653665
}
654666

@@ -1004,6 +1016,17 @@ patterns! {
10041016
})
10051017
}
10061018
)),
1019+
(UIA_ScrollItemPatternId, IScrollItemProvider, IScrollItemProvider_Impl, is_scroll_item_pattern_supported, (), (
1020+
fn ScrollIntoView(&self) -> Result<()> {
1021+
self.do_complex_action(|node| {
1022+
Ok(Some(ActionRequest {
1023+
target: node.id(),
1024+
action: Action::ScrollIntoView,
1025+
data: None,
1026+
}))
1027+
})
1028+
}
1029+
)),
10071030
(UIA_SelectionItemPatternId, ISelectionItemProvider, ISelectionItemProvider_Impl, is_selection_item_pattern_supported, (), (
10081031
fn IsSelected(&self) -> Result<BOOL> {
10091032
self.resolve(|node| {

platforms/windows/src/text.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
#![allow(non_upper_case_globals)]
77

8-
use accesskit::{Action, ActionData, ActionRequest};
8+
use accesskit::{Action, ActionData, ActionRequest, ScrollHint};
99
use accesskit_consumer::{
1010
Node, TextPosition as Position, TextRange as Range, TreeState, WeakTextRange as WeakRange,
1111
};
@@ -582,9 +582,13 @@ impl ITextRangeProvider_Impl for PlatformRange_Impl {
582582
range.end()
583583
};
584584
ActionRequest {
585-
action: Action::ScrollIntoView,
586585
target: position.inner_node().id(),
587-
data: None,
586+
action: Action::ScrollIntoView,
587+
data: Some(ActionData::ScrollHint(if align_to_top.into() {
588+
ScrollHint::TopEdge
589+
} else {
590+
ScrollHint::BottomEdge
591+
})),
588592
}
589593
})
590594
}

0 commit comments

Comments
 (0)