Skip to main content

denise_ui/widgets/
tabs.rs

1//! A row of labels where one is selected.
2
3use alloc::string::String;
4use alloc::vec::Vec;
5
6use denise::Pen;
7use denise::theme::{AA, contrast_x100, derive_content};
8use denise::{Color, ElementState, InputEvent, KeyCode, Point, PointerButton, Rect, Role, Theme};
9use denise_text::{TextEngine, TextStyle};
10
11use crate::widget::{
12    Event, EventCtx, Handled, MeasureCtx, Measured, Offer, PaintCtx, VisualState, Widget,
13};
14use crate::widgets::describe::{
15    Describe, DynDescribe, Group, Mismatch, Payload, Property, PropertyKind, ROLES, Value,
16};
17use crate::widgets::style::{
18    Align, ClickPair, Intent, draw_aligned, hovered_row, interactive_pair, muted,
19};
20
21/// A tab strip: a row of labels, one of them selected, with a rule underneath.
22///
23/// # The strip, and the pages under it
24///
25/// On its own the widget fills its node and selects nothing: an application
26/// listens to its message and shows and hides pages it built itself. A form
27/// file can also nest a page under each `tab`, in which case the node hosts
28/// them and the strip draws in a band along its top — see [`Tabs::over_pages`]
29/// and [`Tabs::strip_height`]. Either way **this widget still owns only which
30/// tab is selected**; what changes is who owns the pages.
31///
32/// ```
33/// # use denise_ui::Tabs;
34/// enum Message { Page(usize) }
35/// Tabs::new(["Oversikt", "Alarmer", "Innstillinger"], Message::Page);
36/// ```
37///
38/// # What it owns, and what it does not
39///
40/// **This widget owns which tab is selected, and nothing else.** Showing and
41/// hiding the pages is [`Ui::set_visible`](crate::Ui::set_visible) on nodes the
42/// application already owns.
43///
44/// That is a deliberate line rather than an omission. A tab strip that owned its
45/// pages would have to own their *layout* — where each page goes, how big it is,
46/// what happens when the strip moves — and that is a layout engine. Owning one
47/// index is the part nobody else can do better.
48///
49/// # A strip people work in
50///
51/// A strip of documents is handled more than a strip of sections: tabs are
52/// closed, dragged into order, renamed and coloured. Made with
53/// [`Tabs::with_events`], the strip reports each of those as a [`TabEvent`],
54/// and the line above still holds — it reports, and the application decides:
55///
56/// - **Closing** is asked for, never done. The close button ([`with_close_buttons`])
57///   and a middle click report [`TabEvent::Close`]; the tab stays until the
58///   application removes it, because a document with unsaved changes asks first.
59/// - **Dragging** is the one thing the strip does on its own, because the tab
60///   has to move under the pointer while it is held and only the strip can draw
61///   that. It reports [`TabEvent::Moved`] once, when the tab is let go, and the
62///   application moves its own list the same way.
63/// - **Renaming** is a double click, reported as [`TabEvent::Activated`]. The
64///   strip has no text field; [`tab_rect`] says where to put one.
65/// - **A right click** reports [`TabEvent::Menu`] with the point, for
66///   [`open_menu_at`](super::open_menu_at).
67/// - **A colour** tints a tab and marks its top edge ([`Tabs::set_color`]). The
68///   label stays readable on the tint whatever colour is chosen.
69///
70/// A strip made with [`Tabs::new`] does none of this and behaves as it always
71/// has.
72///
73/// ```
74/// # use denise_ui::{TabEvent, Tabs};
75/// enum Message { Tab(TabEvent) }
76/// Tabs::with_events(["notes.txt", "server.log"], Message::Tab).with_close_buttons(true);
77/// ```
78///
79/// [`with_close_buttons`]: Tabs::with_close_buttons
80///
81/// # Keyboard
82///
83/// The strip is **one tab stop**, like [`RadioGroup`](super::RadioGroup) and for
84/// the same reason: `Tab` should move from the strip into the page, not through
85/// three tabs first. Left and Right move the selection and wrap; `Home` and `End`
86/// go to the ends.
87///
88/// Up and Down are deliberately *not* handled. A tab strip is horizontal, and
89/// the vertical keys almost always belong to whatever is in the page below it —
90/// which is the opposite of `RadioGroup`, where a vertical list takes all four.
91///
92/// # A row wider than the strip
93///
94/// The row slides left as far as it takes to show the selected tab, so the tab
95/// being looked at is never the one clipped off the end.
96#[derive(Clone, Debug)]
97pub struct Tabs<M> {
98    labels: Vec<String>,
99    /// Each tab's colour, or `None` for the panel's own. As long as `labels`.
100    colors: Vec<Option<Color>>,
101    selected: usize,
102    report: Report<M>,
103    /// Whether each tab carries a button that asks for it to be closed.
104    closable: bool,
105    role: Role,
106    style: TextStyle,
107    /// Whether the node this sits on is hosting a page below the strip.
108    ///
109    /// A strip on its own fills its node, which is what a `tabs` node has
110    /// always been and what a form that sets `h=40` is asking for. A strip over
111    /// pages draws in a band of [`Tabs::strip_height`] along the top and leaves
112    /// the rest to the page, the way `Collapse` leaves everything below its
113    /// header to the body. The *builder* knows which, because it can see
114    /// whether any `tab` node carries children; the widget cannot.
115    over_pages: bool,
116    /// The tab under the pointer, while the pointer is over the strip.
117    hovered: Option<usize>,
118    /// A press on a tab, until it is let go.
119    press: Option<Press>,
120    clicks: ClickPair,
121}
122
123/// What a person did to a strip made with [`Tabs::with_events`].
124#[derive(Clone, Copy, Debug, PartialEq, Eq)]
125pub enum TabEvent {
126    /// A tab was chosen — clicked, or reached with the arrow keys.
127    Selected(usize),
128    /// A tab's close button was clicked, or the tab was middle-clicked. The
129    /// strip removes nothing.
130    Close(usize),
131    /// A tab was dragged from `from` and let go at `to`, and the strip has
132    /// already moved it there. Indices after the move: the tab that was at
133    /// `from` is at `to`, and the ones between moved one place to make room.
134    Moved {
135        /// Where the tab was.
136        from: usize,
137        /// Where it is now.
138        to: usize,
139    },
140    /// A tab was double-clicked: the gesture that renames one.
141    Activated(usize),
142    /// A tab was right-clicked at `at`, in surface pixels: where its context
143    /// menu opens.
144    Menu {
145        /// The tab under the pointer.
146        index: usize,
147        /// Where the pointer was.
148        at: Point,
149    },
150}
151
152/// Who hears about it.
153#[derive(Debug)]
154enum Report<M> {
155    Nothing,
156    Index(fn(usize) -> M),
157    Events(fn(TabEvent) -> M),
158}
159
160// By hand, because the derives would ask for `M: Copy`, and a function pointer
161// is copied whatever it returns.
162impl<M> Clone for Report<M> {
163    fn clone(&self) -> Self {
164        *self
165    }
166}
167
168impl<M> Copy for Report<M> {}
169
170/// A press on a tab.
171#[derive(Clone, Copy, Debug)]
172struct Press {
173    /// Where the tab is now: where it was pressed, until a drag moves it.
174    index: usize,
175    /// Where it was when it was pressed.
176    from: usize,
177    button: PointerButton,
178    /// Whether the press landed on the tab's close button.
179    on_close: bool,
180    start: Point,
181    /// How far into the tab the pointer took hold, so a dragged tab stays
182    /// under the pointer at the place it was picked up.
183    grab: i32,
184    /// The pointer's x once the press has moved far enough to be a drag.
185    dragging: Option<i32>,
186}
187
188impl<M> Tabs<M> {
189    /// A strip with the first tab selected, reporting the index of each tab
190    /// chosen.
191    pub fn new(
192        labels: impl IntoIterator<Item = impl Into<String>>,
193        message: fn(usize) -> M,
194    ) -> Self {
195        Self::reporting(labels, Report::Index(message))
196    }
197
198    /// A strip that reports everything done to it — choosing, closing,
199    /// dragging, renaming and right-clicking a tab — as a [`TabEvent`].
200    pub fn with_events(
201        labels: impl IntoIterator<Item = impl Into<String>>,
202        message: fn(TabEvent) -> M,
203    ) -> Self {
204        Self::reporting(labels, Report::Events(message))
205    }
206
207    /// A strip that emits nothing.
208    pub fn inert(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
209        Self::reporting(labels, Report::Nothing)
210    }
211
212    fn reporting(labels: impl IntoIterator<Item = impl Into<String>>, report: Report<M>) -> Self {
213        let labels: Vec<String> = labels.into_iter().map(Into::into).collect();
214        Self {
215            colors: alloc::vec![None; labels.len()],
216            labels,
217            selected: 0,
218            report,
219            closable: false,
220            role: Role::Primary,
221            style: TextStyle::built_in(16),
222            over_pages: false,
223            hovered: None,
224            press: None,
225            clicks: ClickPair::default(),
226        }
227    }
228
229    /// A strip that sits above pages hosted on its own node.
230    ///
231    /// Set by `denise-forms` when a `tab` in the file carries children. It
232    /// changes only where the strip is drawn — the band along the top rather
233    /// than the whole node — so that the page below it is visible.
234    #[must_use]
235    pub fn over_pages(mut self) -> Self {
236        self.over_pages = true;
237        self
238    }
239
240    /// Whether this strip is drawn in a band rather than filling its node.
241    #[inline]
242    pub const fn is_over_pages(&self) -> bool {
243        self.over_pages
244    }
245
246    /// The strip's own height: the theme's field height.
247    ///
248    /// The band this widget draws in, and the offset a form places a tab's page
249    /// at — one definition, so the two cannot drift. The same shape as
250    /// [`Collapse::header_height`](super::Collapse::header_height), and for the
251    /// same reason: the widget is the strip, and the node it sits on may be
252    /// much taller because it is hosting a page below.
253    ///
254    /// A node no taller than this is a strip and nothing else, which is what a
255    /// `tabs` node was before a `tab` could hold anything.
256    pub fn strip_height(&self, theme: &Theme) -> i32 {
257        theme.metrics.size_field.max(1)
258    }
259
260    /// The part of `bounds` this strip draws in and answers clicks in.
261    fn band(&self, bounds: Rect, theme: &Theme) -> Rect {
262        if !self.over_pages {
263            return bounds;
264        }
265        let height = bounds.height.min(self.strip_height(theme));
266        Rect::new(bounds.x, bounds.y, bounds.width, height)
267    }
268
269    /// Sets the initially selected tab. Out of range selects the last one.
270    pub fn with_selected(mut self, index: usize) -> Self {
271        self.selected = self.clamp(index);
272        self
273    }
274
275    /// Sets the colour of the selected tab's underline.
276    ///
277    /// Only the underline — a bar, not text. An earlier version drew the selected
278    /// *label* in this colour, and `Secondary` on the light theme is 2.34:1
279    /// against the panel, which is a label nobody can read. The rule the labels
280    /// follow now cannot depend on which role a caller passes.
281    pub fn with_role(mut self, role: Role) -> Self {
282        self.role = role;
283        self
284    }
285
286    /// Sets the labels' font and size.
287    pub fn with_style(mut self, style: TextStyle) -> Self {
288        self.style = style;
289        self
290    }
291
292    /// Gives every tab a button at its trailing end that asks for it to be
293    /// closed, shown on the selected tab and the one under the pointer.
294    ///
295    /// Only a strip made with [`Tabs::with_events`] has anything to report it
296    /// with; on any other the button is drawn and does nothing.
297    #[must_use]
298    pub fn with_close_buttons(mut self, on: bool) -> Self {
299        self.closable = on;
300        self
301    }
302
303    /// Sets each tab's colour, in order. See [`Tabs::set_colors`].
304    #[must_use]
305    pub fn with_colors(mut self, colors: impl IntoIterator<Item = Option<Color>>) -> Self {
306        self.set_colors(colors);
307        self
308    }
309
310    /// The selected index. Always in range while the strip has tabs.
311    #[inline]
312    pub const fn selected(&self) -> usize {
313        self.selected
314    }
315
316    /// The selected tab's label, or `None` for an empty strip.
317    #[inline]
318    pub fn selected_label(&self) -> Option<&str> {
319        self.labels.get(self.selected).map(String::as_str)
320    }
321
322    /// Selects a tab **without emitting anything**. Out of range is clamped.
323    pub fn set_selected(&mut self, index: usize) {
324        self.selected = self.clamp(index);
325    }
326
327    /// The labels, in order.
328    #[inline]
329    pub fn labels(&self) -> &[String] {
330        &self.labels
331    }
332
333    /// Replaces the labels, keeping the selection in range.
334    ///
335    /// Colours stay with the places they were set on; a tab past the old end
336    /// has none.
337    pub fn set_labels(&mut self, labels: impl IntoIterator<Item = impl Into<String>>) {
338        self.labels = labels.into_iter().map(Into::into).collect();
339        self.colors.resize(self.labels.len(), None);
340        self.selected = self.clamp(self.selected);
341        self.forget_pointer();
342    }
343
344    /// Renames tab `index`. Out of range is ignored.
345    pub fn set_label(&mut self, index: usize, label: impl Into<String>) {
346        if let Some(slot) = self.labels.get_mut(index) {
347            *slot = label.into();
348        }
349    }
350
351    /// Each tab's colour, in order: `None` for a tab drawn on the panel.
352    #[inline]
353    pub fn colors(&self) -> &[Option<Color>] {
354        &self.colors
355    }
356
357    /// Sets each tab's colour, in order. A list shorter than the tabs leaves
358    /// the rest uncoloured, and a longer one is cut to them.
359    pub fn set_colors(&mut self, colors: impl IntoIterator<Item = Option<Color>>) {
360        self.colors = colors.into_iter().collect();
361        self.colors.resize(self.labels.len(), None);
362    }
363
364    /// Colours tab `index`, or takes its colour away. Out of range is ignored.
365    pub fn set_color(&mut self, index: usize, color: Option<Color>) {
366        if let Some(slot) = self.colors.get_mut(index) {
367            *slot = color;
368        }
369    }
370
371    /// Moves tab `from` to `to` **without emitting anything**, its label and
372    /// colour with it. The selection follows the tab it was on.
373    pub fn move_tab(&mut self, from: usize, to: usize) {
374        let count = self.labels.len();
375        if from >= count || to >= count || from == to {
376            return;
377        }
378        let label = self.labels.remove(from);
379        self.labels.insert(to, label);
380        let color = self.colors.remove(from);
381        self.colors.insert(to, color);
382        self.selected = moved_index(self.selected, from, to);
383        self.hovered = None;
384        self.clicks.forget();
385    }
386
387    /// Whether the tabs carry close buttons.
388    #[inline]
389    pub const fn has_close_buttons(&self) -> bool {
390        self.closable
391    }
392
393    /// Shows the close buttons, or stops. See [`Tabs::with_close_buttons`].
394    pub fn set_close_buttons(&mut self, on: bool) {
395        self.closable = on;
396    }
397
398    /// Replaces the colour role.
399    pub fn set_role(&mut self, role: Role) {
400        self.role = role;
401    }
402
403    /// Replaces the labels' font and size.
404    pub fn set_style(&mut self, style: TextStyle) {
405        self.style = style;
406    }
407
408    /// Width the whole strip needs for every tab at its natural width.
409    ///
410    /// A strip given less than this clips its last tabs rather than shrinking
411    /// them — a tab whose label is cut in half says less than one that is not
412    /// there, and squeezing them all would make the strip reflow every time a
413    /// label changed.
414    pub fn preferred_width(&self, engine: &mut TextEngine) -> i32 {
415        self.widths(engine).iter().sum()
416    }
417
418    /// Each tab's width, in order.
419    fn widths(&self, engine: &mut TextEngine) -> Vec<i32> {
420        widths(&self.labels, self.style, self.closable, engine)
421    }
422
423    /// Where each tab is drawn in `band`.
424    fn layout(&self, band: Rect, engine: &mut TextEngine) -> Vec<Rect> {
425        lay_out(
426            &self.labels,
427            self.style,
428            self.closable,
429            self.selected,
430            band,
431            engine,
432        )
433    }
434
435    #[inline]
436    fn clamp(&self, index: usize) -> usize {
437        index.min(self.labels.len().saturating_sub(1))
438    }
439
440    /// Moves the selection by one, wrapping.
441    fn step(&self, forward: bool) -> usize {
442        let count = self.labels.len();
443        if count == 0 {
444            return 0;
445        }
446        if forward {
447            (self.selected + 1) % count
448        } else {
449            (self.selected + count - 1) % count
450        }
451    }
452
453    /// Drops what the pointer was doing: the tabs it knew about moved.
454    fn forget_pointer(&mut self) {
455        self.hovered = None;
456        self.press = None;
457        self.clicks.forget();
458    }
459
460    fn reports_events(&self) -> bool {
461        matches!(self.report, Report::Events(_))
462    }
463
464    /// Tells the application, in whichever form it asked to be told. A strip
465    /// that reports indices hears only about selection.
466    fn emit(&self, ctx: &mut EventCtx<'_, M>, event: TabEvent) {
467        match (self.report, event) {
468            (Report::Events(message), event) => ctx.emit(message(event)),
469            (Report::Index(message), TabEvent::Selected(index)) => ctx.emit(message(index)),
470            _ => {}
471        }
472    }
473
474    fn select(&mut self, index: usize, ctx: &mut EventCtx<'_, M>) -> Handled {
475        if index == self.selected {
476            // Nothing changed, so nothing is reported — but the event was still
477            // this widget's to handle.
478            return Handled::Yes;
479        }
480        self.selected = index;
481        self.emit(ctx, TabEvent::Selected(index));
482        Handled::Yes
483    }
484
485    /// The close button of a tab drawn at `tab` in `band`: a square at its
486    /// trailing end, level with the label.
487    fn close_rect(&self, tab: Rect, band: Rect) -> Rect {
488        close_rect(self.style.size_px, tab, band)
489    }
490
491    fn pressed(
492        &mut self,
493        button: PointerButton,
494        position: Point,
495        band: Rect,
496        ctx: &mut EventCtx<'_, M>,
497    ) -> Handled {
498        if !self.reports_events() {
499            return Handled::No;
500        }
501        let tabs = self.layout(band, ctx.text);
502        let Some(index) = hit(band, &tabs, position) else {
503            self.press = None;
504            return Handled::No;
505        };
506        match button {
507            PointerButton::Right => {
508                self.press = None;
509                self.emit(
510                    ctx,
511                    TabEvent::Menu {
512                        index,
513                        at: position,
514                    },
515                );
516                Handled::Yes
517            }
518            PointerButton::Left | PointerButton::Middle => {
519                let on_close = button == PointerButton::Left
520                    && self.closable
521                    && self.close_rect(tabs[index], band).contains(position);
522                self.press = Some(Press {
523                    index,
524                    from: index,
525                    button,
526                    on_close,
527                    start: position,
528                    grab: position.x - tabs[index].x,
529                    dragging: None,
530                });
531                Handled::Yes
532            }
533            PointerButton::Other(_) => Handled::No,
534        }
535    }
536
537    fn pointer_moved(&mut self, position: Point, band: Rect, ctx: &mut EventCtx<'_, M>) -> Handled {
538        let tabs = self.layout(band, ctx.text);
539        let over = hit(band, &tabs, position);
540        // The hovered tab only changes the picture when it shows a close button.
541        let mut changed = false;
542        if over != self.hovered {
543            self.hovered = over;
544            changed = self.closable;
545        }
546        let Some(mut press) = self.press else {
547            return if changed { Handled::Yes } else { Handled::No };
548        };
549        if press.button != PointerButton::Left || press.on_close {
550            return if changed { Handled::Yes } else { Handled::No };
551        }
552        if press.dragging.is_none()
553            && (position.x - press.start.x).abs() < drag_threshold(self.style.size_px)
554        {
555            return if changed { Handled::Yes } else { Handled::No };
556        }
557        press.dragging = Some(position.x);
558        let Some(tab) = tabs.get(press.index) else {
559            self.press = None;
560            return Handled::Yes;
561        };
562        let centre = position.x - press.grab + tab.width / 2;
563        if let Some(to) = drag_target(&tabs, press.index, centre) {
564            self.move_tab(press.index, to);
565            press.index = to;
566        }
567        self.press = Some(press);
568        Handled::Yes
569    }
570
571    fn released(
572        &mut self,
573        button: PointerButton,
574        position: Point,
575        band: Rect,
576        ctx: &mut EventCtx<'_, M>,
577    ) -> Handled {
578        let tabs = self.layout(band, ctx.text);
579        if !self.reports_events() {
580            return match hit(band, &tabs, position) {
581                Some(index) => self.select(index, ctx),
582                None => Handled::No,
583            };
584        }
585        let Some(press) = self.press.take() else {
586            return Handled::No;
587        };
588        if press.button != button {
589            return Handled::No;
590        }
591        if press.dragging.is_some() {
592            if press.index != press.from {
593                self.emit(
594                    ctx,
595                    TabEvent::Moved {
596                        from: press.from,
597                        to: press.index,
598                    },
599                );
600            }
601            self.clicks.forget();
602            return self.select(press.index, ctx);
603        }
604        // Let go somewhere other than where it was pressed: nothing, the way a
605        // button's press dragged off is nothing.
606        if hit(band, &tabs, position) != Some(press.index) {
607            return Handled::Yes;
608        }
609        let index = press.index;
610        if button == PointerButton::Middle {
611            self.emit(ctx, TabEvent::Close(index));
612            return Handled::Yes;
613        }
614        if press.on_close {
615            if self.close_rect(tabs[index], band).contains(position) {
616                self.emit(ctx, TabEvent::Close(index));
617            }
618            return Handled::Yes;
619        }
620        self.select(index, ctx);
621        if self.clicks.classify(index, ctx.now_ms, false) == Intent::Activate {
622            self.emit(ctx, TabEvent::Activated(index));
623        }
624        Handled::Yes
625    }
626}
627
628/// Where tab `index` of the strip at `id` is drawn, in surface pixels: where an
629/// application puts the field that renames it, or anchors something to it.
630///
631/// A free function for the reason [`title_layout`](super::title_layout) is one:
632/// measuring a label needs the text engine, which belongs to the tree the strip
633/// is borrowed from. `None` when `id` is not a strip or `index` is past its last
634/// tab.
635pub fn tab_rect<M: 'static>(
636    ui: &mut crate::Ui<M>,
637    id: crate::NodeId,
638    index: usize,
639) -> Option<Rect> {
640    let bounds = ui.bounds(id)?;
641    let theme = *ui.theme();
642    let (band, labels, style, closable, selected) = {
643        let strip = ui.widget::<Tabs<M>>(id)?;
644        (
645            strip.band(bounds, &theme),
646            strip.labels.clone(),
647            strip.style,
648            strip.closable,
649            strip.selected,
650        )
651    };
652    lay_out(&labels, style, closable, selected, band, ui.text_mut())
653        .get(index)
654        .copied()
655}
656
657/// Each tab's width: its label, padding either side, and room for a close
658/// button when there is one.
659fn widths(
660    labels: &[String],
661    style: TextStyle,
662    closable: bool,
663    engine: &mut TextEngine,
664) -> Vec<i32> {
665    let pad = padding(style.size_px);
666    let close = if closable {
667        close_size(style.size_px)
668    } else {
669        0
670    };
671    labels
672        .iter()
673        .map(|label| engine.measure_line(style, label) + pad * 2 + close)
674        .collect()
675}
676
677/// Where each tab is drawn in `band`: end to end from the leading edge, slid
678/// left as far as it takes to show the selected tab.
679fn lay_out(
680    labels: &[String],
681    style: TextStyle,
682    closable: bool,
683    selected: usize,
684    band: Rect,
685    engine: &mut TextEngine,
686) -> Vec<Rect> {
687    let mut tabs = place(band, &widths(labels, style, closable, engine));
688    let shift = reveal_shift(band, &tabs, selected);
689    for tab in &mut tabs {
690        tab.x -= shift;
691    }
692    tabs
693}
694
695/// The panel behind the labels, the selected label's colour, and the others'.
696///
697/// One function so the paint path and the contrast test cannot disagree about
698/// what is actually drawn.
699///
700/// **A disabled strip does not mute**, and it does not have to say so here:
701/// `interactive_pair` derives its disabled content by mixing until it *just*
702/// clears the contrast floor, and [`muted`] hands back anything that cannot
703/// afford the shift. A disabled strip is already recessed as a whole, and the
704/// selection still reads from the underline.
705fn label_colors(
706    theme: &denise::Theme,
707    state: VisualState,
708) -> (denise::Color, denise::Color, denise::Color) {
709    let (surface, content) = interactive_pair(theme, Role::Base100, state);
710    (surface, content, muted(surface, content))
711}
712
713/// How far a tab's colour is mixed into the panel behind it, out of 255.
714///
715/// Enough to tell a red tab from a blue one at a glance; not so much that the
716/// strip turns into a row of buttons. The full colour is in the bar along the
717/// tab's top edge.
718const TINT: u8 = 64;
719
720/// The panel under a tab coloured `color`.
721fn tinted(surface: Color, color: Color) -> Color {
722    surface.mix(color, TINT)
723}
724
725/// The selected and resting label colours on a tint: the strip's own while they
726/// are readable there, and otherwise derived from the tint itself.
727///
728/// A colour is the caller's and can be anything, so unlike a role it comes with
729/// no promise about what reads on it — this is where the promise is made.
730fn labels_on(tint: Color, content: Color) -> (Color, Color) {
731    let selected = if contrast_x100(tint, content) >= AA {
732        content
733    } else {
734        derive_content(tint, AA)
735    };
736    (selected, muted(tint, selected))
737}
738
739/// Space each side of a label.
740#[inline]
741const fn padding(size_px: u16) -> i32 {
742    let value = size_px as i32;
743    if value < 8 { 8 } else { value }
744}
745
746/// The side of a tab's close button.
747#[inline]
748const fn close_size(size_px: u16) -> i32 {
749    let value = size_px as i32;
750    if value < 12 { 12 } else { value }
751}
752
753/// The rule under the strip, and the bar along a coloured tab's top.
754#[inline]
755const fn rule_thickness(band: Rect) -> i32 {
756    let value = band.height / 10;
757    if value < 2 { 2 } else { value }
758}
759
760/// How far a press moves before it is a drag: far enough that a click with an
761/// unsteady hand is still a click.
762#[inline]
763const fn drag_threshold(size_px: u16) -> i32 {
764    let value = size_px as i32 / 3;
765    if value < 4 { 4 } else { value }
766}
767
768/// The close button of a tab drawn at `tab` in `band`.
769fn close_rect(size_px: u16, tab: Rect, band: Rect) -> Rect {
770    let size = close_size(size_px);
771    let pad = padding(size_px);
772    let y = tab.y + (tab.height - rule_thickness(band) - size) / 2;
773    Rect::new(tab.right() - pad / 2 - size, y, size, size)
774}
775
776/// Where each tab sits, laid left to right from the leading edge.
777///
778/// Tabs that fall past the right edge are still placed — the canvas clips them,
779/// and a rectangle that says where a tab *would* be keeps hit testing and
780/// drawing agreeing about it.
781fn place(bounds: Rect, widths: &[i32]) -> Vec<Rect> {
782    let mut x = bounds.x;
783    widths
784        .iter()
785        .map(|width| {
786            let rect = Rect::new(x, bounds.y, *width, bounds.height);
787            x += width;
788            rect
789        })
790        .collect()
791}
792
793/// How far left a row of `tabs` slides so that tab `selected` is entirely in
794/// `band`. A tab wider than the band shows its leading edge.
795fn reveal_shift(band: Rect, tabs: &[Rect], selected: usize) -> i32 {
796    let Some(tab) = tabs.get(selected) else {
797        return 0;
798    };
799    let overflow = tab.right() - band.right();
800    if overflow <= 0 {
801        return 0;
802    }
803    overflow.min(tab.x - band.x).max(0)
804}
805
806/// Which tab contains `point`, if any.
807fn hit(bounds: Rect, tabs: &[Rect], point: Point) -> Option<usize> {
808    if !bounds.contains(point) {
809        return None;
810    }
811    tabs.iter().position(|tab| tab.contains(point))
812}
813
814/// Where the tab at `index`, dragged so its centre is at `centre`, belongs:
815/// past every neighbour whose centre it has crossed.
816///
817/// Centres rather than edges, so tabs of different widths do not trade back
818/// and forth: once a tab has passed its neighbour's centre, the neighbour's new
819/// centre is further behind it by the dragged tab's whole width.
820fn drag_target(tabs: &[Rect], index: usize, centre: i32) -> Option<usize> {
821    let middle = |tab: &Rect| tab.x + tab.width / 2;
822    let mut to = index;
823    while to + 1 < tabs.len() && centre > middle(&tabs[to + 1]) {
824        to += 1;
825    }
826    if to == index {
827        while to > 0 && centre < middle(&tabs[to - 1]) {
828            to -= 1;
829        }
830    }
831    (to != index).then_some(to)
832}
833
834/// Where the tab that was at `index` is after the tab at `from` moved to `to`.
835const fn moved_index(index: usize, from: usize, to: usize) -> usize {
836    if index == from {
837        to
838    } else if from < index && index <= to {
839        index - 1
840    } else if to <= index && index < from {
841        index + 1
842    } else {
843        index
844    }
845}
846
847/// An × filling the middle of `rect`.
848fn draw_cross(canvas: &mut Pen<'_>, rect: Rect, color: Color) {
849    let inset = rect.width / 4;
850    let (x0, y0) = ((rect.x + inset) * 256, (rect.y + inset) * 256);
851    let (x1, y1) = ((rect.right() - inset) * 256, (rect.bottom() - inset) * 256);
852    // Half a stroke, measured square to the diagonal: a stroke is about a
853    // seventh of the button, and never thinner than a pixel.
854    let half = (rect.width * 256 / 14).max(128) * 181 / 256;
855    canvas.fill_polygon_fx(
856        &[
857            (x0 + half, y0 - half),
858            (x1 + half, y1 - half),
859            (x1 - half, y1 + half),
860            (x0 - half, y0 + half),
861        ],
862        color,
863    );
864    canvas.fill_polygon_fx(
865        &[
866            (x1 + half, y0 + half),
867            (x0 + half, y1 + half),
868            (x0 - half, y1 - half),
869            (x1 - half, y0 - half),
870        ],
871        color,
872    );
873}
874
875impl<M: 'static> Widget<M> for Tabs<M> {
876    fn describe(&self) -> Option<&dyn DynDescribe> {
877        Some(self)
878    }
879
880    fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
881        Some(self)
882    }
883    fn measure(&self, ctx: &mut MeasureCtx<'_>, _offered: Offer) -> Measured {
884        Measured::both(
885            self.preferred_width(ctx.text),
886            ctx.theme.metrics.size_field.max(1),
887        )
888    }
889
890    fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
891        // A strip over pages is as tall as the page it shows, and what this
892        // widget draws is the band along its top. A strip on its own fills its
893        // node, which is what `tabs h=40` has always meant.
894        let bounds = self.band(ctx.bounds, ctx.theme);
895        if bounds.is_empty() || self.labels.is_empty() {
896            return;
897        }
898        let mut tabs = self.layout(bounds, ctx.text);
899
900        // A rule under the whole strip, with the selected tab's segment drawn
901        // over it. Cheaper than a box per tab, and it reads as a strip rather
902        // than as a row of unrelated buttons.
903        let thickness = rule_thickness(bounds);
904        let rule = Rect::new(
905            bounds.x,
906            bounds.bottom() - thickness,
907            bounds.width,
908            thickness,
909        );
910        canvas.fill_rect(rule, ctx.theme.color(Role::Base300));
911
912        // Neither label colour depends on `self.role`: a role is only guaranteed
913        // against *its own* content, not against the surface a label sits on.
914        let (surface, content, resting) = label_colors(ctx.theme, ctx.state);
915        let underline = if ctx.state.contains(VisualState::DISABLED) {
916            resting
917        } else {
918            ctx.theme.color(self.role)
919        };
920        let hovered = hovered_row(ctx.state, self.hovered);
921        let close = if self.closable {
922            close_size(self.style.size_px)
923        } else {
924            0
925        };
926
927        // A tab being dragged is drawn where the pointer holds it, over the
928        // others, and so last.
929        let dragged = self
930            .press
931            .and_then(|press| press.dragging.map(|x| (press.index, x - press.grab)));
932        if let Some((index, x)) = dragged
933            && let Some(tab) = tabs.get_mut(index)
934        {
935            tab.x = x.clamp(bounds.x, (bounds.right() - tab.width).max(bounds.x));
936        }
937        let order = (0..tabs.len())
938            .filter(|&index| Some(index) != dragged.map(|(d, _)| d))
939            .chain(dragged.map(|(d, _)| d));
940
941        for index in order {
942            let tab = tabs[index];
943            let chosen = index == self.selected;
944            let face = Rect::new(tab.x, tab.y, tab.width, tab.height - thickness);
945            let (on, off) = match self.colors.get(index).copied().flatten() {
946                Some(color) => {
947                    let tint = tinted(surface, color);
948                    canvas.fill_rect(face, tint);
949                    canvas.fill_rect(Rect::new(tab.x, tab.y, tab.width, thickness), color);
950                    labels_on(tint, content)
951                }
952                None => {
953                    if dragged.is_some_and(|(d, _)| d == index) {
954                        // Lifted off the strip: opaque, so the tabs it passes
955                        // over do not show through its label.
956                        canvas.fill_rect(face, surface);
957                    }
958                    (content, resting)
959                }
960            };
961            if chosen {
962                canvas.fill_rect(Rect::new(tab.x, rule.y, tab.width, thickness), underline);
963            }
964            // The label sits above the rule, not centred in the whole height, or
965            // a tall strip puts its text on top of its own underline.
966            let text = Rect::new(tab.x, tab.y, tab.width - close, tab.height - thickness);
967            draw_aligned(
968                canvas,
969                ctx.text,
970                self.style,
971                text,
972                (Align::Center, Align::Center),
973                &self.labels[index],
974                if chosen { on } else { off },
975            );
976            if self.closable && (chosen || hovered == Some(index)) {
977                draw_cross(
978                    canvas,
979                    self.close_rect(tab, bounds),
980                    if chosen { on } else { off },
981                );
982            }
983        }
984    }
985
986    fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
987        if self.labels.is_empty() {
988            return Handled::No;
989        }
990        let band = self.band(ctx.bounds, ctx.theme);
991        let chosen = match event {
992            Event::Input(InputEvent::PointerMoved { position }) => {
993                return self.pointer_moved(*position, band, ctx);
994            }
995            Event::Input(InputEvent::PointerButton {
996                button,
997                state: ElementState::Down,
998                position,
999                ..
1000            }) => return self.pressed(*button, *position, band, ctx),
1001            Event::Input(InputEvent::PointerButton {
1002                button,
1003                state: ElementState::Up,
1004                position,
1005                ..
1006            }) => return self.released(*button, *position, band, ctx),
1007            Event::Input(InputEvent::TouchUp {
1008                position,
1009                cancelled: false,
1010                ..
1011            }) => {
1012                let tabs = self.layout(band, ctx.text);
1013                hit(band, &tabs, *position)
1014            }
1015            // Left and Right only. A tab strip is horizontal, and Up and Down
1016            // almost always belong to whatever is in the page below it.
1017            Event::Input(InputEvent::Key {
1018                code,
1019                state: ElementState::Down,
1020                ..
1021            }) if ctx.state.contains(VisualState::FOCUSED) => match code {
1022                KeyCode::ArrowLeft => Some(self.step(false)),
1023                KeyCode::ArrowRight => Some(self.step(true)),
1024                KeyCode::Home => Some(0),
1025                KeyCode::End => Some(self.labels.len() - 1),
1026                _ => return Handled::No,
1027            },
1028            _ => return Handled::No,
1029        };
1030
1031        match chosen {
1032            Some(chosen) => self.select(chosen, ctx),
1033            None => Handled::No,
1034        }
1035    }
1036
1037    fn accepts_pointer(&self) -> bool {
1038        true
1039    }
1040
1041    /// An empty strip is not a tab stop: there is nothing for a key to do.
1042    fn focusable(&self) -> bool {
1043        !self.labels.is_empty()
1044    }
1045}
1046
1047impl<M> Describe for Tabs<M> {
1048    const KIND: &'static str = "tabs";
1049    const DOC: &'static str = "A row of labels where one is selected, for switching what is below.";
1050    const GROUP: Group = Group::Container;
1051    const ICON: &'static denise::icon::Icon = &super::icons::TABS;
1052
1053    const PROPERTIES: &'static [Property] = &[
1054        Property::new(
1055            "tab",
1056            PropertyKind::List,
1057            "The section names, as `tab` child nodes. Real data: a form's sections are the form's. A `tab` that carries children carries that section's page with it.",
1058        ),
1059        Property::new(
1060            "selected",
1061            PropertyKind::Int {
1062                min: 0,
1063                max: i32::MAX,
1064            },
1065            "Index of the selected tab. A strip with tabs always has one, so this is never unset.",
1066        ),
1067        Property::new(
1068            "on-change",
1069            PropertyKind::Message(Payload::Index),
1070            "Emitted with the newly selected tab's index.",
1071        ),
1072        Property::new(
1073            "role",
1074            PropertyKind::Enum(ROLES),
1075            "Colour role of the selected tab's underline, and only that.",
1076        ),
1077        Property::new(
1078            "size",
1079            PropertyKind::Int { min: 6, max: 96 },
1080            "Text size in logical pixels.",
1081        )
1082        .in_pixels(),
1083    ];
1084
1085    fn get(&self, name: &str) -> Option<Value> {
1086        Some(match name {
1087            "selected" => Value::Int(i32::try_from(self.selected).unwrap_or(i32::MAX)),
1088            "role" => Value::role(self.role),
1089            "size" => Value::Int(i32::from(self.style.size_px)),
1090            _ => return None,
1091        })
1092    }
1093
1094    fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
1095        match name {
1096            // Through the setter, which clamps into the labels: a tab strip has
1097            // no way to show nothing selected.
1098            "selected" => self.set_selected(value.as_index()?),
1099            // The engine builds these from the child nodes, and an
1100            // inspector edits them where they live. See
1101            // `PropertyKind::List`.
1102            "on-change" | "tab" => return Err(Mismatch::Supplied),
1103            "role" => self.role = value.as_role()?,
1104            "size" => self.style.size_px = value.as_size()?,
1105            _ => return Err(Mismatch::Unknown),
1106        }
1107        Ok(())
1108    }
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113    use super::*;
1114    use denise::Theme;
1115    use denise::theme;
1116
1117    fn tabs() -> Tabs<usize> {
1118        Tabs::new(["Oversikt", "Alarmer", "Innstillinger"], |index| index)
1119    }
1120
1121    /// Tabs are laid end to end from the leading edge, each its own width — not
1122    /// an equal share of the strip, which is what would make a short label sit
1123    /// in a wide empty box.
1124    #[test]
1125    fn tabs_are_laid_end_to_end_at_their_own_widths() {
1126        let bounds = Rect::new(10, 20, 300, 40);
1127        let placed = place(bounds, &[60, 90, 40]);
1128
1129        assert_eq!(placed[0].x, bounds.x);
1130        for pair in placed.windows(2) {
1131            assert_eq!(pair[1].x, pair[0].right(), "a gap or an overlap");
1132        }
1133        assert_eq!(placed.last().expect("a tab").right(), bounds.x + 190);
1134        for tab in &placed {
1135            assert_eq!(tab.y, bounds.y);
1136            assert_eq!(tab.height, bounds.height);
1137        }
1138    }
1139
1140    /// A strip narrower than its tabs still places them all. The canvas clips
1141    /// what runs off the end, and hit testing and drawing agree about where a
1142    /// tab is even when it is not visible.
1143    #[test]
1144    fn tabs_wider_than_the_strip_are_still_placed() {
1145        let bounds = Rect::new(0, 0, 100, 40);
1146        let placed = place(bounds, &[60, 90, 40]);
1147        assert_eq!(placed.len(), 3);
1148        assert!(
1149            placed[2].x > bounds.right(),
1150            "the last tab should be past the edge"
1151        );
1152    }
1153
1154    /// Every point in the strip belongs to the tab that contains it, and points
1155    /// outside belong to none — including the gap past the last tab, which is
1156    /// strip but not tab.
1157    #[test]
1158    fn a_point_lands_in_the_tab_that_contains_it() {
1159        let bounds = Rect::new(10, 20, 300, 40);
1160        let widths = [60, 90, 40];
1161        let placed = place(bounds, &widths);
1162
1163        assert_eq!(hit(bounds, &placed, Point::new(11, 30)), Some(0));
1164        assert_eq!(hit(bounds, &placed, Point::new(69, 30)), Some(0));
1165        assert_eq!(hit(bounds, &placed, Point::new(70, 30)), Some(1));
1166        assert_eq!(hit(bounds, &placed, Point::new(199, 30)), Some(2));
1167        assert_eq!(
1168            hit(bounds, &placed, Point::new(200, 30)),
1169            None,
1170            "the right edge is exclusive: 160..200 ends at 199"
1171        );
1172        assert_eq!(
1173            hit(bounds, &placed, Point::new(280, 30)),
1174            None,
1175            "and past the last tab is strip, not tab"
1176        );
1177        assert_eq!(hit(bounds, &placed, Point::new(5, 30)), None, "left of it");
1178        assert_eq!(hit(bounds, &placed, Point::new(100, 5)), None, "above it");
1179    }
1180
1181    /// Wrapping in both directions, and the ends.
1182    #[test]
1183    fn the_selection_wraps_and_the_ends_are_reachable() {
1184        let mut tabs = tabs();
1185        assert_eq!(tabs.step(true), 1);
1186        tabs.set_selected(2);
1187        assert_eq!(tabs.step(true), 0, "past the end comes back to the start");
1188        tabs.set_selected(0);
1189        assert_eq!(tabs.step(false), 2, "and before the start goes to the end");
1190    }
1191
1192    /// A one-tab strip steps to itself rather than dividing by nothing.
1193    #[test]
1194    fn a_single_tab_strip_steps_to_itself() {
1195        let tabs: Tabs<usize> = Tabs::new(["Bare én"], |index| index);
1196        assert_eq!(tabs.step(true), 0);
1197        assert_eq!(tabs.step(false), 0);
1198    }
1199
1200    /// An empty strip is inert and is not a tab stop.
1201    #[test]
1202    fn an_empty_strip_is_inert_rather_than_broken() {
1203        let mut tabs: Tabs<usize> = Tabs::inert(Vec::<String>::new());
1204        assert_eq!(tabs.selected(), 0);
1205        assert_eq!(tabs.selected_label(), None);
1206        assert_eq!(tabs.step(true), 0);
1207        assert!(!Widget::<usize>::focusable(&tabs));
1208        tabs.set_selected(9);
1209        assert_eq!(tabs.selected(), 0);
1210        assert!(place(Rect::new(0, 0, 100, 40), &[]).is_empty());
1211    }
1212
1213    /// The selection is always a tab that exists, including after the labels
1214    /// change under it.
1215    #[test]
1216    fn the_selection_survives_the_labels_changing() {
1217        let mut tabs = tabs();
1218        tabs.set_selected(2);
1219        assert_eq!(tabs.selected_label(), Some("Innstillinger"));
1220        tabs.set_labels(["Bare én"]);
1221        assert_eq!(tabs.selected(), 0);
1222        assert_eq!(tabs.selected_label(), Some("Bare én"));
1223    }
1224
1225    /// The preferred width is every tab at its natural size, which is what a
1226    /// caller needs to know before it can size the strip.
1227    #[test]
1228    fn the_preferred_width_is_the_sum_of_the_tabs() {
1229        let mut engine = TextEngine::new();
1230        let tabs = tabs();
1231        let widths = tabs.widths(&mut engine);
1232        assert_eq!(widths.len(), 3);
1233        assert_eq!(
1234            tabs.preferred_width(&mut engine),
1235            widths.iter().sum::<i32>()
1236        );
1237        assert!(
1238            widths[2] > widths[1],
1239            "a longer label should make a wider tab"
1240        );
1241    }
1242
1243    /// A close button is room at the end of every tab, whether or not it is
1244    /// showing: a tab that grew when the pointer reached it would push the row
1245    /// along under the pointer.
1246    #[test]
1247    fn close_buttons_widen_every_tab_by_the_same_amount() {
1248        let mut engine = TextEngine::new();
1249        let plain = tabs().widths(&mut engine);
1250        let closable = tabs().with_close_buttons(true).widths(&mut engine);
1251        for (plain, closable) in plain.iter().zip(&closable) {
1252            assert_eq!(closable - plain, close_size(16));
1253        }
1254    }
1255
1256    /// The close button sits inside its tab, after the label's padding starts,
1257    /// and above the rule.
1258    #[test]
1259    fn the_close_button_sits_inside_the_end_of_its_tab() {
1260        let band = Rect::new(0, 0, 400, 40);
1261        let tab = Rect::new(100, 0, 120, 40);
1262        let close = close_rect(16, tab, band);
1263        assert!(close.x > tab.x && close.right() < tab.right());
1264        assert!(close.bottom() <= band.bottom() - rule_thickness(band));
1265        assert!(close.y >= tab.y);
1266    }
1267
1268    /// A row wider than the strip slides so the selected tab is in it — and not
1269    /// at all while it already is.
1270    #[test]
1271    fn the_selected_tab_is_slid_into_view() {
1272        let band = Rect::new(0, 0, 100, 40);
1273        let placed = place(band, &[60, 90, 40]);
1274        assert_eq!(reveal_shift(band, &placed, 0), 0, "already visible");
1275        let shift = reveal_shift(band, &placed, 2);
1276        assert_eq!(
1277            placed[2].right() - shift,
1278            band.right(),
1279            "its end at the edge"
1280        );
1281
1282        // Wider than the strip on its own: its leading edge shows.
1283        let placed = place(band, &[60, 300]);
1284        assert_eq!(placed[1].x - reveal_shift(band, &placed, 1), band.x);
1285    }
1286
1287    /// A dragged tab passes a neighbour once its centre crosses the
1288    /// neighbour's, and does not trade straight back — even when the two are
1289    /// different widths, which is where trading on edges flickers.
1290    #[test]
1291    fn a_dragged_tab_passes_its_neighbours_at_their_centres_and_stays_passed() {
1292        let band = Rect::new(0, 0, 400, 40);
1293        for widths in [[40, 120, 60], [120, 40, 60]] {
1294            let placed = place(band, &widths);
1295            let neighbour = placed[1].x + placed[1].width / 2;
1296            assert_eq!(drag_target(&placed, 0, neighbour), None, "on the centre");
1297            assert_eq!(drag_target(&placed, 0, neighbour + 1), Some(1));
1298
1299            // After the move, the same centre asks for no move back.
1300            let moved = place(band, &[widths[1], widths[0], widths[2]]);
1301            assert_eq!(drag_target(&moved, 1, neighbour + 1), None, "{widths:?}");
1302        }
1303        let placed = place(band, &[40, 40, 40, 40]);
1304        assert_eq!(drag_target(&placed, 0, 150), Some(3), "several at once");
1305        assert_eq!(drag_target(&placed, 3, 10), Some(0), "and back");
1306    }
1307
1308    /// The selection follows the tab it was on through a move.
1309    #[test]
1310    fn moving_a_tab_takes_its_colour_and_the_selection_with_it() {
1311        let red = Color::rgb(220, 50, 50);
1312        let mut tabs = tabs().with_colors([Some(red)]);
1313        tabs.set_selected(1);
1314        tabs.move_tab(0, 2);
1315        assert_eq!(tabs.labels(), ["Alarmer", "Innstillinger", "Oversikt"]);
1316        assert_eq!(tabs.colors(), [None, None, Some(red)]);
1317        assert_eq!(tabs.selected_label(), Some("Alarmer"));
1318
1319        for (from, to) in [(0, 2), (2, 0), (1, 1), (0, 9)] {
1320            let mut tabs = tabs.clone();
1321            let before = tabs.selected_label().map(String::from);
1322            tabs.move_tab(from, to);
1323            assert_eq!(tabs.selected_label().map(String::from), before);
1324        }
1325    }
1326
1327    /// Colours stay one to a tab whatever the labels do.
1328    #[test]
1329    fn there_is_one_colour_per_tab() {
1330        let blue = Color::rgb(50, 90, 220);
1331        let mut tabs = tabs().with_colors([Some(blue); 5]);
1332        assert_eq!(tabs.colors().len(), 3, "cut to the tabs");
1333        tabs.set_labels(["En", "To", "Tre", "Fire"]);
1334        assert_eq!(tabs.colors(), [Some(blue), Some(blue), Some(blue), None]);
1335        tabs.set_color(9, Some(blue));
1336        tabs.set_color(3, Some(blue));
1337        assert_eq!(tabs.colors()[3], Some(blue));
1338    }
1339
1340    /// Both label colours have to be readable on the panel, in every theme, in
1341    /// every state. This is what rejected drawing the selected label in the role
1342    /// colour: `Secondary` on the light theme is 2.34:1 against `Base100`, which
1343    /// is a tab nobody can read, and it fails in exactly one of the three themes.
1344    #[test]
1345    fn both_label_colours_are_readable_on_the_panel_in_every_theme() {
1346        use denise::theme::{AA_LARGE, contrast_x100};
1347
1348        for theme in Theme::BUILT_IN {
1349            for state in [
1350                VisualState::NONE,
1351                VisualState::HOVERED,
1352                VisualState::FOCUSED,
1353                VisualState::DISABLED,
1354            ] {
1355                let (surface, selected, resting) = label_colors(&theme, state);
1356                for (which, colour) in [("selected", selected), ("unselected", resting)] {
1357                    let ratio = contrast_x100(surface, colour);
1358                    assert!(
1359                        ratio >= AA_LARGE,
1360                        "{} {state:?} {which}: label on the panel is {ratio}, floor \
1361                         is {AA_LARGE}",
1362                        theme.name
1363                    );
1364                }
1365            }
1366        }
1367    }
1368
1369    /// A colour is the caller's, so it promises nothing about what reads on it.
1370    /// The labels on a coloured tab are readable anyway, for colours from both
1371    /// ends and the treacherous middle, in every theme.
1372    #[test]
1373    fn labels_are_readable_on_a_tab_of_any_colour() {
1374        use denise::theme::AA_LARGE;
1375
1376        let colours = [
1377            Color::rgb(229, 72, 77),
1378            Color::rgb(247, 144, 9),
1379            Color::rgb(245, 208, 0),
1380            Color::rgb(48, 164, 108),
1381            Color::rgb(18, 165, 148),
1382            Color::rgb(62, 99, 221),
1383            Color::rgb(142, 78, 198),
1384            Color::rgb(214, 64, 159),
1385            Color::rgb(128, 128, 128),
1386            Color::WHITE,
1387            Color::BLACK,
1388        ];
1389        for theme in Theme::BUILT_IN {
1390            let (surface, content, _) = label_colors(&theme, VisualState::NONE);
1391            for colour in colours {
1392                let tint = tinted(surface, colour);
1393                let (selected, resting) = labels_on(tint, content);
1394                for (which, label) in [("selected", selected), ("resting", resting)] {
1395                    let ratio = contrast_x100(tint, label);
1396                    assert!(
1397                        ratio >= AA_LARGE,
1398                        "{} {colour:?} {which}: {ratio}",
1399                        theme.name
1400                    );
1401                }
1402            }
1403        }
1404    }
1405
1406    /// The mute has to be visible, or the selected tab is marked only by its
1407    /// underline and the labels all look the same.
1408    #[test]
1409    fn the_muted_label_is_actually_different_from_the_selected_one() {
1410        for theme in Theme::BUILT_IN {
1411            let (_, selected, resting) = label_colors(&theme, VisualState::NONE);
1412            assert_ne!(resting, selected, "{}", theme.name);
1413        }
1414    }
1415
1416    /// The exception the rule needs: a disabled strip does not mute, because the
1417    /// colour it would mute was already derived to sit exactly on the floor.
1418    #[test]
1419    fn a_disabled_strip_does_not_mute_a_colour_that_has_nothing_left_to_give() {
1420        for theme in Theme::BUILT_IN {
1421            let (_, selected, resting) = label_colors(&theme, VisualState::DISABLED);
1422            assert_eq!(
1423                resting, selected,
1424                "{}: a disabled label was muted below its own floor",
1425                theme.name
1426            );
1427        }
1428    }
1429
1430    /// Padding never collapses, however small the font.
1431    #[test]
1432    fn padding_survives_an_absurdly_small_font() {
1433        assert!(padding(0) >= 8);
1434        assert!(padding(6) >= 8);
1435        assert_eq!(padding(16), 16);
1436        let _ = theme::DARK;
1437    }
1438}