denise-ui 0.8.0

Scene graph, widgets and compositor for Denise.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
//! A row of labels where one is selected.

use alloc::string::String;
use alloc::vec::Vec;

use denise::{ElementState, InputEvent, KeyCode, Point, Rect, Role};
use denise_render::Canvas;
use denise_text::{TextEngine, TextStyle};

use crate::widget::{Event, EventCtx, Handled, PaintCtx, VisualState, Widget};
use crate::widgets::style::{Align, draw_aligned, interactive_pair, muted};

/// A tab strip: a row of labels, one of them selected, with a rule underneath.
///
/// ```ignore
/// enum Message { Page(usize) }
/// Tabs::new(["Oversikt", "Alarmer", "Innstillinger"], Message::Page)
/// ```
///
/// # What it owns, and what it does not
///
/// **This widget owns which tab is selected, and nothing else.** Showing and
/// hiding the pages is [`Ui::set_visible`](crate::Ui::set_visible) on nodes the
/// application already owns.
///
/// That is a deliberate line rather than an omission. A tab strip that owned its
/// pages would have to own their *layout* — where each page goes, how big it is,
/// what happens when the strip moves — and that is a layout engine. Owning one
/// index is the part nobody else can do better.
///
/// # Keyboard
///
/// The strip is **one tab stop**, like [`RadioGroup`](super::RadioGroup) and for
/// the same reason: `Tab` should move from the strip into the page, not through
/// three tabs first. Left and Right move the selection and wrap; `Home` and `End`
/// go to the ends.
///
/// Up and Down are deliberately *not* handled. A tab strip is horizontal, and
/// the vertical keys almost always belong to whatever is in the page below it —
/// which is the opposite of `RadioGroup`, where a vertical list takes all four.
#[derive(Clone, Debug)]
pub struct Tabs<M> {
    labels: Vec<String>,
    selected: usize,
    message: Option<fn(usize) -> M>,
    role: Role,
    style: TextStyle,
}

impl<M> Tabs<M> {
    /// A strip with the first tab selected.
    pub fn new(
        labels: impl IntoIterator<Item = impl Into<String>>,
        message: fn(usize) -> M,
    ) -> Self {
        Self {
            labels: labels.into_iter().map(Into::into).collect(),
            selected: 0,
            message: Some(message),
            role: Role::Primary,
            style: TextStyle::built_in(16),
        }
    }

    /// A strip that emits nothing.
    pub fn inert(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
        Self {
            labels: labels.into_iter().map(Into::into).collect(),
            selected: 0,
            message: None,
            role: Role::Primary,
            style: TextStyle::built_in(16),
        }
    }

    /// Sets the initially selected tab. Out of range selects the last one.
    pub fn with_selected(mut self, index: usize) -> Self {
        self.selected = self.clamp(index);
        self
    }

    /// Sets the colour of the selected tab's underline.
    ///
    /// Only the underline — a bar, not text. An earlier version drew the selected
    /// *label* in this colour, and `Secondary` on the light theme is 2.34:1
    /// against the panel, which is a label nobody can read. The rule the labels
    /// follow now cannot depend on which role a caller passes.
    pub fn with_role(mut self, role: Role) -> Self {
        self.role = role;
        self
    }

    /// Sets the labels' font and size.
    pub fn with_style(mut self, style: TextStyle) -> Self {
        self.style = style;
        self
    }

    /// The selected index. Always in range while the strip has tabs.
    #[inline]
    pub const fn selected(&self) -> usize {
        self.selected
    }

    /// The selected tab's label, or `None` for an empty strip.
    #[inline]
    pub fn selected_label(&self) -> Option<&str> {
        self.labels.get(self.selected).map(String::as_str)
    }

    /// Selects a tab **without emitting anything**. Out of range is clamped.
    pub fn set_selected(&mut self, index: usize) {
        self.selected = self.clamp(index);
    }

    /// The labels, in order.
    #[inline]
    pub fn labels(&self) -> &[String] {
        &self.labels
    }

    /// Replaces the labels, keeping the selection in range.
    pub fn set_labels(&mut self, labels: impl IntoIterator<Item = impl Into<String>>) {
        self.labels = labels.into_iter().map(Into::into).collect();
        self.selected = self.clamp(self.selected);
    }

    /// Replaces the colour role.
    pub fn set_role(&mut self, role: Role) {
        self.role = role;
    }

    /// Replaces the labels' font and size.
    pub fn set_style(&mut self, style: TextStyle) {
        self.style = style;
    }

    /// Width the whole strip needs for every tab at its natural width.
    ///
    /// A strip given less than this clips its last tabs rather than shrinking
    /// them — a tab whose label is cut in half says less than one that is not
    /// there, and squeezing them all would make the strip reflow every time a
    /// label changed.
    pub fn preferred_width(&self, engine: &mut TextEngine) -> i32 {
        self.widths(engine).iter().sum()
    }

    /// Each tab's width, in order.
    fn widths(&self, engine: &mut TextEngine) -> Vec<i32> {
        let pad = padding(self.style.size_px);
        self.labels
            .iter()
            .map(|label| engine.measure_line(self.style, label) + pad * 2)
            .collect()
    }

    #[inline]
    fn clamp(&self, index: usize) -> usize {
        index.min(self.labels.len().saturating_sub(1))
    }

    /// Moves the selection by one, wrapping.
    fn step(&self, forward: bool) -> usize {
        let count = self.labels.len();
        if count == 0 {
            return 0;
        }
        if forward {
            (self.selected + 1) % count
        } else {
            (self.selected + count - 1) % count
        }
    }
}

/// The panel behind the labels, the selected label's colour, and the others'.
///
/// One function so the paint path and the contrast test cannot disagree about
/// what is actually drawn.
///
/// **A disabled strip does not mute**, and it does not have to say so here:
/// `interactive_pair` derives its disabled content by mixing until it *just*
/// clears the contrast floor, and [`muted`] hands back anything that cannot
/// afford the shift. A disabled strip is already recessed as a whole, and the
/// selection still reads from the underline.
fn label_colors(
    theme: &denise::Theme,
    state: VisualState,
) -> (denise::Color, denise::Color, denise::Color) {
    let (surface, content) = interactive_pair(theme, Role::Base100, state);
    (surface, content, muted(surface, content))
}

/// Space each side of a label.
#[inline]
const fn padding(size_px: u16) -> i32 {
    let value = size_px as i32;
    if value < 8 { 8 } else { value }
}

/// Where each tab sits, laid left to right from the leading edge.
///
/// Tabs that fall past the right edge are still placed — the canvas clips them,
/// and a rectangle that says where a tab *would* be keeps hit testing and
/// drawing agreeing about it.
fn place(bounds: Rect, widths: &[i32]) -> Vec<Rect> {
    let mut x = bounds.x;
    widths
        .iter()
        .map(|width| {
            let rect = Rect::new(x, bounds.y, *width, bounds.height);
            x += width;
            rect
        })
        .collect()
}

/// Which tab contains `point`, if any.
fn hit(bounds: Rect, tabs: &[Rect], point: Point) -> Option<usize> {
    if !bounds.contains(point) {
        return None;
    }
    tabs.iter().position(|tab| tab.contains(point))
}

impl<M: 'static> Widget<M> for Tabs<M> {
    fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Canvas<'_>) {
        let bounds = ctx.bounds;
        if bounds.is_empty() || self.labels.is_empty() {
            return;
        }
        let widths = self.widths(ctx.text);
        let tabs = place(bounds, &widths);

        // A rule under the whole strip, with the selected tab's segment drawn
        // over it. Cheaper than a box per tab, and it reads as a strip rather
        // than as a row of unrelated buttons.
        let thickness = (bounds.height / 10).max(2);
        let rule = Rect::new(
            bounds.x,
            bounds.bottom() - thickness,
            bounds.width,
            thickness,
        );
        canvas.fill_rect(rule, ctx.theme.color(Role::Base300));

        // Neither label colour depends on `self.role`: a role is only guaranteed
        // against *its own* content, not against the surface a label sits on.
        let (_, content, resting) = label_colors(ctx.theme, ctx.state);
        let underline = if ctx.state.contains(VisualState::DISABLED) {
            resting
        } else {
            ctx.theme.color(self.role)
        };

        for (index, tab) in tabs.iter().enumerate() {
            let chosen = index == self.selected;
            if chosen {
                canvas.fill_rect(Rect::new(tab.x, rule.y, tab.width, thickness), underline);
            }
            // The label sits above the rule, not centred in the whole height, or
            // a tall strip puts its text on top of its own underline.
            let text = Rect::new(tab.x, tab.y, tab.width, tab.height - thickness);
            draw_aligned(
                canvas,
                ctx.text,
                self.style,
                text,
                (Align::Center, Align::Center),
                &self.labels[index],
                if chosen { content } else { resting },
            );
        }
    }

    fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
        if self.labels.is_empty() {
            return Handled::No;
        }
        let chosen = match event {
            Event::Input(InputEvent::PointerButton {
                state: ElementState::Up,
                position,
                ..
            })
            | Event::Input(InputEvent::TouchUp {
                position,
                cancelled: false,
                ..
            }) => {
                let widths = self.widths(ctx.text);
                hit(ctx.bounds, &place(ctx.bounds, &widths), *position)
            }
            // Left and Right only. A tab strip is horizontal, and Up and Down
            // almost always belong to whatever is in the page below it.
            Event::Input(InputEvent::Key {
                code,
                state: ElementState::Down,
                ..
            }) if ctx.state.contains(VisualState::FOCUSED) => match code {
                KeyCode::ArrowLeft => Some(self.step(false)),
                KeyCode::ArrowRight => Some(self.step(true)),
                KeyCode::Home => Some(0),
                KeyCode::End => Some(self.labels.len() - 1),
                _ => return Handled::No,
            },
            _ => return Handled::No,
        };

        let Some(chosen) = chosen else {
            return Handled::No;
        };
        if chosen == self.selected {
            // Nothing changed, so nothing is reported — but the event was still
            // this widget's to handle.
            return Handled::Yes;
        }
        self.selected = chosen;
        if let Some(message) = self.message {
            ctx.emit(message(chosen));
        }
        Handled::Yes
    }

    fn accepts_pointer(&self) -> bool {
        true
    }

    /// An empty strip is not a tab stop: there is nothing for a key to do.
    fn focusable(&self) -> bool {
        !self.labels.is_empty()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use denise::Theme;
    use denise::theme;

    fn tabs() -> Tabs<usize> {
        Tabs::new(["Oversikt", "Alarmer", "Innstillinger"], |index| index)
    }

    /// Tabs are laid end to end from the leading edge, each its own width — not
    /// an equal share of the strip, which is what would make a short label sit
    /// in a wide empty box.
    #[test]
    fn tabs_are_laid_end_to_end_at_their_own_widths() {
        let bounds = Rect::new(10, 20, 300, 40);
        let placed = place(bounds, &[60, 90, 40]);

        assert_eq!(placed[0].x, bounds.x);
        for pair in placed.windows(2) {
            assert_eq!(pair[1].x, pair[0].right(), "a gap or an overlap");
        }
        assert_eq!(placed.last().expect("a tab").right(), bounds.x + 190);
        for tab in &placed {
            assert_eq!(tab.y, bounds.y);
            assert_eq!(tab.height, bounds.height);
        }
    }

    /// A strip narrower than its tabs still places them all. The canvas clips
    /// what runs off the end, and hit testing and drawing agree about where a
    /// tab is even when it is not visible.
    #[test]
    fn tabs_wider_than_the_strip_are_still_placed() {
        let bounds = Rect::new(0, 0, 100, 40);
        let placed = place(bounds, &[60, 90, 40]);
        assert_eq!(placed.len(), 3);
        assert!(
            placed[2].x > bounds.right(),
            "the last tab should be past the edge"
        );
    }

    /// Every point in the strip belongs to the tab that contains it, and points
    /// outside belong to none — including the gap past the last tab, which is
    /// strip but not tab.
    #[test]
    fn a_point_lands_in_the_tab_that_contains_it() {
        let bounds = Rect::new(10, 20, 300, 40);
        let widths = [60, 90, 40];
        let placed = place(bounds, &widths);

        assert_eq!(hit(bounds, &placed, Point::new(11, 30)), Some(0));
        assert_eq!(hit(bounds, &placed, Point::new(69, 30)), Some(0));
        assert_eq!(hit(bounds, &placed, Point::new(70, 30)), Some(1));
        assert_eq!(hit(bounds, &placed, Point::new(199, 30)), Some(2));
        assert_eq!(
            hit(bounds, &placed, Point::new(200, 30)),
            None,
            "the right edge is exclusive: 160..200 ends at 199"
        );
        assert_eq!(
            hit(bounds, &placed, Point::new(280, 30)),
            None,
            "and past the last tab is strip, not tab"
        );
        assert_eq!(hit(bounds, &placed, Point::new(5, 30)), None, "left of it");
        assert_eq!(hit(bounds, &placed, Point::new(100, 5)), None, "above it");
    }

    /// Wrapping in both directions, and the ends.
    #[test]
    fn the_selection_wraps_and_the_ends_are_reachable() {
        let mut tabs = tabs();
        assert_eq!(tabs.step(true), 1);
        tabs.set_selected(2);
        assert_eq!(tabs.step(true), 0, "past the end comes back to the start");
        tabs.set_selected(0);
        assert_eq!(tabs.step(false), 2, "and before the start goes to the end");
    }

    /// A one-tab strip steps to itself rather than dividing by nothing.
    #[test]
    fn a_single_tab_strip_steps_to_itself() {
        let tabs: Tabs<usize> = Tabs::new(["Bare én"], |index| index);
        assert_eq!(tabs.step(true), 0);
        assert_eq!(tabs.step(false), 0);
    }

    /// An empty strip is inert and is not a tab stop.
    #[test]
    fn an_empty_strip_is_inert_rather_than_broken() {
        let mut tabs: Tabs<usize> = Tabs::inert(Vec::<String>::new());
        assert_eq!(tabs.selected(), 0);
        assert_eq!(tabs.selected_label(), None);
        assert_eq!(tabs.step(true), 0);
        assert!(!Widget::<usize>::focusable(&tabs));
        tabs.set_selected(9);
        assert_eq!(tabs.selected(), 0);
        assert!(place(Rect::new(0, 0, 100, 40), &[]).is_empty());
    }

    /// The selection is always a tab that exists, including after the labels
    /// change under it.
    #[test]
    fn the_selection_survives_the_labels_changing() {
        let mut tabs = tabs();
        tabs.set_selected(2);
        assert_eq!(tabs.selected_label(), Some("Innstillinger"));
        tabs.set_labels(["Bare én"]);
        assert_eq!(tabs.selected(), 0);
        assert_eq!(tabs.selected_label(), Some("Bare én"));
    }

    /// The preferred width is every tab at its natural size, which is what a
    /// caller needs to know before it can size the strip.
    #[test]
    fn the_preferred_width_is_the_sum_of_the_tabs() {
        let mut engine = TextEngine::new();
        let tabs = tabs();
        let widths = tabs.widths(&mut engine);
        assert_eq!(widths.len(), 3);
        assert_eq!(
            tabs.preferred_width(&mut engine),
            widths.iter().sum::<i32>()
        );
        assert!(
            widths[2] > widths[1],
            "a longer label should make a wider tab"
        );
    }

    /// Both label colours have to be readable on the panel, in every theme, in
    /// every state. This is what rejected drawing the selected label in the role
    /// colour: `Secondary` on the light theme is 2.34:1 against `Base100`, which
    /// is a tab nobody can read, and it fails in exactly one of the three themes.
    #[test]
    fn both_label_colours_are_readable_on_the_panel_in_every_theme() {
        use denise::theme::{AA_LARGE, contrast_x100};

        for theme in Theme::BUILT_IN {
            for state in [
                VisualState::NONE,
                VisualState::HOVERED,
                VisualState::FOCUSED,
                VisualState::DISABLED,
            ] {
                let (surface, selected, resting) = label_colors(&theme, state);
                for (which, colour) in [("selected", selected), ("unselected", resting)] {
                    let ratio = contrast_x100(surface, colour);
                    assert!(
                        ratio >= AA_LARGE,
                        "{} {state:?} {which}: label on the panel is {ratio}, floor \
                         is {AA_LARGE}",
                        theme.name
                    );
                }
            }
        }
    }

    /// The mute has to be visible, or the selected tab is marked only by its
    /// underline and the labels all look the same.
    #[test]
    fn the_muted_label_is_actually_different_from_the_selected_one() {
        for theme in Theme::BUILT_IN {
            let (_, selected, resting) = label_colors(&theme, VisualState::NONE);
            assert_ne!(resting, selected, "{}", theme.name);
        }
    }

    /// The exception the rule needs: a disabled strip does not mute, because the
    /// colour it would mute was already derived to sit exactly on the floor.
    #[test]
    fn a_disabled_strip_does_not_mute_a_colour_that_has_nothing_left_to_give() {
        for theme in Theme::BUILT_IN {
            let (_, selected, resting) = label_colors(&theme, VisualState::DISABLED);
            assert_eq!(
                resting, selected,
                "{}: a disabled label was muted below its own floor",
                theme.name
            );
        }
    }

    /// Padding never collapses, however small the font.
    #[test]
    fn padding_survives_an_absurdly_small_font() {
        assert!(padding(0) >= 8);
        assert!(padding(6) >= 8);
        assert_eq!(padding(16), 16);
        let _ = theme::DARK;
    }
}