1use crate::ext::ArmasContextExt;
28use crate::Theme;
29use egui::{Response, Sense, Stroke, Ui, Vec2};
30
31const RADIO_SIZE: f32 = 16.0; const RADIO_SIZE_SM: f32 = 14.0; const RADIO_SIZE_LG: f32 = 20.0; const BORDER_WIDTH: f32 = 1.0; const INNER_CIRCLE_RATIO: f32 = 0.5; #[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum RadioSize {
43 Small,
45 Medium,
47 Large,
49}
50
51impl RadioSize {
52 const fn diameter(self) -> f32 {
53 match self {
54 Self::Small => RADIO_SIZE_SM,
55 Self::Medium => RADIO_SIZE,
56 Self::Large => RADIO_SIZE_LG,
57 }
58 }
59}
60
61pub struct Radio {
63 id: Option<egui::Id>,
64 size: RadioSize,
65 label: Option<String>,
66 description: Option<String>,
67 disabled: bool,
68}
69
70impl Radio {
71 #[must_use]
73 pub const fn new() -> Self {
74 Self {
75 id: None,
76 size: RadioSize::Medium,
77 label: None,
78 description: None,
79 disabled: false,
80 }
81 }
82
83 #[must_use]
85 pub fn id(mut self, id: impl Into<egui::Id>) -> Self {
86 self.id = Some(id.into());
87 self
88 }
89
90 #[must_use]
92 pub const fn size(mut self, size: RadioSize) -> Self {
93 self.size = size;
94 self
95 }
96
97 #[must_use]
99 pub fn label(mut self, label: impl Into<String>) -> Self {
100 self.label = Some(label.into());
101 self
102 }
103
104 #[must_use]
106 pub fn description(mut self, description: impl Into<String>) -> Self {
107 self.description = Some(description.into());
108 self
109 }
110
111 #[must_use]
113 pub const fn disabled(mut self, disabled: bool) -> Self {
114 self.disabled = disabled;
115 self
116 }
117
118 pub fn show(self, ui: &mut Ui, selected: bool) -> RadioResponse {
120 let theme = ui.ctx().armas_theme();
121 let response = ui
122 .horizontal(|ui| {
123 let diameter = self.size.diameter();
125 let (rect, response) = ui.allocate_exact_size(
126 Vec2::splat(diameter),
127 if self.disabled {
128 Sense::hover()
129 } else {
130 Sense::click()
131 },
132 );
133
134 if ui.is_rect_visible(rect) {
135 self.draw_radio(ui, rect, selected, &theme);
136 }
137
138 if self.label.is_some() || self.description.is_some() {
140 ui.add_space(theme.spacing.sm);
141 ui.vertical(|ui| {
142 ui.spacing_mut().item_spacing.y = theme.spacing.xs;
143 if let Some(label) = &self.label {
144 let label_color = if self.disabled {
145 theme.muted_foreground().linear_multiply(0.5)
146 } else {
147 theme.foreground()
148 };
149
150 ui.label(
151 egui::RichText::new(label)
152 .size(theme.typography.base)
153 .color(label_color),
154 );
155 }
156
157 if let Some(description) = &self.description {
158 ui.label(
159 egui::RichText::new(description)
160 .size(theme.typography.sm)
161 .color(theme.muted_foreground()),
162 );
163 }
164 });
165 }
166
167 response
168 })
169 .inner;
170
171 RadioResponse { response, selected }
172 }
173
174 fn draw_radio(&self, ui: &mut Ui, rect: egui::Rect, selected: bool, theme: &Theme) {
176 let painter = ui.painter();
177 let center = rect.center();
178 let radius = rect.width() / 2.0;
179
180 let border_color = if self.disabled {
182 theme.muted_foreground().gamma_multiply(0.5)
183 } else {
184 theme.primary() };
186
187 painter.circle_stroke(center, radius, Stroke::new(BORDER_WIDTH, border_color));
188
189 if selected {
191 let inner_radius = radius * INNER_CIRCLE_RATIO;
192 let fill_color = if self.disabled {
193 theme.muted_foreground().gamma_multiply(0.5)
194 } else {
195 theme.primary()
196 };
197
198 painter.circle_filled(center, inner_radius, fill_color);
199 }
200 }
201}
202
203impl Default for Radio {
204 fn default() -> Self {
205 Self::new()
206 }
207}
208
209pub struct RadioResponse {
211 pub response: Response,
213 pub selected: bool,
215}
216
217pub struct RadioOptionBuilder {
223 label: String,
224 description: Option<String>,
225}
226
227impl RadioOptionBuilder {
228 fn new(_value: String, label: String) -> Self {
229 Self {
230 label,
231 description: None,
232 }
233 }
234
235 #[must_use]
237 pub fn description(mut self, description: impl Into<String>) -> Self {
238 self.description = Some(description.into());
239 self
240 }
241}
242
243pub struct RadioGroupBuilder<'a> {
245 selected_value: &'a mut Option<String>,
246 ui: &'a mut Ui,
247 changed: &'a mut bool,
248 disabled: bool,
249}
250
251impl RadioGroupBuilder<'_> {
252 pub fn option(&mut self, value: &str, label: &str) -> RadioOptionBuilder {
254 let builder = RadioOptionBuilder::new(value.to_string(), label.to_string());
255
256 let is_selected = self.selected_value.as_ref().is_some_and(|v| v == value);
258
259 let mut radio = Radio::new().label(&builder.label).disabled(self.disabled);
261
262 if let Some(desc) = &builder.description {
263 radio = radio.description(desc);
264 }
265
266 let response = radio.show(self.ui, is_selected);
267
268 if response.response.clicked() && !is_selected {
270 *self.selected_value = Some(value.to_string());
271 *self.changed = true;
272 }
273
274 builder
275 }
276}
277
278pub struct RadioGroupResponse {
280 pub response: Response,
282 pub changed: bool,
284 pub selected: Option<String>,
286}
287
288pub struct RadioGroup<'a> {
308 selected_value: &'a mut Option<String>,
309 label: Option<String>,
310 disabled: bool,
311}
312
313impl<'a> RadioGroup<'a> {
314 pub const fn new(selected_value: &'a mut Option<String>) -> Self {
319 Self {
320 selected_value,
321 label: None,
322 disabled: false,
323 }
324 }
325
326 #[must_use]
328 pub fn label(mut self, label: impl Into<String>) -> Self {
329 self.label = Some(label.into());
330 self
331 }
332
333 #[must_use]
335 pub const fn disabled(mut self, disabled: bool) -> Self {
336 self.disabled = disabled;
337 self
338 }
339
340 pub fn show<R>(
342 self,
343 ui: &mut Ui,
344 content: impl FnOnce(&mut RadioGroupBuilder) -> R,
345 ) -> RadioGroupResponse {
346 let theme = ui.ctx().armas_theme();
347 let mut changed = false;
348
349 let inner_response = ui.vertical(|ui| {
350 ui.spacing_mut().item_spacing.y = theme.spacing.sm;
351
352 if let Some(label) = &self.label {
354 ui.label(
355 egui::RichText::new(label)
356 .size(theme.typography.base)
357 .strong()
358 .color(theme.foreground()),
359 );
360 ui.add_space(theme.spacing.xs);
361 }
362
363 let mut builder = RadioGroupBuilder {
365 selected_value: self.selected_value,
366 ui,
367 changed: &mut changed,
368 disabled: self.disabled,
369 };
370 content(&mut builder);
371 });
372
373 RadioGroupResponse {
374 response: inner_response.response,
375 changed,
376 selected: self.selected_value.clone(),
377 }
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[test]
386 fn test_radio_creation() {
387 let radio = Radio::new();
388 assert_eq!(radio.size, RadioSize::Medium);
389 assert!(!radio.disabled);
390 }
391
392 #[test]
393 fn test_radio_builder() {
394 let radio = Radio::new()
395 .size(RadioSize::Large)
396 .label("Select me")
397 .disabled(true);
398
399 assert_eq!(radio.size, RadioSize::Large);
400 assert_eq!(radio.label, Some("Select me".to_string()));
401 assert!(radio.disabled);
402 }
403
404 #[test]
405 fn test_radio_size_dimensions() {
406 assert_eq!(RadioSize::Small.diameter(), RADIO_SIZE_SM);
407 assert_eq!(RadioSize::Medium.diameter(), RADIO_SIZE);
408 assert_eq!(RadioSize::Large.diameter(), RADIO_SIZE_LG);
409 }
410}