Skip to main content

qframe/widgets/
tab_model.rs

1//! The behaviour every tab view shares: opening, closing and reordering tabs with keys and the
2//! mouse, and the context menu of a tab. [`Tabs`](super::Tabs) lays tabs out across,
3//! [`TabRail`](super::TabRail) down; both keep their options and input handling here, so a
4//! closable, reorderable tab with a menu behaves the same in either.
5
6use crate::event::{Event, KeyEvent, MouseButton, MouseEvent, MouseKind};
7use crate::geometry::Rect;
8use crate::keymap::Key;
9use crate::style::CellStyle;
10use crate::widget::{EventCx, PaintCx, Widget};
11
12use super::IndexMessage;
13use super::context_item::{self, ContextItem};
14use super::context_menu::{self, ContextMenu};
15use super::edge_scroll::{Edge, EdgeScroll, Zone};
16
17/// Builds a message from a tab's old and new index.
18type MoveMessage<Msg> = Box<dyn Fn(usize, usize) -> Msg>;
19
20/// Builds the context menu entries of a tab from its index.
21type MenuItems<Msg> = Box<dyn Fn(usize) -> Vec<ContextItem<Msg>>>;
22
23/// A change a closable or reorderable tab view asks for. Tab views never change the
24/// application's tabs themselves; [`TabEdit::apply`] does the bookkeeping on the application's
25/// own list.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum TabEdit {
28    /// Tab `index` is closed.
29    Close(usize),
30    /// The tab at `from` moves so that its index becomes `to`.
31    Move {
32        /// The tab's index before the move.
33        from: usize,
34        /// The tab's index after the move.
35        to: usize,
36    },
37}
38
39impl TabEdit {
40    /// Applies the edit to `tabs` and keeps `active` on the same tab. Closing the open tab opens
41    /// the tab that took its place, or the new last tab when it was the last one. Indices out of
42    /// range are ignored.
43    pub fn apply<T>(self, tabs: &mut Vec<T>, active: &mut usize) {
44        match self {
45            Self::Close(index) => {
46                if index >= tabs.len() {
47                    return;
48                }
49                tabs.remove(index);
50                if index < *active {
51                    *active -= 1;
52                }
53                *active = (*active).min(tabs.len().saturating_sub(1));
54            }
55            Self::Move { from, to } => {
56                if from >= tabs.len() || to >= tabs.len() || from == to {
57                    return;
58                }
59                let tab = tabs.remove(from);
60                tabs.insert(to, tab);
61                *active = if *active == from {
62                    to
63                } else if from < *active && to >= *active {
64                    *active - 1
65                } else if from > *active && to <= *active {
66                    *active + 1
67                } else {
68                    *active
69                };
70            }
71        }
72    }
73}
74
75/// The axis tabs are laid out along.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub(crate) enum Direction {
78    /// Side by side, like [`Tabs`](super::Tabs).
79    Across,
80    /// Stacked, like [`TabRail`](super::TabRail).
81    Down,
82}
83
84/// What the pointer is over in a tab view.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub(crate) enum TabHit {
87    /// Tab `index`, painted in `rect`.
88    Tab(usize, Rect),
89    /// The close mark of tab `index`.
90    Close(usize),
91}
92
93/// A pointer press on a tab that may become a drag or a close.
94#[derive(Debug, Clone, Copy)]
95struct Pressed {
96    index: usize,
97    start: (i32, i32),
98    pointer: (i32, i32),
99    grab: (i32, i32),
100    dragging: bool,
101    close: bool,
102}
103
104/// Pointer state of a tab view, kept in its memory.
105#[derive(Debug, Default)]
106struct TabPointer {
107    pressed: Option<Pressed>,
108    /// Scrolling while the dragged tab rests against an end of the view.
109    edge: EdgeScroll,
110}
111
112/// A tab being dragged, for painting.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub(crate) struct Drag {
115    /// The dragged tab.
116    pub(crate) index: usize,
117    /// The pointer cell.
118    pub(crate) pointer: (i32, i32),
119    /// Where in the tab the pointer grabbed it, relative to its top-left cell.
120    pub(crate) grab: (i32, i32),
121}
122
123/// Options and messages of a tab view, and the input handling built on them.
124pub(crate) struct TabModel<Msg> {
125    pub(crate) count: usize,
126    pub(crate) active: usize,
127    pub(crate) pinned: Vec<usize>,
128    pub(crate) on_select: Option<IndexMessage<Msg>>,
129    pub(crate) on_close: Option<IndexMessage<Msg>>,
130    pub(crate) on_move: Option<MoveMessage<Msg>>,
131    /// Message for each step a dragged tab scrolls the view, with the first position in view.
132    pub(crate) on_drag_scroll: Option<IndexMessage<Msg>>,
133    menu: Option<MenuItems<Msg>>,
134}
135
136/// The tab whose context menu is open, kept in the view's memory.
137#[derive(Debug, Default)]
138struct MenuTab(usize);
139
140impl<Msg> TabModel<Msg> {
141    pub(crate) fn new(count: usize) -> Self {
142        Self {
143            count,
144            active: 0,
145            pinned: Vec::new(),
146            on_select: None,
147            on_close: None,
148            on_move: None,
149            on_drag_scroll: None,
150            menu: None,
151        }
152    }
153
154    pub(crate) fn set_context_menu(&mut self, items: impl Fn(usize) -> Vec<ContextItem<Msg>> + 'static) {
155        self.menu = Some(Box::new(items));
156    }
157
158    /// The context menu of tab `index` with its messages replaced by positions, and the messages.
159    fn menu_for(&self, index: usize) -> Option<(ContextMenu<usize>, Vec<Msg>)> {
160        let items = self.menu.as_ref()?;
161        let (items, messages) = context_item::keyed(items(index));
162        Some((ContextMenu::new(items), messages))
163    }
164
165    /// Whether the view has a context menu.
166    pub(crate) fn has_menu(&self) -> bool {
167        self.menu.is_some()
168    }
169
170    /// Opens the context menu of tab `index` below `anchor`; from the keyboard its first entry is
171    /// highlighted.
172    fn open_menu(&self, cx: &mut EventCx<'_, Msg>, index: usize, anchor: Rect, keyboard: bool) {
173        let Some((menu, _)) = self.menu_for(index) else {
174            return;
175        };
176        cx.memory::<MenuTab>().0 = index;
177        cx.with_messages(|cx: &mut EventCx<'_, usize>| menu.open(cx, anchor, keyboard));
178    }
179
180    /// Offers `event` to the context menu, when the view has one; true when the menu used it.
181    ///
182    /// A right press on a tab opens the menu of that tab at the pointer, the menu key or
183    /// shift+F10 opens the menu of the open tab below `open_tab` (its rect on screen, if shown).
184    /// While the menu is open it takes the keys, the wheel and presses on itself, and sends the
185    /// message of the chosen entry; a left press anywhere else closes it and is not used, so the
186    /// view still acts on it. `hit` is the tab under the pointer.
187    pub(crate) fn menu_event(
188        &self,
189        cx: &mut EventCx<'_, Msg>,
190        event: &Event,
191        hit: Option<TabHit>,
192        open_tab: Option<Rect>,
193    ) -> bool {
194        if self.menu.is_none() || self.count == 0 {
195            return false;
196        }
197        let right_press = match event {
198            Event::Mouse(mouse) if mouse.kind == MouseKind::Down(MouseButton::Right) => Some((mouse.x, mouse.y)),
199            _ => None,
200        };
201        if context_menu::is_open_in(cx) {
202            let tab = cx.memory::<MenuTab>().0;
203            let elsewhere = right_press.is_some_and(|(x, y)| !context_menu::contains(cx, x, y));
204            // A right press beside the menu, or a tab that went away, closes it; the press may open
205            // the menu of another tab below.
206            if elsewhere || tab >= self.count {
207                cx.with_messages(|cx: &mut EventCx<'_, usize>| ContextMenu::<usize>::close(cx));
208            } else if let Some((menu, messages)) = self.menu_for(tab) {
209                let (used, chosen) = cx.with_messages(|cx| menu.event(cx, event));
210                if let Some(message) = chosen.last().and_then(|chosen| messages.into_iter().nth(*chosen)) {
211                    cx.emit(message);
212                }
213                return used;
214            }
215        }
216        match event {
217            Event::Mouse(_) => {
218                let Some((x, y)) = right_press else {
219                    return false;
220                };
221                if let Some(TabHit::Tab(index, _) | TabHit::Close(index)) = hit {
222                    self.open_menu(cx, index, Rect::new(x, y, 1, 1), false);
223                }
224                true
225            }
226            Event::Key(key) if context_menu::is_menu_key(key) => {
227                let area = cx.area();
228                let anchor = open_tab.unwrap_or(Rect::new(area.x, area.y, 1, 1));
229                self.open_menu(cx, self.active(), anchor, true);
230                true
231            }
232            _ => false,
233        }
234    }
235
236    /// Paints the open context menu; call it from `paint_overlay`. True when it painted one.
237    pub(crate) fn paint_menu(&self, cx: &mut PaintCx<'_>, anchor: Rect) -> bool {
238        match self.menu_tab(cx).and_then(|tab| self.menu_for(tab)) {
239            Some((menu, _)) => {
240                menu.paint_overlay(cx, anchor);
241                true
242            }
243            None => false,
244        }
245    }
246
247    /// The tab whose context menu is open in the view being painted. The view keeps that tab
248    /// raised as if hovered, so it is clear what the menu acts on.
249    pub(crate) fn menu_tab(&self, cx: &mut PaintCx<'_>) -> Option<usize> {
250        if self.menu.is_none() || !context_menu::is_open(cx) {
251            return None;
252        }
253        Some(cx.memory::<MenuTab>().0).filter(|tab| *tab < self.count)
254    }
255
256    pub(crate) fn set_on_close(&mut self, message: impl Fn(usize) -> Msg + 'static) {
257        self.on_close = Some(Box::new(message));
258    }
259
260    pub(crate) fn set_on_move(&mut self, message: impl Fn(usize, usize) -> Msg + 'static) {
261        self.on_move = Some(Box::new(message));
262    }
263
264    /// The open tab, within range.
265    pub(crate) fn active(&self) -> usize {
266        self.active.min(self.count.saturating_sub(1))
267    }
268
269    /// Whether tab `index` shows a close mark and can be closed.
270    pub(crate) fn closable(&self, index: usize) -> bool {
271        self.on_close.is_some() && !self.pinned.contains(&index)
272    }
273
274    pub(crate) fn reorderable(&self) -> bool {
275        self.on_move.is_some()
276    }
277
278    /// Opens tab `index`; true when it exists.
279    pub(crate) fn open(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
280        if index >= self.count {
281            return false;
282        }
283        if index != self.active
284            && let Some(message) = &self.on_select
285        {
286            cx.emit(message(index));
287        }
288        true
289    }
290
291    /// Closes tab `index` when it can be closed.
292    pub(crate) fn close(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
293        if index >= self.count || !self.closable(index) {
294            return false;
295        }
296        if let Some(message) = &self.on_close {
297            cx.emit(message(index));
298        }
299        true
300    }
301
302    fn move_tab(&self, cx: &mut EventCx<'_, Msg>, from: usize, to: usize) {
303        if from != to
304            && to < self.count
305            && let Some(message) = &self.on_move
306        {
307            cx.emit(message(from, to));
308        }
309    }
310
311    /// Keys every tab view shares: the arrows along `direction` (and h/l or k/j) open the
312    /// neighbour; with closing on, ctrl+w closes the open tab; with reordering on, ctrl+shift
313    /// and the arrows move it.
314    pub(crate) fn key(&self, cx: &mut EventCx<'_, Msg>, key: &KeyEvent, direction: Direction) -> bool {
315        let (back, forward, back_letter, forward_letter) = match direction {
316            Direction::Across => (Key::Left, Key::Right, 'h', 'l'),
317            Direction::Down => (Key::Up, Key::Down, 'k', 'j'),
318        };
319        let active = self.active();
320        if key.is_plain(back) || key.is_plain(Key::Char(back_letter)) {
321            return active > 0 && self.open(cx, active - 1);
322        }
323        if key.is_plain(forward) || key.is_plain(Key::Char(forward_letter)) {
324            return self.open(cx, active + 1);
325        }
326        let mods = key.chord.mods;
327        if self.on_close.is_some() && mods.ctrl && !mods.shift && !mods.alt && key.chord.key == Key::Char('w') {
328            self.close(cx, active);
329            return true;
330        }
331        if self.reorderable() && mods.ctrl && mods.shift && !mods.alt {
332            if key.chord.key == back {
333                if active > 0 {
334                    self.move_tab(cx, active, active - 1);
335                }
336                return true;
337            }
338            if key.chord.key == forward {
339                self.move_tab(cx, active, active + 1);
340                return true;
341            }
342        }
343        false
344    }
345
346    /// Pointer input. `hit` is what the pointer is over; `slots` are the tabs on screen in
347    /// their resting order, used to find where a dragged tab lands.
348    pub(crate) fn pointer(
349        &self,
350        cx: &mut EventCx<'_, Msg>,
351        mouse: &MouseEvent,
352        hit: Option<TabHit>,
353        slots: &[(usize, Rect)],
354        direction: Direction,
355    ) -> bool {
356        let at = (mouse.x, mouse.y);
357        match mouse.kind {
358            MouseKind::Down(MouseButton::Middle) => match hit {
359                Some(TabHit::Tab(index, _) | TabHit::Close(index)) if self.on_close.is_some() => {
360                    self.close(cx, index);
361                    true
362                }
363                _ => false,
364            },
365            MouseKind::Down(MouseButton::Left) => match hit {
366                Some(TabHit::Close(index)) => {
367                    cx.capture_pointer();
368                    let press = Pressed { index, start: at, pointer: at, grab: (0, 0), dragging: false, close: true };
369                    *cx.memory::<TabPointer>() = TabPointer { pressed: Some(press), edge: EdgeScroll::default() };
370                    true
371                }
372                Some(TabHit::Tab(index, rect)) => {
373                    self.open(cx, index);
374                    if self.reorderable() {
375                        cx.capture_pointer();
376                        let grab = (at.0 - rect.x, at.1 - rect.y);
377                        let press = Pressed { index, start: at, pointer: at, grab, dragging: false, close: false };
378                        *cx.memory::<TabPointer>() = TabPointer { pressed: Some(press), edge: EdgeScroll::default() };
379                    }
380                    true
381                }
382                None => false,
383            },
384            MouseKind::Drag(MouseButton::Left) => {
385                let memory = cx.memory::<TabPointer>();
386                let Some(press) = &mut memory.pressed else {
387                    return false;
388                };
389                press.pointer = at;
390                let travel = match direction {
391                    Direction::Across => (at.0 - press.start.0).abs() >= 2,
392                    Direction::Down => (at.1 - press.start.1).abs() >= 1,
393                };
394                if !press.close && travel {
395                    press.dragging = true;
396                }
397                true
398            }
399            MouseKind::Up(MouseButton::Left) => {
400                let memory = cx.memory::<TabPointer>();
401                memory.edge = EdgeScroll::default();
402                let Some(press) = memory.pressed.take() else {
403                    return false;
404                };
405                if press.close {
406                    if hit == Some(TabHit::Close(press.index)) {
407                        self.close(cx, press.index);
408                    }
409                } else if press.dragging {
410                    let to = drop_target(slots, press.index, at, direction);
411                    self.move_tab(cx, press.index, to);
412                }
413                true
414            }
415            _ => false,
416        }
417    }
418
419    /// Scrolls the view while a dragged tab rests against one of its ends; call it with every left
420    /// drag, after [`pointer`](Self::pointer). `zone` is the end the pointer is on, if any; it only
421    /// counts while a tab is being dragged, so a press held on a tab or on its close mark never
422    /// scrolls. `step` scrolls the view one tab towards an end and returns the first position now in
423    /// view, or none at that end. Each step sends the drag-scroll message.
424    pub(crate) fn edge_scroll(
425        &self,
426        cx: &mut EventCx<'_, Msg>,
427        zone: Option<Zone>,
428        step: impl FnOnce(&mut EventCx<'_, Msg>, Edge) -> Option<usize>,
429    ) {
430        let memory = cx.memory::<TabPointer>();
431        let dragging = memory.pressed.is_some_and(|press| press.dragging);
432        let mut edge = memory.edge;
433        let first = edge.drive(cx, zone.filter(|_| dragging), step);
434        cx.memory::<TabPointer>().edge = edge;
435        if let (Some(first), Some(message)) = (first, &self.on_drag_scroll) {
436            cx.emit(message(first));
437        }
438    }
439
440    /// The tab being dragged right now, if any.
441    pub(crate) fn drag(&self, cx: &mut PaintCx<'_>) -> Option<Drag> {
442        let press = cx.memory::<TabPointer>().pressed?;
443        (press.dragging && press.index < self.count).then_some(Drag {
444            index: press.index,
445            pointer: press.pointer,
446            grab: press.grab,
447        })
448    }
449}
450
451/// Paints the tinted slot where a dragged tab will land, in `tab-drop`.
452pub(crate) fn paint_drop_slot(cx: &mut PaintCx<'_>, rect: Rect) {
453    let drop = cx.style("tab-drop", None, &[]).text();
454    cx.clear(rect, drop.bg.unwrap_or_else(|| cx.color("raised")));
455}
456
457/// Paints the surface of the ghost that follows the pointer while a tab is dragged, in `tab-ghost`,
458/// and returns that style for the ghost's text.
459pub(crate) fn paint_ghost_surface(cx: &mut PaintCx<'_>, rect: Rect) -> CellStyle {
460    let ghost = cx.style("tab-ghost", None, &[]).text();
461    cx.clear(rect, ghost.bg.unwrap_or_else(|| cx.color("active")));
462    ghost
463}
464
465/// The index a tab dragged from `from` gets when dropped at `pointer`: the index of the resting
466/// tab under the pointer, or of the nearest one on screen. Computing it from the resting layout
467/// keeps the target steady while the preview moves tabs around.
468pub(crate) fn drop_target(slots: &[(usize, Rect)], from: usize, pointer: (i32, i32), direction: Direction) -> usize {
469    let along = |rect: &Rect| match direction {
470        Direction::Across => rect.x,
471        Direction::Down => rect.y,
472    };
473    let position = match direction {
474        Direction::Across => pointer.0,
475        Direction::Down => pointer.1,
476    };
477    let Some((first, _)) = slots.first() else {
478        return from;
479    };
480    slots.iter().take_while(|(_, rect)| along(rect) <= position).last().map_or(*first, |(index, _)| *index)
481}
482
483/// The order tabs are shown in while `drag` would drop tab `from` at `to`.
484pub(crate) fn preview_order(count: usize, drag: Option<(usize, usize)>) -> Vec<usize> {
485    let mut order: Vec<usize> = (0..count).collect();
486    if let Some((from, to)) = drag
487        && from < count
488        && to < count
489    {
490        let tab = order.remove(from);
491        order.insert(to, tab);
492    }
493    order
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn edits_keep_the_open_tab() {
502        let mut tabs = vec!["a", "b", "c", "d"];
503        let mut active = 2;
504        TabEdit::Close(0).apply(&mut tabs, &mut active);
505        assert_eq!((tabs.as_slice(), active), (&["b", "c", "d"][..], 1));
506        TabEdit::Close(1).apply(&mut tabs, &mut active);
507        assert_eq!((tabs.as_slice(), active), (&["b", "d"][..], 1));
508        TabEdit::Close(1).apply(&mut tabs, &mut active);
509        assert_eq!((tabs.as_slice(), active), (&["b"][..], 0));
510
511        let mut tabs = vec!["a", "b", "c", "d"];
512        let mut active = 1;
513        TabEdit::Move { from: 1, to: 3 }.apply(&mut tabs, &mut active);
514        assert_eq!((tabs.as_slice(), active), (&["a", "c", "d", "b"][..], 3));
515        TabEdit::Move { from: 0, to: 3 }.apply(&mut tabs, &mut active);
516        assert_eq!((tabs.as_slice(), active), (&["c", "d", "b", "a"][..], 2));
517        TabEdit::Move { from: 3, to: 0 }.apply(&mut tabs, &mut active);
518        assert_eq!((tabs.as_slice(), active), (&["a", "c", "d", "b"][..], 3));
519        TabEdit::Move { from: 9, to: 0 }.apply(&mut tabs, &mut active);
520        assert_eq!(active, 3);
521    }
522
523    #[test]
524    fn drop_target_is_the_resting_tab_under_the_pointer() {
525        let slots = [(2, Rect::new(4, 0, 6, 1)), (3, Rect::new(11, 0, 9, 1)), (4, Rect::new(21, 0, 5, 1))];
526        assert_eq!(drop_target(&slots, 3, (0, 0), Direction::Across), 2);
527        assert_eq!(drop_target(&slots, 2, (12, 0), Direction::Across), 3);
528        assert_eq!(drop_target(&slots, 2, (40, 0), Direction::Across), 4);
529        assert_eq!(preview_order(4, Some((0, 2))), vec![1, 2, 0, 3]);
530    }
531}