Skip to main content

elegance/
segmented.rs

1//! A "segmented" toggle button with an LED dot.
2
3use egui::{
4    Color32, CornerRadius, Response, Sense, Stroke, Ui, Vec2, Widget, WidgetInfo, WidgetText,
5    WidgetType,
6};
7
8use crate::theme::{with_alpha, Accent, Theme};
9
10/// A toggle button with a built-in LED dot.
11///
12/// ```no_run
13/// # use elegance::{Accent, SegmentedButton};
14/// # egui::__run_test_ui(|ui| {
15/// let mut on = false;
16/// if ui.add(SegmentedButton::new(&mut on, "Continuous").accent(Accent::Green))
17///     .clicked()
18/// {
19///     // ...
20/// }
21/// # });
22/// ```
23#[must_use = "Add with `ui.add(...)`."]
24pub struct SegmentedButton<'a> {
25    on: &'a mut bool,
26    label: WidgetText,
27    accent: Accent,
28    /// When `true`, the `on` state is dimmed — useful for showing that a
29    /// linked toggle or prerequisite isn't active.
30    dim_when_on: bool,
31    rounded: bool,
32    corner_radius: Option<CornerRadius>,
33    min_width: Option<f32>,
34}
35
36impl<'a> std::fmt::Debug for SegmentedButton<'a> {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        f.debug_struct("SegmentedButton")
39            .field("on", &*self.on)
40            .field("label", &self.label.text())
41            .field("accent", &self.accent)
42            .field("dim_when_on", &self.dim_when_on)
43            .field("rounded", &self.rounded)
44            .field("corner_radius", &self.corner_radius)
45            .field("min_width", &self.min_width)
46            .finish()
47    }
48}
49
50impl<'a> SegmentedButton<'a> {
51    /// Create a segmented button bound to `on` with the given label.
52    pub fn new(on: &'a mut bool, label: impl Into<WidgetText>) -> Self {
53        Self {
54            on,
55            label: label.into(),
56            accent: Accent::Green,
57            dim_when_on: false,
58            rounded: true,
59            corner_radius: None,
60            min_width: None,
61        }
62    }
63
64    /// Pick the `on`-state colour from one of the theme's accents. Default: [`Accent::Green`].
65    pub fn accent(mut self, accent: Accent) -> Self {
66        self.accent = accent;
67        self
68    }
69
70    /// When the button is on, render its fill dimmed and the label muted.
71    /// Used to indicate "enabled but not currently applicable".
72    pub fn dim_when_on(mut self, dim: bool) -> Self {
73        self.dim_when_on = dim;
74        self
75    }
76
77    /// Set whether the button has rounded corners. Disable for segmented
78    /// groups where neighbours share edges.
79    pub fn rounded(mut self, rounded: bool) -> Self {
80        self.rounded = rounded;
81        self
82    }
83
84    /// Explicitly set the corner radius (per-corner). Overrides [`Self::rounded`].
85    /// Useful for segmented strips where only the end cells should be rounded.
86    pub fn corner_radius(mut self, radius: impl Into<CornerRadius>) -> Self {
87        self.corner_radius = Some(radius.into());
88        self
89    }
90
91    /// Force the button to occupy at least this width. When wider than
92    /// the LED + text, the content is centered horizontally.
93    pub fn min_width(mut self, width: f32) -> Self {
94        self.min_width = Some(width);
95        self
96    }
97
98    fn on_fill(&self, theme: &Theme) -> Color32 {
99        theme.palette.accent_fill(self.accent)
100    }
101
102    fn on_fill_hover(&self, theme: &Theme) -> Color32 {
103        theme.palette.accent_hover(self.accent)
104    }
105}
106
107impl<'a> Widget for SegmentedButton<'a> {
108    fn ui(self, ui: &mut Ui) -> Response {
109        let theme = Theme::current(ui.ctx());
110        let p = &theme.palette;
111        let t = &theme.typography;
112
113        let pad_x = theme.control_padding_x;
114        // Intentionally taller than `theme.control_padding_y` — segmented
115        // toggles read as chunkier than standard controls.
116        let pad_y = 10.0;
117        let led_size = 8.0;
118        let led_gap = 7.0;
119
120        let galley =
121            crate::theme::placeholder_galley(ui, self.label.text(), t.button, true, f32::INFINITY);
122
123        let content_w = led_size + led_gap + galley.size().x;
124        let mut desired = Vec2::new(pad_x * 2.0 + content_w, pad_y * 2.0 + galley.size().y);
125        if let Some(min_w) = self.min_width {
126            desired.x = desired.x.max(min_w);
127        }
128        let (rect, mut response) = ui.allocate_exact_size(desired, Sense::click());
129
130        if response.clicked() {
131            *self.on = !*self.on;
132            response.mark_changed();
133        }
134
135        if ui.is_rect_visible(rect) {
136            let on = *self.on;
137            let hovered = response.hovered();
138            let is_down = response.is_pointer_button_down_on();
139
140            let (fill, text_color, led_color, led_glow) = if on {
141                let mut fill = if is_down {
142                    crate::theme::mix(self.on_fill_hover(&theme), Color32::BLACK, 0.1)
143                } else if hovered {
144                    self.on_fill_hover(&theme)
145                } else {
146                    self.on_fill(&theme)
147                };
148                let mut text = Color32::WHITE;
149                if self.dim_when_on {
150                    fill = crate::theme::mix(fill, p.card, 0.55);
151                    text = p.text_muted;
152                }
153                let led = Color32::WHITE;
154                let glow = !self.dim_when_on;
155                (fill, text, led, glow)
156            } else {
157                let fill = if hovered {
158                    p.depth_tint(p.input_bg, 0.05)
159                } else {
160                    p.input_bg
161                };
162                let text = if hovered { p.text_muted } else { p.text_faint };
163                let led = p.text_faint;
164                (fill, text, led, false)
165            };
166
167            let radius = self.corner_radius.unwrap_or_else(|| {
168                if self.rounded {
169                    CornerRadius::same(theme.control_radius as u8 + 2)
170                } else {
171                    CornerRadius::ZERO
172                }
173            });
174            ui.painter()
175                .rect(rect, radius, fill, Stroke::NONE, egui::StrokeKind::Inside);
176
177            // Center the LED + text combo within the allocated rect.
178            let content_start = rect.center().x - content_w * 0.5;
179            let led_center = egui::pos2(content_start + led_size * 0.5, rect.center().y);
180            if led_glow {
181                ui.painter().circle_filled(
182                    led_center,
183                    led_size * 0.5 + 2.0,
184                    with_alpha(Color32::WHITE, 70),
185                );
186            }
187            ui.painter()
188                .circle_filled(led_center, led_size * 0.5, led_color);
189
190            let text_pos = egui::pos2(
191                led_center.x + led_size * 0.5 + led_gap,
192                rect.center().y - galley.size().y * 0.5,
193            );
194            ui.painter().galley(text_pos, galley, text_color);
195        }
196
197        response.widget_info(|| {
198            WidgetInfo::selected(WidgetType::Checkbox, true, *self.on, self.label.text())
199        });
200        response
201    }
202}