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 }