Skip to content

Commit 61ab778

Browse files
author
Roy Lin
committed
feat: add layout-aware collection page navigation
1 parent bb421e0 commit 61ab778

23 files changed

Lines changed: 908 additions & 57 deletions

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@ object graph at the host boundary.
3838
cases of each `Button`, `DisclosureSummary`, `Link`, `ImageMapArea`,
3939
`MenuItem`, `ListBoxItem`, and `TreeItem` role (98 of 98 WinUI cases).
4040
Populating the AppKit and GTK4 manifests with real platform automation,
41-
role-edge input parity, layout-aware collection page movement, IME/dead-key
42-
conformance, measured overlay collision/arrow conformance and scroll locking,
43-
remaining native focus conformance, and locale formatting are still in
44-
progress.
41+
role-edge input parity, real-platform layout-aware page-navigation fixtures,
42+
IME/dead-key conformance, measured overlay collision/arrow conformance and
43+
scroll locking, remaining native focus conformance, and locale formatting
44+
are still in progress.
4545
- `.rsx` component source modules with imports, local Rust types, hook
4646
registrations, Rust selector/reducer expressions, and a final `rsx!(...)`
4747
view.

docs/architecture.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -381,11 +381,18 @@ listbox items or tabs.
381381
Before that activation fallback, the mounted selection registry resolves
382382
collection navigation for ListBox, Menu, Tree, Tabs/TabList, and RadioGroup.
383383
Arrow keys follow the collection orientation and inherited text direction;
384-
Home and PageUp move to the first focusable item, while End and PageDown move to
385-
the last. Fully disabled items are skipped, items disabled only for selection
386-
remain focusable, and `shouldFocusWrap` controls end-to-start navigation except
387-
for Tabs and RadioGroup, which wrap by default. Focus movement is issued through
388-
the same constrained `RequestFocus` capability as imperative navigation.
384+
Home and End move to the first and last focusable item. PageUp and PageDown
385+
request a fresh `CollectionLayoutSnapshot` from the host and move by one
386+
visible width or height using stable-key item rectangles, including
387+
variable-size rows. AppKit reads the list document and row frames, GTK4 reads
388+
list allocations and ancestor scroll adjustments, and WinUI reads actual
389+
list/item layout. Custom or headless hosts can install the same typed snapshot
390+
through `GuiRuntime::set_collection_layout`; missing measurement falls back to
391+
the corresponding collection boundary. Fully disabled items are skipped, items
392+
disabled only for selection remain focusable, and `shouldFocusWrap` controls
393+
end-to-start navigation except for Tabs and RadioGroup, which wrap by default.
394+
Focus movement is issued through the same constrained `RequestFocus` capability
395+
as imperative navigation.
389396
Replace-selection ListBox and Tree collections select as focus moves, toggle
390397
collections move focus only, radio navigation selects, and Menu navigation is
391398
focus-only. Tabs select on focus unless `keyboardActivation="manual"` is set.
@@ -396,8 +403,7 @@ that clear behavior, can be released with `escapeKeyBehavior="none"`, and still
396403
honors `disallowEmptySelection`. These commands route through the collection
397404
root as complete stable-key snapshots, including normal action failure rollback.
398405
Explicit `onKeyDown` bindings on the target route take precedence over all
399-
built-in collection handling. Page movement deliberately uses collection
400-
boundaries until portable native layout geometry is available.
406+
built-in collection handling.
401407
Collection `onAction` is a separate semantic channel from selection. The
402408
mounted registry projects an internal action marker onto owned ListBoxItem and
403409
TreeItem rows so native adapters capture a press lifecycle without copying the

docs/react-aria-native.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,14 @@ The first shared interaction milestone is available in the portable runtime:
218218
selection unless `escapeKeyBehavior="none"` is configured, and
219219
`disallowEmptySelection` still prevents the clear. Both shortcuts emit the
220220
same complete stable-key payload as pointer selection. An explicit
221-
`onKeyDown` on the target route retains ownership of the key. Until native
222-
layout geometry is available to the portable layer, PageUp and PageDown use
223-
deterministic collection-boundary behavior.
221+
`onKeyDown` on the target route retains ownership of the key. PageUp and
222+
PageDown use a `CollectionLayoutSnapshot` containing the visible rectangle,
223+
content size, and stable-key item rectangles. Command hosts measure that
224+
snapshot from AppKit, GTK4, or WinUI immediately before navigation; custom
225+
and headless hosts can inject it through `GuiRuntime::set_collection_layout`.
226+
Variable-size rows move by one visible extent and fully disabled rows remain
227+
excluded. A host that cannot measure layout retains deterministic
228+
collection-boundary behavior.
224229
- Tree owns controlled `expandedKeys` or uncontrolled `defaultExpandedKeys`
225230
independently from selection. Nested semantic `TreeItem` nodes lower to
226231
stable, same-level native rows with parent-key, level, position, and set-size
@@ -369,7 +374,7 @@ props:
369374
| P0 | Native input conformance | WinUI's complete 98-case V1 manifest passes real OS automation. Populate the AppKit and GTK4 manifests with platform-run mouse, pen, touch where applicable, keyboard, assistive activation, disabled, cancellation, and keyed-rerender fixtures for every role currently marked native; then close or retain evidence-backed menu/item exceptions. |
370375
| P1 | Event propagation | Add platform-run conformance fixtures for conditional `Stop`/`Continue` across nested native controls. |
371376
| P1 | Focus management | Add platform-run conformance fixtures for post-mount `autoFocus`, nested containment, and restoration. |
372-
| P1 | Collections and selection | Add layout-aware page navigation and complete IME/dead-key typeahead conformance. |
377+
| P1 | Collections and selection | Complete IME/dead-key typeahead conformance and add real-platform fixtures for layout-aware page navigation. |
373378
| P1 | Internationalization | Add message formatting, number/date formatting, locale-aware collation, and localized interaction behavior on top of inherited locale/direction. |
374379
| P1 | Accessibility conformance | Complete OS accessibility API projection, relationships, live regions, value announcements, and role-specific native adapter coverage. |
375380
| P2 | Overlays | Complete measured boundary-driven collision and arrow projection, native scroll locking, configurable outside-interaction filters, multi-window layer coordination, and real-platform positioning conformance fixtures. |

src/appkit_native/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ use crate::backend::{
4343
};
4444
use crate::error::{GuiError, GuiResult};
4545
use crate::event::{virtual_press_events, NativeEvent, NativeEventKind};
46-
use crate::geometry::Orientation;
46+
use crate::geometry::{Orientation, Rect, Size};
4747
use crate::host::HostNodeId;
4848
use crate::html::HTML_TAG_METADATA_KEY;
4949
use crate::native_backends::appkit::menu::AppKitMenuRegistry;
@@ -55,6 +55,7 @@ use crate::platform::{
5555
NativeWidgetConfig, NativeWidgetSetter,
5656
};
5757
use crate::protocol::UiFrame;
58+
use crate::selection::{CollectionKey, CollectionLayoutSnapshot};
5859
use crate::style::{
5960
EdgeInsets, FontWeight, OverflowMode, PortableStyle, StyleColor, StyleLength, TextAlign,
6061
};

src/appkit_native/surface.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,56 @@ impl NativeWidgetSurface for AppKitNativeSurface {
123123
Ok(())
124124
}
125125

126+
fn measure_native_collection_layout(
127+
&mut self,
128+
collection: HostNodeId,
129+
collection_handle: &Self::Handle,
130+
items: &[(HostNodeId, CollectionKey, Self::Handle)],
131+
) -> GuiResult<Option<CollectionLayoutSnapshot>> {
132+
if collection_handle.id != collection {
133+
return Err(GuiError::host(format!(
134+
"AppKit handle id does not match collection {}",
135+
collection.get()
136+
)));
137+
}
138+
let AppKitOsWidget::ListView(scroll_view) = &collection_handle.widget else {
139+
return Ok(None);
140+
};
141+
let Some(state) = self.list_views.get(&collection) else {
142+
return Ok(None);
143+
};
144+
state.stack_view.layoutSubtreeIfNeeded();
145+
let visible = scroll_view.documentVisibleRect();
146+
let content = state.stack_view.frame().size;
147+
let rows = state.rows.borrow();
148+
let mut layout = CollectionLayoutSnapshot::new(
149+
Rect::new(
150+
visible.origin.x,
151+
visible.origin.y,
152+
visible.size.width.max(0.0),
153+
visible.size.height.max(0.0),
154+
),
155+
Size::new(content.width.max(0.0), content.height.max(0.0)),
156+
);
157+
for (node, key, _) in items {
158+
let Some(row) = rows.iter().find(|row| row.node == *node) else {
159+
continue;
160+
};
161+
let frame = row.button_view().frame();
162+
layout.insert_item_rect(
163+
key.clone(),
164+
Rect::new(
165+
frame.origin.x,
166+
frame.origin.y,
167+
frame.size.width.max(0.0),
168+
frame.size.height.max(0.0),
169+
),
170+
);
171+
}
172+
layout.validate()?;
173+
Ok(Some(layout))
174+
}
175+
126176
fn position_native_overlay(
127177
&mut self,
128178
overlay: HostNodeId,

src/backend/command_host.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use crate::overlay_position::OverlayPositionRequest;
1010
use crate::platform::{
1111
BlueprintHost, NativeWidgetBlueprint, PlatformAdapter, PlatformPlanningHost,
1212
};
13+
use crate::selection::{CollectionKey, CollectionLayoutSnapshot};
1314
use crate::style::PortableStyle;
1415

1516
use super::traits::{
@@ -379,6 +380,15 @@ impl<A: PlatformAdapter, E: PlatformCommandExecutor> NativeHost for CommandExecu
379380
fn overlay_position_host(&mut self) -> Option<&mut dyn OverlayPositionHost> {
380381
Some(self)
381382
}
383+
384+
fn measure_collection_layout(
385+
&mut self,
386+
collection: HostNodeId,
387+
items: &[(HostNodeId, CollectionKey)],
388+
) -> GuiResult<Option<CollectionLayoutSnapshot>> {
389+
self.ensure_healthy()?;
390+
self.executor.measure_collection_layout(collection, items)
391+
}
382392
}
383393

384394
impl<A: PlatformAdapter, E: PlatformCommandExecutor> ProgrammaticFocusHost

src/backend/driver.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use crate::event::NativeEvent;
66
use crate::host::HostNodeId;
77
use crate::overlay_position::OverlayPositionRequest;
88
use crate::platform::{NativeBackendKind, NativeWidgetBlueprint, NativeWidgetConfig};
9+
use crate::selection::{CollectionKey, CollectionLayoutSnapshot};
910

1011
use super::traits::{NativeEventSource, NativeHandleAdapter, NativeWidgetDriver};
1112

@@ -337,4 +338,18 @@ impl<A: NativeHandleAdapter> NativeWidgetDriver for HandleWidgetDriver<A> {
337338
request,
338339
)
339340
}
341+
342+
fn measure_collection_layout(
343+
&mut self,
344+
collection: HostNodeId,
345+
items: &[(HostNodeId, CollectionKey)],
346+
) -> GuiResult<Option<CollectionLayoutSnapshot>> {
347+
let collection_handle = self.cloned_handle(collection)?;
348+
let items = items
349+
.iter()
350+
.map(|(id, key)| Ok((*id, key.clone(), self.cloned_handle(*id)?)))
351+
.collect::<GuiResult<Vec<_>>>()?;
352+
self.adapter
353+
.measure_collection_layout_handles(collection, &collection_handle, &items)
354+
}
340355
}

src/backend/executor.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use crate::error::{GuiError, GuiResult};
22
use crate::event::NativeEvent;
3+
use crate::host::HostNodeId;
34
use crate::platform::{NativeWidgetBlueprint, PlatformCommand};
5+
use crate::selection::{CollectionKey, CollectionLayoutSnapshot};
46

57
use super::traits::{
68
NativeEventSource, NativeWidgetDriver, PlatformCommandBatch, PlatformCommandExecutor,
@@ -81,6 +83,14 @@ impl<D: NativeWidgetDriver + Default> Default for DriverCommandExecutor<D> {
8183
}
8284

8385
impl<D: NativeWidgetDriver> PlatformCommandExecutor for DriverCommandExecutor<D> {
86+
fn measure_collection_layout(
87+
&mut self,
88+
collection: HostNodeId,
89+
items: &[(HostNodeId, CollectionKey)],
90+
) -> GuiResult<Option<CollectionLayoutSnapshot>> {
91+
self.driver.measure_collection_layout(collection, items)
92+
}
93+
8494
fn prepare_batch(&mut self, batch: &PlatformCommandBatch) -> GuiResult<()> {
8595
for command in &batch.commands {
8696
match command {

src/backend/surface.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use crate::overlay_position::OverlayPositionRequest;
55
use crate::platform::{
66
NativeBackendKind, NativeWidgetBlueprint, NativeWidgetConfigPatch, NativeWidgetSetter,
77
};
8+
use crate::selection::{CollectionKey, CollectionLayoutSnapshot};
89

910
use super::traits::{NativeHandleAdapter, NativeWidgetSurface};
1011

@@ -129,6 +130,16 @@ impl<S: NativeWidgetSurface> NativeHandleAdapter for SurfaceHandleAdapter<S> {
129130
)
130131
}
131132

133+
fn measure_collection_layout_handles(
134+
&mut self,
135+
collection: HostNodeId,
136+
collection_handle: &Self::Handle,
137+
items: &[(HostNodeId, CollectionKey, Self::Handle)],
138+
) -> GuiResult<Option<CollectionLayoutSnapshot>> {
139+
self.surface
140+
.measure_native_collection_layout(collection, collection_handle, items)
141+
}
142+
132143
fn take_native_events(&mut self) -> Vec<NativeEvent> {
133144
self.surface.take_native_events()
134145
}

src/backend/tests.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ use crate::accessibility::AccessibilityRole;
33
use crate::compiler::CompiledRsxNode;
44
use crate::error::{GuiError, GuiResult};
55
use crate::event::{NativeEvent, NativeEventKind};
6+
use crate::geometry::{Rect, Size};
67
use crate::host::HostNodeId;
78
use crate::native::{NativeElement, NativeProps, NativeRole};
89
use crate::platform::{
910
Gtk4Adapter, NativeBackendKind, NativeWidgetBlueprint, NativeWidgetConfigPatch,
1011
NativeWidgetSetter, PlatformAdapter, PlatformCommand, WinUiAdapter,
1112
};
1213
use crate::runtime::GuiRuntime;
14+
use crate::selection::{CollectionKey, CollectionLayoutSnapshot};
1315
use std::cell::RefCell;
1416
use std::rc::Rc;
1517

@@ -173,6 +175,31 @@ impl NativeWidgetSurface for TestNativeSurface {
173175
Ok(())
174176
}
175177

178+
fn measure_native_collection_layout(
179+
&mut self,
180+
collection: HostNodeId,
181+
collection_handle: &Self::Handle,
182+
items: &[(HostNodeId, CollectionKey, Self::Handle)],
183+
) -> GuiResult<Option<CollectionLayoutSnapshot>> {
184+
self.calls.borrow_mut().push(format!(
185+
"layout:{}:{}:{}",
186+
collection.get(),
187+
collection_handle.widget_class,
188+
items.len()
189+
));
190+
let mut layout = CollectionLayoutSnapshot::new(
191+
Rect::new(0.0, 0.0, 200.0, 100.0),
192+
Size::new(200.0, items.len() as f64 * 40.0),
193+
);
194+
for (index, (_, key, _)) in items.iter().enumerate() {
195+
layout.insert_item_rect(
196+
key.clone(),
197+
Rect::new(0.0, index as f64 * 40.0, 200.0, 40.0),
198+
);
199+
}
200+
Ok(Some(layout))
201+
}
202+
176203
fn take_native_events(&mut self) -> Vec<NativeEvent> {
177204
std::mem::take(&mut self.events.borrow_mut())
178205
}
@@ -1121,6 +1148,45 @@ fn surface_handle_adapter_drains_native_surface_events() {
11211148
assert!(driver.take_native_events().is_empty());
11221149
}
11231150

1151+
#[test]
1152+
fn page_navigation_measures_layout_through_the_native_backend_stack() {
1153+
let surface = TestNativeSurface::default();
1154+
let calls = surface.calls.clone();
1155+
let executor =
1156+
DriverCommandExecutor::new(HandleWidgetDriver::new(SurfaceHandleAdapter::new(surface)));
1157+
let host = CommandExecutingHost::new(Gtk4Adapter, executor);
1158+
let mut runtime = GuiRuntime::new(host);
1159+
let list = NativeElement::new("list", NativeRole::ListBox)
1160+
.with_props(
1161+
NativeProps::new()
1162+
.web(crate::web::WebProps::new().attribute("data-selection-mode", "none")),
1163+
)
1164+
.child(NativeElement::new("a", NativeRole::ListBoxItem))
1165+
.child(NativeElement::new("b", NativeRole::ListBoxItem))
1166+
.child(NativeElement::new("c", NativeRole::ListBoxItem))
1167+
.child(NativeElement::new("d", NativeRole::ListBoxItem));
1168+
let root = runtime.render_native(&list).unwrap();
1169+
let items = runtime
1170+
.host()
1171+
.planning()
1172+
.node(root)
1173+
.unwrap()
1174+
.children
1175+
.clone();
1176+
1177+
runtime
1178+
.handle_native_event_with_changes(
1179+
NativeEvent::new(items[0], NativeEventKind::KeyDown).value("PageDown"),
1180+
)
1181+
.unwrap();
1182+
1183+
assert_eq!(runtime.host().planning().focused(), Some(items[2]));
1184+
assert!(calls
1185+
.borrow()
1186+
.iter()
1187+
.any(|call| call == &format!("layout:{}:gtk::ListBox:4", root.get())));
1188+
}
1189+
11241190
#[test]
11251191
fn recording_backend_reparents_children_and_rejects_cycles() {
11261192
let mut backend = RecordingBackend::default();

0 commit comments

Comments
 (0)