Skip to main content

qframe/widgets/tabs/
mod.rs

1//! Tab strips.
2//!
3//! [`Tabs`] and its options live here; `strip` lays the tabs out, `paint` draws them and the
4//! shared behaviour of every tab view (opening, closing, reordering) is in
5//! [`tab_model`](super::tab_model).
6
7mod paint;
8mod strip;
9#[cfg(test)]
10mod tests;
11
12use std::time::Duration;
13
14use crate::event::{Event, MouseButton, MouseKind};
15use crate::geometry::{Rect, Size, clamp_u16};
16use crate::keymap::{Key, Modifiers};
17use crate::theme::State;
18use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
19
20use super::close_mark;
21use super::context_item::ContextItem;
22use super::context_menu;
23use super::edge_scroll::{Edge, Zone};
24use super::popup_menu::{PopupAction, PopupMenu};
25use super::tab_model::{self, Direction, TabModel, drop_target, preview_order};
26use strip::Strip;
27
28/// How wide each tab of a [`Tabs`] strip is.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum TabWidth {
31    /// As wide as its label. The default.
32    #[default]
33    Fit,
34    /// Exactly this many cells; longer labels end in `…`.
35    Fixed(u16),
36    /// The tabs share the strip's width equally; when there are too many to fit, they keep a
37    /// readable minimum and the strip overflows.
38    Fill,
39}
40
41/// What a [`Tabs`] strip does when its tabs do not fit.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum Overflow {
44    /// Arrow controls at both ends scroll the strip one tab at a time, without opening anything.
45    /// They are small buttons: hovered they brighten and raise the pillar, pressed they flash one
46    /// tone brighter, and at the end of the strip they fade and ignore presses. ctrl+PgUp,
47    /// ctrl+PgDn and the wheel scroll the same way, and opening a tab brings it into view. The
48    /// default.
49    #[default]
50    Arrows,
51    /// A control at the end shows how many tabs are hidden and opens a menu of them; ↓ opens it
52    /// from the keyboard.
53    Menu,
54}
55
56/// Width of the gap between tabs.
57const GAP: u16 = 1;
58/// Cells before a label.
59const PAD: u16 = 2;
60/// Extra cells a close mark takes: a gap after the label and the three cells of the mark, which
61/// end where the right padding begins.
62const CLOSE: u16 = 1 + close_mark::WIDTH - 1;
63/// The narrowest tab `TabWidth::Fill` shrinks to before the strip overflows.
64const FILL_MIN: u16 = 12;
65/// Width of a scroll arrow: a space, the chevron and a space.
66const ARROW: u16 = 3;
67
68/// A row of tabs. The open tab is a raised surface; tabs are never boxed or bracketed.
69///
70/// With no options it is the plainest strip: keys while focused are ←/→ (or h/l) to open the
71/// neighbouring tab, and 1–9 open a tab by number when numbers are shown. When the tabs do not
72/// fit, arrow controls appear at both ends: a click scrolls one tab, and ctrl+PgUp, ctrl+PgDn
73/// and the wheel do the same. Opening a tab brings it into view.
74///
75/// Capabilities are independent options:
76/// - [`closable`](Self::closable): a faint `×` on every tab that brightens on hover; clicking
77///   it, a middle click on the tab or ctrl+w closes the tab. [`pinned`](Self::pinned) tabs
78///   cannot be closed.
79/// - [`tab_width`](Self::tab_width): fit, fixed or filling tabs.
80/// - [`overflow`](Self::overflow): scroll arrows or a menu of hidden tabs.
81/// - [`reorderable`](Self::reorderable): drag a tab to move it; a ghost follows the pointer and
82///   the tabs make room where it will land. ctrl+shift+←/→ moves the open tab. Held on a scroll
83///   arrow or past an end of an overflowing strip, the dragged tab scrolls the strip: one tab after
84///   400 ms, then one every 150 ms (sooner the further past the end) until the pointer leaves or the
85///   strip ends; the arrow lights up meanwhile and the drop slot follows the tabs coming into view.
86///   [`on_drag_scroll`](Self::on_drag_scroll) reports each step.
87/// - [`context_menu`](Self::context_menu): a right click on a tab opens a menu of actions for
88///   it at the pointer; the menu key or shift+F10 opens the menu of the open tab. Without it a
89///   right click does nothing.
90///
91/// Style keys: `tab` with `hover`, `selected`, `focus`; `tab-index` for numbers; `close-mark`
92/// (with `active` on a raised tab, `hover` under the pointer); `tab-arrow` (`bg`, `fg`, `pillar`)
93/// with `hover`, `pressed`, `disabled`; `tab-menu` (`bg`, `fg`, `pillar`) with `hover`, `active`
94/// while its menu is open; `tab-ghost` and `tab-drop` (`bg`) while dragging; `popup-menu`,
95/// `popup-item`, `popup-check` for the menu of hidden tabs; the keys of
96/// [`ContextItem`](super::ContextItem) for the context menu.
97pub struct Tabs<Msg> {
98    labels: Vec<String>,
99    numbered: bool,
100    width: TabWidth,
101    overflow: Overflow,
102    model: TabModel<Msg>,
103}
104
105#[derive(Debug, Default)]
106struct TabsMemory {
107    offset: usize,
108    followed: Option<usize>,
109    hidden: Vec<usize>,
110    /// The arrow last pressed (by click or key) and when, for its flash.
111    pressed: Option<(Arrow, Duration)>,
112}
113
114/// One of the two scroll arrows, named by the end of the strip it scrolls towards.
115use Edge as Arrow;
116
117impl<Msg: 'static> Tabs<Msg> {
118    /// Tabs with `labels`; the first is open.
119    #[must_use]
120    pub fn new(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
121        let labels: Vec<String> = labels.into_iter().map(Into::into).collect();
122        let model = TabModel::new(labels.len());
123        Self { labels, numbered: false, width: TabWidth::Fit, overflow: Overflow::Arrows, model }
124    }
125
126    /// The open tab.
127    #[must_use]
128    pub fn active(mut self, index: usize) -> Self {
129        self.model.active = index;
130        self
131    }
132
133    /// Shows 1, 2, 3… before the labels and lets number keys open tabs.
134    #[must_use]
135    pub fn numbered(mut self, numbered: bool) -> Self {
136        self.numbered = numbered;
137        self
138    }
139
140    /// Message for opening tab `index`.
141    #[must_use]
142    pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
143        self.model.on_select = Some(Box::new(message));
144        self
145    }
146
147    /// Makes tabs closable: `message(index)` asks the application to close tab `index`.
148    /// [`TabEdit::Close`](super::TabEdit) applies it to the application's list.
149    #[must_use]
150    pub fn closable(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
151        self.model.set_on_close(message);
152        self
153    }
154
155    /// Tabs that cannot be closed, such as a pinned start page. They show no close mark.
156    #[must_use]
157    pub fn pinned(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
158        self.model.pinned = indices.into_iter().collect();
159        self
160    }
161
162    /// How wide the tabs are; [`TabWidth::Fit`] by default.
163    #[must_use]
164    pub fn tab_width(mut self, width: TabWidth) -> Self {
165        self.width = width;
166        self
167    }
168
169    /// What happens when the tabs do not fit; [`Overflow::Arrows`] by default.
170    #[must_use]
171    pub fn overflow(mut self, overflow: Overflow) -> Self {
172        self.overflow = overflow;
173        self
174    }
175
176    /// Makes tabs reorderable: `message(from, to)` asks the application to move a tab.
177    /// [`TabEdit::Move`](super::TabEdit) applies it to the application's list.
178    #[must_use]
179    pub fn reorderable(mut self, message: impl Fn(usize, usize) -> Msg + 'static) -> Self {
180        self.model.set_on_move(message);
181        self
182    }
183
184    /// Message for each step a dragged tab scrolls an overflowing strip, with the position of the
185    /// first tab now in view (counted from 0), e.g. to log it. Only reorderable strips scroll this
186    /// way.
187    #[must_use]
188    pub fn on_drag_scroll(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
189        self.model.on_drag_scroll = Some(Box::new(message));
190        self
191    }
192
193    /// Gives every tab a context menu: `items(index)` builds the entries for tab `index`, such as
194    /// Close, Close others or Pin. A right click on a tab opens its menu at the pointer; the menu
195    /// key or shift+F10 opens the menu of the open tab below it, scrolling the tab into view first.
196    /// Choosing an entry sends its message.
197    #[must_use]
198    pub fn context_menu(mut self, items: impl Fn(usize) -> Vec<ContextItem<Msg>> + 'static) -> Self {
199        self.model.set_context_menu(items);
200        self
201    }
202}
203
204impl<Msg: 'static> Widget<Msg> for Tabs<Msg> {
205    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
206        let width = match self.width {
207            TabWidth::Fill => available.width,
208            TabWidth::Fit | TabWidth::Fixed(_) => Self::total_width(&self.widths(available.width)),
209        };
210        Size::new(width, 1).min(available)
211    }
212
213    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
214        cx.register_hit(area);
215        if self.labels.is_empty() {
216            return;
217        }
218        let focused = cx.is_focus_visible();
219        let pointer = cx.pointer();
220        let widths = self.widths(area.width);
221        let active = self.model.active();
222        let drag = self.model.drag(cx);
223        let identity = self.identity();
224
225        // Scroll to the open tab: always with a menu, only when it changed with arrows, so the
226        // arrows can look around without being pulled back.
227        let offset = {
228            let memory = cx.memory::<TabsMemory>();
229            let (offset, followed) = (memory.offset, memory.followed);
230            let follows = drag.is_none() && (self.overflow == Overflow::Menu || followed != Some(active));
231            let offset = if follows {
232                self.follow(area, &identity, offset, active, &widths)
233            } else {
234                offset.min(self.labels.len() - 1)
235            };
236            let offset = if drag.is_none() { self.settle(area, &identity, offset, &widths) } else { offset };
237            let memory = cx.memory::<TabsMemory>();
238            memory.offset = offset;
239            memory.followed = Some(active);
240            offset
241        };
242
243        let resting = self.strip(area, &identity, offset, &widths);
244        let target = drag.map(|d| (d.index, drop_target(&resting.tabs, d.index, d.pointer, Direction::Across)));
245        let order = preview_order(self.labels.len(), target);
246        let strip = if target.is_some() { self.strip(area, &order, offset, &widths) } else { resting };
247
248        // An open context menu takes the pointer; only the tab it acts on stays raised.
249        let menu_tab = self.model.menu_tab(cx);
250        if menu_tab.is_some() {
251            cx.request_overlay(area);
252        }
253        let pointer = pointer.filter(|_| menu_tab.is_none());
254
255        for (position, (index, rect)) in strip.tabs.iter().enumerate() {
256            if drag.is_some_and(|d| d.index == *index) {
257                tab_model::paint_drop_slot(cx, *rect);
258                continue;
259            }
260            let mut states = Vec::new();
261            if menu_tab == Some(*index) || (drag.is_none() && pointer.is_some_and(|(x, y)| rect.contains(x, y))) {
262                states.push(State::Hover);
263            }
264            if *index == active {
265                states.push(State::Selected);
266                if focused {
267                    states.push(State::Focus);
268                }
269            }
270            self.paint_tab(cx, *rect, *index, offset + position, &states);
271        }
272
273        // The arrows and the menu control take no hover while a tab is dragged, except the arrow
274        // of the end the dragged tab is held against, which lights up as it scrolls the strip.
275        let control_pointer = drag.is_none().then_some(pointer).flatten();
276        let held = drag.and_then(|drag| self.drag_zone(area, &strip, drag.pointer.0)).map(|zone| zone.edge);
277        match self.overflow {
278            Overflow::Arrows => self.paint_arrows(cx, &strip, offset, control_pointer, held),
279            Overflow::Menu => self.paint_menu_control(cx, &strip, control_pointer),
280        }
281
282        if let Some(drag) = drag {
283            self.paint_ghost(cx, area, &strip, drag, widths[drag.index], &order);
284        }
285    }
286
287    fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
288        if self.model.paint_menu(cx, anchor) {
289            return;
290        }
291        let hidden = cx.memory::<TabsMemory>().hidden.clone();
292        let labels: Vec<String> = hidden.iter().map(|i| self.labels[*i].clone()).collect();
293        // The strip keeps the open tab in view whenever it has room for a tab at all; a strip with
294        // room only for the control lists every tab and checks the open one.
295        let current = hidden.iter().position(|i| *i == self.model.active());
296        PopupMenu::paint(cx, anchor, &labels, current);
297    }
298
299    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
300        if self.labels.is_empty() {
301            return false;
302        }
303        let area = cx.area();
304        let widths = self.widths(area.width);
305        let identity = self.identity();
306        let mut offset = cx.memory::<TabsMemory>().offset;
307        // The menu key opens the open tab's menu below that tab, so it first scrolls the tab back
308        // into view when the arrows moved it away.
309        if let Event::Key(key) = event
310            && self.model.has_menu()
311            && context_menu::is_menu_key(key)
312            && !context_menu::is_open_in(cx)
313        {
314            offset = self.follow(area, &identity, offset, self.model.active(), &widths);
315            cx.memory::<TabsMemory>().offset = offset;
316        }
317        let strip = self.strip(area, &identity, offset, &widths);
318        if self.overflow == Overflow::Menu && PopupMenu::is_open(cx) {
319            let hidden = cx.memory::<TabsMemory>().hidden.clone();
320            let labels: Vec<String> = hidden.iter().map(|i| self.labels[*i].clone()).collect();
321            match PopupMenu::event(cx, event, &labels) {
322                PopupAction::Chosen(row) => {
323                    self.model.open(cx, hidden[row]);
324                    return true;
325                }
326                // A press elsewhere on the strip closes the menu and still does what it pressed,
327                // so one click on a tab both closes the menu and opens the tab. A press on the
328                // control that opened the menu only closes it.
329                PopupAction::Closed if Self::presses_beside(event, strip.menu) => {}
330                PopupAction::Used | PopupAction::Closed => return true,
331                PopupAction::Ignored => {}
332            }
333        }
334        let hit = match event {
335            Event::Mouse(mouse) => self.hit(&strip, mouse.x, mouse.y),
336            _ => None,
337        };
338        let open_tab = strip.tabs.iter().find(|(index, _)| *index == self.model.active()).map(|(_, rect)| *rect);
339        if self.model.menu_event(cx, event, hit, open_tab) {
340            return true;
341        }
342        match event {
343            Event::Key(key) => {
344                if self.model.key(cx, key, Direction::Across) {
345                    return true;
346                }
347                if self.numbered
348                    && let Key::Char(c @ '1'..='9') = key.chord.key
349                    && key.chord.mods == Modifiers::default()
350                {
351                    let index = usize::try_from(u32::from(c) - u32::from('1')).unwrap_or(usize::MAX);
352                    return self.model.open(cx, index);
353                }
354                let ctrl = Modifiers { ctrl: true, ..Modifiers::default() };
355                // Scrolling keys only mean something while tabs are hidden; a strip that fits
356                // leaves ctrl+PgUp and ctrl+PgDn to the application.
357                if self.scrolls(&strip) && key.chord.mods == ctrl {
358                    let arrow = match key.chord.key {
359                        Key::PageUp => Arrow::Back,
360                        Key::PageDown => Arrow::Forward,
361                        _ => return false,
362                    };
363                    self.scroll(cx, &strip, arrow, true);
364                    return true;
365                }
366                if strip.menu.is_some() && key.is_plain(Key::Down) {
367                    self.open_menu(cx, &strip);
368                    return true;
369                }
370                false
371            }
372            Event::Mouse(mouse) => {
373                let left_down = mouse.kind == MouseKind::Down(MouseButton::Left);
374                // Presses on the arrows and the menu control are theirs; drags and releases pass on,
375                // so a tab dragged over an arrow still lands.
376                let down = matches!(mouse.kind, MouseKind::Down(_));
377                if down && let Some(arrow) = strip.arrow_at(mouse.x, mouse.y) {
378                    if left_down {
379                        self.scroll(cx, &strip, arrow, true);
380                    }
381                    return true;
382                }
383                if self.scrolls(&strip) && matches!(mouse.kind, MouseKind::ScrollUp | MouseKind::ScrollDown) {
384                    let arrow = if mouse.kind == MouseKind::ScrollUp { Arrow::Back } else { Arrow::Forward };
385                    self.scroll(cx, &strip, arrow, false);
386                    return true;
387                }
388                if down && strip.menu.is_some_and(|menu| menu.contains(mouse.x, mouse.y)) {
389                    if left_down {
390                        self.open_menu(cx, &strip);
391                    }
392                    return true;
393                }
394                let used = self.model.pointer(cx, mouse, hit, &strip.tabs, Direction::Across);
395                if mouse.kind == MouseKind::Drag(MouseButton::Left) {
396                    let zone = self.drag_zone(area, &strip, mouse.x);
397                    self.model.edge_scroll(cx, zone, |cx, arrow| {
398                        self.scroll(cx, &strip, arrow, true).then(|| cx.memory::<TabsMemory>().offset)
399                    });
400                }
401                used
402            }
403            _ => false,
404        }
405    }
406
407    fn focusable(&self) -> bool {
408        !self.labels.is_empty()
409    }
410}
411
412impl<Msg: 'static> Tabs<Msg> {
413    /// Whether `event` is a press somewhere other than on the menu control `menu`.
414    fn presses_beside(event: &Event, menu: Option<Rect>) -> bool {
415        matches!(event, Event::Mouse(mouse)
416            if matches!(mouse.kind, MouseKind::Down(_)) && !menu.is_some_and(|rect| rect.contains(mouse.x, mouse.y)))
417    }
418
419    /// Whether `strip` scrolls: it hides tabs and has no menu for them. A strip too narrow for
420    /// its arrows still scrolls with the wheel and the keys.
421    fn scrolls(&self, strip: &Strip) -> bool {
422        self.overflow == Overflow::Arrows && strip.tabs.len() < self.labels.len()
423    }
424
425    /// Opens the menu of hidden tabs, starting on the open tab when the strip had no room to show
426    /// it.
427    fn open_menu(&self, cx: &mut EventCx<'_, Msg>, strip: &Strip) {
428        let hidden = Self::hidden(self.labels.len(), strip);
429        PopupMenu::open(cx, hidden.iter().position(|i| *i == self.model.active()).unwrap_or(0));
430    }
431
432    /// The tabs `strip` does not show, in index order.
433    fn hidden(count: usize, strip: &Strip) -> Vec<usize> {
434        (0..count).filter(|i| !strip.tabs.iter().any(|(visible, _)| visible == i)).collect()
435    }
436
437    /// Whether `arrow` can scroll the strip `strip`, which starts at position `offset`.
438    fn can_scroll(&self, strip: &Strip, offset: usize, arrow: Arrow) -> bool {
439        match arrow {
440            Arrow::Back => offset > 0,
441            Arrow::Forward => offset + strip.tabs.len() < self.labels.len(),
442        }
443    }
444
445    /// The end of the strip a tab dragged to column `x` is held against, if the strip scrolls: a
446    /// scroll arrow, or past the strip's edge when it has no room for arrows. Only the column counts,
447    /// so a pointer that strays off the strip's line while dragging still scrolls.
448    fn drag_zone(&self, area: Rect, strip: &Strip, x: i32) -> Option<Zone> {
449        if !self.scrolls(strip) {
450            return None;
451        }
452        let (back_end, forward_start) =
453            strip.arrows.map_or((area.x, area.right()), |(back, forward)| (back.right(), forward.x));
454        if x < back_end {
455            Some(Zone { edge: Edge::Back, beyond: clamp_u16(area.x - x) })
456        } else if x >= forward_start {
457            Some(Zone { edge: Edge::Forward, beyond: clamp_u16(x + 1 - area.right()) })
458        } else {
459            None
460        }
461    }
462
463    /// Scrolls the strip one tab with `arrow`; `flash` lights the arrow as pressed, for clicks,
464    /// keys and a dragged tab. An arrow at the end of the strip does nothing. True when it scrolled.
465    fn scroll(&self, cx: &mut EventCx<'_, Msg>, strip: &Strip, arrow: Arrow, flash: bool) -> bool {
466        let now = cx.now();
467        let memory = cx.memory::<TabsMemory>();
468        if !self.can_scroll(strip, memory.offset, arrow) {
469            return false;
470        }
471        memory.offset = match arrow {
472            Arrow::Back => memory.offset - 1,
473            Arrow::Forward => memory.offset + 1,
474        };
475        if flash {
476            memory.pressed = Some((arrow, now));
477        }
478        true
479    }
480}