Skip to main content

teksilo_widgets/tab_widget/
delegate.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Per-tab metadata extraction for `TabBar<T>` and `TabWidget<T>`.
5//!
6//! `TabDelegate<T>` is a struct of closures the tab bar invokes against
7//! each item to obtain its label, icon, slots, tooltip, and capability
8//! flags (closable / pinned / enabled). Mirrors `ListView`'s
9//! `Fn(usize, &T, bool) -> Box<dyn Widget>` delegate pattern, but split
10//! into per-aspect callbacks so callers don't have to compose every
11//! affordance into one giant builder.
12//!
13//! Closures are called at build time. Mutating an item via
14//! `ListModel::set(i, …)` fires `DataChange::ItemUpdated` which
15//! triggers a rebuild of the bar — closures re-run, labels and icons
16//! re-resolve. Locale changes propagate through the same path because
17//! `LocalizedString` already carries reactive resolution semantics.
18
19use std::rc::Rc;
20
21use teksilo_canvas::Point;
22use teksilo_core::widget::{EventContext, Widget};
23use teksilo_i18n::LocalizedString;
24
25use crate::IconWidget;
26use crate::tooltip::{RichTooltipSource, TooltipContent};
27
28/// A reusable widget factory the framework calls every time a context
29/// menu opens. Returns a fresh widget instance each call (the
30/// framework can't reuse a single widget across multiple openings).
31///
32/// Same shape as the framework's
33/// [`teksilo_core::widget_builder::ContextMenuFactory`] — receives the
34/// click position (in tab-local coords) and a full
35/// [`EventContext`], and returns `Some(menu)` to mount or `None` to
36/// decline. The `Rc` wrapping is a tab-widget convenience: the
37/// delegate clones the factory per-tab without reallocating.
38pub type ContextMenuFactory = Rc<dyn Fn(Point, &mut EventContext) -> Option<Box<dyn Widget>>>;
39
40/// Bar orientation. Selects between a horizontal row of tabs (default
41/// for browser-style document tabs) and a vertical column of pills
42/// (sidebar / IDE perspective convention).
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum TabBarOrientation {
45    /// Tabs flow left-to-right in a horizontal row. Scroll axis is
46    /// horizontal; a vertical wheel maps to horizontal scroll
47    /// (Firefox / Chrome convention) when
48    /// `vertical_wheel_scrolls_horizontally` is on.
49    #[default]
50    Horizontal,
51    /// Tabs flow top-to-bottom in a vertical column. Scroll axis is
52    /// vertical; vertical wheel scrolls vertically. Pinned tabs
53    /// render in a non-scrolling strip at the top of the column.
54    Vertical,
55}
56
57impl From<TabBarOrientation> for teksilo_core::styles::TabBarOrientation {
58    fn from(o: TabBarOrientation) -> Self {
59        match o {
60            TabBarOrientation::Horizontal => teksilo_core::styles::TabBarOrientation::Horizontal,
61            TabBarOrientation::Vertical => teksilo_core::styles::TabBarOrientation::Vertical,
62        }
63    }
64}
65
66/// How wide each tab is: shared across all unpinned tabs, chosen
67/// per-tab from content, or stretched to fill the bar.
68///
69/// `Shared` and `Independent` size the **layout axis** (width in
70/// horizontal bars, height in vertical bars); `Fill` sizes the tab's
71/// **width** in both orientations — see each variant. See the module
72/// docs of [`crate::tab_widget`] for how this is applied per
73/// orientation. In wrap (multi-line horizontal) mode `Independent` is
74/// forced regardless of this setting — equal-width tabs in a wrapping
75/// row look like a tile grid and lose the bookmark-bar / pill-strip
76/// aesthetic.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum TabSizing {
79    /// All non-pinned tabs share the same extent on the layout axis.
80    /// The available region is divided equally across the unpinned
81    /// count, then clamped to `[min_tab_extent, max_tab_extent]`.
82    /// Below the min, content overflows into scroll. Above the max,
83    /// slack is left as empty space at the trailing edge.
84    ///
85    /// In a **vertical** bar the layout axis is the pill *height*, so
86    /// this yields uniform pills whose width fits the widest label
87    /// (clamped to `[min_tab_width, max_tab_width]`).
88    Shared,
89    /// Each tab sizes to its content (icon + label + slots), clamped
90    /// to `[min_tab_extent, max_tab_extent]`. Truncation via ellipsis
91    /// when content hits `max`.
92    Independent,
93    /// Tabs stretch to the full width the bar is offered — no slack
94    /// left over, no fit-to-content shrinking. The nav-rail /
95    /// segmented-control look (VS Code's settings sidebar, a
96    /// full-bleed tab strip).
97    ///
98    /// - **Horizontal:** the viewport width is divided equally across
99    ///   the unpinned tabs and `max_tab_width` is *not* applied, so
100    ///   the strip is filled edge to edge instead of leaving trailing
101    ///   slack. `min_tab_width` still holds — below it the headers
102    ///   overflow into scroll rather than squeezing to nothing.
103    /// - **Vertical:** every pill takes the bar's full proposed width
104    ///   (the widest-label clamp is bypassed), so the tabs span the
105    ///   sidebar. Pill height is unchanged (the intrinsic
106    ///   `editor_tab_height`, or the `tab_bar_height` override).
107    ///
108    /// With no width proposed at all (an unbounded measure — a
109    /// `Center`, an `HStack` asking for the natural size), there is
110    /// nothing to fill: a vertical bar falls back to the `Shared`
111    /// fit-to-widest-label width. Give the bar a bounded width (a
112    /// `FixedSize`, an `Expand` in a sized parent) for `Fill` to have
113    /// any effect.
114    Fill,
115}
116
117/// Bar-level control over what each tab shows — its icon, its label, or both.
118///
119/// Each tab still declares both a title and (optionally) an icon; this mode
120/// decides which are painted, so a caller can offer a "tab size" toggle
121/// (VS Code's activity-bar / panel convention) without rebuilding the tabs by
122/// hand. Icon-only tabs size to their icon (they don't pad out to a text
123/// width), and the full title is promoted to the hover tooltip.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
125pub enum TabDisplayMode {
126    /// Render each tab exactly as its [`TabInfo`](super::TabInfo) declares —
127    /// the title if set, the icon if set. The default; preserves per-tab
128    /// `no_title()` control.
129    #[default]
130    Auto,
131    /// Title only — icons are hidden even when present.
132    Text,
133    /// Icon only — the title becomes the hover tooltip. A tab with no icon
134    /// falls back to its title's initial letter so the mode is never blank.
135    Icon,
136    /// Icon + title.
137    IconText,
138}
139
140/// When the trailing "show all tabs" overflow dropdown button appears.
141///
142/// The dropdown is a chevron-down `PopoverIconButton` whose popover lists every
143/// tab (a jump-to menu for tabs scrolled out of view). This mode governs *when*
144/// the button itself is shown — independent of whether the tabs actually
145/// overflow the viewport (which is what drives the scroll arrows).
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
147pub enum TabOverflowButton {
148    /// Show the button **only when the tab headers overflow** the bar's
149    /// viewport — i.e. exactly when there is something scrolled out of view, the
150    /// same condition that auto-reveals the scroll arrows. The default: the
151    /// button stays out of the way until it is useful.
152    #[default]
153    Auto,
154    /// Always show the button whenever the bar has at least one tab, even when
155    /// every tab is already visible (a persistent jump-to affordance).
156    Always,
157    /// Never show the button.
158    Never,
159}
160
161/// Type alias for label-resolving callbacks.
162type LabelFn<T> = Box<dyn Fn(usize, &T) -> LocalizedString>;
163/// Type alias for icon-resolving callbacks.
164type IconFn<T> = Box<dyn Fn(usize, &T) -> Option<IconWidget>>;
165/// Type alias for slot-widget-resolving callbacks.
166type SlotFn<T> = Box<dyn Fn(usize, &T) -> Option<Box<dyn Widget>>>;
167/// Type alias for context-menu-factory-resolving callbacks. The
168/// returned factory is callable many times (once per right-click).
169type ContextMenuFn<T> = Box<dyn Fn(usize, &T) -> Option<ContextMenuFactory>>;
170/// Type alias for tooltip callbacks.
171type TooltipFn<T> = Box<dyn Fn(usize, &T) -> Option<LocalizedString>>;
172/// Type alias for rich-tooltip-key callbacks (returns a registry key per tab).
173type RichTooltipKeyFn<T> = Box<dyn Fn(usize, &T) -> Option<String>>;
174/// Type alias for inline rich-tooltip-content callbacks.
175type RichTooltipContentFn<T> = Box<dyn Fn(usize, &T) -> Option<TooltipContent>>;
176/// Type alias for composite-tooltip callbacks. Returns a boxed widget
177/// because closure-returning-`impl Trait` isn't object-safe.
178type CompositeTooltipFn<T> = Box<dyn Fn(usize, &T) -> Option<Box<dyn Widget>>>;
179/// Type alias for boolean capability callbacks.
180type FlagFn<T> = Box<dyn Fn(usize, &T) -> bool>;
181
182/// Resolves per-tab UI from a model item.
183///
184/// Required: a `label` callback. Everything else is optional and
185/// defaults to "no leading icon, no slots, no tooltip, not closable,
186/// not pinned, enabled".
187pub struct TabDelegate<T: 'static> {
188    pub(crate) label: LabelFn<T>,
189    pub(crate) icon: Option<IconFn<T>>,
190    pub(crate) leading: Option<SlotFn<T>>,
191    pub(crate) trailing: Option<SlotFn<T>>,
192    pub(crate) context_menu: Option<ContextMenuFn<T>>,
193    pub(crate) closable: Option<FlagFn<T>>,
194    pub(crate) pinned: Option<FlagFn<T>>,
195    pub(crate) enabled: Option<FlagFn<T>>,
196    pub(crate) tooltip: Option<TooltipFn<T>>,
197    pub(crate) rich_tooltip_key: Option<RichTooltipKeyFn<T>>,
198    pub(crate) rich_tooltip_content: Option<RichTooltipContentFn<T>>,
199    pub(crate) composite_tooltip: Option<CompositeTooltipFn<T>>,
200}
201
202impl<T: 'static> std::fmt::Debug for TabDelegate<T> {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        f.debug_struct("TabDelegate")
205            .field("has_icon", &self.icon.is_some())
206            .field("has_leading", &self.leading.is_some())
207            .field("has_trailing", &self.trailing.is_some())
208            .field("has_context_menu", &self.context_menu.is_some())
209            .field("has_closable", &self.closable.is_some())
210            .field("has_pinned", &self.pinned.is_some())
211            .field("has_enabled", &self.enabled.is_some())
212            .field("has_tooltip", &self.tooltip.is_some())
213            .field("has_rich_tooltip_key", &self.rich_tooltip_key.is_some())
214            .field(
215                "has_rich_tooltip_content",
216                &self.rich_tooltip_content.is_some(),
217            )
218            .field("has_composite_tooltip", &self.composite_tooltip.is_some())
219            .finish()
220    }
221}
222
223impl<T: 'static> TabDelegate<T> {
224    /// Construct from the label callback. Every other field defaults
225    /// to its identity behavior.
226    pub fn new(label: impl Fn(usize, &T) -> LocalizedString + 'static) -> Self {
227        Self {
228            label: Box::new(label),
229            icon: None,
230            leading: None,
231            trailing: None,
232            context_menu: None,
233            closable: None,
234            pinned: None,
235            enabled: None,
236            tooltip: None,
237            rich_tooltip_key: None,
238            rich_tooltip_content: None,
239            composite_tooltip: None,
240        }
241    }
242
243    /// Per-tab leading icon (rendered before the label).
244    pub fn icon(mut self, f: impl Fn(usize, &T) -> Option<IconWidget> + 'static) -> Self {
245        self.icon = Some(Box::new(f));
246        self
247    }
248
249    /// Per-tab leading slot (between the icon and label, or before
250    /// the label when no icon is present).
251    pub fn leading(mut self, f: impl Fn(usize, &T) -> Option<Box<dyn Widget>> + 'static) -> Self {
252        self.leading = Some(Box::new(f));
253        self
254    }
255
256    /// Per-tab trailing slot (between the label and the close button,
257    /// or at the trailing edge when no close button is present).
258    pub fn trailing(mut self, f: impl Fn(usize, &T) -> Option<Box<dyn Widget>> + 'static) -> Self {
259        self.trailing = Some(Box::new(f));
260        self
261    }
262
263    /// Per-tab context menu factory. Activated by right-click /
264    /// long-press / `accesskit::Action::ShowContextMenu`.
265    ///
266    /// The closure runs once per build and returns an optional
267    /// [`ContextMenuFactory`]. The factory itself is called every
268    /// time the menu opens, returning a fresh menu widget each call —
269    /// the framework cannot reuse a single widget instance across
270    /// multiple openings.
271    pub fn context_menu(
272        mut self,
273        f: impl Fn(usize, &T) -> Option<ContextMenuFactory> + 'static,
274    ) -> Self {
275        self.context_menu = Some(Box::new(f));
276        self
277    }
278
279    /// Per-tab closable flag. When `true`, the tab gets a trailing
280    /// close button and middle-click / `Ctrl+W` close affordances.
281    /// Pinned tabs suppress the close button regardless of this flag
282    /// (pinned tabs only close via the context menu — Firefox
283    /// convention).
284    pub fn closable(mut self, f: impl Fn(usize, &T) -> bool + 'static) -> Self {
285        self.closable = Some(Box::new(f));
286        self
287    }
288
289    /// Per-tab pinned flag. Pinned tabs render in a leading
290    /// non-scrolling region with a fixed icon-only width.
291    pub fn pinned(mut self, f: impl Fn(usize, &T) -> bool + 'static) -> Self {
292        self.pinned = Some(Box::new(f));
293        self
294    }
295
296    /// Per-tab enabled flag. Disabled tabs are visible but not
297    /// activatable, skipped by keyboard navigation, and excluded from
298    /// the close / pin / context-menu affordances.
299    pub fn enabled(mut self, f: impl Fn(usize, &T) -> bool + 'static) -> Self {
300        self.enabled = Some(Box::new(f));
301        self
302    }
303
304    /// Per-tab tooltip text. Shown on hover via the existing
305    /// `WidgetBuilder::tooltip` mechanism.
306    pub fn tooltip(mut self, f: impl Fn(usize, &T) -> Option<LocalizedString> + 'static) -> Self {
307        self.tooltip = Some(Box::new(f));
308        self.rich_tooltip_key = None;
309        self.rich_tooltip_content = None;
310        self.composite_tooltip = None;
311        self
312    }
313
314    /// Per-tab rich-tooltip registry key. Returning `Some(key)` makes
315    /// the tab show a rich tooltip resolved against
316    /// [`TooltipRegistry`](crate::tooltip::TooltipRegistry).
317    pub fn rich_tooltip_key(mut self, f: impl Fn(usize, &T) -> Option<String> + 'static) -> Self {
318        self.rich_tooltip_key = Some(Box::new(f));
319        self.tooltip = None;
320        self.rich_tooltip_content = None;
321        self.composite_tooltip = None;
322        self
323    }
324
325    /// Per-tab inline rich-tooltip content. Skips the registry — useful
326    /// for tooltips whose body depends on `T`'s state.
327    pub fn rich_tooltip_content_with(
328        mut self,
329        f: impl Fn(usize, &T) -> Option<TooltipContent> + 'static,
330    ) -> Self {
331        self.rich_tooltip_content = Some(Box::new(f));
332        self.tooltip = None;
333        self.rich_tooltip_key = None;
334        self.composite_tooltip = None;
335        self
336    }
337
338    /// Per-tab composite-tooltip body factory. Returning
339    /// `Some(boxed_widget)` makes the tab show a composite tooltip
340    /// containing that subtree. The closure runs at tab-header build
341    /// time (and on every rebuild after data changes), so the body
342    /// can carry per-tab dynamic state.
343    pub fn composite_tooltip_with(
344        mut self,
345        f: impl Fn(usize, &T) -> Option<Box<dyn Widget>> + 'static,
346    ) -> Self {
347        self.composite_tooltip = Some(Box::new(f));
348        self.tooltip = None;
349        self.rich_tooltip_key = None;
350        self.rich_tooltip_content = None;
351        self
352    }
353
354    pub(crate) fn resolve_label(&self, index: usize, item: &T) -> LocalizedString {
355        (self.label)(index, item)
356    }
357
358    pub(crate) fn resolve_icon(&self, index: usize, item: &T) -> Option<IconWidget> {
359        self.icon.as_ref().and_then(|f| f(index, item))
360    }
361
362    pub(crate) fn resolve_leading(&self, index: usize, item: &T) -> Option<Box<dyn Widget>> {
363        self.leading.as_ref().and_then(|f| f(index, item))
364    }
365
366    pub(crate) fn resolve_trailing(&self, index: usize, item: &T) -> Option<Box<dyn Widget>> {
367        self.trailing.as_ref().and_then(|f| f(index, item))
368    }
369
370    pub(crate) fn resolve_context_menu(
371        &self,
372        index: usize,
373        item: &T,
374    ) -> Option<ContextMenuFactory> {
375        self.context_menu.as_ref().and_then(|f| f(index, item))
376    }
377
378    pub(crate) fn resolve_closable(&self, index: usize, item: &T) -> bool {
379        self.closable
380            .as_ref()
381            .map(|f| f(index, item))
382            .unwrap_or(false)
383    }
384
385    pub(crate) fn resolve_pinned(&self, index: usize, item: &T) -> bool {
386        self.pinned
387            .as_ref()
388            .map(|f| f(index, item))
389            .unwrap_or(false)
390    }
391
392    pub(crate) fn resolve_enabled(&self, index: usize, item: &T) -> bool {
393        self.enabled
394            .as_ref()
395            .map(|f| f(index, item))
396            .unwrap_or(true)
397    }
398
399    pub(crate) fn resolve_tooltip(&self, index: usize, item: &T) -> Option<LocalizedString> {
400        self.tooltip.as_ref().and_then(|f| f(index, item))
401    }
402
403    pub(crate) fn resolve_rich_tooltip(&self, index: usize, item: &T) -> Option<RichTooltipSource> {
404        if let Some(ref f) = self.rich_tooltip_key
405            && let Some(k) = f(index, item)
406        {
407            return Some(RichTooltipSource::Key(k));
408        }
409        if let Some(ref f) = self.rich_tooltip_content
410            && let Some(c) = f(index, item)
411        {
412            return Some(RichTooltipSource::Content(c));
413        }
414        None
415    }
416
417    pub(crate) fn resolve_composite_tooltip(
418        &self,
419        index: usize,
420        item: &T,
421    ) -> Option<Box<dyn Widget>> {
422        self.composite_tooltip.as_ref().and_then(|f| f(index, item))
423    }
424}