1use 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
37const KEYBOARD_NEUTRAL_PRESSURE: PanePressureSnapProfile = PanePressureSnapProfile {
42 strength_bps: 5_000,
43 hysteresis_bps: PANE_SNAP_DEFAULT_HYSTERESIS_BPS,
44};
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum PaneCardinalDirection {
49 Left,
51 Right,
53 Up,
55 Down,
57}
58
59impl PaneCardinalDirection {
60 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83pub enum PaneFocusOrdinal {
84 Next,
86 Previous,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum PaneCommand {
98 FocusNext,
100 FocusPrevious,
102 FocusDirectional(PaneCardinalDirection),
104 FocusEdge(PaneCardinalDirection),
106 ResizeStep {
109 direction: PaneResizeDirection,
111 units: u16,
113 },
114 Split(SplitAxis),
116 Close,
118 MovePane(PaneCardinalDirection),
120 SwapPane(PaneFocusOrdinal),
122 Maximize,
124 Restore,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
133pub struct PaneFocusContext {
134 pub active: Option<PaneId>,
136 pub maximized: Option<PaneId>,
138}
139
140impl PaneFocusContext {
141 #[must_use]
144 pub const fn active(active: PaneId) -> Self {
145 Self {
146 active: Some(active),
147 maximized: None,
148 }
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum PaneCommandEffect {
157 Focus {
159 previous: Option<PaneId>,
161 active: PaneId,
163 },
164 Structural(Vec<PaneOperation>),
166 Maximize {
168 target: PaneId,
170 },
171 Restore {
173 previous: PaneId,
175 },
176 Noop(PaneCommandNoopReason),
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum PaneCommandNoopReason {
183 NoActivePane,
185 ActiveNotLeaf,
187 OnlyOnePane,
189 NoTargetInDirection,
191 RootCannotClose,
193 NoEnclosingSplit,
195 AlreadyMaximized,
197 NotMaximized,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct PaneCommandResolution {
205 pub effect: PaneCommandEffect,
207 pub next_active: Option<PaneId>,
209 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 #[must_use]
224 pub const fn is_effective(&self) -> bool {
225 !matches!(self.effect, PaneCommandEffect::Noop(_))
226 }
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub struct PaneCommandAcceleration {
234 pub base_units: u16,
236 pub accelerated_units: u16,
238 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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
266pub enum PaneKeymapPrecedence {
267 #[default]
271 PaneManagerFirst,
272 ApplicationFirst,
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum PaneKeymapOwner {
279 Application,
281 PaneManager,
283 Unbound,
285}
286
287impl PaneKeymapPrecedence {
288 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
324pub struct PaneAccessibilityPreferences {
325 pub reduced_motion: bool,
327 pub high_contrast: bool,
329 pub large_target: bool,
331}
332
333impl PaneAccessibilityPreferences {
334 #[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 #[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 #[must_use]
356 pub const fn with_reduced_motion(mut self, on: bool) -> Self {
357 self.reduced_motion = on;
358 self
359 }
360
361 #[must_use]
363 pub const fn with_high_contrast(mut self, on: bool) -> Self {
364 self.high_contrast = on;
365 self
366 }
367
368 #[must_use]
370 pub const fn with_large_target(mut self, on: bool) -> Self {
371 self.large_target = on;
372 self
373 }
374
375 #[must_use]
377 pub const fn any(self) -> bool {
378 self.reduced_motion || self.high_contrast || self.large_target
379 }
380
381 #[must_use]
384 pub fn affordance_motion(self) -> PaneAffordanceMotion {
385 PaneAffordanceMotion::default().with_reduced_motion(self.reduced_motion)
386 }
387
388 #[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#[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 stack.push(split.second);
425 stack.push(split.first);
426 }
427 }
428 }
429 out
430}
431
432#[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
456fn 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#[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 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 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#[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 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#[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
596fn 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
667fn 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
867pub enum PaneAnnouncementCategory {
868 Focus,
870 Resize,
872 Split,
874 Close,
876 Move,
878 Swap,
880 Maximize,
882 Restore,
884}
885
886#[derive(Debug, Clone, PartialEq, Eq)]
890pub struct PaneAnnouncement {
891 pub text: String,
893 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
922fn 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 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#[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#[derive(Debug, Clone, Default)]
1009pub struct PaneAnnouncer {
1010 pending: Option<PaneAnnouncement>,
1011 last_spoken: Option<String>,
1012}
1013
1014impl PaneAnnouncer {
1015 #[must_use]
1017 pub fn new() -> Self {
1018 Self::default()
1019 }
1020
1021 pub fn offer(&mut self, announcement: Option<PaneAnnouncement>) {
1024 if announcement.is_some() {
1025 self.pending = announcement;
1026 }
1027 }
1028
1029 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 #[must_use]
1042 pub fn pending(&self) -> Option<&PaneAnnouncement> {
1043 self.pending.as_ref()
1044 }
1045
1046 #[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 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 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 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 assert_eq!(normal.enlarge_target(base), base);
1109 let grown = large.enlarge_target(base);
1110 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 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 assert_eq!(large.enlarge_target(1), 2);
1123 assert_eq!(large.enlarge_target(2), 3);
1124 assert!(large.enlarge_target(u16::MAX) >= u16::MAX - 1);
1126 }
1127
1128 #[test]
1129 fn accessibility_preferences_are_not_an_input_to_resolution() {
1130 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 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 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 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 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 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 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 assert_eq!(
1314 focus_directional(&tree, &layout, pid(2), PaneCardinalDirection::Right),
1315 Some(pid(4))
1316 );
1317 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 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 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 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 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 assert_eq!(res.next_active, Some(pid(4)));
1457 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 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 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 #[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 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 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 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 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 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 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 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 assert!(announcer.take().is_none());
1707 announcer.offer(Some(PaneAnnouncement::new(
1709 PaneAnnouncementCategory::Resize,
1710 "Resized pane to 65 percent",
1711 )));
1712 assert!(announcer.take().is_none());
1713 announcer.offer(Some(PaneAnnouncement::new(
1715 PaneAnnouncementCategory::Focus,
1716 "Focused pane left",
1717 )));
1718 assert_eq!(announcer.take().unwrap().text, "Focused pane left");
1719 announcer.offer(None);
1721 assert!(announcer.take().is_none());
1722 }
1723}