Skip to main content

ravenrs_widgets/lists/
document_tabs.rs

1//! Tabs for open documents.
2
3use std::cell::RefCell;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Color, CursorIcon, Insets, Key, Role};
8use ravenrs_layout::{Align, Axis, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle};
11
12use super::reorder::{self, Extents, Reorder};
13use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
14use crate::quick_edit::is_f2;
15use crate::stack::ScrollEdgeArrow;
16use crate::util::{self, FocusTarget, Roving};
17use crate::view::{Keyed, View};
18use crate::{ButtonSize, ContextMenu, Dot, HStack, IconButton, IconName, InlineRename, MenuItem, Tone};
19
20/// The widest a title gets; a longer one ends in `…`.
21const TITLE_MAX_WIDTH: f32 = 160.0;
22const TAB_HEIGHT: f32 = 34.0;
23/// How thick the band of a tab's color is.
24const COLOR_BAND: f32 = 3.0;
25
26/// An open document in a [`DocumentTabs`].
27#[derive(Clone, Debug, PartialEq)]
28#[non_exhaustive]
29#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
30pub struct DocumentTab {
31    /// What identifies it, whatever its place.
32    pub id: u64,
33    /// What it is called.
34    pub title: String,
35    /// A color for telling it apart.
36    pub color: Option<Color>,
37    /// It has changes that were not saved.
38    pub unsaved: bool,
39    /// An icon before its title.
40    pub icon: Option<IconName>,
41    /// It has a button that closes it (on by default).
42    pub closable: bool,
43}
44
45#[cfg_attr(feature = "live", ravenrs_live::expose)]
46impl DocumentTab {
47    /// A tab called `title`.
48    pub fn new(id: u64, title: impl Into<String>) -> Self {
49        Self { id, title: title.into(), color: None, unsaved: false, icon: None, closable: true }
50    }
51
52    /// A color for telling it apart.
53    pub fn color(mut self, color: Color) -> Self {
54        self.color = Some(color);
55        self
56    }
57
58    /// Whether it has changes that were not saved, shown with a dot.
59    pub fn unsaved(mut self, unsaved: bool) -> Self {
60        self.unsaved = unsaved;
61        self
62    }
63
64    /// An icon before its title.
65    pub fn icon(mut self, icon: IconName) -> Self {
66        self.icon = Some(icon);
67        self
68    }
69
70    /// Whether it has a button that closes it.
71    pub fn closable(mut self, closable: bool) -> Self {
72        self.closable = closable;
73        self
74    }
75}
76
77/// Tabs for open documents, as a browser or an editor has: they close, change places by dragging
78/// or with Alt and the arrows, are renamed with a double click, F2 or the menu, carry a color and
79/// a dot for what was not saved, and scroll with arrows when they do not fit. The arrows alone
80/// move between them, and Delete closes. The tabs belong to the app, which applies what the
81/// handlers tell it.
82///
83/// ```
84/// use ravenrs_reactive::State;
85/// use ravenrs_widgets::{DocumentTab, DocumentTabs, Ui};
86///
87/// let active = State::new(1_u64);
88/// let tabs = DocumentTabs::new(
89///     vec![DocumentTab::new(1, "Wedding"), DocumentTab::new(2, "Portraits").unsaved(true)],
90///     active.binding(),
91/// )
92/// .on_close(|id| println!("close {id}"))
93/// .on_reorder(|from, to| println!("{from} to {to}"));
94/// let mut ui = Ui::new(tabs, ravenrs_style::Theme::raven_dark());
95/// ui.update();
96/// ```
97#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
98pub struct DocumentTabs {
99    tabs: Vec<DocumentTab>,
100    active: Binding<u64>,
101    on_close: Option<Rc<dyn Fn(u64)>>,
102    on_reorder: Option<Rc<dyn Fn(usize, usize)>>,
103    on_rename: Option<Rc<dyn Fn(u64, String)>>,
104    on_new: Option<Rc<dyn Fn()>>,
105    menu: Option<Rc<dyn Fn(u64) -> Vec<MenuItem>>>,
106    label: Option<String>,
107    fade: Semantic,
108}
109
110impl fmt::Debug for DocumentTabs {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.debug_struct("DocumentTabs").field("tabs", &self.tabs).finish_non_exhaustive()
113    }
114}
115
116#[cfg_attr(feature = "live", ravenrs_live::expose)]
117impl DocumentTabs {
118    /// These tabs, in this order, with the id of the open one in `active`.
119    pub fn new(tabs: Vec<DocumentTab>, active: Binding<u64>) -> Self {
120        Self {
121            tabs,
122            active,
123            on_close: None,
124            on_reorder: None,
125            on_rename: None,
126            on_new: None,
127            menu: None,
128            label: None,
129            fade: Semantic::Canvas,
130        }
131    }
132
133    /// Called with a tab to close, and turns on what closes them.
134    pub fn on_close(mut self, handler: impl Fn(u64) + 'static) -> Self {
135        self.on_close = Some(Rc::new(handler));
136        self
137    }
138
139    /// Called with where a tab was and where it should go, and lets them be moved.
140    pub fn on_reorder(mut self, handler: impl Fn(usize, usize) + 'static) -> Self {
141        self.on_reorder = Some(Rc::new(handler));
142        self
143    }
144
145    /// Called with a tab and its new name, and lets them be renamed.
146    pub fn on_rename(mut self, handler: impl Fn(u64, String) + 'static) -> Self {
147        self.on_rename = Some(Rc::new(handler));
148        self
149    }
150
151    /// Shows a button at the end that calls this.
152    pub fn on_new(mut self, handler: impl Fn() + 'static) -> Self {
153        self.on_new = Some(Rc::new(handler));
154        self
155    }
156
157    /// More items for a tab's menu, above the ones it already has.
158    pub fn menu(mut self, items: impl Fn(u64) -> Vec<MenuItem> + 'static) -> Self {
159        self.menu = Some(Rc::new(items));
160        self
161    }
162
163    /// What screen readers call the strip, instead of the name in [`Labels`](crate::Labels).
164    pub fn label(mut self, label: impl Into<String>) -> Self {
165        self.label = Some(label.into());
166        self
167    }
168
169    /// The color behind the strip, which its arrows fade from.
170    pub fn fade(mut self, fade: Semantic) -> Self {
171        self.fade = fade;
172        self
173    }
174}
175
176struct TabView<'a> {
177    strip: &'a DocumentTabs,
178    tab: &'a DocumentTab,
179    index: usize,
180    extents: Extents,
181    drag: State<Option<Reorder>>,
182    editing: State<bool>,
183    roving: Roving,
184    spoken: State<String>,
185}
186
187impl View for TabView<'_> {
188    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
189        let theme = *cx.theme();
190        let labels = cx.labels();
191        let (tab, strip, index) = (self.tab, self.strip, self.index);
192        let frame = cx.scroll_viewport();
193        if let (Some(size), Some(slot)) = (frame, self.extents.borrow_mut().get_mut(index)) {
194            *slot = size.width;
195        }
196        let on = strip.active.get() == tab.id;
197        let hovered = cx.is_hover_within();
198        let editing = self.editing.get() && strip.on_rename.is_some();
199        let ink = theme.color(if on { Semantic::Ink } else { Semantic::InkMuted });
200        let mut parts = Vec::with_capacity(6);
201        // Tabs keep their width and the strip scrolls: squeezed, none of them could be read.
202        let attrs = util::attrs(&theme, TextStyle::Callout, None);
203        let title_width = if editing {
204            TITLE_MAX_WIDTH
205        } else {
206            (cx.measure_text(&tab.title, &attrs).width.ceil() + 1.0).min(TITLE_MAX_WIDTH)
207        };
208        let gap = theme.spacing.s2;
209        let closes = tab.closable && strip.on_close.is_some();
210        let right_pad = if closes { theme.spacing.s2 } else { theme.spacing.s3 };
211        let mut width = theme.spacing.s3 + right_pad + title_width;
212        if tab.icon.is_some() {
213            width += 14.0 + gap;
214        }
215        if tab.unsaved {
216            width += 7.0 + gap;
217        }
218        if closes {
219            width += ButtonSize::Mini.height() + gap;
220        }
221        if let Some(icon) = tab.icon {
222            parts.push(util::icon(cx, icon, 14.0, ink));
223        }
224        if editing {
225            let name: State<String> = cx.use_state(|| tab.title.clone());
226            name.set_if_changed(tab.title.clone());
227            let (rename, id) = (strip.on_rename.clone(), tab.id);
228            let field = InlineRename::new(name.binding())
229                .editing(self.editing.binding())
230                .label(labels.rename.clone())
231                .width(TITLE_MAX_WIDTH)
232                .on_rename(move |new| {
233                    if let Some(rename) = &rename {
234                        rename(id, new);
235                    }
236                });
237            parts.push(cx.child(&field));
238        } else {
239            let _: State<String> = cx.use_state(|| tab.title.clone());
240            let title = cx.text_truncated(
241                &tab.title,
242                &attrs,
243                ink,
244                LayoutStyle { width: Some(title_width), min_width: Some(0.0), ..LayoutStyle::leaf() },
245            );
246            cx.semantics(title, Semantics::hidden());
247            parts.push(title);
248        }
249        if tab.unsaved {
250            parts.push(cx.child(&Dot::new().tone(Tone::Accent).size(7.0).label(labels.unsaved.clone())));
251        }
252        if closes {
253            let (close, id) = (strip.on_close.clone(), tab.id);
254            let button = IconButton::new(IconName::X)
255                .label(labels.close_tab.clone())
256                .size(ButtonSize::Mini)
257                .on_click(move || {
258                    if let Some(close) = &close {
259                        close(id);
260                    }
261                });
262            parts.push(cx.child(&button));
263        }
264        if let (Some(color), Some(size)) = (tab.color, frame) {
265            parts.push(cx.container(
266                LayoutStyle {
267                    absolute: Some((0.0, 0.0)),
268                    width: Some(size.width),
269                    height: Some(COLOR_BAND),
270                    ..LayoutStyle::leaf()
271                },
272                &[],
273                Paint::Quad(QuadPaint::new(color)),
274            ));
275        }
276        if let (Some(gap), Some(size)) = (self.drag.get().and_then(|d| d.marked_gap()), frame) {
277            if gap == index {
278                parts.push(reorder::marker(cx, false, size.height, 0.0));
279            } else if gap == index + 1 && gap == strip.tabs.len() {
280                parts.push(reorder::marker(cx, false, size.height, size.width));
281            }
282        }
283        // By its surface as well as its text: the open tab joins the page under it.
284        let fill = if on {
285            theme.color(Semantic::Surface)
286        } else if hovered {
287            theme.color(Semantic::Ink).with_alpha(0.05)
288        } else {
289            Color::TRANSPARENT
290        };
291        let radius = theme.radii.sm;
292        let node = cx.container(
293            LayoutStyle {
294                height: Some(TAB_HEIGHT),
295                width: Some(width),
296                min_width: Some(width),
297                spacing: gap,
298                padding: Insets { top: 0.0, bottom: 0.0, left: theme.spacing.s3, right: right_pad },
299                align: Align::Center,
300                ..LayoutStyle::stack(Axis::Horizontal)
301            },
302            &parts,
303            Paint::Quad(QuadPaint { corner_radius: radius, ..QuadPaint::new(fill) }),
304        );
305        cx.report_frame(node);
306        let (active, id) = (strip.active.clone(), tab.id);
307        cx.on_click(node, Rc::new(move || active.set(id)));
308        cx.cursor(node, CursorIcon::Pointer);
309        cx.focusable(node, radius);
310        cx.semantics(node, {
311            let semantics = Semantics::new(Role::Tab).name(tab.title.clone()).selected(on);
312            if tab.unsaved { semantics.description(labels.unsaved.clone()) } else { semantics }
313        });
314        let count = strip.tabs.len();
315        let apply: Rc<dyn Fn(usize, usize)> = {
316            let (handler, spoken, focus_to, words) =
317                (strip.on_reorder.clone(), self.spoken.clone(), self.roving.focus_to.clone(), labels.clone());
318            Rc::new(move |from, to| {
319                if let Some(handler) = &handler {
320                    handler(from, to);
321                    spoken.set(words.moved_to_position(to + 1, count));
322                    focus_to.request(to);
323                }
324            })
325        };
326        if strip.on_reorder.is_some() && !editing {
327            cx.on_drag(
328                node,
329                reorder::drag_handler(
330                    self.drag.clone(),
331                    self.extents.clone(),
332                    index,
333                    0.0,
334                    false,
335                    apply.clone(),
336                ),
337            );
338        }
339        if strip.on_rename.is_some() {
340            let starts = self.editing.clone();
341            cx.on_double_click(node, Rc::new(move || starts.set(true)));
342        }
343        let (rename_key, close_key, called_off) =
344            (self.editing.clone(), strip.on_close.clone().filter(|_| closes), self.drag.clone());
345        let renames = strip.on_rename.is_some();
346        let before: crate::KeyHandler = Rc::new(move |key, modifiers| {
347            if *key == Key::Escape && called_off.peek().is_some() {
348                called_off.set(None);
349                return true;
350            }
351            if renames && is_f2(key) {
352                rename_key.set(true);
353                return true;
354            }
355            if *key == Key::Delete
356                && let Some(close) = &close_key
357            {
358                close(id);
359                return true;
360            }
361            if let Some(to) = reorder::key_target(key, modifiers, index, count, false) {
362                apply(index, to);
363            }
364            reorder::is_move_key(key, modifiers)
365        });
366        let active = strip.active.clone();
367        let ids: Rc<Vec<u64>> = Rc::new(strip.tabs.iter().map(|t| t.id).collect());
368        let opens: Rc<dyn Fn(usize)> = Rc::new(move |to| {
369            if let Some(id) = ids.get(to) {
370                active.set(*id);
371            }
372        });
373        self.roving.wire_with(cx, node, Some(opens), Some(before));
374        node
375    }
376}
377
378/// The tab, the state it keeps for itself and its menu.
379struct TabWithMenu<'a> {
380    strip: &'a DocumentTabs,
381    index: usize,
382    extents: Extents,
383    drag: State<Option<Reorder>>,
384    focus_to: FocusTarget,
385    spoken: State<String>,
386}
387
388impl View for TabWithMenu<'_> {
389    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
390        let labels = cx.labels();
391        let strip = self.strip;
392        let Some(tab) = strip.tabs.get(self.index) else { return cx.empty() };
393        let editing: State<bool> = cx.use_state(|| false);
394        let view = TabView {
395            strip,
396            tab,
397            index: self.index,
398            extents: self.extents.clone(),
399            drag: self.drag.clone(),
400            editing: editing.clone(),
401            roving: Roving { index: self.index, count: strip.tabs.len(), focus_to: self.focus_to.clone() },
402            spoken: self.spoken.clone(),
403        };
404        let mut menu = ContextMenu::new(view);
405        let mut any_above = false;
406        if let Some(items) = &strip.menu {
407            for item in items(tab.id) {
408                menu = menu.item(item);
409                any_above = true;
410            }
411        }
412        if any_above {
413            menu = menu.separator();
414        }
415        if strip.on_rename.is_some() {
416            menu = menu.item(MenuItem::new(labels.rename.clone()).on_select(move || editing.set(true)));
417        }
418        if let Some(close) = &strip.on_close {
419            let id = tab.id;
420            if tab.closable {
421                let close_one = close.clone();
422                menu = menu.item(MenuItem::new(labels.close_tab.clone()).on_select(move || close_one(id)));
423            }
424            let others: Vec<u64> =
425                strip.tabs.iter().filter(|t| t.id != id && t.closable).map(|t| t.id).collect();
426            if !others.is_empty() {
427                let close_all = close.clone();
428                menu = menu.item(MenuItem::new(labels.close_other_tabs.clone()).on_select(move || {
429                    others.iter().for_each(|other| close_all(*other));
430                }));
431            }
432            let right: Vec<u64> =
433                strip.tabs.iter().skip(self.index + 1).filter(|t| t.closable).map(|t| t.id).collect();
434            if !right.is_empty() {
435                let close_all = close.clone();
436                menu = menu.item(MenuItem::new(labels.close_tabs_to_right.clone()).on_select(move || {
437                    right.iter().for_each(|other| close_all(*other));
438                }));
439            }
440        }
441        menu.build(cx)
442    }
443}
444
445impl View for DocumentTabs {
446    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
447        let theme = *cx.theme();
448        let labels = cx.labels();
449        let drag: State<Option<Reorder>> = cx.use_state(|| None);
450        let extents: Extents = cx.use_state(|| Rc::new(RefCell::new(Vec::new()))).peek().clone();
451        extents.borrow_mut().resize(self.tabs.len(), 0.0);
452        let spoken: State<String> = cx.use_state(String::new);
453        let focus_to = util::roving_cell(cx, None);
454        let tabs: Vec<Keyed<TabWithMenu<'_>>> = self
455            .tabs
456            .iter()
457            .enumerate()
458            .map(|(index, tab)| {
459                Keyed::new(
460                    tab.id,
461                    TabWithMenu {
462                        strip: self,
463                        index,
464                        extents: extents.clone(),
465                        drag: drag.clone(),
466                        focus_to: focus_to.clone(),
467                        spoken: spoken.clone(),
468                    },
469                )
470            })
471            .collect();
472        let strip = cx.child(&ScrollEdgeArrow::new(tabs).spacing(2.0).fade(self.fade).height(TAB_HEIGHT));
473        let strip = cx.container(
474            LayoutStyle {
475                grow: 1.0,
476                min_width: Some(0.0),
477                align: Align::Stretch,
478                ..LayoutStyle::stack(Axis::Vertical)
479            },
480            &[strip],
481            Paint::None,
482        );
483        let mut nodes = vec![strip];
484        if let Some(new) = self.on_new.clone() {
485            nodes.push(
486                cx.child(
487                    &HStack::new((IconButton::new(IconName::Plus)
488                        .label(labels.new_tab.clone())
489                        .size(ButtonSize::Small)
490                        .on_click(move || new()),))
491                    .padding(Insets::symmetric(0.0, theme.spacing.s1)),
492                ),
493            );
494        }
495        let announcement = spoken.get();
496        nodes.push(reorder::announcer(cx, announcement));
497        let node = cx.container(
498            LayoutStyle {
499                min_width: Some(0.0),
500                align: Align::Center,
501                align_self: Some(Align::Stretch),
502                ..LayoutStyle::stack(Axis::Horizontal)
503            },
504            &nodes,
505            Paint::None,
506        );
507        let name = self.label.clone().unwrap_or_else(|| labels.document_tabs_name.clone());
508        cx.semantics(node, Semantics::new(Role::TabList).name(name));
509        node
510    }
511}