1use super::{Swatch, Theme, Tint, Tone};
5
6use egui::{Color32, Pos2, Rect, Sense, Stroke, vec2};
7
8use crate::tr;
9
10const CHIP: f32 = 24.0;
12const GAP: f32 = 4.0;
13const DISK: f32 = 160.0;
15const SECTORS: usize = 48;
16const RINGS: usize = 8;
17const 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
29pub 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 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 pub fn tone(mut self, tone: Tone) -> Self {
64 self.tone = tone;
65 self
66 }
67
68 pub fn preview(mut self, on: bool) -> Self {
70 self.preview = on;
71 self
72 }
73
74 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 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 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
223fn 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 Swatch::Accent => "corundum-theme-default",
236 }
237}
238
239fn 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
257fn 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
269fn 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
277fn 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
318fn 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
328pub(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#[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 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 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}