Skip to main content

ravenrs_widgets/
ribbon.rs

1//! An Office-style ribbon: application
2//! menu, Quick Access Toolbar, pages (tabs) with groups of large and small
3//! commands, contextual page categories, search, and a collapsed mode.
4
5use std::fmt;
6use std::rc::Rc;
7
8use ravenrs_core::{Border, Color, CursorIcon, Insets, Quad, Rect, Role};
9use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
10use ravenrs_reactive::{Binding, State};
11use ravenrs_style::{Semantic, TextStyle, Theme, Weight};
12
13use crate::context::{BuildCx, NodeId, Paint, Placement, QuadPaint, Semantics};
14use crate::overlay::{Entry, MenuList, panel};
15use crate::{IconName, MenuItem, TextField, Tone, Tooltip, View, util};
16
17/// How much room a ribbon command takes.
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19#[non_exhaustive]
20#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
21pub enum RibbonSize {
22    /// A tall button: large icon above a (wrapping) label.
23    #[default]
24    Large,
25    /// A row with a small icon and a label; three stack in a column.
26    Small,
27    /// A small icon only (with a tooltip); three stack in a column.
28    IconOnly,
29}
30
31/// A command in a ribbon group: a button, a toggle, or a button with a menu.
32#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
33pub struct RibbonItem {
34    label: String,
35    icon: Option<IconName>,
36    size: RibbonSize,
37    action: Option<Rc<dyn Fn()>>,
38    toggle: Option<Binding<bool>>,
39    menu: Vec<Entry>,
40    enabled: bool,
41    tooltip: Option<String>,
42}
43
44impl fmt::Debug for RibbonItem {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        f.debug_struct("RibbonItem")
47            .field("label", &self.label)
48            .field("size", &self.size)
49            .finish_non_exhaustive()
50    }
51}
52
53#[cfg_attr(feature = "live", ravenrs_live::expose)]
54impl RibbonItem {
55    /// A command button (large by default).
56    pub fn button(label: impl Into<String>, icon: IconName) -> Self {
57        Self {
58            label: label.into(),
59            icon: Some(icon),
60            size: RibbonSize::Large,
61            action: None,
62            toggle: None,
63            menu: Vec::new(),
64            enabled: true,
65            tooltip: None,
66        }
67    }
68
69    /// A button that stays pressed while `value` is true (Bold, Ruler…).
70    pub fn toggle(label: impl Into<String>, icon: IconName, value: Binding<bool>) -> Self {
71        Self { toggle: Some(value), size: RibbonSize::Small, ..Self::button(label, icon) }
72    }
73
74    /// Shows the item as a large button.
75    pub fn large(mut self) -> Self {
76        self.size = RibbonSize::Large;
77        self
78    }
79
80    /// Shows the item as a small icon-and-label row.
81    pub fn small(mut self) -> Self {
82        self.size = RibbonSize::Small;
83        self
84    }
85
86    /// Shows only the icon, with the label as tooltip.
87    pub fn icon_only(mut self) -> Self {
88        self.size = RibbonSize::IconOnly;
89        self
90    }
91
92    /// Runs `handler` when the item is clicked.
93    pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self {
94        self.action = Some(Rc::new(handler));
95        self
96    }
97
98    /// Adds a drop-down item; a button with items opens a menu.
99    pub fn menu_item(mut self, item: MenuItem) -> Self {
100        self.menu.push(Entry::Item(item));
101        self
102    }
103
104    /// Appends a separator to the drop-down menu.
105    pub fn menu_separator(mut self) -> Self {
106        self.menu.push(Entry::Separator);
107        self
108    }
109
110    /// Enables or disables the item; disabled items are dimmed and ignore clicks.
111    pub fn enabled(mut self, enabled: bool) -> Self {
112        self.enabled = enabled;
113        self
114    }
115
116    /// Hover text (defaults to the label for icon-only items).
117    pub fn tooltip(mut self, text: impl Into<String>) -> Self {
118        self.tooltip = Some(text.into());
119        self
120    }
121}
122
123/// A labeled set of commands inside a page.
124#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
125pub struct RibbonGroup {
126    title: String,
127    items: Vec<RibbonItem>,
128    launcher: Option<Rc<dyn Fn()>>,
129}
130
131impl fmt::Debug for RibbonGroup {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        f.debug_struct("RibbonGroup")
134            .field("title", &self.title)
135            .field("items", &self.items)
136            .finish_non_exhaustive()
137    }
138}
139
140#[cfg_attr(feature = "live", ravenrs_live::expose)]
141impl RibbonGroup {
142    /// An empty group with this caption.
143    pub fn new(title: impl Into<String>) -> Self {
144        Self { title: title.into(), items: Vec::new(), launcher: None }
145    }
146
147    /// Appends a command.
148    pub fn item(mut self, item: RibbonItem) -> Self {
149        self.items.push(item);
150        self
151    }
152
153    /// The small arrow in the group caption that opens the full dialog.
154    pub fn dialog_launcher(mut self, handler: impl Fn() + 'static) -> Self {
155        self.launcher = Some(Rc::new(handler));
156        self
157    }
158}
159
160/// A tab of the ribbon.
161#[derive(Debug)]
162#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
163pub struct RibbonPage {
164    title: String,
165    groups: Vec<RibbonGroup>,
166    /// Contextual pages belong to a colored category and appear on demand.
167    category: Option<(String, Tone)>,
168    visible: bool,
169}
170
171#[cfg_attr(feature = "live", ravenrs_live::expose)]
172impl RibbonPage {
173    /// An empty page with this tab title.
174    pub fn new(title: impl Into<String>) -> Self {
175        Self { title: title.into(), groups: Vec::new(), category: None, visible: true }
176    }
177
178    /// Appends a group.
179    pub fn group(mut self, group: RibbonGroup) -> Self {
180        self.groups.push(group);
181        self
182    }
183
184    /// Makes this a contextual page (e.g. "Table Tools") shown only while `visible`.
185    pub fn contextual(mut self, category: impl Into<String>, tone: Tone, visible: bool) -> Self {
186        self.category = Some((category.into(), tone));
187        self.visible = visible;
188        self
189    }
190}
191
192/// The ribbon.
193///
194/// ```
195/// use ravenrs_reactive::State;
196/// use ravenrs_widgets::{IconName, MenuItem, Ribbon, RibbonGroup, RibbonItem, RibbonPage, Ui};
197///
198/// let bold = State::new(false);
199/// let ribbon = Ribbon::new()
200///     .app_menu("File", vec![MenuItem::new("Open…"), MenuItem::new("Save")])
201///     .quick_access(RibbonItem::button("Save", IconName::Save))
202///     .page(
203///         RibbonPage::new("Home").group(
204///             RibbonGroup::new("Font")
205///                 .item(RibbonItem::button("Paste", IconName::Clipboard).on_click(|| {}))
206///                 .item(RibbonItem::toggle("Bold", IconName::Bold, bold.binding())),
207///         ),
208///     );
209///
210/// let mut ui = Ui::new(ribbon, ravenrs_style::Theme::raven_dark());
211/// ui.update();
212/// ```
213#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
214pub struct Ribbon {
215    pages: Vec<RibbonPage>,
216    app_menu: Option<(String, Vec<Entry>)>,
217    quick_access: Vec<RibbonItem>,
218    search: Option<Binding<String>>,
219    selected: Option<Binding<usize>>,
220}
221
222impl fmt::Debug for Ribbon {
223    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224        f.debug_struct("Ribbon").field("pages", &self.pages).finish_non_exhaustive()
225    }
226}
227
228impl Default for Ribbon {
229    fn default() -> Self {
230        Self::new()
231    }
232}
233
234#[cfg_attr(feature = "live", ravenrs_live::expose)]
235impl Ribbon {
236    /// An empty ribbon.
237    pub fn new() -> Self {
238        Self { pages: Vec::new(), app_menu: None, quick_access: Vec::new(), search: None, selected: None }
239    }
240
241    /// Appends a page (tab).
242    pub fn page(mut self, page: RibbonPage) -> Self {
243        self.pages.push(page);
244        self
245    }
246
247    /// The accent "File" button and its menu.
248    pub fn app_menu(mut self, label: impl Into<String>, items: Vec<MenuItem>) -> Self {
249        self.app_menu = Some((label.into(), items.into_iter().map(Entry::Item).collect()));
250        self
251    }
252
253    /// Icon commands always visible at the top-left (Save, Undo, Redo…).
254    pub fn quick_access(mut self, item: RibbonItem) -> Self {
255        self.quick_access.push(item.icon_only());
256        self
257    }
258
259    /// A "Tell me what you want to do" search box.
260    pub fn search(mut self, query: Binding<String>) -> Self {
261        self.search = Some(query);
262        self
263    }
264
265    /// Where the selected page index lives (otherwise the ribbon keeps it).
266    pub fn selected_page(mut self, page: Binding<usize>) -> Self {
267        self.selected = Some(page);
268        self
269    }
270}
271
272/// Large button height area (icon + two lines of label).
273const BODY_HEIGHT: f32 = 92.0;
274const CAPTION_HEIGHT: f32 = 22.0;
275
276fn tone_color(theme: &Theme, tone: Tone) -> Color {
277    match tone {
278        Tone::Neutral => theme.color(Semantic::InkMuted),
279        Tone::Accent => theme.color(Semantic::Accent),
280        Tone::Success => theme.color(Semantic::Success),
281        Tone::Warning => theme.color(Semantic::Warning),
282        Tone::Danger => theme.color(Semantic::Danger),
283    }
284}
285
286struct ItemView<'a>(&'a RibbonItem);
287
288impl View for ItemView<'_> {
289    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
290        let theme = *cx.theme();
291        let item = self.0;
292        let open: State<bool> = cx.use_state(|| false);
293        let on = item.toggle.as_ref().is_some_and(Binding::get);
294        let hovered = cx.is_hovered() && item.enabled;
295        let pressed = cx.is_pressed() && item.enabled;
296        let ink = theme.color(Semantic::Ink);
297        let color = if item.enabled { ink } else { theme.color(Semantic::InkMuted).with_alpha(0.5) };
298        let icon_color = if !item.enabled {
299            color
300        } else if on {
301            theme.color(Semantic::Accent).lerp(ink, 0.2)
302        } else {
303            theme.color(Semantic::Accent).lerp(ink, 0.45)
304        };
305        let has_menu = !item.menu.is_empty();
306        let mut children = Vec::new();
307        let node_style = match item.size {
308            RibbonSize::Large => {
309                if let Some(i) = item.icon {
310                    children.push(util::icon(cx, i, 28.0, icon_color));
311                }
312                let mut a = util::attrs(&theme, TextStyle::Caption, None);
313                a.size = 12.0;
314                a.line_height = 15.0;
315                a.letter_spacing = 0.0;
316                let label = cx.text(
317                    &item.label,
318                    &a,
319                    color,
320                    LayoutStyle { max_width: Some(72.0), ..LayoutStyle::leaf() },
321                );
322                children.push(label);
323                if has_menu {
324                    children.push(util::icon(
325                        cx,
326                        IconName::ChevronDown,
327                        12.0,
328                        theme.color(Semantic::InkMuted),
329                    ));
330                }
331                LayoutStyle {
332                    height: Some(BODY_HEIGHT - 6.0),
333                    min_width: Some(52.0),
334                    padding: Insets { top: 8.0, bottom: 4.0, left: 6.0, right: 6.0 },
335                    spacing: 4.0,
336                    align: Align::Center,
337                    ..LayoutStyle::stack(Axis::Vertical)
338                }
339            }
340            RibbonSize::Small | RibbonSize::IconOnly => {
341                if let Some(i) = item.icon {
342                    children.push(util::icon(cx, i, 16.0, icon_color));
343                }
344                if item.size == RibbonSize::Small {
345                    let mut a = util::attrs(&theme, TextStyle::Callout, None);
346                    a.size = 13.0;
347                    children.push(cx.text(&item.label, &a, color, LayoutStyle::leaf()));
348                }
349                if has_menu {
350                    children.push(util::icon(
351                        cx,
352                        IconName::ChevronDown,
353                        12.0,
354                        theme.color(Semantic::InkMuted),
355                    ));
356                }
357                let icon_only = item.size == RibbonSize::IconOnly;
358                LayoutStyle {
359                    height: Some(26.0),
360                    width: (icon_only && !has_menu).then_some(28.0),
361                    padding: Insets::symmetric(0.0, 6.0),
362                    spacing: 6.0,
363                    align: Align::Center,
364                    justify: if icon_only { Justify::Center } else { Justify::Start },
365                    ..LayoutStyle::stack(Axis::Horizontal)
366                }
367            }
368        };
369        let fill = if on || open.get() {
370            theme.color(Semantic::Accent).with_alpha(if pressed { 0.28 } else { 0.18 })
371        } else if pressed {
372            ink.with_alpha(0.12)
373        } else if hovered {
374            ink.with_alpha(0.07)
375        } else {
376            Color::TRANSPARENT
377        };
378        let border = (on || open.get())
379            .then(|| Border { width: 1.0, color: theme.color(Semantic::Accent).with_alpha(0.45) });
380        let node = cx.container(
381            node_style,
382            &children,
383            Paint::Quad(QuadPaint { corner_radius: 6.0, border, ..QuadPaint::new(fill) }),
384        );
385        crate::motion::control(cx, node);
386        let mut semantics = Semantics::new(Role::Button).name(item.label.clone()).disabled(!item.enabled);
387        if item.toggle.is_some() {
388            semantics = semantics.toggled(on);
389        }
390        if has_menu {
391            semantics = semantics.expanded(open.get());
392        }
393        cx.semantics(node, semantics);
394        if item.enabled {
395            let (action, toggle, menu_open) = (item.action.clone(), item.toggle.clone(), open.clone());
396            cx.on_click(
397                node,
398                Rc::new(move || {
399                    if has_menu {
400                        menu_open.update(|o| *o = !*o);
401                        return;
402                    }
403                    if let Some(t) = &toggle {
404                        t.update(|v| *v = !*v);
405                    }
406                    if let Some(a) = &action {
407                        a();
408                    }
409                }),
410            );
411            cx.focusable(node, 6.0);
412            cx.cursor(node, CursorIcon::Pointer);
413        }
414        if open.get() {
415            let close: Rc<dyn Fn()> = {
416                let open = open.clone();
417                Rc::new(move || open.set(false))
418            };
419            cx.overlay(
420                node,
421                Placement::Below,
422                &MenuList { entries: &item.menu, close: close.clone(), on_side: None },
423                Some(close),
424            );
425        }
426        node
427    }
428}
429
430/// An item, wrapped in a tooltip when it has one (icon-only items always do).
431struct Command<'a>(&'a RibbonItem);
432
433impl View for Command<'_> {
434    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
435        let tip = self
436            .0
437            .tooltip
438            .clone()
439            .or_else(|| (self.0.size == RibbonSize::IconOnly).then(|| self.0.label.clone()));
440        match tip {
441            Some(t) if !t.is_empty() => cx.child(&Tooltip::new(ItemView(self.0), t)),
442            _ => cx.child(&ItemView(self.0)),
443        }
444    }
445}
446
447struct GroupView<'a>(&'a RibbonGroup);
448
449impl View for GroupView<'_> {
450    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
451        let theme = *cx.theme();
452        let g = self.0;
453        // Large items stand alone; runs of small items stack three to a column.
454        let mut columns = Vec::new();
455        let mut stack: Vec<NodeId> = Vec::new();
456        let flush = |cx: &mut BuildCx<'_>, stack: &mut Vec<NodeId>, columns: &mut Vec<NodeId>| {
457            if !stack.is_empty() {
458                columns.push(cx.container(
459                    LayoutStyle {
460                        spacing: 2.0,
461                        justify: Justify::Start,
462                        ..LayoutStyle::stack(Axis::Vertical)
463                    },
464                    stack,
465                    Paint::None,
466                ));
467                stack.clear();
468            }
469        };
470        for (i, item) in g.items.iter().enumerate() {
471            let node = cx.keyed_child(i as u64, &Command(item));
472            if item.size == RibbonSize::Large {
473                flush(cx, &mut stack, &mut columns);
474                columns.push(node);
475            } else {
476                stack.push(node);
477                if stack.len() == 3 {
478                    flush(cx, &mut stack, &mut columns);
479                }
480            }
481        }
482        flush(cx, &mut stack, &mut columns);
483        let body = cx.container(
484            LayoutStyle {
485                height: Some(BODY_HEIGHT),
486                padding: Insets::symmetric(3.0, 4.0),
487                spacing: 2.0,
488                align: Align::Start,
489                ..LayoutStyle::stack(Axis::Horizontal)
490            },
491            &columns,
492            Paint::None,
493        );
494        let muted = theme.color(Semantic::InkMuted);
495        let title = util::label(cx, &g.title, TextStyle::Caption, None, muted);
496        // The group's toolbar is already named after it.
497        cx.semantics(title, Semantics::hidden());
498        let mut caption = vec![title];
499        if let Some(l) = &g.launcher {
500            let arrow = util::icon(cx, IconName::ArrowDown, 11.0, muted);
501            let b = cx.container(
502                LayoutStyle {
503                    width: Some(18.0),
504                    height: Some(18.0),
505                    align: Align::Center,
506                    justify: Justify::Center,
507                    ..LayoutStyle::stack(Axis::Horizontal)
508                },
509                &[arrow],
510                Paint::None,
511            );
512            cx.semantics(b, Semantics::new(Role::Button).name(format!("{} options", g.title)));
513            cx.on_click(b, l.clone());
514            cx.cursor(b, CursorIcon::Pointer);
515            caption.push(b);
516        }
517        let caption = cx.container(
518            LayoutStyle {
519                height: Some(CAPTION_HEIGHT),
520                spacing: 4.0,
521                align: Align::Center,
522                justify: Justify::Center,
523                align_self: Some(Align::Stretch),
524                ..LayoutStyle::stack(Axis::Horizontal)
525            },
526            &caption,
527            Paint::None,
528        );
529        let line = theme.color(Semantic::Border);
530        let node = cx.container(
531            LayoutStyle {
532                padding: Insets { right: 6.0, left: 2.0, ..Insets::ZERO },
533                align: Align::Center,
534                ..LayoutStyle::stack(Axis::Vertical)
535            },
536            &[body, caption],
537            Paint::Custom(Rc::new(move |f: Rect, scene| {
538                scene.push_quad(Quad::new(
539                    Rect::new(f.max_x() - 1.0, f.y() + 8.0, 1.0, f.height() - 16.0),
540                    line,
541                ));
542            })),
543        );
544        cx.semantics(node, Semantics::new(Role::Toolbar).name(g.title.clone()));
545        node
546    }
547}
548
549struct PageBody<'a>(&'a RibbonPage);
550
551impl View for PageBody<'_> {
552    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
553        let groups: Vec<NodeId> =
554            self.0.groups.iter().enumerate().map(|(i, g)| cx.keyed_child(i as u64, &GroupView(g))).collect();
555        let node = cx.container(
556            LayoutStyle {
557                padding: Insets::symmetric(4.0, 8.0),
558                spacing: 6.0,
559                align_self: Some(Align::Stretch),
560                overflow_hidden: true,
561                ..LayoutStyle::stack(Axis::Horizontal)
562            },
563            &groups,
564            Paint::None,
565        );
566        cx.clip(node);
567        node
568    }
569}
570
571/// The page body shown as a popup while the ribbon is collapsed.
572struct BodyPopup<'a>(&'a RibbonPage, f32);
573
574impl View for BodyPopup<'_> {
575    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
576        let body = cx.child(&PageBody(self.0));
577        panel(cx, &[body], 0.0, self.1)
578    }
579}
580
581struct Tab<'a> {
582    page: &'a RibbonPage,
583    index: usize,
584    selected: Binding<usize>,
585    collapsed: State<bool>,
586    popup: State<bool>,
587}
588
589impl View for Tab<'_> {
590    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
591        let theme = *cx.theme();
592        let on = self.selected.get() == self.index;
593        let hovered = cx.is_hovered();
594        let ink = theme.color(Semantic::Ink);
595        let tone = self.page.category.as_ref().map(|(_, t)| tone_color(&theme, *t));
596        let color = match (on, tone) {
597            (true, Some(t)) => t.lerp(ink, 0.25),
598            (true, None) => ink,
599            _ if hovered => ink,
600            _ => theme.color(Semantic::InkMuted),
601        };
602        let label = util::label(cx, &self.page.title, TextStyle::Callout, Some(Weight::SEMIBOLD), color);
603        crate::motion::control(cx, label);
604        let accent = tone.unwrap_or(theme.color(Semantic::Accent));
605        let category_fill = tone.map(|t| t.with_alpha(0.10));
606        let node = cx.container(
607            LayoutStyle {
608                height: Some(34.0),
609                padding: Insets::symmetric(0.0, 12.0),
610                align: Align::Center,
611                ..LayoutStyle::stack(Axis::Horizontal)
612            },
613            &[label],
614            Paint::Custom(Rc::new(move |f: Rect, scene| {
615                if let Some(fill) = category_fill {
616                    scene.push_quad(Quad::new(f, fill));
617                }
618                if on {
619                    scene.push_quad(
620                        Quad::new(Rect::new(f.x() + 8.0, f.max_y() - 2.0, f.width() - 16.0, 2.0), accent)
621                            .radius(1.0),
622                    );
623                }
624            })),
625        );
626        cx.semantics(node, Semantics::new(Role::Tab).selected(on));
627        let (sel, i, collapsed, popup) =
628            (self.selected.clone(), self.index, self.collapsed.clone(), self.popup.clone());
629        cx.on_click(
630            node,
631            Rc::new(move || {
632                let same = sel.get() == i;
633                sel.set(i);
634                if collapsed.get() {
635                    popup.set(!(same && popup.get()));
636                }
637            }),
638        );
639        let (collapsed, popup) = (self.collapsed.clone(), self.popup.clone());
640        cx.on_double_click(
641            node,
642            Rc::new(move || {
643                popup.set(false);
644                collapsed.update(|c| *c = !*c);
645            }),
646        );
647        cx.focusable(node, 6.0);
648        cx.cursor(node, CursorIcon::Pointer);
649        node
650    }
651}
652
653impl View for Ribbon {
654    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
655        let theme = *cx.theme();
656        let own: State<usize> = cx.use_state(|| 0);
657        let selected = self.selected.clone().unwrap_or_else(|| own.binding());
658        let collapsed: State<bool> = cx.use_state(|| false);
659        let popup: State<bool> = cx.use_state(|| false);
660        let app_open: State<bool> = cx.use_state(|| false);
661        let visible: Vec<usize> = (0..self.pages.len()).filter(|&i| self.pages[i].visible).collect();
662        let current = {
663            let s = selected.get();
664            if visible.contains(&s) { s } else { visible.first().copied().unwrap_or(0) }
665        };
666
667        // Title row: application menu, quick access, tabs, search, collapse.
668        let mut row = Vec::new();
669        if let Some((label, entries)) = &self.app_menu {
670            let ink = theme.color(Semantic::Ink);
671            let text = util::label(cx, label, TextStyle::Callout, Some(Weight::SEMIBOLD), ink);
672            let hovered = cx.is_hovered();
673            let control = theme.color(Semantic::Control);
674            let fill = if hovered { control.lerp(ink, 0.08) } else { control };
675            let app = cx.container(
676                LayoutStyle {
677                    height: Some(32.0),
678                    padding: Insets::symmetric(0.0, theme.spacing.s3),
679                    align: Align::Center,
680                    ..LayoutStyle::stack(Axis::Horizontal)
681                },
682                &[text],
683                Paint::Quad(QuadPaint {
684                    corner_radius: theme.radii.md,
685                    border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
686                    ..QuadPaint::new(fill)
687                }),
688            );
689            cx.semantics(app, Semantics::new(Role::Button).expanded(app_open.get()));
690            let o = app_open.clone();
691            cx.on_click(app, Rc::new(move || o.update(|v| *v = !*v)));
692            cx.focusable(app, theme.radii.md);
693            cx.cursor(app, CursorIcon::Pointer);
694            if app_open.get() {
695                let close: Rc<dyn Fn()> = {
696                    let o = app_open.clone();
697                    Rc::new(move || o.set(false))
698                };
699                cx.overlay(
700                    app,
701                    Placement::Below,
702                    &MenuList { entries, close: close.clone(), on_side: None },
703                    Some(close),
704                );
705            }
706            row.push(app);
707        }
708        if !self.quick_access.is_empty() {
709            let items: Vec<NodeId> = self
710                .quick_access
711                .iter()
712                .enumerate()
713                .map(|(i, q)| cx.keyed_child(500 + i as u64, &Command(q)))
714                .collect();
715            row.push(cx.container(
716                LayoutStyle {
717                    spacing: 1.0,
718                    padding: Insets::symmetric(0.0, 6.0),
719                    align: Align::Center,
720                    ..LayoutStyle::stack(Axis::Horizontal)
721                },
722                &items,
723                Paint::None,
724            ));
725        }
726        // Contextual categories get a caption above their tabs.
727        let mut tabs = Vec::new();
728        for &i in &visible {
729            let page = &self.pages[i];
730            let tab = cx.keyed_child(
731                1000 + i as u64,
732                &Tab {
733                    page,
734                    index: i,
735                    selected: selected.clone(),
736                    collapsed: collapsed.clone(),
737                    popup: popup.clone(),
738                },
739            );
740            if let Some((name, tone)) = &page.category {
741                let color = tone_color(&theme, *tone);
742                let cap = util::label(cx, &name.to_uppercase(), TextStyle::Caption, None, color);
743                tabs.push(cx.container(
744                    LayoutStyle { spacing: 0.0, align: Align::Center, ..LayoutStyle::stack(Axis::Vertical) },
745                    &[cap, tab],
746                    Paint::None,
747                ));
748            } else {
749                tabs.push(tab);
750            }
751        }
752        let tab_list = cx.container(
753            LayoutStyle {
754                spacing: 2.0,
755                align: Align::End,
756                grow: 1.0,
757                min_width: Some(0.0),
758                ..LayoutStyle::stack(Axis::Horizontal)
759            },
760            &tabs,
761            Paint::None,
762        );
763        cx.semantics(tab_list, Semantics::new(Role::TabList).name(cx.labels().ribbon.clone()));
764        row.push(tab_list);
765        if let Some(q) = &self.search {
766            row.push(
767                cx.child(
768                    &TextField::new(q.clone())
769                        .placeholder(cx.labels().ribbon_search.clone())
770                        .icon(IconName::Search)
771                        .compact()
772                        .width(240.0),
773                ),
774            );
775        }
776        let is_collapsed = collapsed.get();
777        let (c, p) = (collapsed.clone(), popup.clone());
778        row.push(
779            cx.child(&Tooltip::new(
780                crate::IconButton::new(if is_collapsed {
781                    IconName::ChevronDown
782                } else {
783                    IconName::ChevronUp
784                })
785                .size(crate::ButtonSize::Small)
786                .on_click(move || {
787                    p.set(false);
788                    c.update(|v| *v = !*v);
789                }),
790                if is_collapsed {
791                    cx.labels().ribbon_pin.clone()
792                } else {
793                    cx.labels().ribbon_collapse.clone()
794                },
795            )),
796        );
797        let title_row = cx.container(
798            LayoutStyle {
799                padding: Insets { top: 6.0, bottom: 0.0, left: 8.0, right: 8.0 },
800                spacing: 8.0,
801                align: Align::End,
802                align_self: Some(Align::Stretch),
803                ..LayoutStyle::stack(Axis::Horizontal)
804            },
805            &row,
806            Paint::None,
807        );
808        let line = theme.color(Semantic::Border);
809        let mut sections = vec![title_row];
810        if let Some(page) = self.pages.get(current) {
811            if is_collapsed {
812                if popup.get() {
813                    let close: Rc<dyn Fn()> = {
814                        let p = popup.clone();
815                        Rc::new(move || p.set(false))
816                    };
817                    let width = cx.viewport().width - 32.0;
818                    cx.overlay(title_row, Placement::Below, &BodyPopup(page, width.min(1100.0)), Some(close));
819                }
820            } else {
821                sections.push(cx.keyed_child(2000 + current as u64, &PageBody(page)));
822            }
823        }
824        let surface = theme.color(Semantic::Surface);
825        cx.container(
826            LayoutStyle {
827                align: Align::Stretch,
828                align_self: Some(Align::Stretch),
829                ..LayoutStyle::stack(Axis::Vertical)
830            },
831            &sections,
832            Paint::Custom(Rc::new(move |f: Rect, scene| {
833                scene.push_quad(Quad::new(f, surface));
834                scene.push_quad(Quad::new(Rect::new(f.x(), f.max_y() - 1.0, f.width(), 1.0), line));
835                // Hairline under the tab strip.
836                scene.push_quad(Quad::new(Rect::new(f.x(), f.y() + 40.0, f.width(), 1.0), line.faded(0.6)));
837            })),
838        )
839    }
840}