teksilo_widgets/tab_widget/info.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Per-tab presentation metadata.
5//!
6//! [`TabInfo`] is the bundle of "what does this tab look like?"
7//! values: title, icon, tooltip, capability flags. Decoupled from
8//! [`crate::tab_widget::TabHandle`] so the same struct is reusable
9//! by both static and dynamic tab construction paths.
10//!
11//! Title and tooltip are [`LocalizedString`] — they
12//! accept `tr!(...)` (locale-reactive) and convert from raw
13//! strings via `LocalizedString::literal`. Icon is a factory
14//! closure (no `IconWidget: Clone` requirement) so the same
15//! `TabInfo` can be cloned cheaply and the icon is rebuilt each
16//! TabHeader build, picking up theme / state changes naturally.
17
18use std::rc::Rc;
19
20use teksilo_canvas::Point;
21use teksilo_core::widget::{EventContext, Widget};
22use teksilo_i18n::LocalizedString;
23
24use super::delegate::ContextMenuFactory;
25use crate::IconWidget;
26use crate::tooltip::RichTooltipSource;
27
28/// Reusable factory for an [`IconWidget`]. Boxed in `Rc` so
29/// [`TabInfo`] is `Clone` without forcing `IconWidget: Clone`.
30pub type IconFactory = Rc<dyn Fn() -> IconWidget>;
31
32/// Reusable factory for a composite-tooltip body widget. Boxed in
33/// `Rc` so [`TabInfo`] is `Clone` without forcing the body's type to
34/// be `Clone`. The factory is called each time the tab's header
35/// builds — typically once per tab lifetime, plus rebuilds triggered
36/// by data-source mutations.
37pub type CompositeTooltipFactory = Rc<dyn Fn() -> Box<dyn Widget>>;
38
39/// Per-tab presentation metadata. Build with [`TabInfo::new`] and
40/// fluent setters.
41///
42/// ```rust
43/// # use teksilo_widgets::tab_widget::TabInfo;
44/// # use teksilo_widgets::primitives::IconWidget;
45/// # use teksilo_i18n::lit;
46/// let _info = TabInfo::new()
47/// .title(lit!("Welcome"))
48/// .icon(|| IconWidget::checkmark(16.0))
49/// .closable(true);
50/// ```
51#[derive(Clone)]
52pub struct TabInfo {
53 pub(crate) title: Option<LocalizedString>,
54 pub(crate) icon: Option<IconFactory>,
55 pub(crate) tooltip: Option<LocalizedString>,
56 /// Optional rich tooltip — registry key or inline content.
57 /// Mutually exclusive with `tooltip` and `composite_tooltip`.
58 pub(crate) rich_tooltip: Option<RichTooltipSource>,
59 /// Optional composite tooltip body factory. Mutually exclusive
60 /// with the other two tooltip slots.
61 pub(crate) composite_tooltip: Option<CompositeTooltipFactory>,
62 pub(crate) closable: bool,
63 pub(crate) pinned: bool,
64 /// Initial-enabled hint. Forwarded into the arena at build time
65 /// via `ctx.enabled_when(header_id, false)` when `false`; the
66 /// arena is then the single source of truth and ANDs with
67 /// ancestors. A disabled `TabBar` ancestor disables every tab
68 /// regardless of this flag.
69 pub(crate) initial_enabled: teksilo_core::signal::Prop<bool>,
70 /// Mark the tab's content pane as focusable so keyboard users can
71 /// reach it. ARIA: a `tabpanel` with no focusable content must
72 /// itself be focusable (`tabindex="0"`). Opt-in because the
73 /// framework can't reliably auto-detect at build time (children
74 /// are built lazily). Default: `false`.
75 pub(crate) focusable_panel: bool,
76 /// Optional per-tab context menu (right-click the tab header). Same
77 /// shape as the delegate's `context_menu`; for dynamic tabs this is
78 /// the per-handle way to attach one.
79 pub(crate) context_menu: Option<ContextMenuFactory>,
80}
81
82impl Default for TabInfo {
83 fn default() -> Self {
84 Self::new()
85 }
86}
87
88impl std::fmt::Debug for TabInfo {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 f.debug_struct("TabInfo")
91 .field("title", &self.title)
92 .field("has_icon", &self.icon.is_some())
93 .field("tooltip", &self.tooltip)
94 .field("has_rich_tooltip", &self.rich_tooltip.is_some())
95 .field("has_composite_tooltip", &self.composite_tooltip.is_some())
96 .field("closable", &self.closable)
97 .field("pinned", &self.pinned)
98 .field("initial_enabled", &self.initial_enabled.get())
99 .field("focusable_panel", &self.focusable_panel)
100 .field("has_context_menu", &self.context_menu.is_some())
101 .finish()
102 }
103}
104
105impl TabInfo {
106 /// Empty defaults: no title, no icon, no tooltip, not closable,
107 /// not pinned, enabled.
108 pub fn new() -> Self {
109 Self {
110 title: None,
111 icon: None,
112 tooltip: None,
113 rich_tooltip: None,
114 composite_tooltip: None,
115 closable: false,
116 pinned: false,
117 initial_enabled: teksilo_core::signal::Prop::Static(true),
118 focusable_panel: false,
119 context_menu: None,
120 }
121 }
122
123 /// Attach a per-tab context menu (right-click the tab header). The
124 /// factory receives the click position (tab-local) and a full
125 /// [`EventContext`], and returns `Some(menu)` to mount or `None` to
126 /// decline (falling through to an ancestor). Cloned per header build.
127 pub fn context_menu(
128 mut self,
129 f: impl Fn(Point, &mut EventContext) -> Option<Box<dyn Widget>> + 'static,
130 ) -> Self {
131 self.context_menu = Some(Rc::new(f));
132 self
133 }
134
135 /// Set the tab's title. Accepts `tr!(...)`, a literal string,
136 /// or any value implementing `Into<LocalizedString>`.
137 /// `None` means icon-only (the pinned-tab presentation).
138 pub fn title(mut self, t: impl Into<LocalizedString>) -> Self {
139 self.title = Some(t.into());
140 self
141 }
142
143 /// Untitled — useful for icon-only tabs even when not pinned.
144 pub fn no_title(mut self) -> Self {
145 self.title = None;
146 self
147 }
148
149 /// Set the leading icon via a factory closure. The closure is
150 /// called each time the `TabHeader`
151 /// is built — typically once per tab lifetime, plus any rebuild
152 /// triggered by data-source mutations.
153 pub fn icon(mut self, factory: impl Fn() -> IconWidget + 'static) -> Self {
154 self.icon = Some(Rc::new(factory));
155 self
156 }
157
158 /// Tooltip text shown on hover. If unset and the tab is
159 /// [pinned](Self::pinned), the framework promotes [title](Self::title)
160 /// to the tooltip — pinned tabs render icon-only and otherwise
161 /// have no way for the user to identify them.
162 pub fn tooltip(mut self, t: impl Into<LocalizedString>) -> Self {
163 self.tooltip = Some(t.into());
164 self.rich_tooltip = None;
165 self.composite_tooltip = None;
166 self
167 }
168
169 /// Attach a rich tooltip resolved from the app-wide tooltip
170 /// registry. See [`Button::rich_tooltip`](crate::button::Button::rich_tooltip).
171 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
172 self.rich_tooltip = Some(RichTooltipSource::Key(key.into()));
173 self.tooltip = None;
174 self.composite_tooltip = None;
175 self
176 }
177
178 /// Attach a rich tooltip driven by inline `TooltipContent`.
179 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
180 self.rich_tooltip = Some(RichTooltipSource::Content(content));
181 self.tooltip = None;
182 self.composite_tooltip = None;
183 self
184 }
185
186 /// Attach a composite tooltip — third tier, hosting an arbitrary
187 /// widget tree. The `factory` closure is called each time the
188 /// tab's header rebuilds, so the body picks up theme / locale
189 /// changes naturally without retaining state across rebuilds.
190 pub fn composite_tooltip<W>(mut self, factory: impl Fn() -> W + 'static) -> Self
191 where
192 W: Widget + 'static,
193 {
194 self.composite_tooltip = Some(Rc::new(move || -> Box<dyn Widget> { Box::new(factory()) }));
195 self.tooltip = None;
196 self.rich_tooltip = None;
197 self
198 }
199
200 /// Whether the tab shows a close button + responds to
201 /// middle-click. Default: `false`.
202 pub fn closable(mut self, b: bool) -> Self {
203 self.closable = b;
204 self
205 }
206
207 /// Whether the tab renders in the leading pinned strip
208 /// (icon-only, fixed-width, no close button — Firefox / Chrome
209 /// convention). Default: `false`.
210 pub fn pinned(mut self, b: bool) -> Self {
211 self.pinned = b;
212 self
213 }
214
215 /// Whether the tab can be activated. Disabled tabs render but
216 /// are skipped by keyboard navigation, can't be clicked, and
217 /// don't get the close button. Default: `true`.
218 ///
219 /// Forwarded to the arena via `ctx.enabled_when(header_id, false)`
220 /// at build time when `false`. Ancestor-driven disable (e.g. a
221 /// disabled `TabBar`) ANDs with this flag automatically.
222 pub fn enabled(mut self, enabled: impl Into<teksilo_core::signal::Prop<bool>>) -> Self {
223 self.initial_enabled = enabled.into();
224 self
225 }
226
227 /// Make the tab's content pane itself focusable, so keyboard users
228 /// can press `Tab` from the selected tab header and land inside
229 /// the panel.
230 ///
231 /// Opt in for panels you know contain no focusable descendants —
232 /// a static text-only "About" tab, a chart-only metrics tab.
233 /// Panels that already host a `Button`, `TextInput`, `ListView`,
234 /// or any other interactive widget don't need this: focus will
235 /// flow naturally into the descendant.
236 ///
237 /// ARIA: this implements the `tabindex="0"` requirement that an
238 /// empty `tabpanel` must be focusable so its content can be read
239 /// by screen readers in browse mode. AccessKit has no `tabindex`
240 /// field; the framework advertises `Action::Focus` on the panel
241 /// node to signal focusability to AT. Default: `false`.
242 pub fn focusable_panel(mut self, b: bool) -> Self {
243 self.focusable_panel = b;
244 self
245 }
246}