snora_core/tab.rs
1//! Tab bar — horizontal selector for switching between sibling views.
2//!
3//! Like [`crate::menu`] and [`crate::sidebar`], the tab bar is described
4//! as plain data here and rendered by `snora-widgets::app_tab_bar`. The
5//! engine never inspects these types directly; they exist so that
6//! applications and widget engines speak the same shape.
7//!
8//! # Choosing between tabs and a sidebar
9//!
10//! Both let users switch among sibling views, but they imply different
11//! information density and depth:
12//!
13//! * **Tabs** — flat, horizontal, label-first. Three to seven peer views
14//! that the user expects to switch among frequently. Sits below the
15//! header.
16//! * **Sidebar** ([`crate::SideBar`]) — vertical, icon-first, scales to
17//! more entries. Use when the navigation is the primary structural
18//! element of the app.
19//!
20//! Use both at once when tabs subdivide a sidebar-selected workspace.
21
22use crate::Icon;
23
24/// One tab in a [`TabBar`]. Carries an application-defined `TabId` so
25/// that the application is the source of truth for which tab is which.
26///
27/// # Example
28///
29/// ```
30/// use snora_core::Tab;
31///
32/// #[derive(Clone, PartialEq, Eq, Debug)]
33/// enum WorkspaceTab { Library, Editor, Settings }
34///
35/// let editor = Tab {
36/// id: WorkspaceTab::Editor,
37/// label: "Editor".into(),
38/// icon: None,
39/// };
40/// assert_eq!(editor.label, "Editor");
41/// assert_eq!(editor.id, WorkspaceTab::Editor);
42/// ```
43#[derive(Debug, Clone)]
44pub struct Tab<TabId: Clone + PartialEq> {
45 /// Application-defined identifier. Compared against [`TabBar::active`]
46 /// to decide which tab to highlight.
47 pub id: TabId,
48 /// Visible label.
49 pub label: String,
50 /// Optional leading icon.
51 pub icon: Option<Icon>,
52}
53
54/// A horizontal tab strip.
55///
56/// `TabBar` is generic over `TabId` so that applications can use any
57/// `Clone + PartialEq` type — typically a small enum.
58///
59/// # Example
60///
61/// ```
62/// use snora_core::{Tab, TabBar};
63///
64/// #[derive(Clone, PartialEq, Eq, Debug)]
65/// enum WorkspaceTab { Library, Editor }
66///
67/// let bar = TabBar {
68/// tabs: vec![
69/// Tab { id: WorkspaceTab::Library, label: "Library".into(), icon: None },
70/// Tab { id: WorkspaceTab::Editor, label: "Editor".into(), icon: None },
71/// ],
72/// active: WorkspaceTab::Library,
73/// };
74/// assert_eq!(bar.tabs.len(), 2);
75/// assert_eq!(bar.active, WorkspaceTab::Library);
76/// ```
77#[derive(Debug, Clone)]
78pub struct TabBar<TabId: Clone + PartialEq> {
79 /// Tabs in display order. The widget engine respects this order;
80 /// horizontal mirroring under [`crate::LayoutDirection::Rtl`] is
81 /// the engine's responsibility.
82 pub tabs: Vec<Tab<TabId>>,
83 /// The currently selected tab id. The widget renders this tab with
84 /// an active treatment (typically an underline). If `active` does
85 /// not match any tab in `tabs`, no tab is highlighted.
86 pub active: TabId,
87}
88
89/// What happens when the user interacts with a tab.
90///
91/// Tab bars only emit one kind of event — a tab being pressed. We
92/// still wrap it in an enum (rather than a bare `TabId`) so that
93/// future extensions (close button on a tab, drag-to-reorder) can be
94/// added without breaking the existing handler shape.
95///
96/// # Example
97///
98/// ```
99/// use snora_core::TabAction;
100///
101/// #[derive(Clone, Debug, PartialEq, Eq)]
102/// enum WorkspaceTab { Library, Editor }
103///
104/// // Application code typically maps `TabAction` into its own message
105/// // enum; here we just match on it directly.
106/// let received: TabAction<WorkspaceTab> = TabAction::Pressed(WorkspaceTab::Editor);
107/// match received {
108/// TabAction::Pressed(id) => assert_eq!(id, WorkspaceTab::Editor),
109/// }
110/// ```
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum TabAction<TabId> {
113 /// The user pressed a tab. The application typically responds by
114 /// updating its `active` state and re-rendering.
115 Pressed(TabId),
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[derive(Clone, PartialEq, Eq, Debug)]
123 enum DemoTab {
124 A,
125 B,
126 C,
127 }
128
129 #[test]
130 fn tab_bar_is_constructible() {
131 let bar = TabBar {
132 tabs: vec![
133 Tab {
134 id: DemoTab::A,
135 label: "A".into(),
136 icon: None,
137 },
138 Tab {
139 id: DemoTab::B,
140 label: "B".into(),
141 icon: None,
142 },
143 ],
144 active: DemoTab::A,
145 };
146 assert_eq!(bar.tabs.len(), 2);
147 assert_eq!(bar.active, DemoTab::A);
148 }
149
150 #[test]
151 fn tab_action_carries_id() {
152 let action = TabAction::Pressed(DemoTab::C);
153 assert_eq!(action, TabAction::Pressed(DemoTab::C));
154 }
155}