fret_ui_headless/tab_strip_controller.rs
1//! Shared (policy-layer) helpers for editor-grade tab strip click arbitration.
2//!
3//! This module intentionally does **not** define geometry/layout rules. Adapters (workspace,
4//! docking, etc.) are responsible for hit-testing and surfacing a `TabStripHitTarget`.
5//!
6//! Rationale:
7//! - Pure mechanism helpers (surface classification, overflow membership, canonical insert index)
8//! live next to this module.
9//! - Click arbitration is small but easy to accidentally diverge across UIs, so we centralize it.
10//! - This module is headless and has no dependency on `fret-ui` runtime contracts.
11//!
12//! Note:
13//! - The `index` fields are adapter-defined. For docking this is typically a numeric tab index; for
14//! workspace it may be an index into a canonical tab list.
15
16/// A coarse-grained hit-test result for a tab strip.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum TabStripHitTarget {
19 /// The overflow dropdown/menu surface.
20 OverflowMenuRow {
21 index: usize,
22 part: OverflowMenuPart,
23 },
24 /// The overflow control button in the strip.
25 OverflowButton,
26 /// A tab in the strip.
27 Tab { index: usize, part: TabPart },
28 /// Header / empty space inside the strip (e.g. end-drop surface).
29 HeaderSpace,
30 /// Outside the strip.
31 None,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum OverflowMenuPart {
36 Content,
37 Close,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum TabPart {
42 Content,
43 Close,
44}
45
46/// An intent that an adapter can translate into its domain operations.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum TabStripIntent {
49 /// Toggle open/close for the overflow dropdown/menu.
50 ToggleOverflowMenu,
51 /// Activate the given tab index. Adapters can decide what "activate" means (focus, selection).
52 Activate { index: usize, ensure_visible: bool },
53 /// Close the given tab index. Adapters must ensure this does not implicitly activate.
54 Close { index: usize },
55 /// No action.
56 None,
57}
58
59/// Policy: map a click hit target to an intent.
60///
61/// This function encodes the editor-grade arbitration rules:
62/// - Overflow menu close should close without activation.
63/// - Overflow menu content should activate and keep the tab visible.
64/// - Strip tab close should close without activation.
65/// - Strip tab content should activate (without forcing ensure-visible unless adapter wants it).
66/// - Overflow button toggles the menu.
67pub fn intent_for_click(hit: TabStripHitTarget) -> TabStripIntent {
68 match hit {
69 TabStripHitTarget::OverflowMenuRow {
70 index,
71 part: OverflowMenuPart::Close,
72 } => TabStripIntent::Close { index },
73 TabStripHitTarget::OverflowMenuRow {
74 index,
75 part: OverflowMenuPart::Content,
76 } => TabStripIntent::Activate {
77 index,
78 ensure_visible: true,
79 },
80 TabStripHitTarget::OverflowButton => TabStripIntent::ToggleOverflowMenu,
81 TabStripHitTarget::Tab {
82 index,
83 part: TabPart::Close,
84 } => TabStripIntent::Close { index },
85 TabStripHitTarget::Tab {
86 index,
87 part: TabPart::Content,
88 } => TabStripIntent::Activate {
89 index,
90 ensure_visible: false,
91 },
92 TabStripHitTarget::HeaderSpace | TabStripHitTarget::None => TabStripIntent::None,
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 #[test]
101 fn overflow_menu_close_does_not_activate() {
102 let intent = intent_for_click(TabStripHitTarget::OverflowMenuRow {
103 index: 3,
104 part: OverflowMenuPart::Close,
105 });
106 assert_eq!(intent, TabStripIntent::Close { index: 3 });
107 }
108
109 #[test]
110 fn overflow_menu_content_activates_and_ensures_visible() {
111 let intent = intent_for_click(TabStripHitTarget::OverflowMenuRow {
112 index: 3,
113 part: OverflowMenuPart::Content,
114 });
115 assert_eq!(
116 intent,
117 TabStripIntent::Activate {
118 index: 3,
119 ensure_visible: true
120 }
121 );
122 }
123
124 #[test]
125 fn strip_tab_close_does_not_activate() {
126 let intent = intent_for_click(TabStripHitTarget::Tab {
127 index: 1,
128 part: TabPart::Close,
129 });
130 assert_eq!(intent, TabStripIntent::Close { index: 1 });
131 }
132}