Skip to main content

bexa_ui_core/widgets/
radio.rs

1use glyphon::Metrics;
2use glyphon::cosmic_text::Align;
3use taffy::prelude::*;
4use winit::event::{ElementState, KeyEvent, MouseButton, WindowEvent};
5use winit::keyboard::{Key, ModifiersState, NamedKey};
6
7use crate::framework::{DrawContext, EventContext, Widget};
8use crate::signal::{Signal, SetSignal};
9use crate::WidgetNode;
10
11pub struct RadioButton {
12    label: String,
13    index: usize,
14    selected: Signal<usize>,
15    set_selected: SetSignal<usize>,
16    metrics: Metrics,
17    circle_size: f32,
18    gap: f32,
19    // Colors
20    circle_bg: [f32; 4],
21    circle_border: [f32; 4],
22    dot_color: [f32; 4],
23    text_color: [u8; 3],
24    // State
25    hover: bool,
26    focus: bool,
27}
28
29impl RadioButton {
30    pub fn new(
31        label: impl Into<String>,
32        index: usize,
33        selected: Signal<usize>,
34        set_selected: SetSignal<usize>,
35        metrics: Metrics,
36    ) -> Self {
37        Self {
38            label: label.into(),
39            index,
40            selected,
41            set_selected,
42            metrics,
43            circle_size: 20.0,
44            gap: 8.0,
45            circle_bg: [0.16, 0.28, 0.38, 1.0],
46            circle_border: [0.4, 0.55, 0.7, 1.0],
47            dot_color: [0.20, 0.65, 0.85, 1.0],
48            text_color: [230, 230, 230],
49            hover: false,
50            focus: false,
51        }
52    }
53
54    pub fn with_circle_size(mut self, size: f32) -> Self {
55        self.circle_size = size;
56        self
57    }
58
59    pub fn with_gap(mut self, gap: f32) -> Self {
60        self.gap = gap;
61        self
62    }
63
64    pub fn with_colors(
65        mut self,
66        circle_bg: [f32; 4],
67        circle_border: [f32; 4],
68        dot_color: [f32; 4],
69    ) -> Self {
70        self.circle_bg = circle_bg;
71        self.circle_border = circle_border;
72        self.dot_color = dot_color;
73        self
74    }
75
76    pub fn with_text_color(mut self, color: [u8; 3]) -> Self {
77        self.text_color = color;
78        self
79    }
80
81    fn select(&self) {
82        self.set_selected.set(self.index);
83    }
84
85    fn is_selected(&self) -> bool {
86        self.selected.get() == self.index
87    }
88
89    fn hit_test(&self, layout: &Layout, x: f32, y: f32) -> bool {
90        x >= layout.location.x
91            && x <= layout.location.x + layout.size.width
92            && y >= layout.location.y
93            && y <= layout.location.y + layout.size.height
94    }
95}
96
97impl Widget for RadioButton {
98    fn style(&self) -> Style {
99        let height = self.circle_size.max(self.metrics.line_height) + 8.0;
100        Style {
101            size: Size {
102                width: Dimension::Auto,
103                height: Dimension::Length(height),
104            },
105            flex_shrink: 0.0,
106            ..Default::default()
107        }
108    }
109
110    fn draw(&self, ctx: &mut DrawContext) {
111        let layout = ctx.layout;
112        let selected = self.is_selected();
113
114        // 1. Draw outer circle (border_radius = half size makes it a circle)
115        let cx = layout.location.x + 4.0;
116        let cy = layout.location.y + (layout.size.height - self.circle_size) / 2.0;
117        let radius = self.circle_size / 2.0;
118        let border_w = if self.focus { 2.0 } else { 1.0 };
119        let border_c = if self.focus {
120            [0.3, 0.6, 0.9, 1.0]
121        } else {
122            self.circle_border
123        };
124        ctx.renderer.fill_rect_styled(
125            (cx, cy, self.circle_size, self.circle_size),
126            self.circle_bg,
127            radius,
128            border_w,
129            border_c,
130        );
131
132        // 2. Draw inner filled dot if selected
133        if selected {
134            let dot_size = self.circle_size * 0.5;
135            let dot_x = cx + (self.circle_size - dot_size) / 2.0;
136            let dot_y = cy + (self.circle_size - dot_size) / 2.0;
137            ctx.renderer.fill_rect_rounded(
138                (dot_x, dot_y, dot_size, dot_size),
139                self.dot_color,
140                dot_size / 2.0,
141            );
142        }
143
144        // 3. Draw label text
145        let text_x = cx + self.circle_size + self.gap;
146        let text_y = layout.location.y + (layout.size.height - self.metrics.line_height) / 2.0;
147        let text_w = (layout.size.width - (text_x - layout.location.x)).max(0.0);
148        ctx.renderer.draw_text(
149            &self.label,
150            (text_x, text_y),
151            self.text_color,
152            (text_w, self.metrics.line_height),
153            self.metrics,
154            Align::Left,
155        );
156    }
157
158    fn handle_event(&mut self, ctx: &mut EventContext) -> bool {
159        let layout = ctx.layout;
160        let mut changed = false;
161        match ctx.event {
162            WindowEvent::CursorMoved { position, .. } => {
163                let over = self.hit_test(layout, position.x as f32, position.y as f32);
164                if over != self.hover {
165                    self.hover = over;
166                    changed = true;
167                }
168            }
169            WindowEvent::MouseInput {
170                state: ElementState::Pressed,
171                button: MouseButton::Left,
172                ..
173            } => {
174                if self.hover && !self.is_selected() {
175                    self.select();
176                    changed = true;
177                }
178            }
179            _ => {}
180        }
181        changed
182    }
183
184    fn handle_key_event(&mut self, event: &KeyEvent, _modifiers: ModifiersState) -> bool {
185        if event.state != ElementState::Pressed {
186            return false;
187        }
188        match &event.logical_key {
189            Key::Named(NamedKey::Space) | Key::Named(NamedKey::Enter) => {
190                if !self.is_selected() {
191                    self.select();
192                }
193                true
194            }
195            _ => false,
196        }
197    }
198
199    fn is_focusable(&self) -> bool {
200        true
201    }
202
203    fn set_focus(&mut self, focused: bool) {
204        self.focus = focused;
205    }
206
207    fn activate(&mut self) {
208        self.select();
209    }
210}
211
212/// Convenience: create a column of radio buttons sharing the same signal.
213pub fn radio_group(
214    options: &[&str],
215    selected: Signal<usize>,
216    set_selected: SetSignal<usize>,
217    metrics: Metrics,
218) -> Vec<WidgetNode> {
219    options
220        .iter()
221        .enumerate()
222        .map(|(i, label)| {
223            WidgetNode::new(
224                RadioButton::new(*label, i, selected.clone(), set_selected.clone(), metrics),
225                vec![],
226            )
227        })
228        .collect()
229}