Skip to main content

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}