Skip to main content

ftui_web/
pane_keyboard.rs

1//! Web pane keyboard bindings + roving tabindex and ARIA semantics (bd-21pbi.3).
2//!
3//! This is the browser host's binding layer over the canonical command model in
4//! `ftui_layout::pane_command` (bd-21pbi.1), the web counterpart to the terminal
5//! binding in `ftui_runtime::pane_keymap` (bd-21pbi.2). It provides:
6//!
7//! - [`key_to_pane_command`]: a **browser-safe** keymap. It refuses any key
8//!   carrying Ctrl or Super (Meta/Cmd) so it never steals browser/OS shortcuts
9//!   (Ctrl+W close-tab, Ctrl+T, Ctrl++ zoom, Cmd+…). It binds only plain and
10//!   Shift-qualified keys, gated by the host to fire only while the pane
11//!   container (not pane content) holds focus.
12//! - [`pane_accessibility_tree`]: the roving-tabindex + ARIA model. Exactly one
13//!   leaf (the active pane) carries `tabindex = 0`; all other leaves carry
14//!   `-1`. Splitters expose `role="separator"`, `aria-orientation`, and
15//!   `aria-valuenow/min/max` (the split ratio), so assistive tech can identify
16//!   the active pane, splitter orientation, and resize affordances.
17//! - [`PaneWebKeyboardController`]: a reusable host helper that translates a
18//!   key, resolves it, applies structural operations, tracks focus, and exposes
19//!   the up-to-date accessibility tree.
20//!
21//! Cross-host parity: the bindings differ from the terminal host (browsers
22//! reserve different keys), but both emit the **same** [`PaneCommand`] for
23//! equivalent intent, so both hosts reach identical pane state — the parity
24//! guarantee the validation epic checks.
25
26use ftui_core::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers};
27use ftui_layout::{
28    PaneAccessibilityPreferences, PaneAffordanceMotion, PaneAnnouncement, PaneAnnouncer,
29    PaneCardinalDirection, PaneCommand, PaneCommandAcceleration, PaneCommandEffect,
30    PaneCommandResolution, PaneFocusContext, PaneFocusOrdinal, PaneId, PaneLayout, PaneNodeKind,
31    PaneResizeDirection, PaneTree, SplitAxis, announce_command, focus_order, resolve_pane_command,
32};
33
34/// Translate a browser key event into a canonical [`PaneCommand`].
35///
36/// Returns `None` for key releases, for any key carrying Ctrl or Super (those
37/// are reserved by the browser/OS and must never be stolen), and for unbound
38/// keys. The default browser-safe keymap (pane container focused):
39///
40/// | Key | Command |
41/// |-----|---------|
42/// | Arrows | `FocusDirectional` |
43/// | Shift+Arrows | `MovePane` |
44/// | Home / End / PageUp / PageDown | `FocusEdge` left / right / up / down |
45/// | `n` / `p` | `FocusNext` / `FocusPrevious` |
46/// | `=` / `-` | `ResizeStep` grow / shrink |
47/// | `s` / `v` | `Split` horizontal / vertical |
48/// | `x` / `Delete` | `Close` |
49/// | `[` / `]` | `SwapPane` previous / next |
50/// | `f` / `Escape` | `Maximize` / `Restore` |
51///
52/// `resize_units` is the snap-step count for `ResizeStep` (computed by the
53/// caller via [`PaneCommandAcceleration`]); it is ignored for other commands.
54#[must_use]
55pub fn key_to_pane_command(key: &KeyEvent, resize_units: u16) -> Option<PaneCommand> {
56    if key.kind == KeyEventKind::Release {
57        return None;
58    }
59    let m = key.modifiers;
60    // Never consume browser/OS-reserved chords. Alt is included: Alt+Arrow is
61    // browser Back/Forward on Windows/Linux, and this keymap binds only plain
62    // and Shift-qualified keys anyway.
63    if m.contains(Modifiers::CTRL) || m.contains(Modifiers::SUPER) || m.contains(Modifiers::ALT) {
64        return None;
65    }
66    let shift = m.contains(Modifiers::SHIFT);
67
68    match key.code {
69        KeyCode::Left if shift => Some(PaneCommand::MovePane(PaneCardinalDirection::Left)),
70        KeyCode::Right if shift => Some(PaneCommand::MovePane(PaneCardinalDirection::Right)),
71        KeyCode::Up if shift => Some(PaneCommand::MovePane(PaneCardinalDirection::Up)),
72        KeyCode::Down if shift => Some(PaneCommand::MovePane(PaneCardinalDirection::Down)),
73
74        KeyCode::Left => Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Left)),
75        KeyCode::Right => Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Right)),
76        KeyCode::Up => Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Up)),
77        KeyCode::Down => Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Down)),
78
79        KeyCode::Home => Some(PaneCommand::FocusEdge(PaneCardinalDirection::Left)),
80        KeyCode::End => Some(PaneCommand::FocusEdge(PaneCardinalDirection::Right)),
81        KeyCode::PageUp => Some(PaneCommand::FocusEdge(PaneCardinalDirection::Up)),
82        KeyCode::PageDown => Some(PaneCommand::FocusEdge(PaneCardinalDirection::Down)),
83
84        KeyCode::Char('n') => Some(PaneCommand::FocusNext),
85        KeyCode::Char('p') => Some(PaneCommand::FocusPrevious),
86
87        KeyCode::Char('=' | '+') => Some(PaneCommand::ResizeStep {
88            direction: PaneResizeDirection::Increase,
89            units: resize_units,
90        }),
91        KeyCode::Char('-' | '_') => Some(PaneCommand::ResizeStep {
92            direction: PaneResizeDirection::Decrease,
93            units: resize_units,
94        }),
95
96        KeyCode::Char('s' | 'S') => Some(PaneCommand::Split(SplitAxis::Horizontal)),
97        KeyCode::Char('v' | 'V') => Some(PaneCommand::Split(SplitAxis::Vertical)),
98        KeyCode::Char('x' | 'X') | KeyCode::Delete => Some(PaneCommand::Close),
99        KeyCode::Char('[') => Some(PaneCommand::SwapPane(PaneFocusOrdinal::Previous)),
100        KeyCode::Char(']') => Some(PaneCommand::SwapPane(PaneFocusOrdinal::Next)),
101        KeyCode::Char('f' | 'F') => Some(PaneCommand::Maximize),
102        KeyCode::Escape => Some(PaneCommand::Restore),
103
104        _ => None,
105    }
106}
107
108/// ARIA role for a pane node in the accessibility tree.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum PaneAriaRole {
111    /// A pane (leaf): `role="group"`.
112    Group,
113    /// A splitter (split node): `role="separator"`.
114    Separator,
115}
116
117impl PaneAriaRole {
118    /// The DOM `role` attribute value.
119    #[must_use]
120    pub const fn as_str(self) -> &'static str {
121        match self {
122            Self::Group => "group",
123            Self::Separator => "separator",
124        }
125    }
126}
127
128/// ARIA orientation of a splitter (the divider line's orientation).
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum PaneAriaOrientation {
131    /// A horizontal divider line (between vertically-stacked panes).
132    Horizontal,
133    /// A vertical divider line (between side-by-side panes).
134    Vertical,
135}
136
137impl PaneAriaOrientation {
138    /// The DOM `aria-orientation` attribute value.
139    #[must_use]
140    pub const fn as_str(self) -> &'static str {
141        match self {
142            Self::Horizontal => "horizontal",
143            Self::Vertical => "vertical",
144        }
145    }
146}
147
148/// One node in the pane accessibility tree, mapping directly to DOM ARIA
149/// attributes the web host applies to its pane/splitter elements.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct PaneAriaNode {
152    /// The pane node this describes.
153    pub pane_id: PaneId,
154    /// `role`.
155    pub role: PaneAriaRole,
156    /// `tabindex`. Roving: exactly one leaf (the active pane) is `0`; all other
157    /// leaves are `-1`. Splitters are `-1` (described, not tab-focused).
158    pub tabindex: i32,
159    /// `aria-label`.
160    pub label: String,
161    /// `aria-orientation` (splitters only).
162    pub orientation: Option<PaneAriaOrientation>,
163    /// `aria-valuenow` — splitter first-child share, 0..=100 (splitters only).
164    pub value_now: Option<u16>,
165    /// `aria-valuemin` (splitters only).
166    pub value_min: Option<u16>,
167    /// `aria-valuemax` (splitters only).
168    pub value_max: Option<u16>,
169    /// `aria-current="true"` / `aria-selected` — the active pane.
170    pub current: bool,
171}
172
173/// Compute the roving-tabindex + ARIA accessibility tree for a pane workspace.
174///
175/// The roving invariant: among leaves, exactly the effective-active leaf (the
176/// focused pane, or the first pane in focus order when nothing is focused)
177/// carries `tabindex = 0`; every other leaf carries `-1`. This makes the whole
178/// pane manager a single Tab stop whose internal navigation is driven by the
179/// keymap above.
180#[must_use]
181pub fn pane_accessibility_tree(tree: &PaneTree, focus: PaneFocusContext) -> Vec<PaneAriaNode> {
182    let order = focus_order(tree);
183    let effective_active = focus
184        .active
185        .filter(|id| order.contains(id))
186        .or_else(|| order.first().copied());
187
188    let mut nodes = Vec::new();
189    for record in tree.nodes() {
190        match &record.kind {
191            PaneNodeKind::Leaf(leaf) => {
192                let is_active = Some(record.id) == effective_active;
193                nodes.push(PaneAriaNode {
194                    pane_id: record.id,
195                    role: PaneAriaRole::Group,
196                    tabindex: i32::from(is_active) - 1, // true -> 0, false -> -1
197                    label: format!("pane {}", leaf.surface_key),
198                    orientation: None,
199                    value_now: None,
200                    value_min: None,
201                    value_max: None,
202                    current: is_active,
203                });
204            }
205            PaneNodeKind::Split(split) => {
206                // A horizontal split (side-by-side children) has a vertical
207                // divider, and vice versa.
208                let orientation = match split.axis {
209                    SplitAxis::Horizontal => PaneAriaOrientation::Vertical,
210                    SplitAxis::Vertical => PaneAriaOrientation::Horizontal,
211                };
212                // u64 arithmetic: `num + den` can overflow u32 (any nonzero
213                // pair is a valid ratio), which would wrap into a division by
214                // zero in release builds — unwrap_or cannot catch a panic.
215                let num = u64::from(split.ratio.numerator());
216                let den = u64::from(split.ratio.denominator());
217                let first_share = u16::try_from(num * 100 / (num + den)).unwrap_or(50);
218                nodes.push(PaneAriaNode {
219                    pane_id: record.id,
220                    role: PaneAriaRole::Separator,
221                    tabindex: -1,
222                    label: format!("{} pane divider", orientation.as_str()),
223                    orientation: Some(orientation),
224                    value_now: Some(first_share),
225                    value_min: Some(0),
226                    value_max: Some(100),
227                    current: false,
228                });
229            }
230        }
231    }
232    nodes
233}
234
235/// Outcome of feeding one key event to a [`PaneWebKeyboardController`].
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub enum PaneWebKeyOutcome {
238    /// Not a pane binding (or browser-reserved); the host handles it normally.
239    Unbound,
240    /// The command was resolved (and any structural operations applied).
241    Handled {
242        /// The command the key mapped to.
243        command: PaneCommand,
244        /// The full resolution.
245        resolution: PaneCommandResolution,
246    },
247    /// A structural operation failed to apply; focus state is left unchanged.
248    Failed {
249        /// The command that failed.
250        command: PaneCommand,
251    },
252}
253
254/// A reusable web pane keyboard host: browser-safe keymap + canonical resolution
255/// + focus tracking + a live roving-tabindex/ARIA tree.
256#[derive(Debug, Clone)]
257pub struct PaneWebKeyboardController {
258    focus: PaneFocusContext,
259    acceleration: PaneCommandAcceleration,
260    op_seed: u64,
261    repeat_count: u16,
262    announcer: PaneAnnouncer,
263    preferences: PaneAccessibilityPreferences,
264}
265
266impl PaneWebKeyboardController {
267    /// Create a controller focused on `active` (or unfocused if `None`).
268    #[must_use]
269    pub fn new(active: Option<PaneId>) -> Self {
270        Self {
271            focus: PaneFocusContext {
272                active,
273                maximized: None,
274            },
275            acceleration: PaneCommandAcceleration::default(),
276            op_seed: 0,
277            repeat_count: 0,
278            announcer: PaneAnnouncer::new(),
279            preferences: PaneAccessibilityPreferences::none(),
280        }
281    }
282
283    /// Take the pending accessibility announcement (for an `aria-live` region),
284    /// coalesced and de-duplicated. Call once per render / live-region update.
285    pub fn take_announcement(&mut self) -> Option<PaneAnnouncement> {
286        self.announcer.take()
287    }
288
289    /// Peek the pending accessibility announcement without consuming it.
290    #[must_use]
291    pub fn pending_announcement(&self) -> Option<&PaneAnnouncement> {
292        self.announcer.pending()
293    }
294
295    /// Override the resize acceleration policy.
296    #[must_use]
297    pub const fn with_acceleration(mut self, acceleration: PaneCommandAcceleration) -> Self {
298        self.acceleration = acceleration;
299        self
300    }
301
302    /// Set the accessibility preferences (reduced-motion / high-contrast /
303    /// large-target) at construction time (bd-21pbi.5).
304    #[must_use]
305    pub const fn with_preferences(mut self, preferences: PaneAccessibilityPreferences) -> Self {
306        self.preferences = preferences;
307        self
308    }
309
310    /// Update the accessibility preferences at runtime (e.g. when the browser
311    /// reports a `prefers-reduced-motion` / `prefers-contrast` media-query
312    /// change). Never affects focus/command semantics — presentation only.
313    pub const fn set_preferences(&mut self, preferences: PaneAccessibilityPreferences) {
314        self.preferences = preferences;
315    }
316
317    /// The active accessibility preferences.
318    #[must_use]
319    pub const fn preferences(&self) -> PaneAccessibilityPreferences {
320        self.preferences
321    }
322
323    /// The affordance micro-animation policy implied by the current
324    /// preferences (motion is stepped under reduced-motion).
325    #[must_use]
326    pub fn affordance_motion(&self) -> PaneAffordanceMotion {
327        self.preferences.affordance_motion()
328    }
329
330    /// Accessibility state as `data-*` attribute pairs for the workspace root.
331    ///
332    /// The browser host stamps these on the pane container so CSS can apply
333    /// high-contrast palettes, reduced-motion overrides, and enlarged hit
334    /// targets via attribute selectors (e.g.
335    /// `[data-pane-high-contrast="true"] .pane-splitter { … }`). This is the web
336    /// counterpart to the terminal host's themed focus ring + sizing: the same
337    /// host-agnostic preference drives presentation on both platforms.
338    #[must_use]
339    pub fn accessibility_dataset(&self) -> [(&'static str, bool); 3] {
340        [
341            ("data-pane-reduced-motion", self.preferences.reduced_motion),
342            ("data-pane-high-contrast", self.preferences.high_contrast),
343            ("data-pane-large-target", self.preferences.large_target),
344        ]
345    }
346
347    /// The current focus context.
348    #[must_use]
349    pub const fn focus(&self) -> PaneFocusContext {
350        self.focus
351    }
352
353    /// The currently active (focused) pane.
354    #[must_use]
355    pub const fn active(&self) -> Option<PaneId> {
356        self.focus.active
357    }
358
359    /// The currently maximized pane, if any.
360    #[must_use]
361    pub const fn maximized(&self) -> Option<PaneId> {
362        self.focus.maximized
363    }
364
365    /// Set the active pane (e.g. when a pointer interaction changes focus).
366    pub const fn set_active(&mut self, active: Option<PaneId>) {
367        self.focus.active = active;
368    }
369
370    /// The current roving-tabindex + ARIA tree for `tree`, reflecting focus.
371    #[must_use]
372    pub fn accessibility_tree(&self, tree: &PaneTree) -> Vec<PaneAriaNode> {
373        pane_accessibility_tree(tree, self.focus)
374    }
375
376    /// Translate, resolve, and apply one browser key event. Structural commands
377    /// mutate `tree`; focus/maximize commands update internal state. `layout`
378    /// must be the solved layout of `tree`.
379    pub fn handle_key(
380        &mut self,
381        key: &KeyEvent,
382        tree: &mut PaneTree,
383        layout: &PaneLayout,
384    ) -> PaneWebKeyOutcome {
385        if key.kind == KeyEventKind::Repeat {
386            self.repeat_count = self.repeat_count.saturating_add(1);
387        } else {
388            self.repeat_count = 0;
389        }
390        let resize_units = self.acceleration.units_for(self.repeat_count);
391
392        let Some(command) = key_to_pane_command(key, resize_units) else {
393            return PaneWebKeyOutcome::Unbound;
394        };
395
396        let resolution = resolve_pane_command(tree, layout, self.focus, command);
397        if let PaneCommandEffect::Structural(ops) = &resolution.effect {
398            for op in ops {
399                if tree.apply_operation(self.op_seed, op.clone()).is_err() {
400                    return PaneWebKeyOutcome::Failed { command };
401                }
402                self.op_seed += 1;
403            }
404        }
405        self.focus.active = resolution.next_active;
406        self.focus.maximized = resolution.next_maximized;
407        self.announcer
408            .offer(announce_command(command, &resolution, tree));
409        PaneWebKeyOutcome::Handled {
410            command,
411            resolution,
412        }
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use ftui_layout::{
420        PANE_TREE_SCHEMA_VERSION, PaneLeaf, PaneNodeRecord, PaneSplit, PaneSplitRatio,
421        PaneTreeSnapshot, Rect,
422    };
423    use std::collections::BTreeMap;
424
425    fn pid(raw: u64) -> PaneId {
426        PaneId::new(raw).expect("non-zero id")
427    }
428
429    fn key(code: KeyCode, modifiers: Modifiers) -> KeyEvent {
430        KeyEvent {
431            code,
432            modifiers,
433            kind: KeyEventKind::Press,
434        }
435    }
436
437    #[test]
438    fn alt_chords_are_never_bound() {
439        // Alt+Arrow is browser Back/Forward on Windows/Linux; the browser-safe
440        // keymap must ignore every Alt-qualified chord.
441        assert_eq!(
442            key_to_pane_command(&key(KeyCode::Left, Modifiers::ALT), 1),
443            None
444        );
445        assert_eq!(
446            key_to_pane_command(&key(KeyCode::Right, Modifiers::ALT), 1),
447            None
448        );
449        assert_eq!(
450            key_to_pane_command(&key(KeyCode::Left, Modifiers::SHIFT | Modifiers::ALT), 1),
451            None
452        );
453    }
454
455    /// Horizontal root: left(2) | vertical(top(4)/bottom(5)). Focus order [2,4,5].
456    fn nested() -> PaneTree {
457        let snapshot = PaneTreeSnapshot {
458            schema_version: PANE_TREE_SCHEMA_VERSION,
459            root: pid(1),
460            next_id: pid(6),
461            nodes: vec![
462                PaneNodeRecord::split(
463                    pid(1),
464                    None,
465                    PaneSplit {
466                        axis: SplitAxis::Horizontal,
467                        ratio: PaneSplitRatio::new(1, 1).unwrap(),
468                        first: pid(2),
469                        second: pid(3),
470                    },
471                ),
472                PaneNodeRecord::leaf(pid(2), Some(pid(1)), PaneLeaf::new("left")),
473                PaneNodeRecord::split(
474                    pid(3),
475                    Some(pid(1)),
476                    PaneSplit {
477                        axis: SplitAxis::Vertical,
478                        ratio: PaneSplitRatio::new(3, 1).unwrap(),
479                        first: pid(4),
480                        second: pid(5),
481                    },
482                ),
483                PaneNodeRecord::leaf(pid(4), Some(pid(3)), PaneLeaf::new("right_top")),
484                PaneNodeRecord::leaf(pid(5), Some(pid(3)), PaneLeaf::new("right_bottom")),
485            ],
486            extensions: BTreeMap::new(),
487        };
488        PaneTree::from_snapshot(snapshot).expect("valid tree")
489    }
490
491    #[test]
492    fn web_keymap_is_browser_safe() {
493        // Any Ctrl/Super chord is never consumed (browser/OS reserved).
494        for code in [
495            KeyCode::Char('w'),
496            KeyCode::Char('t'),
497            KeyCode::Char('='),
498            KeyCode::Left,
499        ] {
500            assert_eq!(key_to_pane_command(&key(code, Modifiers::CTRL), 1), None);
501            assert_eq!(key_to_pane_command(&key(code, Modifiers::SUPER), 1), None);
502        }
503        // Release events ignored.
504        let mut release = key(KeyCode::Left, Modifiers::NONE);
505        release.kind = KeyEventKind::Release;
506        assert_eq!(key_to_pane_command(&release, 1), None);
507    }
508
509    #[test]
510    fn web_keymap_covers_the_vocabulary() {
511        let none = Modifiers::NONE;
512        let shift = Modifiers::SHIFT;
513        assert_eq!(
514            key_to_pane_command(&key(KeyCode::Left, none), 1),
515            Some(PaneCommand::FocusDirectional(PaneCardinalDirection::Left))
516        );
517        assert_eq!(
518            key_to_pane_command(&key(KeyCode::Down, shift), 1),
519            Some(PaneCommand::MovePane(PaneCardinalDirection::Down))
520        );
521        assert_eq!(
522            key_to_pane_command(&key(KeyCode::Home, none), 1),
523            Some(PaneCommand::FocusEdge(PaneCardinalDirection::Left))
524        );
525        assert_eq!(
526            key_to_pane_command(&key(KeyCode::Char('n'), none), 1),
527            Some(PaneCommand::FocusNext)
528        );
529        assert_eq!(
530            key_to_pane_command(&key(KeyCode::Char('='), none), 3),
531            Some(PaneCommand::ResizeStep {
532                direction: PaneResizeDirection::Increase,
533                units: 3
534            })
535        );
536        assert_eq!(
537            key_to_pane_command(&key(KeyCode::Char('s'), none), 1),
538            Some(PaneCommand::Split(SplitAxis::Horizontal))
539        );
540        assert_eq!(
541            key_to_pane_command(&key(KeyCode::Delete, none), 1),
542            Some(PaneCommand::Close)
543        );
544        assert_eq!(
545            key_to_pane_command(&key(KeyCode::Char(']'), none), 1),
546            Some(PaneCommand::SwapPane(PaneFocusOrdinal::Next))
547        );
548        assert_eq!(
549            key_to_pane_command(&key(KeyCode::Char('f'), none), 1),
550            Some(PaneCommand::Maximize)
551        );
552        assert_eq!(
553            key_to_pane_command(&key(KeyCode::Escape, none), 1),
554            Some(PaneCommand::Restore)
555        );
556    }
557
558    #[test]
559    fn roving_tabindex_has_exactly_one_zero() {
560        let tree = nested();
561        let nodes = pane_accessibility_tree(&tree, PaneFocusContext::active(pid(4)));
562        let zeros: Vec<_> = nodes
563            .iter()
564            .filter(|n| n.role == PaneAriaRole::Group && n.tabindex == 0)
565            .collect();
566        assert_eq!(zeros.len(), 1, "exactly one leaf is the roving Tab stop");
567        assert_eq!(zeros[0].pane_id, pid(4));
568        assert!(zeros[0].current);
569        // Every other leaf is -1.
570        for n in nodes
571            .iter()
572            .filter(|n| n.role == PaneAriaRole::Group && n.pane_id != pid(4))
573        {
574            assert_eq!(n.tabindex, -1);
575            assert!(!n.current);
576        }
577    }
578
579    #[test]
580    fn roving_tabindex_defaults_to_first_pane_when_unfocused() {
581        let tree = nested();
582        let nodes = pane_accessibility_tree(&tree, PaneFocusContext::default());
583        let active: Vec<_> = nodes.iter().filter(|n| n.tabindex == 0).collect();
584        assert_eq!(active.len(), 1);
585        // First leaf in focus order is left(2).
586        assert_eq!(active[0].pane_id, pid(2));
587    }
588
589    #[test]
590    fn separator_exposes_orientation_and_value() {
591        let tree = nested();
592        let nodes = pane_accessibility_tree(&tree, PaneFocusContext::active(pid(2)));
593        // Root split(1) is Horizontal (side-by-side) -> vertical divider, 1:1 -> 50.
594        let root = nodes.iter().find(|n| n.pane_id == pid(1)).unwrap();
595        assert_eq!(root.role, PaneAriaRole::Separator);
596        assert_eq!(root.orientation, Some(PaneAriaOrientation::Vertical));
597        assert_eq!(root.value_now, Some(50));
598        // Split(3) is Vertical (stacked) -> horizontal divider, 3:1 -> 75.
599        let inner = nodes.iter().find(|n| n.pane_id == pid(3)).unwrap();
600        assert_eq!(inner.orientation, Some(PaneAriaOrientation::Horizontal));
601        assert_eq!(inner.value_now, Some(75));
602    }
603
604    #[test]
605    fn controller_drives_pointer_free_web_workflow() {
606        fn run() -> (u64, Option<PaneId>, usize) {
607            let mut tree = nested();
608            let mut controller = PaneWebKeyboardController::new(Some(pid(2)));
609            let area = Rect::new(0, 0, 80, 24);
610            let solve = |t: &PaneTree| t.solve_layout(area).expect("solves");
611
612            // Right arrow -> focus the top-right pane.
613            let layout = solve(&tree);
614            controller.handle_key(&key(KeyCode::Right, Modifiers::NONE), &mut tree, &layout);
615            assert_eq!(controller.active(), Some(pid(4)));
616
617            // 's' -> split the active pane (structural).
618            let layout = solve(&tree);
619            let before = tree.nodes().count();
620            controller.handle_key(
621                &key(KeyCode::Char('s'), Modifiers::NONE),
622                &mut tree,
623                &layout,
624            );
625            assert!(tree.nodes().count() > before);
626
627            // 'f' then Escape -> maximize then restore.
628            let layout = solve(&tree);
629            controller.handle_key(
630                &key(KeyCode::Char('f'), Modifiers::NONE),
631                &mut tree,
632                &layout,
633            );
634            assert!(controller.maximized().is_some());
635            let layout = solve(&tree);
636            controller.handle_key(&key(KeyCode::Escape, Modifiers::NONE), &mut tree, &layout);
637            assert_eq!(controller.maximized(), None);
638
639            // Accessibility tree stays consistent: one roving Tab stop.
640            let aria = controller.accessibility_tree(&tree);
641            let zeros = aria.iter().filter(|n| n.tabindex == 0).count();
642            (tree.state_hash(), controller.active(), zeros)
643        }
644
645        let first = run();
646        let second = run();
647        assert_eq!(first, second, "web keyboard workflow must be deterministic");
648        assert_eq!(first.2, 1, "roving invariant holds after the workflow");
649    }
650
651    #[test]
652    fn unbound_key_does_not_touch_state() {
653        let mut tree = nested();
654        let mut controller = PaneWebKeyboardController::new(Some(pid(2)));
655        let layout = tree.solve_layout(Rect::new(0, 0, 80, 24)).expect("solves");
656        let before = tree.state_hash();
657        let out = controller.handle_key(
658            &key(KeyCode::Char('q'), Modifiers::NONE),
659            &mut tree,
660            &layout,
661        );
662        assert_eq!(out, PaneWebKeyOutcome::Unbound);
663        assert_eq!(tree.state_hash(), before);
664        assert_eq!(controller.active(), Some(pid(2)));
665    }
666
667    #[test]
668    fn controller_surfaces_announcements_and_coalesces_resize() {
669        let mut tree = nested();
670        let mut controller = PaneWebKeyboardController::new(Some(pid(2)));
671        let area = Rect::new(0, 0, 80, 24);
672
673        // Right arrow -> focus announcement for the live region.
674        let layout = tree.solve_layout(area).expect("solves");
675        controller.handle_key(&key(KeyCode::Right, Modifiers::NONE), &mut tree, &layout);
676        assert_eq!(
677            controller
678                .take_announcement()
679                .expect("focus announces")
680                .text,
681            "Focused pane right_top"
682        );
683
684        // A burst of resize key presses coalesces to a single announcement
685        // reflecting the final value (non-spammy live region).
686        controller.set_active(Some(pid(2)));
687        for _ in 0..3 {
688            let layout = tree.solve_layout(area).expect("solves");
689            controller.handle_key(
690                &key(KeyCode::Char('='), Modifiers::NONE),
691                &mut tree,
692                &layout,
693            );
694        }
695        let spoken = controller.take_announcement().expect("resize announces");
696        assert_eq!(
697            spoken.category,
698            ftui_layout::PaneAnnouncementCategory::Resize
699        );
700        // Only one announcement for the burst.
701        assert!(controller.take_announcement().is_none());
702    }
703
704    #[test]
705    fn web_preferences_do_not_alter_keyboard_semantics() {
706        use ftui_layout::PaneAccessibilityPreferences;
707        // Same browser key stream, no-modes vs all-modes controller: focus /
708        // maximize / topology / announcements must be identical (a11y modes are
709        // presentation-only, mirroring the terminal-host invariant).
710        fn drive(
711            prefs: PaneAccessibilityPreferences,
712        ) -> (u64, Option<PaneId>, Option<PaneId>, Vec<String>) {
713            let mut tree = nested();
714            let mut controller =
715                PaneWebKeyboardController::new(Some(pid(2))).with_preferences(prefs);
716            let area = Rect::new(0, 0, 80, 24);
717            let seq = [
718                key(KeyCode::Char('n'), Modifiers::NONE), // focus next
719                key(KeyCode::Down, Modifiers::NONE),      // focus directional
720                key(KeyCode::Char('s'), Modifiers::NONE), // split
721                key(KeyCode::Char('f'), Modifiers::NONE), // maximize
722                key(KeyCode::Escape, Modifiers::NONE),    // restore
723            ];
724            let mut announcements = Vec::new();
725            for k in seq {
726                let layout = tree.solve_layout(area).expect("solves");
727                controller.handle_key(&k, &mut tree, &layout);
728                if let Some(a) = controller.take_announcement() {
729                    announcements.push(a.text);
730                }
731            }
732            (
733                tree.state_hash(),
734                controller.active(),
735                controller.maximized(),
736                announcements,
737            )
738        }
739        let plain = drive(PaneAccessibilityPreferences::none());
740        let full = drive(PaneAccessibilityPreferences::all());
741        assert_eq!(plain, full, "a11y modes must not change pane semantics");
742    }
743
744    #[test]
745    fn accessibility_dataset_reflects_preferences() {
746        use ftui_layout::PaneAccessibilityPreferences;
747        // Default: every data-attribute is false.
748        let plain = PaneWebKeyboardController::new(None);
749        assert_eq!(
750            plain.accessibility_dataset(),
751            [
752                ("data-pane-reduced-motion", false),
753                ("data-pane-high-contrast", false),
754                ("data-pane-large-target", false),
755            ]
756        );
757        // Each preference flips exactly its own attribute.
758        let prefs = PaneAccessibilityPreferences::none()
759            .with_high_contrast(true)
760            .with_large_target(true);
761        let controller = PaneWebKeyboardController::new(None).with_preferences(prefs);
762        let ds = controller.accessibility_dataset();
763        assert_eq!(ds[0], ("data-pane-reduced-motion", false));
764        assert_eq!(ds[1], ("data-pane-high-contrast", true));
765        assert_eq!(ds[2], ("data-pane-large-target", true));
766    }
767
768    #[test]
769    fn web_affordance_motion_honors_reduced_motion() {
770        use ftui_layout::PaneAccessibilityPreferences;
771        let reduced = PaneWebKeyboardController::new(None)
772            .with_preferences(PaneAccessibilityPreferences::none().with_reduced_motion(true));
773        assert!(reduced.affordance_motion().reduced_motion);
774        assert!(
775            !PaneWebKeyboardController::new(None)
776                .affordance_motion()
777                .reduced_motion
778        );
779    }
780}