Skip to main content

ftui_layout/
pane_command.rs

1//! Canonical keyboard command model and focus-graph semantics for panes
2//! (bd-21pbi.1).
3//!
4//! This module defines a **host-agnostic** keyboard interaction contract for
5//! the pane workspace: a normative command vocabulary ([`PaneCommand`]), a
6//! deterministic focus graph over the split tree (in-order traversal and
7//! spatial/directional navigation with explicit tie-breaks), a pure command
8//! resolver ([`resolve`]) that maps each command to a focus change, a list of
9//! structural [`PaneOperation`]s, or a transient view-state change, plus
10//! explicit repeat/acceleration ([`PaneCommandAcceleration`]) and keymap
11//! precedence ([`PaneKeymapPrecedence`]) policies.
12//!
13//! Why a command layer? Pointer drag-resize already has a first-class semantic
14//! pipeline (`PaneSemanticInputEvent` -> `PaneDragResizeMachine` ->
15//! `PaneTree::operations_for_transition`). Keyboard interaction needs an
16//! equivalent, but its vocabulary is broader than resize (focus navigation,
17//! split/close/move/swap, maximize/restore) and must NOT be expressed as
18//! host-specific key events. [`PaneCommand`] is that vocabulary: terminal
19//! (bd-21pbi.2) and web (bd-21pbi.3) hosts translate raw key events into
20//! `PaneCommand`s, and [`resolve`] turns each command into the same outcome on
21//! every host. This is the executable form of the normative spec in
22//! `docs/spec/pane-keyboard-interaction.md`.
23//!
24//! Determinism guarantee: for a fixed split-tree topology, layout, and focus
25//! context, [`resolve`] is a pure function. Equivalent command streams
26//! therefore yield identical pane state (topology hash + active pane) across
27//! hosts — the cross-host parity property the pane validation epic requires.
28
29use crate::Rect;
30use crate::pane::{
31    PANE_SNAP_DEFAULT_HYSTERESIS_BPS, PaneAffordanceMotion, PaneDragResizeMachine, PaneId,
32    PaneLayout, PaneLeaf, PaneNodeKind, PaneOperation, PanePlacement, PanePressureSnapProfile,
33    PaneResizeDirection, PaneResizeTarget, PaneSemanticInputEvent, PaneSemanticInputEventKind,
34    PaneSplitRatio, PaneTree, SplitAxis,
35};
36
37/// Neutral snap profile used when lowering keyboard resize commands through the
38/// shared drag/resize machine. Keyboard nudges apply a fixed basis-point step
39/// and are independent of snap pressure, so any valid profile yields identical
40/// operations; this constant keeps the lowering deterministic.
41const KEYBOARD_NEUTRAL_PRESSURE: PanePressureSnapProfile = PanePressureSnapProfile {
42    strength_bps: 5_000,
43    hysteresis_bps: PANE_SNAP_DEFAULT_HYSTERESIS_BPS,
44};
45
46/// A cardinal direction for spatial focus and pane movement.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum PaneCardinalDirection {
49    /// Toward smaller x.
50    Left,
51    /// Toward larger x.
52    Right,
53    /// Toward smaller y.
54    Up,
55    /// Toward larger y.
56    Down,
57}
58
59impl PaneCardinalDirection {
60    /// The split axis a move/dock in this direction creates.
61    #[must_use]
62    pub const fn axis(self) -> SplitAxis {
63        match self {
64            Self::Left | Self::Right => SplitAxis::Horizontal,
65            Self::Up | Self::Down => SplitAxis::Vertical,
66        }
67    }
68
69    /// Placement of an incoming (moved) pane relative to a dock target in this
70    /// direction. Left/Up place the incoming pane first; Right/Down place it
71    /// second.
72    #[must_use]
73    pub const fn incoming_placement(self) -> PanePlacement {
74        match self {
75            Self::Left | Self::Up => PanePlacement::IncomingFirst,
76            Self::Right | Self::Down => PanePlacement::ExistingFirst,
77        }
78    }
79}
80
81/// Cyclic ordinal over the deterministic focus order.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83pub enum PaneFocusOrdinal {
84    /// The next leaf in focus order (wraps to the first).
85    Next,
86    /// The previous leaf in focus order (wraps to the last).
87    Previous,
88}
89
90/// The canonical, host-agnostic pane keyboard command vocabulary.
91///
92/// Hosts translate raw key events into these commands; [`resolve`] maps each to
93/// a deterministic outcome. Resize unit counts are supplied by the caller
94/// (computed via [`PaneCommandAcceleration`]) so the command itself stays a
95/// pure intent.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum PaneCommand {
98    /// Move focus to the next leaf in deterministic focus order (cyclic).
99    FocusNext,
100    /// Move focus to the previous leaf in deterministic focus order (cyclic).
101    FocusPrevious,
102    /// Move focus to the nearest leaf in a direction (spatial).
103    FocusDirectional(PaneCardinalDirection),
104    /// Move focus to the extreme leaf in a direction (jump to edge).
105    FocusEdge(PaneCardinalDirection),
106    /// Grow (`Increase`) or shrink (`Decrease`) the active pane by `units`
107    /// snap steps, resizing its enclosing split.
108    ResizeStep {
109        /// `Increase` grows the active pane; `Decrease` shrinks it.
110        direction: PaneResizeDirection,
111        /// Number of snap steps (one step = `PANE_SNAP_DEFAULT_STEP_BPS`).
112        units: u16,
113    },
114    /// Split the active leaf along `axis`, adding a new sibling leaf.
115    Split(SplitAxis),
116    /// Close the active leaf, promoting its sibling.
117    Close,
118    /// Move the active pane to dock against the nearest pane in a direction.
119    MovePane(PaneCardinalDirection),
120    /// Swap the active pane with its cyclic neighbour.
121    SwapPane(PaneFocusOrdinal),
122    /// Maximize the active pane (transient view state; no topology change).
123    Maximize,
124    /// Restore from a maximized state (transient view state).
125    Restore,
126}
127
128/// Host-owned focus context consumed by [`resolve`]. This is plain input data,
129/// not competing persistent state: hosts store the active/maximized pane
130/// wherever they like (e.g. `WorkspaceSnapshot::active_pane_id`) and pass a
131/// snapshot here.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
133pub struct PaneFocusContext {
134    /// The currently focused leaf, if any.
135    pub active: Option<PaneId>,
136    /// The currently maximized leaf, if any.
137    pub maximized: Option<PaneId>,
138}
139
140impl PaneFocusContext {
141    /// Construct a focus context with the given active leaf and no maximized
142    /// pane.
143    #[must_use]
144    pub const fn active(active: PaneId) -> Self {
145        Self {
146            active: Some(active),
147            maximized: None,
148        }
149    }
150}
151
152/// The concrete effect a resolved command produces. Focus changes and maximize
153/// state are transient (no tree mutation); structural changes are a list of
154/// [`PaneOperation`]s the host applies via `PaneTree::apply_operation`.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum PaneCommandEffect {
157    /// Focus moved from `previous` to `active` with no topology change.
158    Focus {
159        /// The previously focused leaf, if any.
160        previous: Option<PaneId>,
161        /// The newly focused leaf.
162        active: PaneId,
163    },
164    /// Apply these structural operations in order.
165    Structural(Vec<PaneOperation>),
166    /// Enter the maximized view state for `target`.
167    Maximize {
168        /// The leaf to maximize.
169        target: PaneId,
170    },
171    /// Leave the maximized view state previously held by `previous`.
172    Restore {
173        /// The leaf that was maximized.
174        previous: PaneId,
175    },
176    /// The command had no effect; see the reason.
177    Noop(PaneCommandNoopReason),
178}
179
180/// Why a command resolved to a no-op.
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum PaneCommandNoopReason {
183    /// No active pane was set in the focus context.
184    NoActivePane,
185    /// The active pane id was not a leaf in the tree.
186    ActiveNotLeaf,
187    /// Only one pane exists; navigation/structural change is meaningless.
188    OnlyOnePane,
189    /// No pane exists in the requested direction.
190    NoTargetInDirection,
191    /// The active pane is the root and cannot be closed.
192    RootCannotClose,
193    /// The active leaf has no enclosing split to resize.
194    NoEnclosingSplit,
195    /// A pane is already maximized (or the active one is).
196    AlreadyMaximized,
197    /// No pane is currently maximized.
198    NotMaximized,
199}
200
201/// The full result of resolving one [`PaneCommand`]: the effect plus the
202/// resulting focus context the host should adopt.
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct PaneCommandResolution {
205    /// What the command does.
206    pub effect: PaneCommandEffect,
207    /// The active pane after the command (unchanged on no-op).
208    pub next_active: Option<PaneId>,
209    /// The maximized pane after the command (unchanged on no-op).
210    pub next_maximized: Option<PaneId>,
211}
212
213impl PaneCommandResolution {
214    fn noop(reason: PaneCommandNoopReason, ctx: PaneFocusContext) -> Self {
215        Self {
216            effect: PaneCommandEffect::Noop(reason),
217            next_active: ctx.active,
218            next_maximized: ctx.maximized,
219        }
220    }
221
222    /// True when the command produced a real effect (not a no-op).
223    #[must_use]
224    pub const fn is_effective(&self) -> bool {
225        !matches!(self.effect, PaneCommandEffect::Noop(_))
226    }
227}
228
229/// Repeat-key acceleration policy for resize stepping. Explicit and testable:
230/// the first `accelerate_after_repeats` presses use `base_units`; sustained
231/// repeats escalate to `accelerated_units`.
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub struct PaneCommandAcceleration {
234    /// Snap steps applied on a non-repeated (or early-repeat) press.
235    pub base_units: u16,
236    /// Snap steps applied once the key has repeated enough.
237    pub accelerated_units: u16,
238    /// Repeat count at/after which acceleration engages.
239    pub accelerate_after_repeats: u16,
240}
241
242impl Default for PaneCommandAcceleration {
243    fn default() -> Self {
244        Self {
245            base_units: 1,
246            accelerated_units: 5,
247            accelerate_after_repeats: 3,
248        }
249    }
250}
251
252impl PaneCommandAcceleration {
253    /// Snap steps for a key held with the given repeat count (0 = first press).
254    #[must_use]
255    pub const fn units_for(&self, repeat_count: u16) -> u16 {
256        if repeat_count >= self.accelerate_after_repeats {
257            self.accelerated_units
258        } else {
259            self.base_units
260        }
261    }
262}
263
264/// Conflict precedence between application-level keymaps and the pane manager.
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
266pub enum PaneKeymapPrecedence {
267    /// The pane manager wins conflicts while it holds focus (default). Apps
268    /// must opt specific keys out via [`PaneKeymapPrecedence::ApplicationFirst`]
269    /// or by not binding them to pane commands.
270    #[default]
271    PaneManagerFirst,
272    /// The application wins conflicts (e.g. for globally reserved shortcuts).
273    ApplicationFirst,
274}
275
276/// Who owns a key given its bindings and the active precedence policy.
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum PaneKeymapOwner {
279    /// The application handles the key.
280    Application,
281    /// The pane manager handles the key.
282    PaneManager,
283    /// Neither binds the key.
284    Unbound,
285}
286
287impl PaneKeymapPrecedence {
288    /// Resolve which layer owns a key given whether each layer binds it.
289    #[must_use]
290    pub const fn resolve(self, app_bound: bool, pane_bound: bool) -> PaneKeymapOwner {
291        match (app_bound, pane_bound) {
292            (false, false) => PaneKeymapOwner::Unbound,
293            (true, false) => PaneKeymapOwner::Application,
294            (false, true) => PaneKeymapOwner::PaneManager,
295            (true, true) => match self {
296                Self::PaneManagerFirst => PaneKeymapOwner::PaneManager,
297                Self::ApplicationFirst => PaneKeymapOwner::Application,
298            },
299        }
300    }
301}
302
303/// Host-agnostic accessibility preferences for pane interaction (bd-21pbi.5).
304///
305/// These three adaptive modes are applied uniformly across hosts so a
306/// pointer-free, low-vision, or touch user gets the same behavior in the
307/// terminal and in the browser:
308///
309/// - **Reduced motion** collapses affordance micro-animations to instant steps
310///   (see [`PaneAffordanceMotion`]) — the state change is preserved, the motion
311///   is not.
312/// - **High contrast** lifts splitter / focus-ring affordance colors to WCAG
313///   AAA (applied by the host theme; see `ftui_style::PaneAffordanceTheme`,
314///   which takes this flag).
315/// - **Large target** enlarges splitter handles / hit regions for precision and
316///   touch ergonomics (see [`enlarge_target`](Self::enlarge_target)).
317///
318/// Crucially, none of these modes changes the keyboard command vocabulary, the
319/// focus graph, or announcements — pane *semantics* are mode-invariant. They are
320/// not inputs to [`resolve`] at all; they only affect presentation (motion,
321/// color, size). This is what keeps keyboard/focus behavior deterministic and
322/// identical regardless of the active accessibility modes.
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
324pub struct PaneAccessibilityPreferences {
325    /// Collapse affordance micro-animations to instant steps.
326    pub reduced_motion: bool,
327    /// Use high-contrast (WCAG AAA) affordance colors.
328    pub high_contrast: bool,
329    /// Enlarge splitter handles / hit regions.
330    pub large_target: bool,
331}
332
333impl PaneAccessibilityPreferences {
334    /// No accessibility modes enabled (equivalent to [`Default`]).
335    #[must_use]
336    pub const fn none() -> Self {
337        Self {
338            reduced_motion: false,
339            high_contrast: false,
340            large_target: false,
341        }
342    }
343
344    /// All accessibility modes enabled.
345    #[must_use]
346    pub const fn all() -> Self {
347        Self {
348            reduced_motion: true,
349            high_contrast: true,
350            large_target: true,
351        }
352    }
353
354    /// Set the reduced-motion preference.
355    #[must_use]
356    pub const fn with_reduced_motion(mut self, on: bool) -> Self {
357        self.reduced_motion = on;
358        self
359    }
360
361    /// Set the high-contrast preference.
362    #[must_use]
363    pub const fn with_high_contrast(mut self, on: bool) -> Self {
364        self.high_contrast = on;
365        self
366    }
367
368    /// Set the large-target preference.
369    #[must_use]
370    pub const fn with_large_target(mut self, on: bool) -> Self {
371        self.large_target = on;
372        self
373    }
374
375    /// Whether any adaptive mode is active.
376    #[must_use]
377    pub const fn any(self) -> bool {
378        self.reduced_motion || self.high_contrast || self.large_target
379    }
380
381    /// The affordance micro-animation policy implied by these preferences:
382    /// the default timing, with motion stepped when reduced-motion is set.
383    #[must_use]
384    pub fn affordance_motion(self) -> PaneAffordanceMotion {
385        PaneAffordanceMotion::default().with_reduced_motion(self.reduced_motion)
386    }
387
388    /// The minimum interactive target size (in cells) for a `base` size.
389    ///
390    /// In large-target mode a base of `n` cells grows to ~150% (rounded up),
391    /// with at least a +1-cell bump so even a 1-cell rail becomes easier to hit;
392    /// otherwise `base` is returned unchanged. Monotonic non-decreasing in
393    /// `base`, and never smaller than `base`.
394    #[must_use]
395    pub fn enlarge_target(self, base: u16) -> u16 {
396        if !self.large_target {
397            return base;
398        }
399        let scaled = (u32::from(base) * 3).div_ceil(2) as u16;
400        scaled.max(base.saturating_add(1))
401    }
402}
403
404// --------------------------------------------------------------------------
405// Focus graph
406// --------------------------------------------------------------------------
407
408/// Deterministic focus order: leaves in topological depth-first order
409/// (`first` child before `second` at every split). This is the canonical "tab
410/// order" and is independent of solved geometry, so it is stable for any
411/// layout area.
412#[must_use]
413pub fn focus_order(tree: &PaneTree) -> Vec<PaneId> {
414    let mut out = Vec::new();
415    let mut stack = vec![tree.root()];
416    while let Some(id) = stack.pop() {
417        let Some(record) = tree.node(id) else {
418            continue;
419        };
420        match &record.kind {
421            PaneNodeKind::Leaf(_) => out.push(id),
422            PaneNodeKind::Split(split) => {
423                // Push second then first so first is visited first (in-order).
424                stack.push(split.second);
425                stack.push(split.first);
426            }
427        }
428    }
429    out
430}
431
432/// The cyclic neighbour of `active` in [`focus_order`]. Returns `None` when
433/// `active` is absent from the order or the tree has fewer than two leaves.
434#[must_use]
435pub fn focus_cyclic(tree: &PaneTree, active: PaneId, ordinal: PaneFocusOrdinal) -> Option<PaneId> {
436    let order = focus_order(tree);
437    if order.len() < 2 {
438        return None;
439    }
440    let index = order.iter().position(|&id| id == active)?;
441    let len = order.len();
442    let next = match ordinal {
443        PaneFocusOrdinal::Next => (index + 1) % len,
444        PaneFocusOrdinal::Previous => (index + len - 1) % len,
445    };
446    Some(order[next])
447}
448
449fn center(rect: Rect) -> (i32, i32) {
450    (
451        i32::from(rect.x) + i32::from(rect.width) / 2,
452        i32::from(rect.y) + i32::from(rect.height) / 2,
453    )
454}
455
456/// Overlap length of two half-open intervals `[a0, a1)` and `[b0, b1)`.
457fn overlap_1d(a0: u16, a1: u16, b0: u16, b1: u16) -> i32 {
458    let lo = a0.max(b0);
459    let hi = a1.min(b1);
460    i32::from(hi.saturating_sub(lo))
461}
462
463/// The nearest leaf to `active` in a cardinal direction, using solved geometry.
464///
465/// Tie-break order (all deterministic): smallest primary-axis center distance,
466/// then largest perpendicular overlap, then earliest [`focus_order`] index.
467/// Returns `None` when no leaf lies in the direction.
468#[must_use]
469pub fn focus_directional(
470    tree: &PaneTree,
471    layout: &PaneLayout,
472    active: PaneId,
473    direction: PaneCardinalDirection,
474) -> Option<PaneId> {
475    let order = focus_order(tree);
476    let active_rect = layout.rect(active)?;
477    let (acx, acy) = center(active_rect);
478
479    let mut best: Option<((i32, i32, usize), PaneId)> = None;
480    for (index, &leaf) in order.iter().enumerate() {
481        if leaf == active {
482            continue;
483        }
484        let Some(rect) = layout.rect(leaf) else {
485            continue;
486        };
487        let (cx, cy) = center(rect);
488        // Pane layouts are guillotine splits (axis-aligned tilings), so the
489        // correct "in direction" test is by EDGE, not center: a candidate must
490        // lie wholly on the requested side of the active pane. This excludes
491        // diagonal neighbours that merely have a more extreme center.
492        let in_direction = match direction {
493            PaneCardinalDirection::Left => rect.right() <= active_rect.left(),
494            PaneCardinalDirection::Right => rect.left() >= active_rect.right(),
495            PaneCardinalDirection::Up => rect.bottom() <= active_rect.top(),
496            PaneCardinalDirection::Down => rect.top() >= active_rect.bottom(),
497        };
498        if !in_direction {
499            continue;
500        }
501        let primary = match direction {
502            PaneCardinalDirection::Left | PaneCardinalDirection::Right => (cx - acx).abs(),
503            PaneCardinalDirection::Up | PaneCardinalDirection::Down => (cy - acy).abs(),
504        };
505        let overlap = match direction {
506            PaneCardinalDirection::Left | PaneCardinalDirection::Right => overlap_1d(
507                active_rect.top(),
508                active_rect.bottom(),
509                rect.top(),
510                rect.bottom(),
511            ),
512            PaneCardinalDirection::Up | PaneCardinalDirection::Down => overlap_1d(
513                active_rect.left(),
514                active_rect.right(),
515                rect.left(),
516                rect.right(),
517            ),
518        };
519        // Minimize primary distance, then maximize overlap (store negated),
520        // then minimize focus-order index.
521        let key = (primary, -overlap, index);
522        if best.as_ref().is_none_or(|(best_key, _)| key < *best_key) {
523            best = Some((key, leaf));
524        }
525    }
526    best.map(|(_, leaf)| leaf)
527}
528
529/// The extreme leaf in a cardinal direction (jump to edge). Returns the active
530/// leaf's own id when it is already the extreme, so callers can treat that as a
531/// no-op.
532#[must_use]
533pub fn focus_edge(
534    tree: &PaneTree,
535    layout: &PaneLayout,
536    direction: PaneCardinalDirection,
537) -> Option<PaneId> {
538    let order = focus_order(tree);
539    let mut best: Option<((i32, i32, usize), PaneId)> = None;
540    for (index, &leaf) in order.iter().enumerate() {
541        let Some(rect) = layout.rect(leaf) else {
542            continue;
543        };
544        let (cx, cy) = center(rect);
545        // Sort key: the primary coordinate oriented so the extreme is the
546        // minimum, then a stable secondary coordinate, then focus-order index.
547        let key = match direction {
548            PaneCardinalDirection::Left => (cx, cy, index),
549            PaneCardinalDirection::Right => (-cx, cy, index),
550            PaneCardinalDirection::Up => (cy, cx, index),
551            PaneCardinalDirection::Down => (-cy, cx, index),
552        };
553        if best.as_ref().is_none_or(|(best_key, _)| key < *best_key) {
554            best = Some((key, leaf));
555        }
556    }
557    best.map(|(_, leaf)| leaf)
558}
559
560// --------------------------------------------------------------------------
561// Resolution
562// --------------------------------------------------------------------------
563
564/// Resolve a single [`PaneCommand`] against the current tree, solved layout,
565/// and focus context. Pure and deterministic: identical inputs always yield an
566/// identical [`PaneCommandResolution`], which is what makes equivalent command
567/// streams produce identical pane state across hosts.
568#[must_use]
569pub fn resolve(
570    tree: &PaneTree,
571    layout: &PaneLayout,
572    ctx: PaneFocusContext,
573    command: PaneCommand,
574) -> PaneCommandResolution {
575    match command {
576        PaneCommand::FocusNext => resolve_cyclic(tree, ctx, PaneFocusOrdinal::Next),
577        PaneCommand::FocusPrevious => resolve_cyclic(tree, ctx, PaneFocusOrdinal::Previous),
578        PaneCommand::FocusDirectional(direction) => {
579            resolve_focus(tree, ctx, focus_dir(tree, layout, ctx, direction))
580        }
581        PaneCommand::FocusEdge(direction) => {
582            resolve_focus(tree, ctx, focus_edge(tree, layout, direction))
583        }
584        PaneCommand::ResizeStep { direction, units } => {
585            resolve_resize(tree, layout, ctx, direction, units)
586        }
587        PaneCommand::Split(axis) => resolve_split(tree, ctx, axis),
588        PaneCommand::Close => resolve_close(tree, ctx),
589        PaneCommand::MovePane(direction) => resolve_move(tree, layout, ctx, direction),
590        PaneCommand::SwapPane(ordinal) => resolve_swap(tree, ctx, ordinal),
591        PaneCommand::Maximize => resolve_maximize(tree, ctx),
592        PaneCommand::Restore => resolve_restore(ctx),
593    }
594}
595
596/// The active leaf id, if present and actually a leaf.
597fn active_leaf(tree: &PaneTree, ctx: PaneFocusContext) -> Result<PaneId, PaneCommandNoopReason> {
598    let active = ctx.active.ok_or(PaneCommandNoopReason::NoActivePane)?;
599    match tree.node(active).map(|record| &record.kind) {
600        Some(PaneNodeKind::Leaf(_)) => Ok(active),
601        _ => Err(PaneCommandNoopReason::ActiveNotLeaf),
602    }
603}
604
605fn focus_dir(
606    tree: &PaneTree,
607    layout: &PaneLayout,
608    ctx: PaneFocusContext,
609    direction: PaneCardinalDirection,
610) -> Option<PaneId> {
611    let active = ctx.active?;
612    focus_directional(tree, layout, active, direction)
613}
614
615fn resolve_cyclic(
616    tree: &PaneTree,
617    ctx: PaneFocusContext,
618    ordinal: PaneFocusOrdinal,
619) -> PaneCommandResolution {
620    let active = match active_leaf(tree, ctx) {
621        Ok(active) => active,
622        Err(reason) => return PaneCommandResolution::noop(reason, ctx),
623    };
624    match focus_cyclic(tree, active, ordinal) {
625        Some(next) => focus_changed(ctx, next),
626        None => PaneCommandResolution::noop(PaneCommandNoopReason::OnlyOnePane, ctx),
627    }
628}
629
630fn resolve_focus(
631    tree: &PaneTree,
632    ctx: PaneFocusContext,
633    target: Option<PaneId>,
634) -> PaneCommandResolution {
635    if active_leaf(tree, ctx).is_err() && ctx.active.is_some() {
636        return PaneCommandResolution::noop(PaneCommandNoopReason::ActiveNotLeaf, ctx);
637    }
638    match target {
639        Some(next) if Some(next) != ctx.active => focus_changed(ctx, next),
640        _ => PaneCommandResolution::noop(PaneCommandNoopReason::NoTargetInDirection, ctx),
641    }
642}
643
644fn focus_changed(ctx: PaneFocusContext, next: PaneId) -> PaneCommandResolution {
645    PaneCommandResolution {
646        effect: PaneCommandEffect::Focus {
647            previous: ctx.active,
648            active: next,
649        },
650        next_active: Some(next),
651        next_maximized: ctx.maximized,
652    }
653}
654
655fn structural(
656    ctx: PaneFocusContext,
657    operations: Vec<PaneOperation>,
658    next_active: Option<PaneId>,
659) -> PaneCommandResolution {
660    PaneCommandResolution {
661        effect: PaneCommandEffect::Structural(operations),
662        next_active,
663        next_maximized: ctx.maximized,
664    }
665}
666
667/// Enclosing split of a leaf: its parent, when that parent is a split.
668fn enclosing_split(tree: &PaneTree, leaf: PaneId) -> Option<PaneId> {
669    let parent = tree.node(leaf)?.parent?;
670    match &tree.node(parent)?.kind {
671        PaneNodeKind::Split(_) => Some(parent),
672        PaneNodeKind::Leaf(_) => None,
673    }
674}
675
676fn resolve_resize(
677    tree: &PaneTree,
678    layout: &PaneLayout,
679    ctx: PaneFocusContext,
680    direction: PaneResizeDirection,
681    units: u16,
682) -> PaneCommandResolution {
683    let active = match active_leaf(tree, ctx) {
684        Ok(active) => active,
685        Err(reason) => return PaneCommandResolution::noop(reason, ctx),
686    };
687    let Some(split_id) = enclosing_split(tree, active) else {
688        return PaneCommandResolution::noop(PaneCommandNoopReason::NoEnclosingSplit, ctx);
689    };
690    let Some(PaneNodeKind::Split(split)) = tree.node(split_id).map(|record| &record.kind) else {
691        return PaneCommandResolution::noop(PaneCommandNoopReason::NoEnclosingSplit, ctx);
692    };
693    // The command direction is active-pane-relative (grow/shrink the active
694    // pane). Translate to the split's first-share direction: growing the
695    // first child increases first-share; growing the second decreases it.
696    let split_direction = if split.first == active {
697        direction
698    } else {
699        flip_direction(direction)
700    };
701    let target = PaneResizeTarget {
702        split_id,
703        axis: split.axis,
704    };
705    // Lower through the shared resize machine so keyboard resize uses the exact
706    // same nudge math as pointer/wheel resize (no duplicated logic).
707    let event = PaneSemanticInputEvent::new(
708        1,
709        PaneSemanticInputEventKind::KeyboardResize {
710            target,
711            direction: split_direction,
712            units,
713        },
714    );
715    let mut machine = PaneDragResizeMachine::default();
716    let Ok(transition) = machine.apply_event(&event) else {
717        return PaneCommandResolution::noop(PaneCommandNoopReason::NoEnclosingSplit, ctx);
718    };
719    let operations = tree.operations_for_transition(&transition, layout, KEYBOARD_NEUTRAL_PRESSURE);
720    structural(ctx, operations, ctx.active)
721}
722
723const fn flip_direction(direction: PaneResizeDirection) -> PaneResizeDirection {
724    match direction {
725        PaneResizeDirection::Increase => PaneResizeDirection::Decrease,
726        PaneResizeDirection::Decrease => PaneResizeDirection::Increase,
727    }
728}
729
730fn resolve_split(tree: &PaneTree, ctx: PaneFocusContext, axis: SplitAxis) -> PaneCommandResolution {
731    let active = match active_leaf(tree, ctx) {
732        Ok(active) => active,
733        Err(reason) => return PaneCommandResolution::noop(reason, ctx),
734    };
735    let surface_key = leaf_surface_key(tree, active).unwrap_or_default();
736    let new_leaf = PaneLeaf::new(format!("{surface_key}#split"));
737    let ratio = PaneSplitRatio::default();
738    let op = PaneOperation::SplitLeaf {
739        target: active,
740        axis,
741        ratio,
742        placement: PanePlacement::ExistingFirst,
743        new_leaf,
744    };
745    // The new leaf id is allocated by `apply_operation`, so focus stays on the
746    // existing pane; hosts may refocus the new leaf after applying.
747    structural(ctx, vec![op], ctx.active)
748}
749
750fn leaf_surface_key(tree: &PaneTree, leaf: PaneId) -> Option<String> {
751    match &tree.node(leaf)?.kind {
752        PaneNodeKind::Leaf(record) => Some(record.surface_key.clone()),
753        PaneNodeKind::Split(_) => None,
754    }
755}
756
757fn resolve_close(tree: &PaneTree, ctx: PaneFocusContext) -> PaneCommandResolution {
758    let active = match active_leaf(tree, ctx) {
759        Ok(active) => active,
760        Err(reason) => return PaneCommandResolution::noop(reason, ctx),
761    };
762    if active == tree.root() {
763        return PaneCommandResolution::noop(PaneCommandNoopReason::RootCannotClose, ctx);
764    }
765    let order = focus_order(tree);
766    if order.len() < 2 {
767        return PaneCommandResolution::noop(PaneCommandNoopReason::OnlyOnePane, ctx);
768    }
769    // Focus the deterministic survivor: the next leaf in focus order, or the
770    // previous when the active pane was last.
771    let next_active = focus_cyclic(tree, active, PaneFocusOrdinal::Next)
772        .filter(|&id| id != active)
773        .or_else(|| focus_cyclic(tree, active, PaneFocusOrdinal::Previous))
774        .filter(|&id| id != active);
775    let mut resolution = structural(
776        ctx,
777        vec![PaneOperation::CloseNode { target: active }],
778        next_active,
779    );
780    // Closing the maximized pane must clear the maximize state — carrying it
781    // forward would leave a dangling id pointing at a removed node.
782    if ctx.maximized == Some(active) {
783        resolution.next_maximized = None;
784    }
785    resolution
786}
787
788fn resolve_move(
789    tree: &PaneTree,
790    layout: &PaneLayout,
791    ctx: PaneFocusContext,
792    direction: PaneCardinalDirection,
793) -> PaneCommandResolution {
794    let active = match active_leaf(tree, ctx) {
795        Ok(active) => active,
796        Err(reason) => return PaneCommandResolution::noop(reason, ctx),
797    };
798    let Some(target) = focus_directional(tree, layout, active, direction) else {
799        return PaneCommandResolution::noop(PaneCommandNoopReason::NoTargetInDirection, ctx);
800    };
801    let ratio = PaneSplitRatio::default();
802    let op = PaneOperation::MoveSubtree {
803        source: active,
804        target,
805        axis: direction.axis(),
806        ratio,
807        placement: direction.incoming_placement(),
808    };
809    structural(ctx, vec![op], ctx.active)
810}
811
812fn resolve_swap(
813    tree: &PaneTree,
814    ctx: PaneFocusContext,
815    ordinal: PaneFocusOrdinal,
816) -> PaneCommandResolution {
817    let active = match active_leaf(tree, ctx) {
818        Ok(active) => active,
819        Err(reason) => return PaneCommandResolution::noop(reason, ctx),
820    };
821    match focus_cyclic(tree, active, ordinal) {
822        Some(other) if other != active => structural(
823            ctx,
824            vec![PaneOperation::SwapNodes {
825                first: active,
826                second: other,
827            }],
828            ctx.active,
829        ),
830        _ => PaneCommandResolution::noop(PaneCommandNoopReason::OnlyOnePane, ctx),
831    }
832}
833
834fn resolve_maximize(tree: &PaneTree, ctx: PaneFocusContext) -> PaneCommandResolution {
835    let active = match active_leaf(tree, ctx) {
836        Ok(active) => active,
837        Err(reason) => return PaneCommandResolution::noop(reason, ctx),
838    };
839    if ctx.maximized == Some(active) {
840        return PaneCommandResolution::noop(PaneCommandNoopReason::AlreadyMaximized, ctx);
841    }
842    PaneCommandResolution {
843        effect: PaneCommandEffect::Maximize { target: active },
844        next_active: Some(active),
845        next_maximized: Some(active),
846    }
847}
848
849fn resolve_restore(ctx: PaneFocusContext) -> PaneCommandResolution {
850    match ctx.maximized {
851        Some(previous) => PaneCommandResolution {
852            effect: PaneCommandEffect::Restore { previous },
853            next_active: ctx.active,
854            next_maximized: None,
855        },
856        None => PaneCommandResolution::noop(PaneCommandNoopReason::NotMaximized, ctx),
857    }
858}
859
860// --------------------------------------------------------------------------
861// Accessible announcements (bd-21pbi.4)
862// --------------------------------------------------------------------------
863
864/// Category of a pane state-change announcement (for host channel routing and
865/// coalescing).
866#[derive(Debug, Clone, Copy, PartialEq, Eq)]
867pub enum PaneAnnouncementCategory {
868    /// Focus moved to a different pane.
869    Focus,
870    /// A pane was resized.
871    Resize,
872    /// A pane was split.
873    Split,
874    /// A pane was closed.
875    Close,
876    /// A pane was moved/docked.
877    Move,
878    /// Two panes were swapped.
879    Swap,
880    /// A pane was maximized.
881    Maximize,
882    /// The layout was restored from maximized.
883    Restore,
884}
885
886/// A concise, host-agnostic accessibility announcement for a pane state change.
887/// The web host renders `text` into an `aria-live` region; the terminal host
888/// surfaces it via a status line / log hook.
889#[derive(Debug, Clone, PartialEq, Eq)]
890pub struct PaneAnnouncement {
891    /// The human-readable announcement text.
892    pub text: String,
893    /// The announcement category.
894    pub category: PaneAnnouncementCategory,
895}
896
897impl PaneAnnouncement {
898    fn new(category: PaneAnnouncementCategory, text: impl Into<String>) -> Self {
899        Self {
900            category,
901            text: text.into(),
902        }
903    }
904}
905
906const fn cardinal_label(direction: PaneCardinalDirection) -> &'static str {
907    match direction {
908        PaneCardinalDirection::Left => "left",
909        PaneCardinalDirection::Right => "right",
910        PaneCardinalDirection::Up => "up",
911        PaneCardinalDirection::Down => "down",
912    }
913}
914
915const fn axis_label(axis: SplitAxis) -> &'static str {
916    match axis {
917        SplitAxis::Horizontal => "horizontal",
918        SplitAxis::Vertical => "vertical",
919    }
920}
921
922/// The active pane's own share of its enclosing split, as a percentage.
923fn active_pane_share_pct(tree: &PaneTree, active: PaneId) -> Option<u16> {
924    let split_id = enclosing_split(tree, active)?;
925    let PaneNodeKind::Split(split) = &tree.node(split_id)?.kind else {
926        return None;
927    };
928    // u64 arithmetic: `num + den` can overflow u32 (any nonzero pair is a
929    // valid PaneSplitRatio, e.g. u32::MAX:1), which would wrap into a
930    // division by zero in release builds.
931    let num = u64::from(split.ratio.numerator());
932    let den = u64::from(split.ratio.denominator());
933    let first_share = num * 100 / (num + den);
934    let share = if split.first == active {
935        first_share
936    } else {
937        100 - first_share
938    };
939    u16::try_from(share).ok()
940}
941
942/// Generate a concise accessibility announcement for a resolved command.
943///
944/// Returns `None` for no-ops (nothing changed). `tree` MUST be the post-apply
945/// state, so counts and ratios reflect the result.
946#[must_use]
947pub fn announce_command(
948    command: PaneCommand,
949    resolution: &PaneCommandResolution,
950    tree: &PaneTree,
951) -> Option<PaneAnnouncement> {
952    if matches!(resolution.effect, PaneCommandEffect::Noop(_)) {
953        return None;
954    }
955    let leaf_count = focus_order(tree).len();
956    match command {
957        PaneCommand::FocusNext
958        | PaneCommand::FocusPrevious
959        | PaneCommand::FocusDirectional(_)
960        | PaneCommand::FocusEdge(_) => {
961            let active = resolution.next_active?;
962            let label = leaf_surface_key(tree, active).unwrap_or_else(|| "pane".to_owned());
963            Some(PaneAnnouncement::new(
964                PaneAnnouncementCategory::Focus,
965                format!("Focused pane {label}"),
966            ))
967        }
968        PaneCommand::ResizeStep { .. } => {
969            let active = resolution.next_active?;
970            let pct = active_pane_share_pct(tree, active)?;
971            Some(PaneAnnouncement::new(
972                PaneAnnouncementCategory::Resize,
973                format!("Resized pane to {pct} percent"),
974            ))
975        }
976        PaneCommand::Split(axis) => Some(PaneAnnouncement::new(
977            PaneAnnouncementCategory::Split,
978            format!("Split pane {}, {leaf_count} panes", axis_label(axis)),
979        )),
980        PaneCommand::Close => Some(PaneAnnouncement::new(
981            PaneAnnouncementCategory::Close,
982            format!("Closed pane, {leaf_count} remaining"),
983        )),
984        PaneCommand::MovePane(dir) => Some(PaneAnnouncement::new(
985            PaneAnnouncementCategory::Move,
986            format!("Moved pane {}", cardinal_label(dir)),
987        )),
988        PaneCommand::SwapPane(_) => Some(PaneAnnouncement::new(
989            PaneAnnouncementCategory::Swap,
990            "Swapped pane",
991        )),
992        PaneCommand::Maximize => Some(PaneAnnouncement::new(
993            PaneAnnouncementCategory::Maximize,
994            "Maximized pane",
995        )),
996        PaneCommand::Restore => Some(PaneAnnouncement::new(
997            PaneAnnouncementCategory::Restore,
998            "Restored pane",
999        )),
1000    }
1001}
1002
1003/// Coalescing announcer that keeps host live regions / status lines bounded and
1004/// non-spammy: the latest offered announcement wins (so a burst of resize/repeat
1005/// announcements collapses to one), and consecutive identical text is
1006/// suppressed. Hosts call [`PaneAnnouncer::take`] once per render / live-region
1007/// update.
1008#[derive(Debug, Clone, Default)]
1009pub struct PaneAnnouncer {
1010    pending: Option<PaneAnnouncement>,
1011    last_spoken: Option<String>,
1012}
1013
1014impl PaneAnnouncer {
1015    /// Create an empty announcer.
1016    #[must_use]
1017    pub fn new() -> Self {
1018        Self::default()
1019    }
1020
1021    /// Offer an announcement; the most recent non-empty offer is retained, so
1022    /// rapid bursts coalesce to the final state.
1023    pub fn offer(&mut self, announcement: Option<PaneAnnouncement>) {
1024        if announcement.is_some() {
1025            self.pending = announcement;
1026        }
1027    }
1028
1029    /// Take the pending announcement to speak now, suppressing an exact repeat
1030    /// of the previously spoken text.
1031    pub fn take(&mut self) -> Option<PaneAnnouncement> {
1032        let pending = self.pending.take()?;
1033        if self.last_spoken.as_deref() == Some(pending.text.as_str()) {
1034            return None;
1035        }
1036        self.last_spoken = Some(pending.text.clone());
1037        Some(pending)
1038    }
1039
1040    /// Peek the pending announcement without consuming it.
1041    #[must_use]
1042    pub fn pending(&self) -> Option<&PaneAnnouncement> {
1043        self.pending.as_ref()
1044    }
1045
1046    /// The most recently spoken announcement text.
1047    #[must_use]
1048    pub fn last_spoken(&self) -> Option<&str> {
1049        self.last_spoken.as_deref()
1050    }
1051}
1052
1053#[cfg(test)]
1054mod tests {
1055    use super::*;
1056    use crate::pane::{
1057        PANE_SNAP_DEFAULT_STEP_BPS, PANE_TREE_SCHEMA_VERSION, PaneNodeRecord, PaneSplit,
1058        PaneTreeSnapshot,
1059    };
1060    use std::collections::BTreeMap;
1061
1062    fn pid(raw: u64) -> PaneId {
1063        PaneId::new(raw).expect("non-zero id")
1064    }
1065
1066    #[test]
1067    fn accessibility_preferences_constructors() {
1068        assert_eq!(
1069            PaneAccessibilityPreferences::none(),
1070            PaneAccessibilityPreferences::default()
1071        );
1072        assert!(!PaneAccessibilityPreferences::none().any());
1073        let all = PaneAccessibilityPreferences::all();
1074        assert!(all.reduced_motion && all.high_contrast && all.large_target);
1075        assert!(all.any());
1076        // Builder setters are independent.
1077        let only_hc = PaneAccessibilityPreferences::none().with_high_contrast(true);
1078        assert!(only_hc.high_contrast && !only_hc.reduced_motion && !only_hc.large_target);
1079        assert!(only_hc.any());
1080    }
1081
1082    #[test]
1083    fn affordance_motion_reflects_reduced_motion_preference() {
1084        let on = PaneAccessibilityPreferences::none().with_reduced_motion(true);
1085        assert!(on.affordance_motion().reduced_motion);
1086        // Stepped: full emphasis on frame 0.
1087        assert_eq!(
1088            on.affordance_motion().hover_emphasis_bps(0),
1089            crate::pane::PANE_AFFORDANCE_EMPHASIS_FULL_BPS
1090        );
1091        let off = PaneAccessibilityPreferences::none();
1092        assert!(!off.affordance_motion().reduced_motion);
1093        // Animated: frame 0 is below full when not reduced.
1094        assert!(
1095            off.affordance_motion().hover_emphasis_bps(0)
1096                < crate::pane::PANE_AFFORDANCE_EMPHASIS_FULL_BPS
1097        );
1098    }
1099
1100    #[test]
1101    fn enlarge_target_grows_only_in_large_target_mode_and_is_monotonic() {
1102        let normal = PaneAccessibilityPreferences::none();
1103        let large = PaneAccessibilityPreferences::none().with_large_target(true);
1104        let mut prev_normal = 0u16;
1105        let mut prev_large = 0u16;
1106        for base in 0..=20u16 {
1107            // Default mode is identity.
1108            assert_eq!(normal.enlarge_target(base), base);
1109            let grown = large.enlarge_target(base);
1110            // Large target never shrinks, and strictly grows any positive base.
1111            assert!(grown >= base, "large target must not shrink base {base}");
1112            if base > 0 {
1113                assert!(grown > base, "large target must grow base {base}");
1114            }
1115            // Both functions are monotonic non-decreasing in base.
1116            assert!(normal.enlarge_target(base) >= prev_normal);
1117            assert!(grown >= prev_large);
1118            prev_normal = normal.enlarge_target(base);
1119            prev_large = grown;
1120        }
1121        // Concrete ergonomics: a 1-cell rail becomes 2, a 2-cell rail becomes 3.
1122        assert_eq!(large.enlarge_target(1), 2);
1123        assert_eq!(large.enlarge_target(2), 3);
1124        // Saturating: a max base does not overflow.
1125        assert!(large.enlarge_target(u16::MAX) >= u16::MAX - 1);
1126    }
1127
1128    #[test]
1129    fn accessibility_preferences_are_not_an_input_to_resolution() {
1130        // The modes are presentation-only: resolve() takes no preferences, so a
1131        // fixed command resolves to byte-identical effects regardless of which
1132        // modes a host has enabled. We demonstrate this by resolving the same
1133        // command and confirming the resolution is independent of any prefs the
1134        // caller might be tracking alongside it.
1135        let tree = nested();
1136        let layout = tree.solve_layout(Rect::new(0, 0, 80, 24)).expect("solves");
1137        let ctx = PaneFocusContext {
1138            active: Some(pid(2)),
1139            maximized: None,
1140        };
1141        let baseline = resolve(&tree, &layout, ctx, PaneCommand::FocusNext);
1142        for prefs in [
1143            PaneAccessibilityPreferences::none(),
1144            PaneAccessibilityPreferences::all(),
1145            PaneAccessibilityPreferences::none().with_large_target(true),
1146        ] {
1147            // prefs deliberately do not participate in resolution.
1148            let _ = prefs;
1149            let again = resolve(&tree, &layout, ctx, PaneCommand::FocusNext);
1150            assert_eq!(again.next_active, baseline.next_active);
1151            assert_eq!(again.next_maximized, baseline.next_maximized);
1152        }
1153    }
1154
1155    /// First-child share of a split node in basis points.
1156    fn first_share_bps(tree: &PaneTree, split: PaneId) -> u32 {
1157        match &tree.node(split).expect("split present").kind {
1158            PaneNodeKind::Split(node) => {
1159                node.ratio.numerator() * 10_000
1160                    / (node.ratio.numerator() + node.ratio.denominator())
1161            }
1162            PaneNodeKind::Leaf(_) => panic!("expected split node"),
1163        }
1164    }
1165
1166    /// Single leaf root.
1167    fn single() -> PaneTree {
1168        let snapshot = PaneTreeSnapshot {
1169            schema_version: PANE_TREE_SCHEMA_VERSION,
1170            root: pid(1),
1171            next_id: pid(2),
1172            nodes: vec![PaneNodeRecord::leaf(pid(1), None, PaneLeaf::new("only"))],
1173            extensions: BTreeMap::new(),
1174        };
1175        PaneTree::from_snapshot(snapshot).expect("valid single tree")
1176    }
1177
1178    /// Horizontal root: left(2) | right-column where right is vertical
1179    /// top(4)/bottom(5). Leaves in focus order: [2, 4, 5].
1180    fn nested() -> PaneTree {
1181        let snapshot = PaneTreeSnapshot {
1182            schema_version: PANE_TREE_SCHEMA_VERSION,
1183            root: pid(1),
1184            next_id: pid(6),
1185            nodes: vec![
1186                PaneNodeRecord::split(
1187                    pid(1),
1188                    None,
1189                    PaneSplit {
1190                        axis: SplitAxis::Horizontal,
1191                        ratio: PaneSplitRatio::new(1, 1).unwrap(),
1192                        first: pid(2),
1193                        second: pid(3),
1194                    },
1195                ),
1196                PaneNodeRecord::leaf(pid(2), Some(pid(1)), PaneLeaf::new("left")),
1197                PaneNodeRecord::split(
1198                    pid(3),
1199                    Some(pid(1)),
1200                    PaneSplit {
1201                        axis: SplitAxis::Vertical,
1202                        ratio: PaneSplitRatio::new(1, 1).unwrap(),
1203                        first: pid(4),
1204                        second: pid(5),
1205                    },
1206                ),
1207                PaneNodeRecord::leaf(pid(4), Some(pid(3)), PaneLeaf::new("right_top")),
1208                PaneNodeRecord::leaf(pid(5), Some(pid(3)), PaneLeaf::new("right_bottom")),
1209            ],
1210            extensions: BTreeMap::new(),
1211        };
1212        PaneTree::from_snapshot(snapshot).expect("valid nested tree")
1213    }
1214
1215    fn solved(tree: &PaneTree) -> PaneLayout {
1216        tree.solve_layout(Rect::new(0, 0, 80, 24))
1217            .expect("layout solves")
1218    }
1219
1220    #[test]
1221    fn closing_the_maximized_pane_clears_maximize_state() {
1222        let tree = nested();
1223        let layout = solved(&tree);
1224        let res = resolve(
1225            &tree,
1226            &layout,
1227            PaneFocusContext {
1228                active: Some(pid(4)),
1229                maximized: Some(pid(4)),
1230            },
1231            PaneCommand::Close,
1232        );
1233        assert!(matches!(res.effect, PaneCommandEffect::Structural(_)));
1234        assert_eq!(
1235            res.next_maximized, None,
1236            "a closed pane must not remain the maximize target"
1237        );
1238        // Closing a non-maximized pane keeps the maximize state.
1239        let res2 = resolve(
1240            &tree,
1241            &layout,
1242            PaneFocusContext {
1243                active: Some(pid(4)),
1244                maximized: Some(pid(2)),
1245            },
1246            PaneCommand::Close,
1247        );
1248        assert_eq!(res2.next_maximized, Some(pid(2)));
1249    }
1250
1251    #[test]
1252    fn share_pct_survives_extreme_ratios() {
1253        // Any nonzero u32 pair is a valid ratio; u32 math would overflow (or
1254        // divide by zero on wrap) for u32::MAX:1.
1255        let snapshot = PaneTreeSnapshot {
1256            schema_version: PANE_TREE_SCHEMA_VERSION,
1257            root: pid(1),
1258            next_id: pid(4),
1259            nodes: vec![
1260                PaneNodeRecord::split(
1261                    pid(1),
1262                    None,
1263                    PaneSplit {
1264                        axis: SplitAxis::Horizontal,
1265                        ratio: PaneSplitRatio::new(u32::MAX, 1).unwrap(),
1266                        first: pid(2),
1267                        second: pid(3),
1268                    },
1269                ),
1270                PaneNodeRecord::leaf(pid(2), Some(pid(1)), PaneLeaf::new("a")),
1271                PaneNodeRecord::leaf(pid(3), Some(pid(1)), PaneLeaf::new("b")),
1272            ],
1273            extensions: BTreeMap::new(),
1274        };
1275        let tree = PaneTree::from_snapshot(snapshot).expect("valid tree");
1276        assert_eq!(active_pane_share_pct(&tree, pid(2)), Some(99));
1277        assert_eq!(active_pane_share_pct(&tree, pid(3)), Some(1));
1278    }
1279
1280    #[test]
1281    fn focus_order_is_topological_in_order() {
1282        assert_eq!(focus_order(&single()), vec![pid(1)]);
1283        assert_eq!(focus_order(&nested()), vec![pid(2), pid(4), pid(5)]);
1284    }
1285
1286    #[test]
1287    fn focus_cyclic_wraps_both_directions() {
1288        let tree = nested();
1289        assert_eq!(
1290            focus_cyclic(&tree, pid(2), PaneFocusOrdinal::Next),
1291            Some(pid(4))
1292        );
1293        assert_eq!(
1294            focus_cyclic(&tree, pid(5), PaneFocusOrdinal::Next),
1295            Some(pid(2))
1296        );
1297        assert_eq!(
1298            focus_cyclic(&tree, pid(2), PaneFocusOrdinal::Previous),
1299            Some(pid(5))
1300        );
1301        assert_eq!(
1302            focus_cyclic(&single(), pid(1), PaneFocusOrdinal::Next),
1303            None
1304        );
1305    }
1306
1307    #[test]
1308    fn focus_directional_uses_geometry() {
1309        let tree = nested();
1310        let layout = solved(&tree);
1311        // left(2) is to the LEFT of the right column; right of left is top(4)
1312        // (nearest by overlap/order among 4,5).
1313        assert_eq!(
1314            focus_directional(&tree, &layout, pid(2), PaneCardinalDirection::Right),
1315            Some(pid(4))
1316        );
1317        // From top(4), down -> bottom(5); left -> left(2).
1318        assert_eq!(
1319            focus_directional(&tree, &layout, pid(4), PaneCardinalDirection::Down),
1320            Some(pid(5))
1321        );
1322        assert_eq!(
1323            focus_directional(&tree, &layout, pid(4), PaneCardinalDirection::Left),
1324            Some(pid(2))
1325        );
1326        // Nothing to the left of the leftmost pane.
1327        assert_eq!(
1328            focus_directional(&tree, &layout, pid(2), PaneCardinalDirection::Left),
1329            None
1330        );
1331    }
1332
1333    #[test]
1334    fn focus_edge_jumps_to_extremes() {
1335        let tree = nested();
1336        let layout = solved(&tree);
1337        assert_eq!(
1338            focus_edge(&tree, &layout, PaneCardinalDirection::Left),
1339            Some(pid(2))
1340        );
1341        assert_eq!(
1342            focus_edge(&tree, &layout, PaneCardinalDirection::Down),
1343            Some(pid(5))
1344        );
1345    }
1346
1347    #[test]
1348    fn resolve_focus_next_changes_active() {
1349        let tree = nested();
1350        let layout = solved(&tree);
1351        let res = resolve(
1352            &tree,
1353            &layout,
1354            PaneFocusContext::active(pid(2)),
1355            PaneCommand::FocusNext,
1356        );
1357        assert_eq!(
1358            res.effect,
1359            PaneCommandEffect::Focus {
1360                previous: Some(pid(2)),
1361                active: pid(4)
1362            }
1363        );
1364        assert_eq!(res.next_active, Some(pid(4)));
1365    }
1366
1367    #[test]
1368    fn resolve_no_active_is_noop() {
1369        let tree = nested();
1370        let layout = solved(&tree);
1371        let res = resolve(
1372            &tree,
1373            &layout,
1374            PaneFocusContext::default(),
1375            PaneCommand::FocusNext,
1376        );
1377        assert_eq!(
1378            res.effect,
1379            PaneCommandEffect::Noop(PaneCommandNoopReason::NoActivePane)
1380        );
1381        assert!(!res.is_effective());
1382    }
1383
1384    #[test]
1385    fn resolve_resize_grows_active_via_existing_nudge() {
1386        let tree = nested();
1387        let layout = solved(&tree);
1388        // left(2) is the FIRST child of root split: grow active => increase
1389        // first-share by 2 steps.
1390        let res = resolve(
1391            &tree,
1392            &layout,
1393            PaneFocusContext::active(pid(2)),
1394            PaneCommand::ResizeStep {
1395                direction: PaneResizeDirection::Increase,
1396                units: 2,
1397            },
1398        );
1399        let PaneCommandEffect::Structural(ops) = &res.effect else {
1400            panic!("expected structural effect, got {:?}", res.effect);
1401        };
1402        assert_eq!(ops.len(), 1);
1403        // Apply and confirm the root first-share grew by 2 * step.
1404        let mut applied = tree.clone();
1405        let before = first_share_bps(&applied, pid(1));
1406        for (idx, op) in ops.iter().enumerate() {
1407            applied
1408                .apply_operation(idx as u64, op.clone())
1409                .expect("op applies");
1410        }
1411        let after = first_share_bps(&applied, pid(1));
1412        assert_eq!(after, before + 2 * u32::from(PANE_SNAP_DEFAULT_STEP_BPS));
1413    }
1414
1415    #[test]
1416    fn resolve_resize_second_child_flips_direction() {
1417        let tree = nested();
1418        let layout = solved(&tree);
1419        // bottom(5) is the SECOND child of the vertical split(3): growing it
1420        // must DECREASE that split's first-share.
1421        let res = resolve(
1422            &tree,
1423            &layout,
1424            PaneFocusContext::active(pid(5)),
1425            PaneCommand::ResizeStep {
1426                direction: PaneResizeDirection::Increase,
1427                units: 1,
1428            },
1429        );
1430        let PaneCommandEffect::Structural(ops) = &res.effect else {
1431            panic!("expected structural effect");
1432        };
1433        let mut applied = tree.clone();
1434        let before = first_share_bps(&applied, pid(3));
1435        for (idx, op) in ops.iter().enumerate() {
1436            applied
1437                .apply_operation(idx as u64, op.clone())
1438                .expect("applies");
1439        }
1440        let after = first_share_bps(&applied, pid(3));
1441        assert_eq!(after, before - u32::from(PANE_SNAP_DEFAULT_STEP_BPS));
1442    }
1443
1444    #[test]
1445    fn resolve_close_picks_deterministic_survivor() {
1446        let tree = nested();
1447        let layout = solved(&tree);
1448        let res = resolve(
1449            &tree,
1450            &layout,
1451            PaneFocusContext::active(pid(2)),
1452            PaneCommand::Close,
1453        );
1454        assert!(matches!(res.effect, PaneCommandEffect::Structural(_)));
1455        // Closing left(2): next survivor in focus order is top(4).
1456        assert_eq!(res.next_active, Some(pid(4)));
1457        // Root cannot be closed.
1458        let single_tree = single();
1459        let single_layout = solved(&single_tree);
1460        let res = resolve(
1461            &single_tree,
1462            &single_layout,
1463            PaneFocusContext::active(pid(1)),
1464            PaneCommand::Close,
1465        );
1466        assert_eq!(
1467            res.effect,
1468            PaneCommandEffect::Noop(PaneCommandNoopReason::RootCannotClose)
1469        );
1470    }
1471
1472    #[test]
1473    fn resolve_maximize_and_restore_roundtrip() {
1474        let tree = nested();
1475        let layout = solved(&tree);
1476        let max = resolve(
1477            &tree,
1478            &layout,
1479            PaneFocusContext::active(pid(4)),
1480            PaneCommand::Maximize,
1481        );
1482        assert_eq!(max.effect, PaneCommandEffect::Maximize { target: pid(4) });
1483        assert_eq!(max.next_maximized, Some(pid(4)));
1484        // Maximizing again is a no-op.
1485        let again = resolve(
1486            &tree,
1487            &layout,
1488            PaneFocusContext {
1489                active: Some(pid(4)),
1490                maximized: Some(pid(4)),
1491            },
1492            PaneCommand::Maximize,
1493        );
1494        assert_eq!(
1495            again.effect,
1496            PaneCommandEffect::Noop(PaneCommandNoopReason::AlreadyMaximized)
1497        );
1498        // Restore clears it.
1499        let restore = resolve(
1500            &tree,
1501            &layout,
1502            PaneFocusContext {
1503                active: Some(pid(4)),
1504                maximized: Some(pid(4)),
1505            },
1506            PaneCommand::Restore,
1507        );
1508        assert_eq!(
1509            restore.effect,
1510            PaneCommandEffect::Restore { previous: pid(4) }
1511        );
1512        assert_eq!(restore.next_maximized, None);
1513    }
1514
1515    #[test]
1516    fn acceleration_policy_is_explicit() {
1517        let accel = PaneCommandAcceleration::default();
1518        assert_eq!(accel.units_for(0), 1);
1519        assert_eq!(accel.units_for(2), 1);
1520        assert_eq!(accel.units_for(3), 5);
1521        assert_eq!(accel.units_for(50), 5);
1522    }
1523
1524    #[test]
1525    fn precedence_policy_resolves_conflicts() {
1526        use PaneKeymapOwner::{Application, PaneManager, Unbound};
1527        let pm = PaneKeymapPrecedence::PaneManagerFirst;
1528        let app = PaneKeymapPrecedence::ApplicationFirst;
1529        assert_eq!(pm.resolve(false, false), Unbound);
1530        assert_eq!(pm.resolve(true, false), Application);
1531        assert_eq!(pm.resolve(false, true), PaneManager);
1532        assert_eq!(pm.resolve(true, true), PaneManager);
1533        assert_eq!(app.resolve(true, true), Application);
1534    }
1535
1536    /// Cross-host equivalence + determinism: a fixed command stream, applied
1537    /// via the resolver, produces an identical final topology hash and active
1538    /// pane regardless of which host emitted the commands. Running twice must
1539    /// match byte-for-byte.
1540    #[test]
1541    fn command_stream_is_deterministic_and_host_agnostic() {
1542        fn run_stream() -> (u64, Option<PaneId>) {
1543            let mut tree = nested();
1544            let mut ctx = PaneFocusContext::active(pid(2));
1545            let stream = [
1546                PaneCommand::FocusNext,
1547                PaneCommand::ResizeStep {
1548                    direction: PaneResizeDirection::Increase,
1549                    units: 2,
1550                },
1551                PaneCommand::FocusDirectional(PaneCardinalDirection::Down),
1552                PaneCommand::SwapPane(PaneFocusOrdinal::Previous),
1553                PaneCommand::FocusEdge(PaneCardinalDirection::Left),
1554            ];
1555            let mut op_id = 0u64;
1556            for command in stream {
1557                let layout = tree.solve_layout(Rect::new(0, 0, 80, 24)).expect("solves");
1558                let res = resolve(&tree, &layout, ctx, command);
1559                if let PaneCommandEffect::Structural(ops) = &res.effect {
1560                    for op in ops {
1561                        tree.apply_operation(op_id, op.clone()).expect("applies");
1562                        op_id += 1;
1563                    }
1564                }
1565                ctx.active = res.next_active;
1566                ctx.maximized = res.next_maximized;
1567            }
1568            (tree.state_hash(), ctx.active)
1569        }
1570
1571        let first = run_stream();
1572        let second = run_stream();
1573        assert_eq!(first, second, "command stream must be deterministic");
1574        // Active pane must be a real leaf after the stream.
1575        let tree = nested();
1576        assert!(focus_order(&tree).contains(&first.1.expect("active set")));
1577    }
1578
1579    fn announce(
1580        tree: &PaneTree,
1581        layout: &PaneLayout,
1582        ctx: PaneFocusContext,
1583        command: PaneCommand,
1584    ) -> Option<PaneAnnouncement> {
1585        let res = resolve(tree, layout, ctx, command);
1586        announce_command(command, &res, tree)
1587    }
1588
1589    #[test]
1590    fn announcements_describe_each_transition() {
1591        let tree = nested();
1592        let layout = solved(&tree);
1593        // Focus.
1594        let a = announce(
1595            &tree,
1596            &layout,
1597            PaneFocusContext::active(pid(2)),
1598            PaneCommand::FocusNext,
1599        )
1600        .unwrap();
1601        assert_eq!(a.category, PaneAnnouncementCategory::Focus);
1602        assert_eq!(a.text, "Focused pane right_top");
1603        // Maximize / restore.
1604        let a = announce(
1605            &tree,
1606            &layout,
1607            PaneFocusContext::active(pid(2)),
1608            PaneCommand::Maximize,
1609        )
1610        .unwrap();
1611        assert_eq!(a.text, "Maximized pane");
1612        let restore_ctx = PaneFocusContext {
1613            active: Some(pid(2)),
1614            maximized: Some(pid(2)),
1615        };
1616        let a = announce(&tree, &layout, restore_ctx, PaneCommand::Restore).unwrap();
1617        assert_eq!(a.text, "Restored pane");
1618    }
1619
1620    #[test]
1621    fn announcement_reports_split_and_close_counts() {
1622        // Split grows the leaf count to 4; the announcement reflects the AFTER tree.
1623        let mut tree = nested();
1624        let layout = solved(&tree);
1625        let res = resolve(
1626            &tree,
1627            &layout,
1628            PaneFocusContext::active(pid(2)),
1629            PaneCommand::Split(SplitAxis::Vertical),
1630        );
1631        if let PaneCommandEffect::Structural(ops) = &res.effect {
1632            for (i, op) in ops.iter().enumerate() {
1633                tree.apply_operation(i as u64, op.clone()).unwrap();
1634            }
1635        }
1636        let a = announce_command(PaneCommand::Split(SplitAxis::Vertical), &res, &tree).unwrap();
1637        assert_eq!(a.category, PaneAnnouncementCategory::Split);
1638        assert_eq!(a.text, "Split pane vertical, 4 panes");
1639    }
1640
1641    #[test]
1642    fn resize_announcement_reports_active_share() {
1643        // left(2) is the first child of the 1:1 root; grow by 1 step (+500 bps = +5%).
1644        let mut tree = nested();
1645        let layout = solved(&tree);
1646        let res = resolve(
1647            &tree,
1648            &layout,
1649            PaneFocusContext::active(pid(2)),
1650            PaneCommand::ResizeStep {
1651                direction: PaneResizeDirection::Increase,
1652                units: 1,
1653            },
1654        );
1655        if let PaneCommandEffect::Structural(ops) = &res.effect {
1656            for (i, op) in ops.iter().enumerate() {
1657                tree.apply_operation(i as u64, op.clone()).unwrap();
1658            }
1659        }
1660        let a = announce_command(
1661            PaneCommand::ResizeStep {
1662                direction: PaneResizeDirection::Increase,
1663                units: 1,
1664            },
1665            &res,
1666            &tree,
1667        )
1668        .unwrap();
1669        assert_eq!(a.category, PaneAnnouncementCategory::Resize);
1670        assert_eq!(a.text, "Resized pane to 55 percent");
1671    }
1672
1673    #[test]
1674    fn noop_command_is_not_announced() {
1675        let tree = nested();
1676        let layout = solved(&tree);
1677        // No active pane -> FocusNext is a no-op -> no announcement.
1678        let a = announce(
1679            &tree,
1680            &layout,
1681            PaneFocusContext::default(),
1682            PaneCommand::FocusNext,
1683        );
1684        assert!(a.is_none());
1685    }
1686
1687    #[test]
1688    fn announcer_coalesces_bursts_and_dedupes() {
1689        let mut announcer = PaneAnnouncer::new();
1690        // A burst of resize announcements coalesces to the latest on take().
1691        announcer.offer(Some(PaneAnnouncement::new(
1692            PaneAnnouncementCategory::Resize,
1693            "Resized pane to 55 percent",
1694        )));
1695        announcer.offer(Some(PaneAnnouncement::new(
1696            PaneAnnouncementCategory::Resize,
1697            "Resized pane to 60 percent",
1698        )));
1699        announcer.offer(Some(PaneAnnouncement::new(
1700            PaneAnnouncementCategory::Resize,
1701            "Resized pane to 65 percent",
1702        )));
1703        let spoken = announcer.take().unwrap();
1704        assert_eq!(spoken.text, "Resized pane to 65 percent");
1705        // Nothing pending now.
1706        assert!(announcer.take().is_none());
1707        // Offering the same text again is suppressed (dedupe).
1708        announcer.offer(Some(PaneAnnouncement::new(
1709            PaneAnnouncementCategory::Resize,
1710            "Resized pane to 65 percent",
1711        )));
1712        assert!(announcer.take().is_none());
1713        // A different text is spoken.
1714        announcer.offer(Some(PaneAnnouncement::new(
1715            PaneAnnouncementCategory::Focus,
1716            "Focused pane left",
1717        )));
1718        assert_eq!(announcer.take().unwrap().text, "Focused pane left");
1719        // Offering None does not disturb state.
1720        announcer.offer(None);
1721        assert!(announcer.take().is_none());
1722    }
1723}