Skip to main content

corundum_framework_gui/theme/
picker.rs

1//! [`TintPicker`]: pick a [`Tint`] — from the theme's swatches, at random, or anywhere on a
2//! disk of hues (around) and chromas (outwards).
3
4use super::{Swatch, Theme, Tint, Tone};
5
6use egui::{Color32, Pos2, Rect, Sense, Stroke, vec2};
7
8use crate::tr;
9
10/// The chips' side, and the gap between them.
11const CHIP: f32 = 24.0;
12const GAP: f32 = 4.0;
13/// The disk's widest diameter, and its mesh.
14const DISK: f32 = 160.0;
15const SECTORS: usize = 48;
16const RINGS: usize = 8;
17/// The swatches on the palette, between Default and Random.
18const PALETTE: [Swatch; 8] = [
19    Swatch::Neutral,
20    Swatch::Red,
21    Swatch::Orange,
22    Swatch::Yellow,
23    Swatch::Green,
24    Swatch::Cyan,
25    Swatch::Blue,
26    Swatch::Purple,
27];
28
29/// Picks a [`Tint`]: a palette (Default, the theme's swatches, Random) over a disk of hues
30/// around and chromas outwards, and optionally a preview of the tint in use.
31///
32/// It edits an `Option<Tint>`: `None` is unset, shown as the `default` — a label's
33/// [`Tint::from_bytes`] of its name, say — and the Default chip sets it back.
34///
35/// ```no_run
36/// # use corundum_framework_gui::theme::{Tint, TintPicker, Tone};
37/// # fn example(ui: &mut egui::Ui, colour: &mut Option<Tint>) {
38/// let default = Tint::from_bytes(b"bug");
39/// if TintPicker::new(colour, default).tone(Tone::Raised).show(ui) {
40///     // save `colour`
41/// }
42/// # }
43/// ```
44pub struct TintPicker<'a> {
45    value: &'a mut Option<Tint>,
46    default: Tint,
47    tone: Tone,
48    preview: bool,
49}
50
51impl<'a> TintPicker<'a> {
52    /// A picker over `value`, showing `default` while it is `None`.
53    pub fn new(value: &'a mut Option<Tint>, default: Tint) -> TintPicker<'a> {
54        TintPicker {
55            value,
56            default,
57            tone: Tone::Text,
58            preview: false,
59        }
60    }
61
62    /// Draw the chips and the disk at `tone`: where the tint will be used. Text by default.
63    pub fn tone(mut self, tone: Tone) -> Self {
64        self.tone = tone;
65        self
66    }
67
68    /// Show the tint as link text, a label and a selected row under the disk.
69    pub fn preview(mut self, on: bool) -> Self {
70        self.preview = on;
71        self
72    }
73
74    /// Draw the picker. `true` once a change is made — a click, or the end of a drag (the
75    /// value follows the drag meanwhile) — so save the value then.
76    pub fn show(mut self, ui: &mut egui::Ui) -> bool {
77        let theme = super::current(ui.ctx());
78        let before = *self.value;
79        let mut dragging = false;
80        let mut rects = PickerRects {
81            chips: Vec::new(),
82            die: Rect::NOTHING,
83            disk: Rect::NOTHING,
84            preview: Rect::NOTHING,
85        };
86        let id = ui.next_auto_id();
87        ui.vertical(|ui| {
88            ui.spacing_mut().item_spacing = vec2(GAP, GAP);
89            ui.horizontal_wrapped(|ui| {
90                self.palette_ui(ui, &theme, &mut rects);
91            });
92            let (disk, held) = self.disk_ui(ui, &theme);
93            rects.disk = disk;
94            dragging = held;
95            if self.preview {
96                rects.preview = preview_ui(ui, &theme, self.value.unwrap_or(self.default));
97            }
98        });
99        #[cfg(test)]
100        ui.data_mut(|d| d.insert_temp(PickerRects::id(), rects.clone()));
101        let changed = *self.value != before;
102        let held_id = id.with("held");
103        let mut held = ui.data(|d| d.get_temp(held_id)).unwrap_or(false);
104        let made = report(changed, dragging, &mut held);
105        ui.data_mut(|d| d.insert_temp(held_id, held));
106        made
107    }
108
109    fn palette_ui(&mut self, ui: &mut egui::Ui, theme: &Theme, rects: &mut PickerRects) {
110        let chips = &mut rects.chips;
111        let shown = self.value.unwrap_or(self.default);
112        let fill = theme.tint(self.tone, self.default);
113        let r = chip(
114            ui,
115            theme,
116            fill,
117            self.value.is_none(),
118            &tr!(ui, "corundum-theme-default"),
119        );
120        paint_icon(ui, r.rect, crate::icons::rotate_ccw(), on(theme, self.tone));
121        if r.clicked() {
122            *self.value = None;
123        }
124        chips.push(r.rect);
125        for s in PALETTE {
126            let selected = self.value.is_some() && shown == Tint::Swatch(s);
127            let r = chip(
128                ui,
129                theme,
130                theme.color(self.tone, s),
131                selected,
132                &tr!(ui, swatch_id(s)),
133            );
134            if r.clicked() {
135                *self.value = Some(Tint::Swatch(s));
136            }
137            chips.push(r.rect);
138        }
139        let r = chip(
140            ui,
141            theme,
142            Color32::TRANSPARENT,
143            false,
144            &tr!(ui, "corundum-theme-random"),
145        );
146        let painter = ui.painter();
147        let (c, radius) = (r.rect.center(), CHIP * 0.4);
148        for k in 0..24 {
149            let at = |k: f32| {
150                let a = (k * 15.0).to_radians();
151                c + radius * vec2(a.cos(), -a.sin())
152            };
153            let colour = theme.tint(
154                self.tone,
155                Tint::Custom {
156                    chroma: Tint::MAX_CHROMA,
157                    hue: k as f32 * 15.0,
158                },
159            );
160            painter.line_segment([at(k as f32), at(k as f32 + 1.0)], Stroke::new(3.0, colour));
161        }
162        // Inside the ring, which runs at 0.4 of the chip from its centre.
163        rects.die = paint_icon(
164            ui,
165            r.rect.shrink(2.0),
166            crate::icons::dice_5(),
167            theme.color(Tone::Text, Swatch::Neutral),
168        );
169        if r.clicked() {
170            *self.value = Some(Tint::random());
171        }
172        rects.chips.push(r.rect);
173    }
174
175    /// The disk; whether the pointer is held on it (from the press to the release, so a
176    /// drag is reported once, when it ends).
177    fn disk_ui(&mut self, ui: &mut egui::Ui, theme: &Theme) -> (Rect, bool) {
178        let side = DISK.min(ui.available_width()).max(CHIP);
179        let (rect, response) = ui.allocate_exact_size(vec2(side, side), Sense::click_and_drag());
180        let (centre, radius) = (rect.center(), side / 2.0);
181        let at = |r: f32, hue: f32| {
182            let a = hue.to_radians();
183            centre + r * radius * vec2(a.cos(), -a.sin())
184        };
185        let mut mesh = egui::Mesh::default();
186        for ring in 0..=RINGS {
187            let r = ring as f32 / RINGS as f32;
188            for sector in 0..=SECTORS {
189                let hue = sector as f32 * 360.0 / SECTORS as f32;
190                let tint = Tint::Custom {
191                    chroma: r * Tint::MAX_CHROMA,
192                    hue,
193                };
194                mesh.colored_vertex(at(r, hue), theme.tint(self.tone, tint));
195            }
196        }
197        let row = (SECTORS + 1) as u32;
198        for ring in 0..RINGS as u32 {
199            for sector in 0..SECTORS as u32 {
200                let (a, b) = (ring * row + sector, ring * row + sector + 1);
201                let (c, d) = (a + row, b + row);
202                mesh.add_triangle(a, b, d);
203                mesh.add_triangle(a, d, c);
204            }
205        }
206        ui.painter().add(egui::Shape::mesh(mesh));
207
208        if (response.is_pointer_button_down_on() || response.clicked())
209            && let Some(pos) = response.interact_pointer_pos()
210        {
211            *self.value = Some(from_pointer(pos, centre, radius));
212        }
213        let (chroma, hue) = theme.chroma_hue(self.value.unwrap_or(self.default));
214        let mark = at(chroma / Tint::MAX_CHROMA, hue);
215        ui.painter()
216            .circle_stroke(mark, 5.0, Stroke::new(3.0, Color32::BLACK));
217        ui.painter()
218            .circle_stroke(mark, 5.0, Stroke::new(1.5, Color32::WHITE));
219        (rect, response.is_pointer_button_down_on())
220    }
221}
222
223/// The message naming a palette swatch.
224fn swatch_id(swatch: Swatch) -> &'static str {
225    match swatch {
226        Swatch::Neutral => "corundum-theme-swatch-neutral",
227        Swatch::Red => "corundum-theme-swatch-red",
228        Swatch::Orange => "corundum-theme-swatch-orange",
229        Swatch::Yellow => "corundum-theme-swatch-yellow",
230        Swatch::Green => "corundum-theme-swatch-green",
231        Swatch::Cyan => "corundum-theme-swatch-cyan",
232        Swatch::Blue => "corundum-theme-swatch-blue",
233        Swatch::Purple => "corundum-theme-swatch-purple",
234        // The accent swatch is the Default chip.
235        Swatch::Accent => "corundum-theme-default",
236    }
237}
238
239/// A chip of `fill`, ringed when `selected`, named `name` on hover.
240fn chip(
241    ui: &mut egui::Ui,
242    theme: &Theme,
243    fill: Color32,
244    selected: bool,
245    name: &str,
246) -> egui::Response {
247    let (rect, response) = ui.allocate_exact_size(vec2(CHIP, CHIP), Sense::click());
248    let painter = ui.painter();
249    painter.rect_filled(rect, 4.0, fill);
250    if selected {
251        let ring = Stroke::new(2.0, theme.color(Tone::Text, Swatch::Neutral));
252        painter.rect_stroke(rect.expand(2.0), 6.0, ring, egui::StrokeKind::Outside);
253    }
254    response.on_hover_text(name)
255}
256
257/// `icon` in `colour`, 4 points inside `rect`; where it went.
258fn paint_icon(
259    ui: &egui::Ui,
260    rect: Rect,
261    icon: egui::ImageSource<'static>,
262    colour: Color32,
263) -> Rect {
264    let rect = rect.shrink(4.0);
265    crate::icons::paint(ui, rect, icon, colour);
266    rect
267}
268
269/// A colour that stands out on a fill at `tone`: dark on the text side's light fills in a
270/// dark theme, and so on.
271fn on(theme: &Theme, tone: Tone) -> Color32 {
272    let text_side = matches!(tone, Tone::Muted | Tone::Text | Tone::Strong);
273    let opposite = if text_side { Tone::Base } else { Tone::Text };
274    theme.color(opposite, Swatch::Neutral)
275}
276
277/// The tint in use, small and not clickable or selectable: link text, a label, a selected
278/// row. Where it went.
279fn preview_ui(ui: &mut egui::Ui, theme: &Theme, tint: Tint) -> Rect {
280    use egui::RichText;
281    let text = |ui: &mut egui::Ui, words: RichText| {
282        ui.add(egui::Label::new(words).selectable(false));
283    };
284    ui.horizontal(|ui| {
285        let (link, label_text, selected) = (
286            tr!(ui, "corundum-theme-link"),
287            tr!(ui, "corundum-theme-label"),
288            tr!(ui, "corundum-theme-selected"),
289        );
290        text(
291            ui,
292            RichText::new(link)
293                .small()
294                .underline()
295                .color(theme.tint(Tone::Text, tint)),
296        );
297        let label = |ui: &mut egui::Ui, fill: Tone, colour: Color32, words: &str| {
298            egui::Frame::new()
299                .fill(theme.tint(fill, tint))
300                .corner_radius(6.0)
301                .inner_margin(vec2(6.0, 1.0))
302                .show(ui, |ui| {
303                    text(ui, RichText::new(words).small().color(colour))
304                });
305        };
306        label(ui, Tone::Raised, theme.tint(Tone::Text, tint), &label_text);
307        label(
308            ui,
309            Tone::Pressed,
310            theme.color(Tone::Text, Swatch::Neutral),
311            &selected,
312        );
313    })
314    .response
315    .rect
316}
317
318/// The tint at `pos` on a disk at `centre` of `radius`: hue 0° at the right, counter-
319/// clockwise; outside the rim reads as on it.
320fn from_pointer(pos: Pos2, centre: Pos2, radius: f32) -> Tint {
321    let d = pos - centre;
322    Tint::Custom {
323        chroma: (d.length() / radius).min(1.0) * Tint::MAX_CHROMA,
324        hue: (-d.y).atan2(d.x).to_degrees().rem_euclid(360.0),
325    }
326}
327
328/// Whether a change is made this frame. A change during a drag is `held` until the drag
329/// ends, so an app saving on each change saves once per drag, not once per frame.
330pub(super) fn report(changed: bool, dragging: bool, held: &mut bool) -> bool {
331    if dragging {
332        *held |= changed;
333        false
334    } else {
335        changed | std::mem::take(held)
336    }
337}
338
339/// Where the last picker drew its chips and disk, for the tests to click.
340#[derive(Clone)]
341pub(super) struct PickerRects {
342    pub(super) chips: Vec<Rect>,
343    pub(super) die: Rect,
344    pub(super) disk: Rect,
345    pub(super) preview: Rect,
346}
347
348#[cfg(test)]
349impl PickerRects {
350    pub(super) fn id() -> egui::Id {
351        egui::Id::unique("corundum_tint_picker_rects")
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use crate::theme::{Contrast, ThemeSettings, install};
359
360    /// The frames of a test: a context with the theme and the loaders, and a picker
361    /// redrawn each frame over `value`.
362    struct Rig {
363        ctx: egui::Context,
364        value: Option<Tint>,
365        made: Vec<bool>,
366        cursor: egui::CursorIcon,
367    }
368
369    impl Rig {
370        fn new(value: Option<Tint>) -> Rig {
371            let ctx = egui::Context::default();
372            install(&ctx, &ThemeSettings::default());
373            crate::icons::install_loaders(&ctx);
374            Rig {
375                ctx,
376                value,
377                made: Vec::new(),
378                cursor: egui::CursorIcon::Default,
379            }
380        }
381
382        fn frame(&mut self, width: f32, preview: bool, events: Vec<egui::Event>) {
383            let input = egui::RawInput {
384                screen_rect: Some(egui::Rect::from_min_size(
385                    egui::Pos2::ZERO,
386                    egui::vec2(width, 600.0),
387                )),
388                events,
389                ..Default::default()
390            };
391            let (value, made) = (&mut self.value, &mut self.made);
392            let mut output = self.ctx.run_ui(input, |ui| {
393                made.push(
394                    TintPicker::new(value, Tint::Swatch(Swatch::Blue))
395                        .preview(preview)
396                        .show(ui),
397                );
398            });
399            output.textures_delta.clear();
400            self.cursor = output.platform_output.cursor_icon;
401        }
402
403        fn rects(&self) -> PickerRects {
404            self.ctx
405                .data(|d| d.get_temp::<PickerRects>(PickerRects::id()))
406                .expect("the picker records its rects in tests")
407        }
408
409        /// Lay out, then click at `at(rects)`; what `show` returned meanwhile.
410        fn click(&mut self, at: impl Fn(&PickerRects) -> egui::Pos2) -> Vec<bool> {
411            self.frame(400.0, false, vec![]);
412            self.frame(400.0, false, vec![]);
413            let pos = at(&self.rects());
414            let button = |pressed| egui::Event::PointerButton {
415                pos,
416                button: egui::PointerButton::Primary,
417                pressed,
418                modifiers: egui::Modifiers::NONE,
419            };
420            self.made.clear();
421            self.frame(400.0, false, vec![egui::Event::PointerMoved(pos)]);
422            self.frame(400.0, false, vec![button(true)]);
423            self.frame(400.0, false, vec![button(false)]);
424            self.frame(400.0, false, vec![]);
425            std::mem::take(&mut self.made)
426        }
427    }
428
429    fn once(made: &[bool]) -> bool {
430        made.iter().filter(|m| **m).count() == 1
431    }
432
433    #[test]
434    fn renders_with_and_without_preview() {
435        for (width, preview) in [(400.0, false), (400.0, true), (120.0, true)] {
436            let mut rig = Rig::new(Some(Tint::Swatch(Swatch::Red)));
437            rig.frame(width, preview, vec![]);
438            rig.frame(width, preview, vec![]);
439            let rects = rig.rects();
440            assert_eq!(rects.chips.len(), 10);
441            assert!(rects.disk.is_positive());
442            for r in rects.chips.iter().chain([&rects.disk]) {
443                assert!(r.right() <= width + 0.5, "{r:?} overflows {width}");
444            }
445            assert!(rig.made.iter().all(|m| !m));
446        }
447    }
448
449    #[test]
450    fn the_preview_follows_the_language() {
451        fn collect(shape: &egui::Shape, out: &mut String) {
452            match shape {
453                egui::Shape::Text(t) => out.push_str(t.galley.text()),
454                egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, out)),
455                _ => {}
456            }
457        }
458        let ctx = egui::Context::default();
459        crate::i18n::install(
460            &ctx,
461            &crate::i18n::LanguageSettings {
462                language: crate::i18n::Language::Explicit("ja".into()),
463            },
464        );
465        let theme = Theme::new(false, Contrast::Medium, Tint::Swatch(Swatch::Blue));
466        let mut drawn = String::new();
467        for _ in 0..2 {
468            let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
469                preview_ui(ui, &theme, Tint::Swatch(Swatch::Blue));
470            });
471            output.textures_delta.clear();
472            drawn.clear();
473            for clipped in &output.shapes {
474                collect(&clipped.shape, &mut drawn);
475            }
476        }
477        for ja in ["リンク", "ラベル", "選択中"] {
478            assert!(drawn.contains(ja), "{ja} missing from {drawn}");
479        }
480    }
481
482    #[test]
483    fn default_chip_unsets() {
484        let mut rig = Rig::new(Some(Tint::Swatch(Swatch::Red)));
485        let made = rig.click(|r| r.chips[0].center());
486        assert_eq!(rig.value, None);
487        assert!(once(&made), "{made:?}");
488    }
489
490    #[test]
491    fn colour_chip_sets_swatch() {
492        let mut rig = Rig::new(None);
493        let made = rig.click(|r| r.chips[3].center());
494        assert_eq!(rig.value, Some(Tint::Swatch(Swatch::Orange)));
495        assert!(once(&made), "{made:?}");
496    }
497
498    #[test]
499    fn disk_centre_is_grey() {
500        let mut rig = Rig::new(None);
501        let made = rig.click(|r| r.disk.center());
502        match rig.value {
503            Some(Tint::Custom { chroma, .. }) => assert!(chroma < 0.01, "{chroma}"),
504            other => panic!("{other:?}"),
505        }
506        assert!(once(&made), "{made:?}");
507    }
508
509    #[test]
510    fn random_chip_sets_custom() {
511        let mut rig = Rig::new(None);
512        let made = rig.click(|r| r.chips[9].center());
513        assert!(
514            matches!(rig.value, Some(Tint::Custom { .. })),
515            "{:?}",
516            rig.value
517        );
518        assert!(once(&made), "{made:?}");
519    }
520
521    #[test]
522    fn a_drag_on_the_disk_is_reported_once() {
523        let mut rig = Rig::new(None);
524        rig.frame(400.0, false, vec![]);
525        rig.frame(400.0, false, vec![]);
526        let start = rig.rects().disk.center();
527        let button = |pos, pressed| egui::Event::PointerButton {
528            pos,
529            button: egui::PointerButton::Primary,
530            pressed,
531            modifiers: egui::Modifiers::NONE,
532        };
533        rig.made.clear();
534        rig.frame(400.0, false, vec![egui::Event::PointerMoved(start)]);
535        rig.frame(400.0, false, vec![button(start, true)]);
536        let mut pos = start;
537        for _ in 0..4 {
538            pos += egui::vec2(10.0, 0.0);
539            rig.frame(400.0, false, vec![egui::Event::PointerMoved(pos)]);
540        }
541        rig.frame(400.0, false, vec![button(pos, false)]);
542        rig.frame(400.0, false, vec![]);
543        assert!(once(&rig.made), "{:?}", rig.made);
544        match rig.value {
545            Some(Tint::Custom { chroma, hue }) => {
546                assert!(chroma > 0.05, "{chroma}");
547                assert!(hue < 1.0 || hue > 359.0, "dragged right: {hue}");
548            }
549            other => panic!("{other:?}"),
550        }
551    }
552
553    #[test]
554    fn the_die_is_legible() {
555        let mut rig = Rig::new(None);
556        rig.frame(400.0, false, vec![]);
557        rig.frame(400.0, false, vec![]);
558        let die = rig.rects().die;
559        assert!(die.width() >= 12.0, "{die:?}");
560    }
561
562    #[test]
563    fn preview_text_is_not_selectable() {
564        let mut rig = Rig::new(Some(Tint::Swatch(Swatch::Red)));
565        rig.frame(400.0, true, vec![]);
566        rig.frame(400.0, true, vec![]);
567        let preview = rig.rects().preview;
568        let over = preview.left_center() + egui::vec2(6.0, 0.0);
569        rig.frame(400.0, true, vec![egui::Event::PointerMoved(over)]);
570        rig.frame(400.0, true, vec![]);
571        assert_ne!(rig.cursor, egui::CursorIcon::Text, "over {preview:?}");
572    }
573
574    #[test]
575    fn pointer_outside_clamps_to_rim() {
576        let centre = egui::pos2(100.0, 100.0);
577        let Tint::Custom { chroma, hue } = from_pointer(egui::pos2(100.0, 0.0), centre, 50.0)
578        else {
579            panic!()
580        };
581        assert_eq!(chroma, Tint::MAX_CHROMA);
582        assert!((hue - 90.0).abs() < 1e-3, "up is 90°: {hue}");
583        let Tint::Custom { hue, .. } = from_pointer(egui::pos2(150.0, 100.0), centre, 50.0) else {
584            panic!()
585        };
586        assert!(hue.abs() < 1e-3, "right is 0°: {hue}");
587    }
588
589    #[test]
590    fn accent_swatch_marks_the_accent() {
591        let accent = Tint::Custom {
592            chroma: 0.2,
593            hue: 40.0,
594        };
595        let theme = Theme::new(true, Contrast::Medium, accent);
596        assert_eq!(theme.chroma_hue(Tint::Swatch(Swatch::Accent)), (0.2, 40.0));
597        assert_eq!(
598            theme.chroma_hue(Tint::Swatch(Swatch::Red)),
599            Swatch::Red.chroma_hue()
600        );
601    }
602
603    #[test]
604    fn a_drag_is_reported_once_it_ends() {
605        let mut held = false;
606        assert!(!report(true, true, &mut held), "mid-drag");
607        assert!(!report(true, true, &mut held), "mid-drag");
608        assert!(report(false, false, &mut held), "the drag ended");
609        assert!(!report(false, false, &mut held), "only once");
610        assert!(report(true, false, &mut held), "a click");
611        assert!(
612            !report(false, true, &mut held),
613            "a drag that changed nothing"
614        );
615        assert!(!report(false, false, &mut held));
616    }
617}