Skip to main content

ui/
ribbon.rs

1// Ribbon groups and typography tiles. Additive native UI component by office_ribbon;
2// no legacy counterpart. Uses normal focus, semantics, popups and style tokens.
3use crate::popup::PPF_FOCUS;
4use crate::{
5    Sp, Ui,
6    consts::*,
7    keys::{IC_MORE, IC_OPEN_IN_NEW_WINDOW, VK_DOWN, VK_F4},
8};
9use alloc::vec::Vec;
10
11/// Explicit typography for a document sample, independent of the application's theme.
12/// Sizes and baseline displacement are 24.8 DIP; positive displacement moves downwards.
13#[derive(Clone, Copy, Debug)]
14pub struct RibbonSample {
15    pub font: u32,
16    pub size: i32,
17    pub color: u32,
18    pub highlight: Option<u32>,
19    pub baseline_shift: i32,
20    pub underline: bool,
21    pub strike: bool,
22}
23
24/// Style-owned ribbon geometry in DIP. A scrolling ribbon keeps every group's
25/// original arrangement; a collapsing ribbon can use the compact/flyout layouts.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub struct RibbonMetrics {
28    pub content_height: i32,
29    pub caption_height: i32,
30    pub group_gap: i32,
31    pub content_gap: i32,
32    /// Zero uses the style's existing uniform `ribbon_padding`.
33    pub inset_x: i32,
34    pub inset_y: i32,
35    pub collapse_groups: bool,
36}
37impl Default for RibbonMetrics {
38    fn default() -> Self {
39        Self {
40            content_height: 66,
41            caption_height: 18,
42            group_gap: 8,
43            content_gap: 2,
44            inset_x: 0,
45            inset_y: 0,
46            collapse_groups: true,
47        }
48    }
49}
50
51/// One ribbon group. Reserve `id..=id + 3` for its box, overflow and popup.
52#[derive(Clone, Copy)]
53pub struct RibbonGroup<'a> {
54    /// Stable base ID, unique across the window.
55    pub id: u32,
56    /// Visible group caption and accessible overflow name.
57    pub label: &'a str,
58    /// Preferred group width in DIP.
59    pub width: i32,
60    /// Smallest width that still displays all controls, in DIP.
61    pub compact_width: i32,
62}
63impl<'a> RibbonGroup<'a> {
64    /// Construct a group with a preferred and compact content width.
65    pub const fn new(id: u32, label: &'a str, width: i32, compact_width: i32) -> Self {
66        Self {
67            id,
68            label,
69            width,
70            compact_width,
71        }
72    }
73}
74
75/// Presentation of an open ribbon group; pass it to `ribbon_group_end`.
76#[derive(Clone, Copy, PartialEq, Eq)]
77#[non_exhaustive]
78pub enum RibbonGroupMode {
79    /// Preferred or compact inline width in DIP.
80    Inline(i32),
81    /// Group shown in a popup at its preferred width in DIP.
82    Flyout(i32),
83}
84impl RibbonGroupMode {
85    /// Actual width available to the controls, in DIP.
86    pub fn width(self) -> i32 {
87        match self {
88            Self::Inline(w) | Self::Flyout(w) => w,
89        }
90    }
91}
92
93/// Per-frame ribbon layout: shrink groups before moving them into labeled flyouts.
94pub struct RibbonLayout {
95    widths: Vec<i32>,
96    collapsed: Vec<bool>,
97}
98impl RibbonLayout {
99    /// Compute group presentation for an available inner width in DIP.
100    /// Groups collapse from right to left; extremely narrow windows can scroll.
101    pub fn new(available_width: i32, groups: &[RibbonGroup<'_>]) -> Self {
102        Self::with_metrics(available_width, groups, RibbonMetrics::default())
103    }
104    /// Compute a layout using the active style's sizing and overflow policy.
105    pub fn with_metrics(
106        available_width: i32,
107        groups: &[RibbonGroup<'_>],
108        metrics: RibbonMetrics,
109    ) -> Self {
110        let mut widths: Vec<i32> = groups.iter().map(|g| g.width.max(48)).collect();
111        let mut collapsed = alloc::vec![false; groups.len()];
112        if !metrics.collapse_groups {
113            return Self { widths, collapsed };
114        }
115        let gaps = groups.len().saturating_sub(1) as i64 * i64::from(metrics.group_gap.max(0));
116        let mut total = widths.iter().map(|&w| i64::from(w)).sum::<i64>() + gaps;
117        for (i, g) in groups.iter().enumerate().rev() {
118            if total <= i64::from(available_width) {
119                break;
120            }
121            let smaller = g.compact_width.clamp(48, widths[i]);
122            total -= i64::from(widths[i] - smaller);
123            widths[i] = smaller;
124        }
125        for i in (0..groups.len()).rev() {
126            if total <= i64::from(available_width) {
127                break;
128            }
129            let smaller = widths[i].min(96);
130            total -= i64::from(widths[i] - smaller);
131            widths[i] = smaller;
132            collapsed[i] = true;
133        }
134        Self { widths, collapsed }
135    }
136    /// Whether a group is reachable through its labeled overflow button.
137    pub fn is_collapsed(&self, index: usize) -> bool {
138        self.collapsed.get(index).copied().unwrap_or(false)
139    }
140    /// Width occupied by a group in the ribbon, in DIP.
141    pub fn group_width(&self, index: usize) -> Option<i32> {
142        self.widths.get(index).copied()
143    }
144}
145
146impl Ui {
147    /// Start a style-sized ribbon (110 DIP by default). Pair with `ribbon_end`.
148    /// All groups remain keyboard reachable; a scroll strip is the final fallback.
149    #[cfg_attr(jfgui_dev, track_caller)]
150    pub fn ribbon_begin(&mut self, id: u32, groups: &[RibbonGroup<'_>]) -> RibbonLayout {
151        let _src = self.src_scope();
152        let m = self.style.ribbon_metrics();
153        let py = if m.inset_y > 0 {
154            m.inset_y as u32
155        } else {
156            self.style.ribbon_padding()
157        };
158        self.next_size(-1, self.style.ribbon_height());
159        self.scroll_begin(
160            id,
161            SC_H | BOX_H | BOX_LAYER | pad(py) | if m.collapse_groups { 0 } else { SC_NOBAR },
162        );
163        if m.inset_x > py as i32 {
164            self.spacer(m.inset_x - py as i32);
165        }
166        let available = self.px2dip(self.box_rect()[2]);
167        RibbonLayout::with_metrics(available, groups, m)
168    }
169
170    /// Begin a group's controls when visible, or render its overflow button.
171    /// Only call `ribbon_group_end` when this returns `Some`.
172    #[cfg_attr(jfgui_dev, track_caller)]
173    pub fn ribbon_group_begin(
174        &mut self,
175        layout: &RibbonLayout,
176        index: usize,
177        group: &RibbonGroup<'_>,
178    ) -> Option<RibbonGroupMode> {
179        let _src = self.src_scope();
180        let width = layout.group_width(index)?;
181        let m = self.style.ribbon_metrics();
182        let gap = if m.collapse_groups { 4 } else { 0 };
183        let height = m.content_height + m.caption_height + gap;
184        if index != 0 {
185            self.spacer(m.group_gap);
186        }
187        if layout.is_collapsed(index) {
188            self.next_size(width, height);
189            self.box_begin(group.id, BOX_V | BOX_FIXED, gap);
190            self.next_size(-1, m.content_height);
191            if self.ribbon_button(group.id + 1, group.label, IC_MORE, ST_LARGE) & EV_ACT != 0 {
192                self.popup_open(group.id + 2);
193            }
194            let anchor = self.last_rect();
195            self.next_size(-1, m.caption_height);
196            self.text(
197                TS_CAPTION | TX_SECONDARY | TX_CENTER | TX_ELLIPSIS,
198                crate::i18n::tr("Weitere Befehle"),
199            );
200            self.box_end();
201            self.wp.ctx.last_rect = anchor;
202            if !self.flyout_begin(group.id + 2, 0) {
203                self.flyout_end();
204                return None;
205            }
206            self.next_size(group.width.max(48), m.content_height);
207            self.box_begin(group.id + 3, BOX_V | BOX_FIXED, m.content_gap);
208            Some(RibbonGroupMode::Flyout(group.width.max(48)))
209        } else {
210            // A resize can expand an open overflow group. Remove its old popup
211            // immediately so no invisible popup layer intercepts input.
212            if self.popup_is_open(group.id + 2) {
213                self.popup_close();
214            }
215            self.next_size(width, height);
216            self.box_begin(group.id, BOX_V | BOX_FIXED, gap);
217            let r = self.last_rect();
218            let c = self.color(T_DIVIDER);
219            self.draw_color(c);
220            if m.collapse_groups || index + 1 < layout.widths.len() {
221                self.draw_rect(
222                    r[0] + r[2] + self.dp((m.group_gap - 2).max(0) / 2),
223                    r[1] + self.dp(2),
224                    self.hair(),
225                    r[3] - self.dp(4),
226                );
227            }
228            self.next_size(-1, m.content_height);
229            self.box_begin(group.id + 3, BOX_V | BOX_FIXED, m.content_gap);
230            Some(RibbonGroupMode::Inline(width))
231        }
232    }
233
234    /// Finish controls and draw the inline group caption; closes overflow when appropriate.
235    /// Flyouts already have their named opener, so no trailing caption is painted
236    /// over a nested action menu emitted while building their controls.
237    #[cfg_attr(jfgui_dev, track_caller)]
238    pub fn ribbon_group_end(&mut self, mode: RibbonGroupMode, label: &str) {
239        let _src = self.src_scope();
240        self.box_end();
241        match mode {
242            RibbonGroupMode::Inline(_) => {
243                self.next_size(-1, self.style.ribbon_metrics().caption_height);
244                self.text(TS_CAPTION | TX_SECONDARY | TX_CENTER | TX_ELLIPSIS, label);
245                self.box_end();
246            }
247            RibbonGroupMode::Flyout(_) => self.flyout_end(),
248        }
249    }
250
251    /// Finish a group with a separately focusable dialog-launch button in its caption.
252    /// `id` is an additional unique widget ID; `action_label` is its full semantic name.
253    /// Activation is returned to the app, which opens its existing dialog. Disabled
254    /// launchers retain their label and cannot be invoked through keyboard or agents.
255    #[cfg_attr(jfgui_dev, track_caller)]
256    pub fn ribbon_group_end_launcher(
257        &mut self,
258        mode: RibbonGroupMode,
259        label: &str,
260        id: u32,
261        action_label: &str,
262        flags: u32,
263    ) -> u32 {
264        let _src = self.src_scope();
265        self.box_end();
266        let caption = self.style.ribbon_metrics().caption_height;
267        self.next_size(-1, caption);
268        self.box_begin(0, BOX_H, 2);
269        self.next_size(-1, caption);
270        self.text(TS_CAPTION | TX_SECONDARY | TX_CENTER | TX_ELLIPSIS, label);
271        self.next_size(caption, caption);
272        let ev = self.ribbon_button(
273            id,
274            action_label,
275            IC_OPEN_IN_NEW_WINDOW,
276            flags | ST_ICON_ONLY,
277        );
278        self.box_end();
279        match mode {
280            RibbonGroupMode::Inline(_) => self.box_end(),
281            RibbonGroupMode::Flyout(_) => self.flyout_end(),
282        }
283        ev
284    }
285
286    /// Finish the ribbon begun with `ribbon_begin`.
287    #[cfg_attr(jfgui_dev, track_caller)]
288    pub fn ribbon_end(&mut self) {
289        let _src = self.src_scope();
290        let id = self.box_id();
291        let m = self.style.ribbon_metrics();
292        let py = if m.inset_y > 0 {
293            m.inset_y
294        } else {
295            self.style.ribbon_padding() as i32
296        };
297        if m.inset_x > py {
298            self.spacer(m.inset_x - py);
299        }
300        self.scroll_end();
301        if !m.collapse_groups {
302            let r = self.last_rect();
303            let (extent, _) = self.scroll_extent(id);
304            let (offset, _) = self.scroll_pos(id);
305            let viewport = self.px2dip(r[2]);
306            for (part, shown, x, icon, label, target) in [
307                (
308                    4,
309                    offset > 0,
310                    r[0],
311                    crate::keys::IC_CHEV_LEFT,
312                    "Vorherige Menübandbefehle",
313                    (offset - 160).max(0),
314                ),
315                (
316                    5,
317                    offset + viewport < extent,
318                    r[0] + r[2] - self.dp(18),
319                    crate::keys::IC_CHEV_RIGHT,
320                    "Weitere Menübandbefehle",
321                    (offset + 160).min((extent - viewport).max(0)),
322                ),
323            ] {
324                if !shown {
325                    continue;
326                }
327                self.draw_color(self.color(T_LAYER));
328                self.draw_rect(x, r[1], self.dp(18), r[3]);
329                self.next_rect_px(x, r[1], self.dp(18), r[3]);
330                if self.ribbon_button(
331                    id.wrapping_mul(8).wrapping_add(part) | 0x8000_0000,
332                    label,
333                    icon,
334                    ST_ICON_ONLY,
335                ) & EV_ACT
336                    != 0
337                {
338                    self.scroll_to(id, target, 0);
339                }
340            }
341            self.wp.ctx.last_rect = r;
342        }
343    }
344
345    /// Natural action width in DIP, including the style's icon, label and menu
346    /// spacing. Measuring neither consumes next_* hints nor creates a widget.
347    /// Use it when composing translated ribbon columns before ribbon_begin.
348    pub fn ribbon_button_width(&mut self, label: &str, icon: u32, flags: u32) -> i32 {
349        let saved = self.text.get_font();
350        let mut sp = Sp {
351            flags: flags | ST_SUBTLE | ST_COMPACT,
352            icon,
353            label: Some(label),
354            ..Sp::default()
355        };
356        let st = self.style;
357        let (width, _) = st.button_size(self, &mut sp);
358        self.text.set_font(saved.0, saved.1);
359        // Round up so integer DIP columns never clip a fractional-width glyph.
360        let dpi = self.dpi().max(1) as i64;
361        ((width.max(0) as i64 * 96 + dpi - 1) / dpi).min(i32::MAX as i64) as i32
362    }
363
364    /// Compact ribbon action with clipped drawing, native focus and a full tooltip.
365    /// `ST_ICON_ONLY`, `ST_LARGE`, `ST_CHECKED` and `ST_DISABLED` are supported.
366    #[cfg_attr(jfgui_dev, track_caller)]
367    pub fn ribbon_button(&mut self, id: u32, label: &str, icon: u32, flags: u32) -> u32 {
368        let _src = self.src_scope();
369        let flags = flags | self.take_flags() | ST_SUBTLE | ST_COMPACT;
370        let st = self.style;
371        let mut sp = Sp {
372            flags,
373            icon,
374            label: Some(label),
375            ..Sp::default()
376        };
377        let (w, h) = st.button_size(self, &mut sp);
378        let r = self.place(w, h);
379        let ev = self.behave(
380            id,
381            HB_FOCUS
382                | if flags & ST_DISABLED != 0 {
383                    HB_DISABLED
384                } else {
385                    0
386                },
387        );
388        self.sp_placed(&mut sp, ev);
389        self.draw_clip(r[0], r[1], r[2], r[3]);
390        st.button(self, &sp);
391        self.draw_unclip();
392        self.focus_ring_draw(ev);
393        if flags & ST_CHECKED != 0 {
394            self.wp.ctx.last_state |= SS_CHECKED as u32;
395        }
396        self.sem(K_BUTTON, id, Some(label), 0);
397        self.tooltip(label);
398        ev
399    }
400
401    /// Large ribbon action: the style draws its icon above a visible caption.
402    /// It is one normal focusable, inspectable button. Use `next_size` to override
403    /// its natural style size; checked/disabled flags keep their ordinary meaning.
404    #[cfg_attr(jfgui_dev, track_caller)]
405    pub fn ribbon_large_button(&mut self, id: u32, label: &str, icon: u32, flags: u32) -> u32 {
406        let _src = self.src_scope();
407        let flags = flags | self.take_flags();
408        self.ribbon_button(id, label, icon, (flags | ST_LARGE) & !ST_ICON_ONLY)
409    }
410
411    /// Ribbon-styled menu opener. `id` is also the popup identifier. Its natural
412    /// dimensions, icon, menu indicator and enabled state come from the style.
413    #[cfg_attr(jfgui_dev, track_caller)]
414    pub fn ribbon_menu_button(&mut self, id: u32, label: &str, icon: u32, flags: u32) -> u32 {
415        let _src = self.src_scope();
416        let flags = flags | self.take_flags();
417        let ev = self.ribbon_button(id, label, icon, flags | ST_MENU);
418        if flags & ST_DISABLED == 0 && (ev & EV_ACT != 0 || ev & EV_FOCUS != 0 && self.open_key()) {
419            self.popup_open(id);
420            if self.wp.ctx.focus_visible {
421                self.pp_flag(id, PPF_FOCUS);
422            }
423        }
424        if self.popup_is_open(id) {
425            self.wp.ctx.last_state |= SS_OPEN as u32;
426            if let Some(semantic) = self.sems.last_mut() {
427                if semantic.id == id {
428                    semantic.state |= SS_OPEN;
429                }
430            }
431        }
432        ev | if self.popup_is_open(id) { EV_OPEN } else { 0 }
433    }
434
435    /// Large split action with an icon hit area above its menu caption. Both
436    /// parts remain independently focusable and discoverable through semantics.
437    /// Pair the `menu_id` with a normal flyout. Alt+Down/F4 opens the caption menu.
438    #[cfg_attr(jfgui_dev, track_caller)]
439    pub fn ribbon_stacked_split_button(
440        &mut self,
441        id: u32,
442        menu_id: u32,
443        label: &str,
444        menu_label: &str,
445        icon: u32,
446        flags: u32,
447    ) -> u32 {
448        let _src = self.src_scope();
449        let flags = flags | self.take_flags() | ST_LARGE | ST_COMPACT | ST_SUBTLE | ST_MENU;
450        let st = self.style;
451        let mut sp = Sp {
452            flags,
453            icon,
454            label: Some(label),
455            ..Sp::default()
456        };
457        let (w, h) = st.button_size(self, &mut sp);
458        let whole = self.place(w, h);
459        let top = self.dp(40).min(whole[3]);
460        let hit_flags = HB_FOCUS
461            | if flags & ST_DISABLED != 0 {
462                HB_DISABLED
463            } else {
464                0
465            };
466        self.wp.ctx.last_rect = [whole[0], whole[1], whole[2], top];
467        let ev = self.behave(id, hit_flags);
468        self.sp_placed(&mut sp, ev);
469        self.sem(K_BUTTON, id, Some(label), 0);
470        self.focus_ring_draw(ev);
471        self.tooltip(label);
472        self.wp.ctx.last_rect = [whole[0], whole[1] + top, whole[2], whole[3] - top];
473        let menu = self.behave(menu_id, hit_flags);
474        let keyboard = (ev | menu) & EV_FOCUS != 0 && self.open_key();
475        if flags & ST_DISABLED == 0 && (menu & EV_ACT != 0 || keyboard) {
476            if keyboard {
477                self.focus(menu_id);
478            }
479            self.popup_open(menu_id);
480            if self.wp.ctx.focus_visible {
481                self.pp_flag(menu_id, PPF_FOCUS);
482            }
483        }
484        if self.popup_is_open(menu_id) {
485            self.wp.ctx.last_state |= SS_OPEN as u32;
486        }
487        self.sem(K_BUTTON, menu_id, Some(menu_label), 0);
488        self.focus_ring_draw(menu);
489        self.tooltip(menu_label);
490        if menu & EV_HOVER != 0 {
491            self.sp_placed(&mut sp, menu);
492        }
493        sp.place(whole);
494        self.draw_clip(whole[0], whole[1], whole[2], whole[3]);
495        st.button(self, &sp);
496        self.draw_unclip();
497        self.wp.ctx.last_rect = whole;
498        ev | if self.popup_is_open(menu_id) {
499            EV_OPEN
500        } else {
501            0
502        }
503    }
504
505    /// A large primary action beside an independently focusable dropdown.
506    /// `id` identifies the primary action; `menu_id` identifies both the dropdown
507    /// button and its popup. Both IDs must be unique. `menu_label` is a full semantic
508    /// name, not an unlabeled chevron. Pair with `flyout_begin(menu_id, ..)` and
509    /// `flyout_end` immediately afterwards to provide actual secondary actions.
510    /// Returns primary EV_* flags plus EV_OPEN while the dropdown popup is open.
511    /// Tab reaches both parts; Enter/Space activate the focused part. Alt+Down or
512    /// unbound F4 on the main part forwards to the existing dropdown keyboard path.
513    #[cfg_attr(jfgui_dev, track_caller)]
514    pub fn ribbon_split_button(
515        &mut self,
516        id: u32,
517        menu_id: u32,
518        label: &str,
519        menu_label: &str,
520        icon: u32,
521        flags: u32,
522    ) -> u32 {
523        let _src = self.src_scope();
524        let flags = flags | self.take_flags();
525        let st = self.style;
526        let mut sp = Sp {
527            flags: (flags | ST_LARGE | ST_COMPACT | ST_SUBTLE) & !ST_ICON_ONLY,
528            icon,
529            label: Some(label),
530            ..Sp::default()
531        };
532        let (width, height) = st.button_size(self, &mut sp);
533        let arrow = self.dp(20);
534        let whole = self.place(width + arrow, height);
535        let arrow = arrow.min(whole[2].max(0));
536        let main = [whole[0], whole[1], (whole[2] - arrow).max(0), whole[3]];
537        self.next_rect_px(main[0], main[1], main[2], main[3]);
538        let ev = self.ribbon_large_button(id, label, icon, flags);
539        if ev & EV_FOCUS != 0 && flags & ST_DISABLED == 0 {
540            let key = self.key_first();
541            let unbound_f4 = key == VK_F4
542                && !self
543                    .desc
544                    .cmds
545                    .iter()
546                    .take_while(|command| command.id != 0)
547                    .any(|command| command.key as u32 == VK_F4 && command.mods == 0);
548            if key == (VK_DOWN | (MOD_ALT << 16)) || unbound_f4 {
549                self.focus(menu_id);
550            }
551        }
552        self.next_rect_px(whole[0] + main[2], whole[1], arrow, whole[3]);
553        self.next_flags((flags | ST_SUBTLE | ST_COMPACT | ST_ICON_ONLY) & !ST_LARGE);
554        let menu = self.dropdown_button(menu_id, menu_label);
555        // Popup placement and caller tooltips use the whole combined rectangle;
556        // semantic and focus records still retain each part's own hit rectangle.
557        self.wp.ctx.last_rect = whole;
558        ev | (menu & EV_OPEN)
559    }
560
561    /// Begin an inset horizontal gallery on an input-colored surface. The caller
562    /// supplies its size and tiles; finish with `box_end`. The whole frame is painted
563    /// before applying the three-DIP tile inset, including in dark themes.
564    #[cfg_attr(jfgui_dev, track_caller)]
565    pub fn ribbon_gallery_begin(&mut self, id: u32) {
566        let _src = self.src_scope();
567        self.box_begin(id, BOX_H | BOX_FIXED | pad(3), 2);
568        let r = self.box_rect();
569        let inset = self.dp(3);
570        let stroke = self.color(T_LAYER_STROKE);
571        self.cmd_rrect(r[0] - inset, r[1] - inset, r[2] + 2 * inset, r[3] + 2 * inset,
572            self.dp(4), self.dp(1), self.color(T_INPUT_ACTIVE), stroke, stroke);
573    }
574
575    /// Typography preview tile for a style gallery; selection is exposed semantically.
576    /// `typography` is a `TS_*` style; use `next_size` to set a compact gallery width.
577    #[cfg_attr(jfgui_dev, track_caller)]
578    pub fn ribbon_tile(
579        &mut self,
580        id: u32,
581        label: &str,
582        preview: &str,
583        typography: u32,
584        selected: bool,
585    ) -> u32 {
586        let _src = self.src_scope();
587        self.ribbon_font_tile(id, label, preview, typography, selected, None)
588    }
589
590    /// A gallery tile with an optional document font, 24.8-DIP size and ARGB ink.
591    /// The surrounding control follows the application theme; the sample stays document-colored.
592    #[cfg_attr(jfgui_dev, track_caller)]
593    pub fn ribbon_font_tile(&mut self, id: u32, label: &str, preview: &str, typography: u32,
594        selected: bool, face: Option<(u32, i32, u32)>) -> u32 {
595        let _src = self.src_scope();
596        self.ribbon_sample_tile(id, label, preview, typography, selected, face.map(|(font, size, color)|
597            RibbonSample { font, size, color, highlight: None, baseline_shift: 0, underline: false, strike: false }))
598    }
599    /// Style-gallery tile with explicit highlight, baseline and text decorations.
600    #[cfg_attr(jfgui_dev, track_caller)]
601    pub fn ribbon_sample_tile(&mut self, id: u32, label: &str, preview: &str, typography: u32,
602        selected: bool, sample: Option<RibbonSample>) -> u32 {
603        let _src = self.src_scope();
604        let flags = self.take_flags() | ST_SUBTLE | if selected { ST_CHECKED } else { 0 };
605        let r = self.place(self.dp(106), self.dp(62));
606        let ev = self.behave(
607            id,
608            HB_FOCUS
609                | if flags & ST_DISABLED != 0 {
610                    HB_DISABLED
611                } else {
612                    0
613                },
614        );
615        let mut sp = Sp {
616            flags,
617            ..Sp::default()
618        };
619        self.sp_placed(&mut sp, ev);
620        let st = self.style;
621        st.button(self, &sp);
622        if let Some(sample) = sample.or_else(|| st.ribbon_preview(typography).map(|(font, size, color)|
623            RibbonSample { font, size, color, highlight: None, baseline_shift: 0, underline: false, strike: false })) {
624            // These are document samples. The palette remains independent of
625            // dark chrome and disabled commands do not gray out the preview.
626            self.draw_color(0xffffffff);
627            self.draw_rect(
628                r[0] + self.dp(3),
629                r[1] + self.dp(4),
630                (r[2] - self.dp(6)).max(0),
631                self.dp(32).min((r[3] - self.dp(4)).max(0)),
632            );
633            self.draw_clip(r[0] + self.dp(3), r[1], (r[2] - self.dp(6)).max(0), r[3]);
634            self.text.set_font(sample.font, self.dp_fx(sample.size));
635            let asc = self.text.ascent();
636            let desc = self.text.descent();
637            let x = r[0] + self.dp(4);
638            let baseline = r[1] + self.dp(4) + (self.dp(34) - asc - desc) / 2 + asc
639                + self.dp_fx(sample.baseline_shift) / 256;
640            let width = self.text.width(preview);
641            if let Some(color) = sample.highlight {
642                self.draw_color(color);
643                self.draw_rect(x, baseline - asc, width, asc + desc);
644            }
645            self.emit_text(
646                x,
647                baseline,
648                preview,
649                sample.color,
650            );
651            self.draw_color(sample.color);
652            let thickness = (self.dp_fx(sample.size) / 256 / 16).max(1);
653            if sample.underline { self.draw_rect(x, baseline + thickness, width, thickness); }
654            if sample.strike { self.draw_rect(x, baseline - (asc / 3).max(1), width, thickness); }
655            self.text.set_font(FONT_REG, self.dp_fx(2688)); // 10.5 DIP
656            let room = (r[2] - self.dp(6)).max(0);
657            let truncated = if self.text.width(label) > room {
658                Some(self.ellipsize(label.as_bytes(), room).0)
659            } else {
660                None
661            };
662            let caption = truncated.as_deref().unwrap_or(label);
663            let w = self.text.width(caption);
664            let baseline = r[1] + r[3] - self.dp(9)
665                + (self.text.ascent() - self.text.descent()) / 2;
666            self.emit_text(
667                r[0] + ((r[2] - w) / 2).max(self.dp(3)),
668                baseline,
669                caption,
670                self.color(T_TEXT),
671            );
672            self.draw_unclip();
673            self.focus_ring_draw(ev);
674            if selected {
675                self.wp.ctx.last_state |= SS_CHECKED as u32;
676            }
677            self.sem(K_BUTTON, id, Some(label), i32::from(selected));
678            self.tooltip(label);
679            return ev;
680        }
681        self.draw_clip(r[0] + self.dp(6), r[1], (r[2] - self.dp(12)).max(0), r[3]);
682        let c = self.color(if sp.state == 3 {
683            T_TEXT_DISABLED
684        } else if typography == TS_SUBTITLE {
685            T_ACCENT_TEXT
686        } else {
687            T_TEXT
688        });
689        let base = self.ts_base(typography);
690        let line = self.ts_line(typography);
691        self.emit_text(
692            r[0] + self.dp(8),
693            r[1] + self.dp(4) + (self.dp(34) - line) / 2 + base,
694            preview,
695            c,
696        );
697        let base = self.ts_base(TS_CAPTION);
698        let c = self.color(if sp.state == 3 {
699            T_TEXT_DISABLED
700        } else {
701            T_TEXT2
702        });
703        self.emit_text(r[0] + self.dp(8), r[1] + self.dp(42) + base, label, c);
704        self.draw_unclip();
705        self.focus_ring_draw(ev);
706        if selected {
707            self.wp.ctx.last_state |= SS_CHECKED as u32;
708        }
709        self.sem(K_BUTTON, id, Some(label), i32::from(selected));
710        self.tooltip(label);
711        ev
712    }
713}