1use super::Widget;
8use alloc::{boxed::Box, format, string::String};
9use core::marker::PhantomData;
10use embedded_graphics::{
11 pixelcolor::PixelColor, prelude::*, primitives::Rectangle, text::Alignment,
12};
13use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase, UiAction, WidgetId};
14use zest_theme::{ButtonCatalog, ButtonClass, Status, Theme};
15
16const H_BUTTON_W: u32 = 28;
17const V_BUTTON_H: u32 = 24;
18
19#[derive(Copy, Clone, Debug, PartialEq, Eq)]
21pub enum SpinOrientation {
22 Horizontal,
24 Vertical,
26}
27
28#[derive(Copy, Clone, Debug, PartialEq, Eq)]
29enum Side {
30 Plus,
31 Minus,
32}
33
34pub struct SpinButton<'a, C: PixelColor, M: Clone> {
36 rect: Rectangle,
37 id: Option<WidgetId>,
38 value: i32,
39 min: i32,
40 max: i32,
41 step: i32,
42 orientation: SpinOrientation,
43 display: Option<String>,
44 on_change: Option<Box<dyn Fn(i32) -> M + 'a>>,
45 width: Length,
46 height: Length,
47 focused: Option<Side>,
48 pressed: Option<Side>,
49 _color: PhantomData<C>,
50}
51
52impl<'a, C: PixelColor, M: Clone> SpinButton<'a, C, M> {
53 pub fn new(value: i32) -> Self {
56 Self {
57 rect: Rectangle::zero(),
58 id: None,
59 value,
60 min: 0,
61 max: 99,
62 step: 1,
63 orientation: SpinOrientation::Horizontal,
64 display: None,
65 on_change: None,
66 width: Length::Fill,
67 height: Length::Fill,
68 focused: None,
69 pressed: None,
70 _color: PhantomData,
71 }
72 }
73
74 #[must_use]
76 pub fn min(mut self, min: i32) -> Self {
77 self.min = min;
78 self
79 }
80
81 #[must_use]
83 pub fn max(mut self, max: i32) -> Self {
84 self.max = max;
85 self
86 }
87
88 #[must_use]
90 pub fn step(mut self, step: i32) -> Self {
91 self.step = step;
92 self
93 }
94
95 #[must_use]
97 pub fn orientation(mut self, o: SpinOrientation) -> Self {
98 self.orientation = o;
99 self
100 }
101
102 #[must_use]
104 pub fn display(mut self, s: impl Into<String>) -> Self {
105 self.display = Some(s.into());
106 self
107 }
108
109 #[must_use]
111 pub fn id(mut self, id: WidgetId) -> Self {
112 self.id = Some(id);
113 self
114 }
115
116 #[must_use]
118 pub fn on_change<F: Fn(i32) -> M + 'a>(mut self, f: F) -> Self {
119 self.on_change = Some(Box::new(f));
120 self
121 }
122
123 #[must_use]
125 pub fn width(mut self, w: impl Into<Length>) -> Self {
126 self.width = w.into();
127 self
128 }
129
130 #[must_use]
132 pub fn height(mut self, h: impl Into<Length>) -> Self {
133 self.height = h.into();
134 self
135 }
136
137 fn intrinsic(&self) -> Size {
138 match self.orientation {
139 SpinOrientation::Horizontal => Size::new(90, 24),
140 SpinOrientation::Vertical => Size::new(28, 80),
141 }
142 }
143
144 fn minus_rect(&self) -> Rectangle {
145 let r = self.rect;
146 match self.orientation {
147 SpinOrientation::Horizontal => {
148 Rectangle::new(r.top_left, Size::new(H_BUTTON_W, r.size.height))
149 }
150 SpinOrientation::Vertical => Rectangle::new(
151 r.top_left + Point::new(0, r.size.height.saturating_sub(V_BUTTON_H) as i32),
152 Size::new(r.size.width, V_BUTTON_H),
153 ),
154 }
155 }
156
157 fn plus_rect(&self) -> Rectangle {
158 let r = self.rect;
159 match self.orientation {
160 SpinOrientation::Horizontal => Rectangle::new(
161 r.top_left + Point::new(r.size.width.saturating_sub(H_BUTTON_W) as i32, 0),
162 Size::new(H_BUTTON_W, r.size.height),
163 ),
164 SpinOrientation::Vertical => {
165 Rectangle::new(r.top_left, Size::new(r.size.width, V_BUTTON_H))
166 }
167 }
168 }
169
170 fn value_rect(&self) -> Rectangle {
171 let r = self.rect;
172 match self.orientation {
173 SpinOrientation::Horizontal => {
174 let w = r.size.width.saturating_sub(H_BUTTON_W * 2);
175 Rectangle::new(
176 r.top_left + Point::new(H_BUTTON_W as i32, 0),
177 Size::new(w, r.size.height),
178 )
179 }
180 SpinOrientation::Vertical => {
181 let h = r.size.height.saturating_sub(V_BUTTON_H * 2);
182 Rectangle::new(
183 r.top_left + Point::new(0, V_BUTTON_H as i32),
184 Size::new(r.size.width, h),
185 )
186 }
187 }
188 }
189
190 fn hit_test(&self, point: Point) -> Option<Side> {
191 if rect_contains(self.minus_rect(), point) {
192 Some(Side::Minus)
193 } else if rect_contains(self.plus_rect(), point) {
194 Some(Side::Plus)
195 } else {
196 None
197 }
198 }
199
200 fn side_enabled(&self, side: Side) -> bool {
201 if self.on_change.is_none() {
202 return false;
203 }
204 match side {
205 Side::Minus => self.value > self.min,
206 Side::Plus => self.value < self.max,
207 }
208 }
209
210 fn side_status(&self, side: Side) -> Status {
211 if !self.side_enabled(side) {
212 Status::Disabled
213 } else if self.pressed == Some(side) {
214 Status::Pressed
215 } else if self.focused == Some(side) {
216 Status::Focused
217 } else {
218 Status::Active
219 }
220 }
221
222 fn apply(&self, side: Side) -> i32 {
223 let next = match side {
224 Side::Minus => self.value.saturating_sub(self.step),
225 Side::Plus => self.value.saturating_add(self.step),
226 };
227 next.clamp(self.min, self.max)
228 }
229
230 fn side_id(&self, side: Side) -> Option<WidgetId> {
231 self.id.map(|base| {
232 let offset = match side {
233 Side::Minus => 1,
234 Side::Plus => 2,
235 };
236 WidgetId::new(base.raw().wrapping_add(offset))
237 })
238 }
239
240 fn focused_side(&self, target: WidgetId) -> Option<Side> {
241 [Side::Minus, Side::Plus]
242 .into_iter()
243 .find(|side| self.side_id(*side) == Some(target))
244 }
245
246 fn ordered_sides(&self) -> [Side; 2] {
247 match self.orientation {
248 SpinOrientation::Horizontal => [Side::Minus, Side::Plus],
249 SpinOrientation::Vertical => [Side::Plus, Side::Minus],
250 }
251 }
252
253 fn emit_change(&self, side: Side) -> Option<M> {
254 if !self.side_enabled(side) {
255 return None;
256 }
257
258 self.on_change.as_ref().map(|cb| cb(self.apply(side)))
259 }
260}
261
262fn rect_contains(rect: Rectangle, p: Point) -> bool {
263 let tl = rect.top_left;
264 let br = tl + Point::new(rect.size.width as i32, rect.size.height as i32);
265 p.x >= tl.x && p.x < br.x && p.y >= tl.y && p.y < br.y
266}
267
268pub fn horizontal_spin_button<'a, C: PixelColor, M: Clone>(value: i32) -> SpinButton<'a, C, M> {
270 SpinButton::new(value).orientation(SpinOrientation::Horizontal)
271}
272
273pub fn vertical_spin_button<'a, C: PixelColor, M: Clone>(value: i32) -> SpinButton<'a, C, M> {
275 SpinButton::new(value).orientation(SpinOrientation::Vertical)
276}
277
278impl<'a, C: PixelColor, M: Clone> Widget<C, M> for SpinButton<'a, C, M> {
279 fn measure(&mut self, constraints: Constraints) -> Size {
280 let intrinsic = self.intrinsic();
281 let w = self.width.resolve(intrinsic.width, constraints.max.width);
282 let h = self
283 .height
284 .resolve(intrinsic.height, constraints.max.height);
285 constraints.clamp(Size::new(w, h))
286 }
287
288 fn preferred_size(&self) -> (Length, Length) {
289 (self.width, self.height)
290 }
291
292 fn arrange(&mut self, rect: Rectangle) {
293 self.rect = rect;
294 }
295
296 fn rect(&self) -> Rectangle {
297 self.rect
298 }
299
300 fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
301 if self.on_change.is_none() {
302 return None;
303 }
304 match phase {
305 TouchPhase::Down => {
306 let hit = self.hit_test(point);
307 self.pressed = hit.filter(|s| self.side_enabled(*s));
308 None
309 }
310 TouchPhase::Moved => {
311 if self.pressed.is_some() && self.hit_test(point) != self.pressed {
312 self.pressed = None;
313 }
314 None
315 }
316 TouchPhase::Up => {
317 let now = self.hit_test(point);
318 let was = self.pressed.take();
319 if let (Some(n), Some(w)) = (now, was) {
320 if n == w {
321 if let Some(cb) = self.on_change.as_ref() {
322 return Some(cb(self.apply(n)));
323 }
324 }
325 }
326 None
327 }
328 }
329 }
330
331 fn mark_pressed(&mut self, point: Point) {
332 if self.pressed.is_none() && self.on_change.is_some() {
333 if let Some(side) = self.hit_test(point) {
334 if self.side_enabled(side) {
335 self.pressed = Some(side);
336 }
337 }
338 }
339 }
340
341 fn collect_focusable(&self, out: &mut alloc::vec::Vec<WidgetId>) {
342 for side in self.ordered_sides() {
343 if self.side_enabled(side)
344 && let Some(id) = self.side_id(side)
345 {
346 out.push(id);
347 }
348 }
349 }
350
351 fn sync_focus(&mut self, focused: Option<WidgetId>) {
352 self.focused = focused.and_then(|target| self.focused_side(target));
353 }
354
355 fn route_action(&mut self, target: WidgetId, action: UiAction) -> Option<M> {
356 let focused_side = self.focused_side(target)?;
357 match action {
358 UiAction::Activate => self.emit_change(focused_side),
359 UiAction::Increment | UiAction::NavigateRight | UiAction::NavigateUp => {
360 self.emit_change(Side::Plus)
361 }
362 UiAction::Decrement | UiAction::NavigateLeft | UiAction::NavigateDown => {
363 self.emit_change(Side::Minus)
364 }
365 _ => None,
366 }
367 }
368
369 fn focus_rect(&self, target: WidgetId) -> Option<Rectangle> {
370 let side = self.focused_side(target)?;
371 Some(match side {
372 Side::Minus => self.minus_rect(),
373 Side::Plus => self.plus_rect(),
374 })
375 }
376
377 fn focus_at(&self, point: Point) -> Option<WidgetId> {
378 self.hit_test(point).and_then(|side| self.side_id(side))
379 }
380
381 fn draw<'t>(
382 &self,
383 renderer: &mut dyn Renderer<C>,
384 theme: &Theme<'t, C>,
385 ) -> Result<(), RenderError> {
386 let minus_rect = self.minus_rect();
387 let plus_rect = self.plus_rect();
388 let value_rect = self.value_rect();
389
390 let minus = theme.button(ButtonClass::Standard, self.side_status(Side::Minus));
391 let plus = theme.button(ButtonClass::Standard, self.side_status(Side::Plus));
392
393 if let Some(bg) = minus.background {
394 renderer.fill_rect(minus_rect, bg)?;
395 }
396 if let Some(border) = minus.border {
397 renderer.stroke_rect(minus_rect, border)?;
398 }
399 if let Some(bg) = plus.background {
400 renderer.fill_rect(plus_rect, bg)?;
401 }
402 if let Some(border) = plus.border {
403 renderer.stroke_rect(plus_rect, border)?;
404 }
405
406 renderer.fill_rect(value_rect, theme.background.base)?;
407
408 let body = theme.typography.body;
409 let glyph_y = |rect: Rectangle| {
410 rect.top_left.y
411 + (rect.size.height / 2) as i32
412 + (body.character_size.height / 3) as i32
413 };
414
415 renderer.draw_text(
416 "-",
417 Point::new(
418 minus_rect.top_left.x + (minus_rect.size.width / 2) as i32,
419 glyph_y(minus_rect),
420 ),
421 body,
422 minus.text,
423 Alignment::Center,
424 )?;
425 renderer.draw_text(
426 "+",
427 Point::new(
428 plus_rect.top_left.x + (plus_rect.size.width / 2) as i32,
429 glyph_y(plus_rect),
430 ),
431 body,
432 plus.text,
433 Alignment::Center,
434 )?;
435
436 let label_owned;
437 let label: &str = match self.display.as_deref() {
438 Some(s) => s,
439 None => {
440 label_owned = format!("{}", self.value);
441 &label_owned
442 }
443 };
444 renderer.draw_text(
445 label,
446 Point::new(
447 value_rect.top_left.x + (value_rect.size.width / 2) as i32,
448 glyph_y(value_rect),
449 ),
450 body,
451 theme.background.on_base,
452 Alignment::Center,
453 )?;
454
455 Ok(())
456 }
457}