1use super::{Widget, scroll_core};
32use alloc::{boxed::Box, string::String, vec::Vec};
33use core::marker::PhantomData;
34use embedded_graphics::{
35 mono_font::MonoFont, pixelcolor::PixelColor, prelude::*, primitives::Rectangle,
36 text::Alignment as EgAlignment,
37};
38use zest_core::{
39 Constraints, GesturePhase, Length, RenderError, Renderer, ScrollDirection, ScrollMsg,
40 ScrollState, TouchPhase, UiAction, WidgetId,
41};
42use zest_theme::Theme;
43
44const DEFAULT_ITEM_HEIGHT: u32 = 36;
46const DEFAULT_VISIBLE: u32 = 5;
49
50pub struct Roller<'a, C: PixelColor, M: Clone> {
56 rect: Rectangle,
57 options: Vec<String>,
59 state: ScrollState,
61 item_height: u32,
63 visible: u32,
65 selected: usize,
68 id: Option<WidgetId>,
70 font: Option<&'a MonoFont<'a>>,
72 width: Length,
74 on_scroll: Option<Box<dyn Fn(ScrollMsg) -> M + 'a>>,
76 on_select: Option<Box<dyn Fn(usize) -> M + 'a>>,
78 on_action: Option<Box<dyn Fn(UiAction) -> M + 'a>>,
80 content_h: u32,
82 focused: bool,
83 _color: PhantomData<C>,
84}
85
86impl<'a, C: PixelColor + 'a, M: Clone + 'a> Roller<'a, C, M> {
87 pub fn new() -> Self {
91 Self {
92 rect: Rectangle::zero(),
93 options: Vec::new(),
94 state: ScrollState::new(),
95 item_height: DEFAULT_ITEM_HEIGHT,
96 visible: DEFAULT_VISIBLE,
97 selected: 0,
98 id: None,
99 font: None,
100 width: Length::Fill,
101 on_scroll: None,
102 on_select: None,
103 on_action: None,
104 content_h: 0,
105 focused: false,
106 _color: PhantomData,
107 }
108 }
109
110 #[must_use]
112 pub fn options(mut self, options: &[&str]) -> Self {
113 self.options = options.iter().map(|s| String::from(*s)).collect();
114 self
115 }
116
117 #[must_use]
119 pub fn option(mut self, label: impl Into<String>) -> Self {
120 self.options.push(label.into());
121 self
122 }
123
124 #[must_use]
126 pub fn scroll_state(mut self, state: &ScrollState) -> Self {
127 self.state = *state;
128 self
129 }
130
131 #[must_use]
134 pub fn selected(mut self, index: usize) -> Self {
135 self.selected = index;
136 self
137 }
138
139 #[must_use]
141 pub fn id(mut self, id: WidgetId) -> Self {
142 self.id = Some(id);
143 self
144 }
145
146 #[must_use]
148 pub fn item_height(mut self, height: u32) -> Self {
149 self.item_height = height.max(1);
150 self
151 }
152
153 #[must_use]
156 pub fn visible_count(mut self, count: u32) -> Self {
157 self.visible = count.max(1);
158 self
159 }
160
161 #[must_use]
163 pub fn font(mut self, font: &'a MonoFont<'a>) -> Self {
164 self.font = Some(font);
165 self
166 }
167
168 #[must_use]
170 pub fn width(mut self, width: impl Into<Length>) -> Self {
171 self.width = width.into();
172 self
173 }
174
175 #[must_use]
178 pub fn on_scroll<F>(mut self, f: F) -> Self
179 where
180 F: Fn(ScrollMsg) -> M + 'a,
181 {
182 self.on_scroll = Some(Box::new(f));
183 self
184 }
185
186 #[must_use]
189 pub fn on_select<F>(mut self, f: F) -> Self
190 where
191 F: Fn(usize) -> M + 'a,
192 {
193 self.on_select = Some(Box::new(f));
194 self
195 }
196
197 #[must_use]
199 pub fn on_action<F>(mut self, f: F) -> Self
200 where
201 F: Fn(UiAction) -> M + 'a,
202 {
203 self.on_action = Some(Box::new(f));
204 self
205 }
206
207 fn pad(&self) -> i32 {
210 (self.rect.size.height as i32 - self.item_height as i32).max(0) / 2
211 }
212
213 fn content_height(&self) -> u32 {
215 let rows = self.item_height.saturating_mul(self.options.len() as u32);
216 rows.saturating_add((self.pad() as u32).saturating_mul(2))
217 }
218
219 fn snap_lines(&self) -> Vec<i32> {
224 (0..self.options.len() as i32)
225 .map(|i| i * self.item_height as i32)
226 .collect()
227 }
228
229 fn centered_index(&self) -> usize {
232 if self.options.is_empty() {
233 return 0;
234 }
235 let off = scroll_core::render_offset(self.state, ScrollDirection::Vertical).y;
236 let item_h = self.item_height as i32;
237 let raw = (off + item_h / 2).div_euclid(item_h);
238 raw.clamp(0, self.options.len() as i32 - 1) as usize
239 }
240}
241
242impl<'a, C: PixelColor + 'a, M: Clone + 'a> Default for Roller<'a, C, M> {
243 fn default() -> Self {
244 Self::new()
245 }
246}
247
248impl<'a, C: PixelColor + 'a, M: Clone + 'a> Widget<C, M> for Roller<'a, C, M> {
249 fn measure(&mut self, constraints: Constraints) -> Size {
250 let w = self
251 .width
252 .resolve(constraints.max.width, constraints.max.width);
253 let h = self.item_height.saturating_mul(self.visible);
254 constraints.clamp(Size::new(w, h))
255 }
256
257 fn preferred_size(&self) -> (Length, Length) {
258 (
259 self.width,
260 Length::Fixed(self.item_height.saturating_mul(self.visible)),
261 )
262 }
263
264 fn arrange(&mut self, rect: Rectangle) {
265 self.rect = rect;
266 self.content_h = self.content_height();
267 }
268
269 fn rect(&self) -> Rectangle {
270 self.rect
271 }
272
273 fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
274 if self.options.is_empty() {
275 return None;
276 }
277 let viewport = self.rect;
278 let content = Size::new(self.rect.size.width, self.content_h);
279 let lines = self.snap_lines();
280 let on_scroll = self.on_scroll.as_deref();
281 let off = scroll_core::render_offset(self.state, ScrollDirection::Vertical).y;
288 let band_top = viewport.top_left.y + self.pad();
289 let item_h = self.item_height as i32;
290 let on_select = self.on_select.as_deref();
291 let count = self.options.len();
292 scroll_core::route_touch(
293 self.state,
294 ScrollDirection::Vertical,
295 viewport,
296 content,
297 point,
298 phase,
299 &lines,
300 on_scroll,
301 |p, ph| {
302 if ph != TouchPhase::Up {
303 return None;
304 }
305 let content_y = p.y - band_top + off;
307 if content_y < 0 {
308 return None;
309 }
310 let idx = (content_y / item_h) as usize;
311 if idx >= count {
312 return None;
313 }
314 on_select.map(|f| f(idx))
315 },
316 )
317 }
318
319 fn mark_pressed(&mut self, _point: Point) {}
320
321 fn widget_id(&self) -> Option<WidgetId> {
322 self.id
323 }
324
325 fn is_focusable(&self) -> bool {
326 self.id.is_some() && (!self.options.is_empty())
327 }
328
329 fn handle_action(&mut self, action: UiAction) -> Option<M> {
330 match action {
331 UiAction::Activate => self.on_select.as_ref().map(|cb| cb(self.centered_index())),
332 UiAction::Increment
333 | UiAction::Decrement
334 | UiAction::NavigateUp
335 | UiAction::NavigateDown => self.on_action.as_ref().map(|cb| cb(action)),
336 _ => None,
337 }
338 }
339
340 fn sync_focus(&mut self, focused: Option<WidgetId>) {
341 self.focused = self.id.is_some() && self.id == focused;
342 }
343
344 fn focus_at(&self, point: Point) -> Option<WidgetId> {
345 let top_left = self.rect.top_left;
346 let bottom_right =
347 top_left + Point::new(self.rect.size.width as i32, self.rect.size.height as i32);
348 if self.is_focusable()
349 && point.x >= top_left.x
350 && point.x < bottom_right.x
351 && point.y >= top_left.y
352 && point.y < bottom_right.y
353 {
354 self.id
355 } else {
356 None
357 }
358 }
359
360 fn draw<'t>(
361 &self,
362 renderer: &mut dyn Renderer<C>,
363 theme: &Theme<'t, C>,
364 ) -> Result<(), RenderError> {
365 let font = self.font.unwrap_or(theme.typography.body);
366 let viewport = self.rect;
367 let off = scroll_core::render_offset(self.state, ScrollDirection::Vertical).y;
368 let item_h = self.item_height as i32;
369 let glyph_h = font.character_size.height as i32;
370 let center_x = viewport.top_left.x + viewport.size.width as i32 / 2;
371 let band_top = viewport.top_left.y + self.pad();
372 let centered = self.centered_index();
373
374 renderer.push_clip(viewport);
375
376 let band = Rectangle::new(
379 Point::new(viewport.top_left.x, band_top),
380 Size::new(viewport.size.width, self.item_height),
381 );
382 renderer.fill_rect(band, theme.secondary.base)?;
383 renderer.stroke_line(
384 Point::new(viewport.top_left.x, band_top),
385 Point::new(viewport.top_left.x + viewport.size.width as i32, band_top),
386 theme.accent.base,
387 1,
388 )?;
389 let band_bot = band_top + item_h;
390 renderer.stroke_line(
391 Point::new(viewport.top_left.x, band_bot),
392 Point::new(viewport.top_left.x + viewport.size.width as i32, band_bot),
393 theme.accent.base,
394 1,
395 )?;
396
397 for (i, label) in self.options.iter().enumerate() {
399 let row_top = band_top + i as i32 * item_h - off;
400 if row_top + item_h <= viewport.top_left.y
402 || row_top >= viewport.top_left.y + viewport.size.height as i32
403 {
404 continue;
405 }
406 let baseline_y = row_top + item_h / 2 + glyph_h / 3;
407 let color = if i == centered {
408 theme.background.on_base
409 } else {
410 theme.palette.neutral_2
412 };
413 renderer.draw_text(
414 label,
415 Point::new(center_x, baseline_y),
416 font,
417 color,
418 EgAlignment::Center,
419 )?;
420 }
421
422 renderer.pop_clip();
423 if self.focused {
424 renderer.stroke_rect(viewport, theme.accent.base)?;
425 }
426 Ok(())
427 }
428}
429
430impl<'a, C: PixelColor + 'a, M: Clone + 'a> Roller<'a, C, M> {
431 #[must_use]
439 pub fn centered_for(state: &ScrollState, item_height: u32, count: usize) -> usize {
440 if count == 0 {
441 return 0;
442 }
443 let off = scroll_core::render_offset(*state, ScrollDirection::Vertical).y;
444 let item_h = item_height.max(1) as i32;
445 let raw = (off + item_h / 2).div_euclid(item_h);
446 raw.clamp(0, count as i32 - 1) as usize
447 }
448
449 #[must_use]
458 pub fn select_msg(&self, previous: usize) -> Option<M> {
459 let now = self.centered_index();
460 let settled = self.state.phase == GesturePhase::Idle;
461 if now != previous || settled {
462 self.on_select.as_ref().map(|f| f(now))
463 } else {
464 None
465 }
466 }
467}