Skip to main content

material_ui_rs/widget/component/
theme_picker.rs

1//! Floating Material color theme picker.
2
3use iced_widget::button::{Status, Style};
4use iced_widget::canvas::{self, Canvas, Path, Stroke};
5use iced_widget::core::time::Instant;
6use iced_widget::core::{
7    Background, Color, Element, Length, Padding, Point, Rectangle, Size, alignment, border, mouse,
8};
9use iced_widget::core::{svg as core_svg, text as core_text};
10use iced_widget::graphics::geometry;
11use iced_widget::renderer::wgpu::primitive;
12use iced_widget::{Column, Container, Row, Space, Stack, text};
13
14use super::button::Button;
15use super::support::{AnimatedScalar, bool_value, duration_ms};
16use super::{navigation, viewport};
17use crate::animation::{ThemeRevealTransition, max_radius_from_origin};
18use crate::utils::{HOVERED_LAYER_OPACITY, PRESSED_LAYER_OPACITY, mix, shadow_from_level};
19use crate::{ColorQuartet, ColorScheme, Surface, SurfaceContainer, Theme, tokens};
20
21pub const FLOATING_MARGIN: f32 = 24.0;
22
23const PICKER_PANEL_PADDING: f32 = 12.0;
24const PICKER_PANEL_SPACING: f32 = 8.0;
25const PICKER_PANEL_SHAPE: f32 = tokens::shape::CORNER_EXTRA_LARGE;
26const PICKER_PANEL_ELEVATION_LEVEL: u8 = 3;
27const SWATCH_SIZE: f32 = 40.0;
28const SWATCH_TARGET_SIZE: f32 = 48.0;
29const SWATCH_SHAPE: f32 = tokens::shape::CORNER_FULL;
30const SELECTED_SWATCH_OUTLINE_WIDTH: f32 = 3.0;
31const SWATCH_OUTLINE_WIDTH: f32 = 1.0;
32const SWATCH_COLUMNS: usize = 4;
33const SWATCH_ROWS: usize = 2;
34const PALETTE_BUTTON_SIZE: f32 = 56.0;
35const PICKER_PANEL_TRANSITION_DURATION_MS: u16 = tokens::motion::DURATION_SHORT4_MS;
36const THEME_REVEAL_CENTER_ALPHA: f32 = 0.24;
37const THEME_REVEAL_START_FILL_ALPHA: f32 = 0.30;
38const THEME_REVEAL_EDGE_ALPHA: f32 = 0.54;
39const THEME_REVEAL_EDGE_LAYERS: usize = 20;
40const THEME_REVEAL_MIN_BLUR_WIDTH: f32 = 36.0;
41const THEME_REVEAL_MAX_BLUR_WIDTH: f32 = 180.0;
42const THEME_REVEAL_START_FILL_THRESHOLD: f32 = 0.45;
43const THEME_REVEAL_EDGE_FADE_THRESHOLD: f32 = 0.75;
44
45/// Returns the floating control bottom margin after accounting for an adaptive
46/// navigation layout.
47pub fn bottom_margin(layout: navigation::AdaptiveLayout) -> f32 {
48    FLOATING_MARGIN
49        + match layout {
50            navigation::AdaptiveLayout::NavigationBar => {
51                tokens::component::navigation_bar::CONTAINER_HEIGHT
52            }
53            navigation::AdaptiveLayout::NavigationRail => 0.0,
54        }
55}
56
57#[derive(Debug, Clone, Copy)]
58pub struct State {
59    is_open: bool,
60    panel_reveal: AnimatedScalar,
61}
62
63impl State {
64    pub const fn new() -> Self {
65        Self {
66            is_open: false,
67            panel_reveal: AnimatedScalar::new(0.0),
68        }
69    }
70
71    pub const fn is_open(self) -> bool {
72        self.is_open
73    }
74
75    pub const fn is_animating(self) -> bool {
76        self.panel_reveal.is_animating()
77    }
78
79    pub fn advance(&mut self, now: Instant) -> bool {
80        self.panel_reveal.advance(now)
81    }
82
83    pub fn toggle(&mut self) {
84        self.toggle_at(Instant::now());
85    }
86
87    pub fn open(&mut self) {
88        self.open_at(Instant::now());
89    }
90
91    pub fn close(&mut self) {
92        self.close_at(Instant::now());
93    }
94
95    fn reveal(self) -> f32 {
96        self.panel_reveal.value.clamp(0.0, 1.0)
97    }
98
99    fn toggle_at(&mut self, now: Instant) {
100        self.set_open_at(!self.is_open, now);
101    }
102
103    fn open_at(&mut self, now: Instant) {
104        self.set_open_at(true, now);
105    }
106
107    fn close_at(&mut self, now: Instant) {
108        self.set_open_at(false, now);
109    }
110
111    fn set_open_at(&mut self, is_open: bool, now: Instant) {
112        self.is_open = is_open;
113        self.panel_reveal.set_target(
114            bool_value(is_open),
115            now,
116            duration_ms(PICKER_PANEL_TRANSITION_DURATION_MS),
117            tokens::motion::EASING_EMPHASIZED_DECELERATE,
118        );
119    }
120}
121
122impl Default for State {
123    fn default() -> Self {
124        Self::new()
125    }
126}
127
128impl PartialEq for State {
129    fn eq(&self, other: &Self) -> bool {
130        self.is_open == other.is_open
131    }
132}
133
134impl Eq for State {}
135
136#[derive(Debug, Clone, Copy, PartialEq)]
137pub enum ThemeAction {
138    TogglePicker,
139    SelectColor(MaterialColor),
140    SetDarkMode { dark_mode: bool, origin: Point },
141}
142
143#[derive(Debug, Clone)]
144pub struct ThemeController {
145    picker: State,
146    selected: MaterialColor,
147    dark_mode: bool,
148    visible_scheme: ColorScheme,
149    transition: Option<ThemeRevealTransition>,
150}
151
152impl ThemeController {
153    pub fn new(selected: MaterialColor, dark_mode: bool) -> Self {
154        Self {
155            picker: State::new(),
156            selected,
157            dark_mode,
158            visible_scheme: selected.color_scheme(dark_mode),
159            transition: None,
160        }
161    }
162
163    pub fn theme(&self, name: impl Into<std::borrow::Cow<'static, str>>) -> Theme {
164        Theme::new(name, self.visible_scheme)
165    }
166
167    pub const fn picker_state(&self) -> &State {
168        &self.picker
169    }
170
171    pub const fn is_picker_open(&self) -> bool {
172        self.picker.is_open()
173    }
174
175    pub const fn selected_color(&self) -> MaterialColor {
176        self.selected
177    }
178
179    pub const fn dark_mode(&self) -> bool {
180        self.dark_mode
181    }
182
183    pub const fn visible_scheme(&self) -> ColorScheme {
184        self.visible_scheme
185    }
186
187    pub const fn transition(&self) -> Option<ThemeRevealTransition> {
188        self.transition
189    }
190
191    pub const fn is_animating(&self) -> bool {
192        self.transition.is_some() || self.picker.is_animating()
193    }
194
195    pub fn update(
196        &mut self,
197        action: ThemeAction,
198        viewport: Size,
199        bottom_margin: f32,
200        now: Instant,
201    ) {
202        match action {
203            ThemeAction::TogglePicker => self.picker.toggle_at(now),
204            ThemeAction::SelectColor(color) => {
205                let origin = swatch_center(viewport, bottom_margin, color);
206
207                self.selected = color;
208                self.picker.close_at(now);
209                self.animate_to(color.color_scheme(self.dark_mode), origin, now);
210            }
211            ThemeAction::SetDarkMode { dark_mode, origin } => {
212                self.dark_mode = dark_mode;
213                self.animate_to(self.selected.color_scheme(dark_mode), origin, now);
214            }
215        }
216    }
217
218    pub fn advance(&mut self, now: Instant) -> bool {
219        let picker_advanced = self.picker.advance(now);
220        let Some(transition) = self.transition else {
221            return picker_advanced;
222        };
223
224        self.visible_scheme = transition.value_at(now);
225
226        if transition.is_finished_at(now) {
227            self.visible_scheme = transition.target();
228            self.transition = None;
229        }
230
231        true
232    }
233
234    pub fn dark_mode_switch<'a, Message, Renderer>(
235        &self,
236        label: impl text::IntoFragment<'a>,
237        on_action: impl Fn(ThemeAction) -> Message + 'a,
238    ) -> Element<'a, Message, Theme, Renderer>
239    where
240        Message: 'a,
241        Renderer: iced_widget::core::Renderer + core_text::Renderer + core_svg::Renderer + 'a,
242    {
243        super::toggler::standard_with_origin(self.dark_mode, label, move |dark_mode, origin| {
244            on_action(ThemeAction::SetDarkMode { dark_mode, origin })
245        })
246    }
247
248    pub fn controls_over<'a, Message, Renderer>(
249        &self,
250        content: impl Into<Element<'a, Message, Theme, Renderer>>,
251        bottom_margin: f32,
252        on_action: impl Fn(ThemeAction) -> Message + 'a,
253    ) -> Element<'a, Message, Theme, Renderer>
254    where
255        Message: Clone + 'a,
256        Renderer: iced_widget::core::Renderer
257            + core_text::Renderer
258            + geometry::Renderer
259            + primitive::Renderer
260            + 'a,
261        iced_widget::core::Font: Into<Renderer::Font>,
262    {
263        floating_over(
264            content,
265            &self.picker,
266            self.selected,
267            bottom_margin,
268            on_action(ThemeAction::TogglePicker),
269            move |color| on_action(ThemeAction::SelectColor(color)),
270        )
271    }
272
273    pub fn reveal_over<'a, Message, Renderer>(
274        &self,
275        content: impl Into<Element<'a, Message, Theme, Renderer>>,
276        now: Instant,
277    ) -> Element<'a, Message, Theme, Renderer>
278    where
279        Message: 'a,
280        Renderer: iced_widget::core::Renderer + geometry::Renderer + 'a,
281    {
282        reveal_over(content, self.transition, now)
283    }
284
285    fn animate_to(&mut self, target: ColorScheme, origin: Point, now: Instant) {
286        if let Some(transition) = self.transition {
287            self.visible_scheme = transition.value_at(now);
288        }
289
290        self.transition = (self.visible_scheme != target).then(|| {
291            ThemeRevealTransition::material_theme(self.visible_scheme, target, origin, now)
292        });
293    }
294}
295
296impl Default for ThemeController {
297    fn default() -> Self {
298        Self::new(MaterialColor::Purple, true)
299    }
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub enum MaterialColor {
304    Purple,
305    Blue,
306    Teal,
307    Green,
308    Yellow,
309    Orange,
310    Red,
311    Pink,
312}
313
314impl MaterialColor {
315    pub const ALL: [Self; 8] = [
316        Self::Purple,
317        Self::Blue,
318        Self::Teal,
319        Self::Green,
320        Self::Yellow,
321        Self::Orange,
322        Self::Red,
323        Self::Pink,
324    ];
325
326    pub const fn label(self) -> &'static str {
327        match self {
328            Self::Purple => "Purple",
329            Self::Blue => "Blue",
330            Self::Teal => "Teal",
331            Self::Green => "Green",
332            Self::Yellow => "Yellow",
333            Self::Orange => "Orange",
334            Self::Red => "Red",
335            Self::Pink => "Pink",
336        }
337    }
338
339    pub fn color_scheme(self, dark: bool) -> ColorScheme {
340        let mut scheme = if dark {
341            Theme::Dark.colors()
342        } else {
343            Theme::Light.colors()
344        };
345
346        let primary = self.primary(dark);
347
348        scheme.primary = primary;
349        scheme.secondary = tint_quartet(scheme.secondary, primary, 0.55);
350        scheme.tertiary = tint_quartet(scheme.tertiary, primary, 0.32);
351        scheme.surface = tint_surface(scheme.surface, primary, dark);
352        scheme.inverse.inverse_primary = self.primary(!dark).color;
353        scheme.inverse.inverse_surface = mix(
354            scheme.inverse.inverse_surface,
355            self.primary(!dark).container,
356            0.08,
357        );
358        scheme.outline.color = mix(scheme.outline.color, primary.color, 0.08);
359        scheme.outline.variant = mix(scheme.outline.variant, primary.container, 0.10);
360        scheme
361    }
362
363    pub const fn swatch(self) -> Color {
364        self.primary(false).color
365    }
366
367    const fn index(self) -> usize {
368        match self {
369            Self::Purple => 0,
370            Self::Blue => 1,
371            Self::Teal => 2,
372            Self::Green => 3,
373            Self::Yellow => 4,
374            Self::Orange => 5,
375            Self::Red => 6,
376            Self::Pink => 7,
377        }
378    }
379
380    const fn primary(self, dark: bool) -> ColorQuartet {
381        match (self, dark) {
382            (Self::Purple, false) => ColorQuartet {
383                color: rgb(0x67, 0x50, 0xa4),
384                text: rgb(0xff, 0xff, 0xff),
385                container: rgb(0xea, 0xdd, 0xff),
386                container_text: rgb(0x21, 0x00, 0x5d),
387            },
388            (Self::Purple, true) => ColorQuartet {
389                color: rgb(0xd0, 0xbc, 0xff),
390                text: rgb(0x38, 0x1e, 0x72),
391                container: rgb(0x4f, 0x37, 0x8b),
392                container_text: rgb(0xea, 0xdd, 0xff),
393            },
394            (Self::Blue, false) => ColorQuartet {
395                color: rgb(0x00, 0x61, 0xa4),
396                text: rgb(0xff, 0xff, 0xff),
397                container: rgb(0xd1, 0xe4, 0xff),
398                container_text: rgb(0x00, 0x1d, 0x36),
399            },
400            (Self::Blue, true) => ColorQuartet {
401                color: rgb(0x9e, 0xca, 0xff),
402                text: rgb(0x00, 0x32, 0x58),
403                container: rgb(0x00, 0x49, 0x7d),
404                container_text: rgb(0xd1, 0xe4, 0xff),
405            },
406            (Self::Teal, false) => ColorQuartet {
407                color: rgb(0x00, 0x6a, 0x60),
408                text: rgb(0xff, 0xff, 0xff),
409                container: rgb(0x74, 0xf8, 0xe6),
410                container_text: rgb(0x00, 0x20, 0x1c),
411            },
412            (Self::Teal, true) => ColorQuartet {
413                color: rgb(0x53, 0xdb, 0xc9),
414                text: rgb(0x00, 0x37, 0x31),
415                container: rgb(0x00, 0x50, 0x48),
416                container_text: rgb(0x74, 0xf8, 0xe6),
417            },
418            (Self::Green, false) => ColorQuartet {
419                color: rgb(0x00, 0x6d, 0x3b),
420                text: rgb(0xff, 0xff, 0xff),
421                container: rgb(0x8f, 0xf7, 0xb3),
422                container_text: rgb(0x00, 0x21, 0x0d),
423            },
424            (Self::Green, true) => ColorQuartet {
425                color: rgb(0x73, 0xdb, 0x99),
426                text: rgb(0x00, 0x39, 0x1c),
427                container: rgb(0x00, 0x52, 0x2b),
428                container_text: rgb(0x8f, 0xf7, 0xb3),
429            },
430            (Self::Yellow, false) => ColorQuartet {
431                color: rgb(0x6d, 0x5e, 0x00),
432                text: rgb(0xff, 0xff, 0xff),
433                container: rgb(0xfb, 0xe5, 0x60),
434                container_text: rgb(0x21, 0x1c, 0x00),
435            },
436            (Self::Yellow, true) => ColorQuartet {
437                color: rgb(0xde, 0xc8, 0x48),
438                text: rgb(0x39, 0x31, 0x00),
439                container: rgb(0x52, 0x46, 0x00),
440                container_text: rgb(0xfb, 0xe5, 0x60),
441            },
442            (Self::Orange, false) => ColorQuartet {
443                color: rgb(0x8b, 0x50, 0x00),
444                text: rgb(0xff, 0xff, 0xff),
445                container: rgb(0xff, 0xdc, 0xbe),
446                container_text: rgb(0x2d, 0x16, 0x00),
447            },
448            (Self::Orange, true) => ColorQuartet {
449                color: rgb(0xff, 0xb8, 0x70),
450                text: rgb(0x4a, 0x28, 0x00),
451                container: rgb(0x69, 0x3c, 0x00),
452                container_text: rgb(0xff, 0xdc, 0xbe),
453            },
454            (Self::Red, false) => ColorQuartet {
455                color: rgb(0xba, 0x1a, 0x1a),
456                text: rgb(0xff, 0xff, 0xff),
457                container: rgb(0xff, 0xda, 0xd6),
458                container_text: rgb(0x41, 0x00, 0x02),
459            },
460            (Self::Red, true) => ColorQuartet {
461                color: rgb(0xff, 0xb4, 0xab),
462                text: rgb(0x69, 0x00, 0x05),
463                container: rgb(0x93, 0x00, 0x0a),
464                container_text: rgb(0xff, 0xda, 0xd6),
465            },
466            (Self::Pink, false) => ColorQuartet {
467                color: rgb(0x98, 0x40, 0x61),
468                text: rgb(0xff, 0xff, 0xff),
469                container: rgb(0xff, 0xd9, 0xe3),
470                container_text: rgb(0x3e, 0x00, 0x1d),
471            },
472            (Self::Pink, true) => ColorQuartet {
473                color: rgb(0xff, 0xb1, 0xc8),
474                text: rgb(0x5e, 0x11, 0x32),
475                container: rgb(0x7b, 0x29, 0x49),
476                container_text: rgb(0xff, 0xd9, 0xe3),
477            },
478        }
479    }
480}
481
482pub fn palette_center(viewport: Size, bottom_margin: f32) -> Point {
483    let right = viewport.width - FLOATING_MARGIN;
484    let bottom = viewport.height - bottom_margin;
485
486    Point::new(
487        right - PALETTE_BUTTON_SIZE / 2.0,
488        bottom - PALETTE_BUTTON_SIZE / 2.0,
489    )
490}
491
492pub fn swatch_center(viewport: Size, bottom_margin: f32, color: MaterialColor) -> Point {
493    let index = color.index();
494    let column = index % SWATCH_COLUMNS;
495    let row = index / SWATCH_COLUMNS;
496    let panel_right = viewport.width - FLOATING_MARGIN;
497    let panel_bottom = viewport.height - bottom_margin - PALETTE_BUTTON_SIZE - PICKER_PANEL_SPACING;
498    let panel_left = panel_right - picker_panel_width();
499    let panel_top = panel_bottom - picker_panel_height();
500
501    Point::new(
502        panel_left
503            + PICKER_PANEL_PADDING
504            + column as f32 * (SWATCH_TARGET_SIZE + PICKER_PANEL_SPACING)
505            + SWATCH_TARGET_SIZE / 2.0,
506        panel_top
507            + PICKER_PANEL_PADDING
508            + row as f32 * (SWATCH_TARGET_SIZE + PICKER_PANEL_SPACING)
509            + SWATCH_TARGET_SIZE / 2.0,
510    )
511}
512
513pub fn floating_over<'a, Message, Renderer>(
514    content: impl Into<Element<'a, Message, Theme, Renderer>>,
515    state: &State,
516    selected: MaterialColor,
517    bottom_margin: f32,
518    on_toggle: Message,
519    on_select: impl Fn(MaterialColor) -> Message + 'a,
520) -> Element<'a, Message, Theme, Renderer>
521where
522    Message: Clone + 'a,
523    Renderer: iced_widget::core::Renderer
524        + core_text::Renderer
525        + geometry::Renderer
526        + primitive::Renderer
527        + 'a,
528    iced_widget::core::Font: Into<Renderer::Font>,
529{
530    Stack::with_children([
531        content.into(),
532        floating_layer(state, selected, bottom_margin, on_toggle, on_select),
533    ])
534    .width(Length::Fill)
535    .height(Length::Fill)
536    .into()
537}
538
539pub fn reveal_over<'a, Message, Renderer>(
540    content: impl Into<Element<'a, Message, Theme, Renderer>>,
541    transition: Option<ThemeRevealTransition>,
542    now: Instant,
543) -> Element<'a, Message, Theme, Renderer>
544where
545    Message: 'a,
546    Renderer: iced_widget::core::Renderer + geometry::Renderer + 'a,
547{
548    let content = content.into();
549    let overlay = if let Some(transition) = transition {
550        reveal_overlay(transition, now).into()
551    } else {
552        Space::new().width(Length::Fill).height(Length::Fill).into()
553    };
554
555    Stack::with_children([content, overlay])
556        .width(Length::Fill)
557        .height(Length::Fill)
558        .into()
559}
560
561pub fn reveal_overlay<'a, Message, Renderer>(
562    transition: ThemeRevealTransition,
563    now: Instant,
564) -> Canvas<ThemeRevealOverlay, Message, Theme, Renderer>
565where
566    Renderer: geometry::Renderer + 'a,
567{
568    Canvas::new(ThemeRevealOverlay {
569        origin: transition.origin(),
570        target: transition.target(),
571        progress: transition.eased_progress_at(now),
572    })
573    .width(Length::Fill)
574    .height(Length::Fill)
575}
576
577pub fn floating_layer<'a, Message, Renderer>(
578    state: &State,
579    selected: MaterialColor,
580    bottom_margin: f32,
581    on_toggle: Message,
582    on_select: impl Fn(MaterialColor) -> Message + 'a,
583) -> Element<'a, Message, Theme, Renderer>
584where
585    Message: Clone + 'a,
586    Renderer: iced_widget::core::Renderer
587        + core_text::Renderer
588        + geometry::Renderer
589        + primitive::Renderer
590        + 'a,
591    iced_widget::core::Font: Into<Renderer::Font>,
592{
593    Stack::with_children([
594        floating_panel_layer(state, selected, bottom_margin, on_select),
595        floating_palette_layer(bottom_margin, on_toggle),
596    ])
597    .width(Length::Fill)
598    .height(Length::Fill)
599    .into()
600}
601
602fn floating_panel_layer<'a, Message, Renderer>(
603    state: &State,
604    selected: MaterialColor,
605    bottom_margin: f32,
606    on_select: impl Fn(MaterialColor) -> Message + 'a,
607) -> Element<'a, Message, Theme, Renderer>
608where
609    Message: Clone + 'a,
610    Renderer: iced_widget::core::Renderer + geometry::Renderer + primitive::Renderer + 'a,
611{
612    Container::new(picker_panel_slot(selected, on_select, state.reveal()))
613        .width(Length::Fill)
614        .height(Length::Fill)
615        .padding(floating_padding(
616            FLOATING_MARGIN,
617            bottom_margin + PALETTE_BUTTON_SIZE,
618        ))
619        .align_x(alignment::Horizontal::Right)
620        .align_y(alignment::Vertical::Bottom)
621        .into()
622}
623
624fn floating_palette_layer<'a, Message, Renderer>(
625    bottom_margin: f32,
626    on_toggle: Message,
627) -> Element<'a, Message, Theme, Renderer>
628where
629    Message: Clone + 'a,
630    Renderer: iced_widget::core::Renderer
631        + core_text::Renderer
632        + geometry::Renderer
633        + primitive::Renderer
634        + 'a,
635    iced_widget::core::Font: Into<Renderer::Font>,
636{
637    Container::new(palette_button(on_toggle))
638        .width(Length::Fill)
639        .height(Length::Fill)
640        .padding(floating_padding(FLOATING_MARGIN, bottom_margin))
641        .align_x(alignment::Horizontal::Right)
642        .align_y(alignment::Vertical::Bottom)
643        .into()
644}
645
646fn floating_padding(right: f32, bottom: f32) -> Padding {
647    Padding {
648        top: 0.0,
649        right,
650        bottom,
651        left: 0.0,
652    }
653}
654
655fn picker_panel_slot<'a, Message, Renderer>(
656    selected: MaterialColor,
657    on_select: impl Fn(MaterialColor) -> Message + 'a,
658    reveal: f32,
659) -> Element<'a, Message, Theme, Renderer>
660where
661    Message: Clone + 'a,
662    Renderer: iced_widget::core::Renderer + geometry::Renderer + primitive::Renderer + 'a,
663{
664    let visible_height = picker_panel_reveal_height(reveal);
665    let layout_height = picker_panel_slot_height();
666    let content = Column::new()
667        .push(picker_panel(selected, on_select))
668        .push(Space::new().height(Length::Fixed(PICKER_PANEL_SPACING)));
669
670    viewport::Viewport::fixed_height(content, visible_height, layout_height)
671        .align_y(alignment::Vertical::Bottom)
672        .width(Length::Fixed(picker_panel_width()))
673        .into()
674}
675
676fn picker_panel<'a, Message, Renderer>(
677    selected: MaterialColor,
678    on_select: impl Fn(MaterialColor) -> Message + 'a,
679) -> Container<'a, Message, Theme, Renderer>
680where
681    Message: Clone + 'a,
682    Renderer: iced_widget::core::Renderer + geometry::Renderer + primitive::Renderer + 'a,
683{
684    let mut rows = Column::new().spacing(PICKER_PANEL_SPACING);
685
686    for colors in MaterialColor::ALL.chunks(SWATCH_COLUMNS) {
687        let mut row = Row::new().spacing(PICKER_PANEL_SPACING);
688
689        for color in colors {
690            row = row.push(swatch_button(*color, *color == selected, on_select(*color)));
691        }
692
693        rows = rows.push(row);
694    }
695
696    Container::new(rows)
697        .padding(PICKER_PANEL_PADDING)
698        .style(picker_panel_style)
699}
700
701fn palette_button<'a, Message, Renderer>(on_press: Message) -> Button<'a, Message, Renderer>
702where
703    Message: Clone + 'a,
704    Renderer: iced_widget::core::Renderer + core_text::Renderer + geometry::Renderer + 'a,
705    iced_widget::core::Font: Into<Renderer::Font>,
706{
707    super::button::fab(
708        "palette",
709        super::button::FabVariant::Surface,
710        super::button::FabSize::Standard,
711    )
712    .on_press(on_press)
713}
714
715fn swatch_button<'a, Message, Renderer>(
716    color: MaterialColor,
717    selected: bool,
718    on_press: Message,
719) -> Button<'a, Message, Renderer>
720where
721    Message: Clone + 'a,
722    Renderer: iced_widget::core::Renderer + geometry::Renderer + 'a,
723{
724    Button::new(
725        Container::new(Space::new())
726            .width(Length::Fixed(SWATCH_SIZE))
727            .height(Length::Fixed(SWATCH_SIZE)),
728    )
729    .width(Length::Fixed(SWATCH_TARGET_SIZE))
730    .height(Length::Fixed(SWATCH_TARGET_SIZE))
731    .padding(Padding::from([
732        (SWATCH_TARGET_SIZE - SWATCH_SIZE) / 2.0,
733        (SWATCH_TARGET_SIZE - SWATCH_SIZE) / 2.0,
734    ]))
735    .on_press(on_press)
736    .style(move |theme, status| swatch_style(theme, status, color, selected))
737}
738
739fn picker_panel_style(theme: &Theme) -> iced_widget::container::Style {
740    let colors = theme.colors();
741
742    iced_widget::container::Style {
743        background: Some(Background::Color(colors.surface.container.high)),
744        text_color: Some(colors.surface.text),
745        border: border::rounded(PICKER_PANEL_SHAPE),
746        shadow: shadow_from_level(PICKER_PANEL_ELEVATION_LEVEL, colors.shadow),
747        snap: cfg!(feature = "crisp"),
748    }
749}
750
751fn swatch_style(theme: &Theme, status: Status, color: MaterialColor, selected: bool) -> Style {
752    let colors = theme.colors();
753    let base = color.swatch();
754    let background = match status {
755        Status::Active | Status::Disabled => base,
756        Status::Hovered => mix(base, colors.surface.text, HOVERED_LAYER_OPACITY),
757        Status::Pressed => mix(base, colors.surface.text, PRESSED_LAYER_OPACITY),
758    };
759
760    let outline = if selected {
761        colors.surface.text
762    } else {
763        colors.outline.variant
764    };
765
766    Style {
767        background: Some(Background::Color(background)),
768        text_color: colors.surface.text,
769        border: iced_widget::core::Border {
770            color: outline,
771            width: if selected {
772                SELECTED_SWATCH_OUTLINE_WIDTH
773            } else {
774                SWATCH_OUTLINE_WIDTH
775            },
776            radius: SWATCH_SHAPE.into(),
777        },
778        shadow: shadow_from_level(0, Color::TRANSPARENT),
779        snap: cfg!(feature = "crisp"),
780    }
781}
782
783#[derive(Debug, Clone, Copy)]
784pub struct ThemeRevealOverlay {
785    origin: Point,
786    target: ColorScheme,
787    progress: f32,
788}
789
790impl<Message, Renderer> canvas::Program<Message, Theme, Renderer> for ThemeRevealOverlay
791where
792    Renderer: geometry::Renderer,
793{
794    type State = ();
795
796    fn draw(
797        &self,
798        _state: &Self::State,
799        renderer: &Renderer,
800        _theme: &Theme,
801        bounds: Rectangle,
802        _cursor: mouse::Cursor,
803    ) -> Vec<canvas::Geometry<Renderer>> {
804        let mut frame = canvas::Frame::new(renderer, bounds.size());
805        let progress = self.progress.clamp(0.0, 1.0);
806        let origin = Point::new(self.origin.x - bounds.x, self.origin.y - bounds.y);
807        let max_radius = max_radius_from_origin(origin, bounds.size());
808        let radius = max_radius * progress;
809
810        draw_start_fill(&mut frame, bounds.size(), self.target, progress);
811
812        if radius <= 0.0 {
813            return vec![frame.into_geometry()];
814        }
815
816        draw_reveal_center(&mut frame, origin, radius, self.target, progress);
817        draw_reveal_blur_halo(
818            &mut frame,
819            origin,
820            radius,
821            max_radius,
822            self.target,
823            progress,
824        );
825
826        vec![frame.into_geometry()]
827    }
828}
829
830fn draw_start_fill<Renderer>(
831    frame: &mut canvas::Frame<Renderer>,
832    size: Size,
833    target: ColorScheme,
834    progress: f32,
835) where
836    Renderer: geometry::Renderer,
837{
838    let alpha = reveal_start_fill_alpha(progress);
839
840    if alpha <= 0.0 {
841        return;
842    }
843
844    let mut color = mix(target.surface.color, target.primary.container, 0.16);
845    color.a *= alpha;
846
847    frame.fill(&Path::rectangle(Point::ORIGIN, size), color);
848}
849
850fn draw_reveal_center<Renderer>(
851    frame: &mut canvas::Frame<Renderer>,
852    origin: Point,
853    radius: f32,
854    target: ColorScheme,
855    progress: f32,
856) where
857    Renderer: geometry::Renderer,
858{
859    let mut surface = target.surface.color;
860    surface.a *= THEME_REVEAL_CENTER_ALPHA
861        * reveal_gradient_end_alpha(progress)
862        * (1.0 - reveal_blur_ratio(progress) * 0.35);
863
864    if surface.a > 0.0 {
865        frame.fill(&Path::circle(origin, radius), surface);
866    }
867}
868
869fn draw_reveal_blur_halo<Renderer>(
870    frame: &mut canvas::Frame<Renderer>,
871    origin: Point,
872    radius: f32,
873    max_radius: f32,
874    target: ColorScheme,
875    progress: f32,
876) where
877    Renderer: geometry::Renderer,
878{
879    let edge_alpha = reveal_gradient_end_alpha(progress);
880    let blur_ratio = reveal_blur_ratio(progress);
881    let blur_width = reveal_blur_width(max_radius, progress);
882
883    if edge_alpha <= 0.0 || blur_width <= 0.0 {
884        return;
885    }
886
887    let layer_width = (blur_width / THEME_REVEAL_EDGE_LAYERS as f32).max(1.0);
888    let base = mix(target.primary.container, target.surface.color, 0.28);
889
890    for layer in 0..THEME_REVEAL_EDGE_LAYERS {
891        let t = layer as f32 / (THEME_REVEAL_EDGE_LAYERS - 1) as f32;
892        let offset = (t - 0.5) * blur_width;
893        let ring_radius = (radius + offset).max(layer_width / 2.0);
894        let bell = 1.0 - (2.0 * t - 1.0).abs().powi(2);
895        let mut color = mix(base, target.surface.color, t * 0.55);
896        color.a *= THEME_REVEAL_EDGE_ALPHA * edge_alpha * (0.30 + blur_ratio * 0.70) * bell
897            / (THEME_REVEAL_EDGE_LAYERS as f32).sqrt();
898
899        if color.a <= 0.0 {
900            continue;
901        }
902
903        frame.stroke(
904            &Path::circle(origin, ring_radius),
905            Stroke::default()
906                .with_width(layer_width * (1.0 + blur_ratio * 1.2))
907                .with_color(color),
908        );
909    }
910}
911
912fn percent_past_threshold(value: f32, threshold: f32) -> f32 {
913    let threshold = threshold.clamp(0.0, 0.999_999);
914
915    ((value.clamp(0.0, 1.0) - threshold).max(0.0) / (1.0 - threshold)).clamp(0.0, 1.0)
916}
917
918fn reveal_gradient_end_alpha(progress: f32) -> f32 {
919    1.0 - percent_past_threshold(progress, THEME_REVEAL_EDGE_FADE_THRESHOLD)
920}
921
922fn reveal_start_fill_alpha(progress: f32) -> f32 {
923    THEME_REVEAL_START_FILL_ALPHA
924        * (1.0 - percent_past_threshold(progress, THEME_REVEAL_START_FILL_THRESHOLD))
925}
926
927fn reveal_blur_ratio(progress: f32) -> f32 {
928    let progress = progress.clamp(0.0, 1.0);
929
930    (1.0 - (progress * 2.0 - 1.0).abs()).clamp(0.0, 1.0).sqrt()
931}
932
933fn reveal_blur_width(max_radius: f32, progress: f32) -> f32 {
934    let max_width = THEME_REVEAL_MAX_BLUR_WIDTH.min(max_radius * 0.12);
935
936    lerp(
937        THEME_REVEAL_MIN_BLUR_WIDTH.min(max_width),
938        max_width,
939        reveal_blur_ratio(progress),
940    )
941}
942
943fn lerp(from: f32, to: f32, progress: f32) -> f32 {
944    from + (to - from) * progress.clamp(0.0, 1.0)
945}
946
947fn picker_panel_width() -> f32 {
948    PICKER_PANEL_PADDING * 2.0
949        + SWATCH_COLUMNS as f32 * SWATCH_TARGET_SIZE
950        + (SWATCH_COLUMNS - 1) as f32 * PICKER_PANEL_SPACING
951}
952
953fn picker_panel_height() -> f32 {
954    PICKER_PANEL_PADDING * 2.0
955        + SWATCH_ROWS as f32 * SWATCH_TARGET_SIZE
956        + (SWATCH_ROWS - 1) as f32 * PICKER_PANEL_SPACING
957}
958
959fn picker_panel_slot_height() -> f32 {
960    picker_panel_height() + PICKER_PANEL_SPACING
961}
962
963fn picker_panel_reveal_height(progress: f32) -> f32 {
964    picker_panel_slot_height() * progress.clamp(0.0, 1.0)
965}
966
967fn tint_quartet(base: ColorQuartet, primary: ColorQuartet, amount: f32) -> ColorQuartet {
968    ColorQuartet {
969        color: mix(base.color, primary.color, amount),
970        text: base.text,
971        container: mix(base.container, primary.container, amount),
972        container_text: base.container_text,
973    }
974}
975
976fn tint_surface(base: Surface, primary: ColorQuartet, dark: bool) -> Surface {
977    let anchor = primary.container;
978    let [surface, lowest, low, container, high, highest] = if dark {
979        [0.08, 0.05, 0.09, 0.12, 0.15, 0.18]
980    } else {
981        [0.20, 0.10, 0.18, 0.24, 0.30, 0.36]
982    };
983
984    Surface {
985        color: mix(base.color, anchor, surface),
986        text: base.text,
987        text_variant: base.text_variant,
988        container: SurfaceContainer {
989            lowest: mix(base.container.lowest, anchor, lowest),
990            low: mix(base.container.low, anchor, low),
991            base: mix(base.container.base, anchor, container),
992            high: mix(base.container.high, anchor, high),
993            highest: mix(base.container.highest, anchor, highest),
994        },
995    }
996}
997
998const fn rgb(r: u8, g: u8, b: u8) -> Color {
999    Color::from_rgb8(r, g, b)
1000}
1001
1002#[cfg(test)]
1003#[path = "../../../tests/widget/component/theme_picker.rs"]
1004mod tests;