git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/widget/input/radio_group.rs (13.4K)

  1 //! `RadioGroup` — one of several options, chosen (`docs/rfc-accessibility-locale.md`,
  2 //! phase 3).
  3 //!
  4 //! Each option is the check box's well at a full corner (the DE's superellipse, so a
  5 //! squircle rather than a disc) with a lit bead, a sphere, standing in the chosen one, and
  6 //! its label after it — in a column, or a row with [`Adapted<RadioGroup>::with_horizontal`].
  7 //! The bead is round where the check box's plate is square, which is what tells the two
  8 //! apart at a glance (a flat host, which carries no sphere, draws it as a disc).
  9 //!
 10 //! **The keyboard.** The group is ONE stop in the Tab walk, a plate, as every set of
 11 //! exclusive choices is; the arrows move the choice (Down / Right the next, Up / Left the one
 12 //! before, wrapping; Home / End the ends), and the choice follows them — there is no option
 13 //! that has focus without being chosen. A press on an option chooses it.
 14 //!
 15 //! **A screen reader** sees a radio group whose children are radio buttons, the chosen one
 16 //! checked and focused while the group is, each one clickable
 17 //! ([`Input::a11y_items`] / [`Input::a11y_select_item`]).
 18 
 19 use crate::scene::layout::{Rect, Size};
 20 use crate::scene::paint::{Field, PaintCtx};
 21 use crate::widget::{ElementState, Event, Key, MouseButton, NamedKey};
 22 use crate::widget::model::EventCtx;
 23 use crate::widget::{colors, Adapted, Input, Layout, Paint};
 24 
 25 /// The gap between an option's box and its label.
 26 const LABEL_GAP: f32 = 8.0;
 27 /// The chosen option's bead, as a share of its well's side.
 28 const BEAD_SHARE: f32 = 0.42;
 29 /// The bead's body (linear): lighter than the well it stands in.
 30 const BEAD: [f32; 4] = [0.42, 0.45, 0.56, 1.0];
 31 
 32 #[derive(Debug, Clone)]
 33 pub struct RadioGroup {
 34     options: Vec<String>,
 35     selected: usize,
 36     horizontal: bool,
 37     focused: bool,
 38     just_changed: bool,
 39 }
 40 
 41 impl RadioGroup {
 42     /// A column of `options`, the first chosen.
 43     pub fn new<S: Into<String>>(options: impl IntoIterator<Item = S>) -> Adapted<RadioGroup> {
 44         Adapted::new(RadioGroup {
 45             options: options.into_iter().map(Into::into).collect(),
 46             selected: 0,
 47             horizontal: false,
 48             focused: false,
 49             just_changed: false,
 50         })
 51     }
 52 
 53     pub fn options(&self) -> &[String] {
 54         &self.options
 55     }
 56 
 57     /// The chosen option's index.
 58     pub fn selected(&self) -> usize {
 59         self.selected
 60     }
 61 
 62     /// Choose option `idx` (clamped). Not reported as a change: the host set it.
 63     pub fn set_selected(&mut self, idx: usize) {
 64         self.selected = idx.min(self.options.len().saturating_sub(1));
 65     }
 66 
 67     /// Replace the options, keeping the choice where it still exists.
 68     pub fn set_options<S: Into<String>>(&mut self, options: impl IntoIterator<Item = S>) {
 69         self.options = options.into_iter().map(Into::into).collect();
 70         self.set_selected(self.selected);
 71     }
 72 
 73     /// One option's height: a toggle's, the check box's side.
 74     fn row_h() -> f32 {
 75         crate::layout::toggle_height()
 76     }
 77 
 78     fn gap() -> f32 {
 79         crate::layout::control_gap()
 80     }
 81 
 82     fn label_width(label: &str) -> f32 {
 83         let (fam, size) = crate::layout::control_label_font_parsed();
 84         crate::widget::display::measure_text_width(label, &fam, size)
 85     }
 86 
 87     /// Each option's rect (its box and its label) in `rect`.
 88     pub fn option_rects(&self, rect: Rect) -> Vec<Rect> {
 89         let (h, gap) = (Self::row_h(), Self::gap());
 90         let mut x = rect.x;
 91         self.options
 92             .iter()
 93             .enumerate()
 94             .map(|(i, label)| {
 95                 if self.horizontal {
 96                     let w = h + LABEL_GAP + Self::label_width(label);
 97                     let r = Rect { x, y: rect.y, width: w, height: h };
 98                     x += w + gap * 2.0;
 99                     r
100                 } else {
101                     Rect { x: rect.x, y: rect.y + i as f32 * (h + gap), width: rect.width, height: h }
102                 }
103             })
104             .collect()
105     }
106 
107     /// The option under (`px`, `py`).
108     fn option_at(&self, rect: Rect, px: f32, py: f32) -> Option<usize> {
109         self.option_rects(rect)
110             .iter()
111             .position(|r| px >= r.x && px <= r.x + r.width && py >= r.y && py <= r.y + r.height)
112     }
113 
114     /// An option's box: the check box's well, round.
115     pub fn ring(option: Rect) -> Field {
116         let side = option.height.max(1.0);
117         let square = Rect { x: option.x, y: option.y, width: side, height: side };
118         let depth = crate::layout::bevel_width().min(side * 0.2);
119         let r = side * 0.5;
120         let (outline, _) = crate::layout::carve_inside(square, (r, r, r, r), depth);
121         // Round at the carved outline's own size, whatever the carve did to the corner.
122         let r = outline.width.min(outline.height) * 0.5;
123         Field::well(outline, (r, r, r, r), depth)
124     }
125 
126     fn choose(&mut self, idx: usize) -> bool {
127         if idx >= self.options.len() || idx == self.selected {
128             return false;
129         }
130         self.selected = idx;
131         self.just_changed = true;
132         true
133     }
134 }
135 
136 impl Adapted<RadioGroup> {
137     /// Lay the options out in a row instead of a column.
138     pub fn with_horizontal(mut self, horizontal: bool) -> Self {
139         self.inner_mut().horizontal = horizontal;
140         self
141     }
142 
143     pub fn with_selected(mut self, idx: usize) -> Self {
144         self.inner_mut().set_selected(idx);
145         self
146     }
147 }
148 
149 impl Layout for RadioGroup {
150     fn inline_label(&self) -> bool {
151         true
152     }
153 
154     fn intrinsic_size(&self) -> Option<Size> {
155         let (h, gap, n) = (Self::row_h(), Self::gap(), self.options.len() as f32);
156         if self.horizontal {
157             let w: f32 = self.option_rects(Rect { x: 0.0, y: 0.0, width: 0.0, height: h }).iter().map(|r| r.width).sum::<f32>()
158                 + gap * 2.0 * (n - 1.0).max(0.0);
159             Some(Size::new(w, h))
160         } else {
161             Some(Size::new(0.0, n * h + (n - 1.0).max(0.0) * gap))
162         }
163     }
164 }
165 
166 impl Paint for RadioGroup {
167     fn color(&self) -> [f32; 4] {
168         [0.0; 4]
169     }
170 
171     fn widget_font(&self) -> Option<String> {
172         Some(crate::layout::control_label_font())
173     }
174 
175     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
176         let (_, font_size) = crate::layout::control_label_font_parsed();
177         for (i, (option, label)) in self.option_rects(rect).into_iter().zip(&self.options).enumerate() {
178             let chosen = i == self.selected;
179             let lit = chosen && self.focused;
180             let ring = Self::ring(option).with_tint(lit.then(crate::scene::paint::ControlPlate::focus_tint));
181             super::checkbox::paint_box(ctx, &ring, None, false, lit);
182             if chosen {
183                 let o = ring.rect;
184                 let (cx, cy, r) = (o.x + o.width * 0.5, o.y + o.height * 0.5, o.width.min(o.height) * BEAD_SHARE * 0.5);
185                 // The disc first, for a flat host that drops the sphere; the sphere covers it.
186                 ctx.circle(cx, cy, r, BEAD);
187                 ctx.sphere(cx, cy, r, &crate::scene::material::Material::from_fill(BEAD));
188             }
189             let ty = crate::layout::align_text_y(option.y, option.height, font_size, 0.0);
190             let lx = option.x + option.height + LABEL_GAP;
191             ctx.text_with(
192                 label.clone(),
193                 lx,
194                 ty,
195                 font_size,
196                 colors::control_label_color_for_state(false, lit),
197                 None,
198                 Some([option.x, option.y, rect.x + rect.width.max(option.width), option.y + option.height]),
199             );
200         }
201     }
202 }
203 
204 impl Input for RadioGroup {
205     fn focus_role(&self) -> crate::widget::FocusRole {
206         crate::widget::FocusRole::Plate
207     }
208 
209     fn a11y_role(&self) -> Option<accesskit::Role> {
210         Some(accesskit::Role::RadioGroup)
211     }
212 
213     fn a11y_items(&self, rect: Rect) -> Vec<crate::a11y::A11yItem> {
214         self.option_rects(rect)
215             .into_iter()
216             .zip(&self.options)
217             .enumerate()
218             .map(|(i, (r, label))| crate::a11y::A11yItem {
219                 role: accesskit::Role::RadioButton,
220                 label: label.clone(),
221                 rect: r,
222                 toggled: Some(i == self.selected),
223                 focused: self.focused && i == self.selected,
224             })
225             .collect()
226     }
227 
228     fn a11y_select_item(&mut self, idx: usize) -> bool {
229         self.choose(idx)
230     }
231 
232     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
233         match event {
234             Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x, y, .. } => {
235                 // Already hit-gated by the adapter; a press between options chooses nothing.
236                 match self.option_at(ectx.rect, *x, *y) {
237                     Some(i) => {
238                         self.choose(i);
239                         true
240                     }
241                     None => false,
242                 }
243             }
244             Event::FocusIn => {
245                 self.focused = true;
246                 true
247             }
248             Event::FocusOut => {
249                 self.focused = false;
250                 true
251             }
252             Event::KeyInput(key_event) => {
253                 if !self.focused || key_event.state != ElementState::Pressed || self.options.is_empty() {
254                     return false;
255                 }
256                 let n = self.options.len();
257                 let target = match key_event.logical_key {
258                     Key::Named(NamedKey::ArrowDown) | Key::Named(NamedKey::ArrowRight) => (self.selected + 1) % n,
259                     Key::Named(NamedKey::ArrowUp) | Key::Named(NamedKey::ArrowLeft) => (self.selected + n - 1) % n,
260                     Key::Named(NamedKey::Home) => 0,
261                     Key::Named(NamedKey::End) => n - 1,
262                     // Space / Enter: the focused option is already the chosen one.
263                     Key::Named(NamedKey::Space) | Key::Named(NamedKey::Enter) => return true,
264                     _ => return false,
265                 };
266                 self.choose(target);
267                 true
268             }
269             _ => false,
270         }
271     }
272 
273     fn take_change(&mut self) -> bool {
274         std::mem::take(&mut self.just_changed)
275     }
276 
277     /// The chosen option's label.
278     fn value_string(&self) -> Option<String> {
279         self.options.get(self.selected).cloned()
280     }
281 
282     /// Choose by label, or by index.
283     fn set_value_string(&mut self, val: &str) -> bool {
284         let val = val.trim();
285         let idx = self.options.iter().position(|o| o == val).or_else(|| val.parse::<usize>().ok());
286         idx.is_some_and(|i| self.choose(i))
287     }
288 
289     fn value(&self) -> i32 {
290         self.selected as i32
291     }
292 }
293 
294 #[cfg(test)]
295 mod tests {
296     use super::*;
297     use crate::widget::{UiContext, WidgetHost};
298 
299     fn key(k: NamedKey) -> Event {
300         Event::KeyInput(crate::widget::KeyEvent {
301             logical_key: Key::Named(k),
302             state: ElementState::Pressed,
303             text: None,
304             repeat: false,
305             ctrl: false,
306             shift: false,
307             alt: false,
308         })
309     }
310 
311     /// The arrows move the choice, wrapping, and only while focused; a press chooses the
312     /// option under it.
313     #[test]
314     fn the_arrows_and_a_press_choose_an_option() {
315         let mut ctx = UiContext::new();
316         let mut g = RadioGroup::new(["Small", "Medium", "Large"]);
317         WidgetHost::set_rect(&mut g, 10.0, 10.0, 200.0, 200.0);
318         assert!(!g.handle_event(&key(NamedKey::ArrowDown), &mut ctx), "unfocused: not its key");
319         g.handle_event(&Event::FocusIn, &mut ctx);
320         g.handle_event(&key(NamedKey::ArrowDown), &mut ctx);
321         assert_eq!(g.inner().selected(), 1);
322         assert!(g.take_change());
323         g.handle_event(&key(NamedKey::ArrowUp), &mut ctx);
324         g.handle_event(&key(NamedKey::ArrowUp), &mut ctx);
325         assert_eq!(g.inner().selected(), 2, "wraps to the last");
326         g.handle_event(&key(NamedKey::Home), &mut ctx);
327         assert_eq!(g.inner().selected(), 0);
328 
329         let third = g.inner().option_rects(Rect { x: 10.0, y: 10.0, width: 200.0, height: 200.0 })[2];
330         let (px, py) = (third.x + 4.0, third.y + third.height * 0.5);
331         g.handle_event(
332             &Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x: px, y: py, local_x: px, local_y: py },
333             &mut ctx,
334         );
335         assert_eq!(g.inner().selected(), 2, "a press on the third chooses it");
336         assert_eq!(g.get_value_string().as_deref(), Some("Large"));
337     }
338 
339     /// A reader sees a radio group of radio buttons, the chosen one checked; a click on one
340     /// chooses it.
341     #[test]
342     fn a_reader_sees_and_chooses_radio_buttons() {
343         let mut g = RadioGroup::new(["Small", "Medium"]);
344         WidgetHost::set_rect(&mut g, 0.0, 0.0, 200.0, 80.0);
345         let items = g.a11y_items();
346         assert_eq!(items.len(), 2);
347         assert_eq!((items[0].role, items[0].label.as_str(), items[0].toggled), (accesskit::Role::RadioButton, "Small", Some(true)));
348         assert_eq!(items[1].toggled, Some(false));
349         assert!(g.a11y_select_item(1));
350         assert_eq!(g.inner().selected(), 1);
351         assert!(g.take_change());
352         assert!(!g.a11y_select_item(1), "already chosen: no change");
353     }
354 
355     /// Every option's box is round: the check box's well at a full corner.
356     #[test]
357     fn an_option_is_a_round_well() {
358         let ring = RadioGroup::ring(Rect { x: 0.0, y: 0.0, width: 200.0, height: 24.0 });
359         assert!((ring.radii.0 * 2.0 - ring.rect.width).abs() < 1e-3 && ring.rect.width == ring.rect.height);
360     }
361 }