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}