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