zest_widget/widget/
radio.rs1use super::Widget;
22use alloc::string::String;
23use core::marker::PhantomData;
24use embedded_graphics::{
25 pixelcolor::PixelColor, prelude::*, primitives::Rectangle, text::Alignment,
26};
27use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase, UiAction, WidgetId};
28use zest_theme::Theme;
29
30const CIRCLE_SIZE: u32 = 20;
32const LABEL_GAP: u32 = 6;
34
35pub struct RadioButton<C: PixelColor, M: Clone> {
38 rect: Rectangle,
39 selected: bool,
40 label: Option<String>,
41 id: Option<WidgetId>,
42 on_select: Option<M>,
43 focused: bool,
44 pressed: bool,
45 width: Length,
46 height: Length,
47 _color: PhantomData<C>,
48}
49
50impl<C: PixelColor, M: Clone> RadioButton<C, M> {
51 pub fn new(selected: bool) -> Self {
54 Self {
55 rect: Rectangle::zero(),
56 selected,
57 label: None,
58 id: None,
59 on_select: None,
60 focused: false,
61 pressed: false,
62 width: Length::Shrink,
63 height: Length::Fixed(CIRCLE_SIZE),
64 _color: PhantomData,
65 }
66 }
67
68 #[must_use]
70 pub fn label(mut self, label: impl Into<String>) -> Self {
71 self.label = Some(label.into());
72 self
73 }
74
75 #[must_use]
78 pub fn on_select(mut self, msg: M) -> Self {
79 self.on_select = Some(msg);
80 self
81 }
82
83 #[must_use]
85 pub fn id(mut self, id: WidgetId) -> Self {
86 self.id = Some(id);
87 self
88 }
89
90 #[must_use]
92 pub fn width(mut self, width: impl Into<Length>) -> Self {
93 self.width = width.into();
94 self
95 }
96
97 #[must_use]
99 pub fn height(mut self, height: impl Into<Length>) -> Self {
100 self.height = height.into();
101 self
102 }
103
104 pub fn is_enabled(&self) -> bool {
107 self.on_select.is_some()
108 }
109
110 fn intrinsic(&self) -> Size {
111 let label_w = self
112 .label
113 .as_ref()
114 .map_or(0, |l| LABEL_GAP + l.chars().count() as u32 * 8);
115 Size::new(CIRCLE_SIZE + label_w, CIRCLE_SIZE)
116 }
117
118 fn circle_center(&self) -> Point {
120 let r = CIRCLE_SIZE as i32 / 2;
121 Point::new(
122 self.rect.top_left.x + r,
123 self.rect.top_left.y + self.rect.size.height as i32 / 2,
124 )
125 }
126
127 fn hit_test(&self, point: Point) -> bool {
128 let tl = self.rect.top_left;
129 let br = tl + Point::new(self.rect.size.width as i32, self.rect.size.height as i32);
130 point.x >= tl.x && point.x < br.x && point.y >= tl.y && point.y < br.y
131 }
132}
133
134impl<C: PixelColor, M: Clone> Widget<C, M> for RadioButton<C, M> {
135 fn measure(&mut self, constraints: Constraints) -> Size {
136 let intrinsic = self.intrinsic();
137 let w = self.width.resolve(intrinsic.width, constraints.max.width);
138 let h = self
139 .height
140 .resolve(intrinsic.height, constraints.max.height);
141 constraints.clamp(Size::new(w, h))
142 }
143
144 fn preferred_size(&self) -> (Length, Length) {
145 (self.width, self.height)
146 }
147
148 fn arrange(&mut self, rect: Rectangle) {
149 self.rect = rect;
150 }
151
152 fn rect(&self) -> Rectangle {
153 self.rect
154 }
155
156 fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
157 if !self.is_enabled() || !self.hit_test(point) {
158 if matches!(phase, TouchPhase::Up | TouchPhase::Moved) {
159 self.pressed = false;
160 }
161 return None;
162 }
163 match phase {
164 TouchPhase::Down => {
165 self.pressed = true;
166 None
167 }
168 TouchPhase::Up => {
169 if self.pressed {
170 self.pressed = false;
171 self.on_select.clone()
172 } else {
173 None
174 }
175 }
176 TouchPhase::Moved => None,
177 }
178 }
179
180 fn mark_pressed(&mut self, point: Point) {
181 if self.is_enabled() && self.hit_test(point) {
182 self.pressed = true;
183 }
184 }
185
186 fn widget_id(&self) -> Option<WidgetId> {
187 self.id
188 }
189
190 fn is_focusable(&self) -> bool {
191 self.id.is_some() && self.is_enabled()
192 }
193
194 fn handle_action(&mut self, action: UiAction) -> Option<M> {
195 if !self.is_enabled() {
196 return None;
197 }
198
199 match action {
200 UiAction::Activate => self.on_select.clone(),
201 _ => None,
202 }
203 }
204
205 fn sync_focus(&mut self, focused: Option<WidgetId>) {
206 self.focused = self.id.is_some() && self.id == focused;
207 }
208
209 fn focus_at(&self, point: Point) -> Option<WidgetId> {
210 if self.is_focusable() && self.hit_test(point) {
211 self.id
212 } else {
213 None
214 }
215 }
216
217 fn draw<'t>(
218 &self,
219 renderer: &mut dyn Renderer<C>,
220 theme: &Theme<'t, C>,
221 ) -> Result<(), RenderError> {
222 let accent = &theme.accent;
223 let center = self.circle_center();
224 let outer = CIRCLE_SIZE / 2;
225 let border = if self.focused {
226 accent.base
227 } else {
228 accent.border
229 };
230
231 renderer.fill_circle(center, outer, border)?;
234 let interior_color = if self.pressed {
235 accent.pressed
236 } else {
237 theme.background.base
238 };
239 renderer.fill_circle(center, outer.saturating_sub(2), interior_color)?;
240
241 if self.selected {
243 let dot = if self.is_enabled() {
244 accent.base
245 } else {
246 theme.background.divider
247 };
248 renderer.fill_circle(center, outer.saturating_sub(6), dot)?;
249 }
250
251 if let Some(label) = &self.label {
252 let font = theme.default_font();
253 let text_x = self.rect.top_left.x + CIRCLE_SIZE as i32 + LABEL_GAP as i32;
254 let center_y = self.rect.top_left.y
255 + self.rect.size.height as i32 / 2
256 + font.character_size.height as i32 / 3;
257 let color = if !self.is_enabled() {
258 theme.palette.neutral_2
259 } else if self.focused {
260 theme.accent.base
261 } else {
262 theme.background.on_base
263 };
264 renderer.draw_text(
265 label,
266 Point::new(text_x, center_y),
267 font,
268 color,
269 Alignment::Left,
270 )?;
271 }
272
273 Ok(())
274 }
275}