1use super::{Widget, column::Column};
31use alloc::{boxed::Box, rc::Rc, vec::Vec};
32use core::marker::PhantomData;
33use embedded_graphics::{
34 pixelcolor::PixelColor, prelude::*, primitives::Rectangle, text::Alignment,
35};
36use zest_core::{
37 Constraints, Length, RenderError, Renderer, ScrollDirection, ScrollMsg, ScrollState,
38 ScrollbarMode, SnapMode, TouchPhase, UiAction, WidgetId,
39};
40use zest_theme::Theme;
41
42pub const TABLE_ROW_HEIGHT: u32 = 32;
44pub const CELL_PADDING_X: u32 = 6;
46
47pub struct TableRow<'a, C: PixelColor, M: Clone> {
54 rect: Rectangle,
55 row: usize,
57 base_id: Option<WidgetId>,
59 cells: &'a [&'a str],
61 columns: usize,
63 on_select: Option<Rc<dyn Fn(usize, usize) -> M + 'a>>,
65 alternate: bool,
67 selected_col: Option<usize>,
69 focused_col: Option<usize>,
71 pressed_col: Option<usize>,
73 width: Length,
74 height: Length,
75 _color: PhantomData<C>,
76}
77
78impl<'a, C: PixelColor, M: Clone> TableRow<'a, C, M> {
79 fn new(row: usize, cells: &'a [&'a str], columns: usize) -> Self {
80 Self {
81 rect: Rectangle::zero(),
82 row,
83 base_id: None,
84 cells,
85 columns,
86 on_select: None,
87 alternate: false,
88 selected_col: None,
89 focused_col: None,
90 pressed_col: None,
91 width: Length::Fill,
92 height: Length::Fixed(TABLE_ROW_HEIGHT),
93 _color: PhantomData,
94 }
95 }
96
97 fn is_enabled(&self) -> bool {
98 self.on_select.is_some()
99 }
100
101 fn col_width(&self) -> u32 {
103 let cols = self.columns.max(1) as u32;
104 self.rect.size.width / cols
105 }
106
107 fn col_at(&self, point: Point) -> Option<usize> {
109 let tl = self.rect.top_left;
110 let br = tl + Point::new(self.rect.size.width as i32, self.rect.size.height as i32);
111 if point.x < tl.x || point.x >= br.x || point.y < tl.y || point.y >= br.y {
112 return None;
113 }
114 let col_w = self.col_width().max(1) as i32;
115 let col = ((point.x - tl.x) / col_w) as usize;
116 Some(col.min(self.columns.saturating_sub(1)))
117 }
118
119 fn cell_id(&self, col: usize) -> Option<WidgetId> {
120 self.base_id
121 .map(|base| WidgetId::new(base.raw().wrapping_add(col as u64)))
122 }
123}
124
125impl<'a, C: PixelColor, M: Clone> Widget<C, M> for TableRow<'a, C, M> {
126 fn measure(&mut self, constraints: Constraints) -> Size {
127 let w = self
128 .width
129 .resolve(constraints.max.width, constraints.max.width);
130 let h = self
131 .height
132 .resolve(TABLE_ROW_HEIGHT, constraints.max.height);
133 constraints.clamp(Size::new(w, h))
134 }
135
136 fn preferred_size(&self) -> (Length, Length) {
137 (self.width, self.height)
138 }
139
140 fn arrange(&mut self, rect: Rectangle) {
141 self.rect = rect;
142 }
143
144 fn rect(&self) -> Rectangle {
145 self.rect
146 }
147
148 fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
149 if !self.is_enabled() {
150 return None;
151 }
152 match phase {
153 TouchPhase::Down => {
154 self.pressed_col = self.col_at(point);
155 None
156 }
157 TouchPhase::Up => {
158 let hit = self.col_at(point);
159 let fired = match (self.pressed_col, hit) {
160 (Some(p), Some(h)) if p == h => {
161 self.on_select.as_ref().map(|cb| cb(self.row, h))
162 }
163 _ => None,
164 };
165 self.pressed_col = None;
166 fired
167 }
168 TouchPhase::Moved => {
169 if self.col_at(point) != self.pressed_col {
170 self.pressed_col = None;
171 }
172 None
173 }
174 }
175 }
176
177 fn mark_pressed(&mut self, point: Point) {
178 if self.is_enabled() {
179 self.pressed_col = self.col_at(point);
180 }
181 }
182
183 fn collect_focusable(&self, out: &mut Vec<WidgetId>) {
184 if !self.is_enabled() {
185 return;
186 }
187 for col in 0..self.columns {
188 if let Some(id) = self.cell_id(col) {
189 out.push(id);
190 }
191 }
192 }
193
194 fn sync_focus(&mut self, focused: Option<WidgetId>) {
195 self.focused_col = focused
196 .and_then(|target| (0..self.columns).find(|col| self.cell_id(*col) == Some(target)));
197 }
198
199 fn route_action(&mut self, target: WidgetId, action: UiAction) -> Option<M> {
200 let col = (0..self.columns).find(|candidate| self.cell_id(*candidate) == Some(target))?;
201 match action {
202 UiAction::Activate => self.on_select.as_ref().map(|cb| cb(self.row, col)),
203 _ => None,
204 }
205 }
206
207 fn focus_rect(&self, target: WidgetId) -> Option<Rectangle> {
208 let col = (0..self.columns).find(|candidate| self.cell_id(*candidate) == Some(target))?;
209 let col_w = self.col_width();
210 Some(Rectangle::new(
211 Point::new(
212 self.rect.top_left.x + (col_w * col as u32) as i32,
213 self.rect.top_left.y,
214 ),
215 Size::new(col_w, self.rect.size.height),
216 ))
217 }
218
219 fn focus_at(&self, point: Point) -> Option<WidgetId> {
220 self.col_at(point).and_then(|col| self.cell_id(col))
221 }
222
223 fn draw<'t>(
224 &self,
225 renderer: &mut dyn Renderer<C>,
226 theme: &Theme<'t, C>,
227 ) -> Result<(), RenderError> {
228 let font = theme.default_font();
229 let bg = if self.alternate {
231 theme.secondary.base
232 } else {
233 theme.primary.base
234 };
235 renderer.fill_rect(self.rect, bg)?;
236
237 let col_w = self.col_width();
238 let glyph_h = font.character_size.height as i32;
239 let baseline_y = self.rect.top_left.y + self.rect.size.height as i32 / 2 + glyph_h / 3;
240
241 for col in 0..self.columns {
242 let cell_x = self.rect.top_left.x + (col_w * col as u32) as i32;
243 let cell_rect = Rectangle::new(
244 Point::new(cell_x, self.rect.top_left.y),
245 Size::new(col_w, self.rect.size.height),
246 );
247
248 let highlighted = self.pressed_col == Some(col) || self.selected_col == Some(col);
250 let text_color = if highlighted {
251 renderer.fill_rect(cell_rect, theme.accent.pressed)?;
252 theme.accent.on_base
253 } else {
254 theme.primary.on_base
255 };
256 if self.focused_col == Some(col) {
257 renderer.stroke_rect(cell_rect, theme.accent.base)?;
258 }
259
260 if let Some(text) = self.cells.get(col) {
261 renderer.draw_text(
262 text,
263 Point::new(cell_x + CELL_PADDING_X as i32, baseline_y),
264 font,
265 text_color,
266 Alignment::Left,
267 )?;
268 }
269
270 if col > 0 {
272 let line = Rectangle::new(
273 Point::new(cell_x, self.rect.top_left.y),
274 Size::new(1, self.rect.size.height),
275 );
276 renderer.fill_rect(line, theme.primary.divider)?;
277 }
278 }
279
280 let y = self.rect.top_left.y + self.rect.size.height as i32 - 1;
282 let divider = Rectangle::new(
283 Point::new(self.rect.top_left.x, y),
284 Size::new(self.rect.size.width, 1),
285 );
286 renderer.fill_rect(divider, theme.primary.divider)?;
287
288 Ok(())
289 }
290}
291
292pub struct Table<'a, C: PixelColor, M: Clone> {
299 id: Option<WidgetId>,
301 header: Option<&'a [&'a str]>,
303 body: Vec<&'a [&'a str]>,
305 columns: usize,
307 on_select: Option<Rc<dyn Fn(usize, usize) -> M + 'a>>,
309 selected: Option<(usize, usize)>,
311 striped: bool,
313 width: Length,
314 height: Length,
315 scroll_dir: Option<ScrollDirection>,
317 scroll_state: Option<ScrollState>,
318 scrollbar: Option<ScrollbarMode>,
319 snap: Option<SnapMode>,
320 on_scroll: Option<Box<dyn Fn(ScrollMsg) -> M + 'a>>,
321 rect: Rectangle,
323 inner: Option<Column<'a, C, M>>,
325 header_rect: Rectangle,
327}
328
329impl<'a, C: PixelColor + 'a, M: Clone + 'a> Table<'a, C, M> {
330 pub fn new() -> Self {
333 Self {
334 header: None,
335 body: Vec::new(),
336 columns: 0,
337 id: None,
338 on_select: None,
339 selected: None,
340 striped: true,
341 width: Length::Fill,
342 height: Length::Fill,
343 scroll_dir: None,
344 scroll_state: None,
345 scrollbar: None,
346 snap: None,
347 on_scroll: None,
348 rect: Rectangle::zero(),
349 inner: None,
350 header_rect: Rectangle::zero(),
351 }
352 }
353
354 #[must_use]
356 pub fn width(mut self, width: impl Into<Length>) -> Self {
357 self.width = width.into();
358 self
359 }
360
361 #[must_use]
363 pub fn height(mut self, height: impl Into<Length>) -> Self {
364 self.height = height.into();
365 self
366 }
367
368 #[must_use]
370 pub fn id(mut self, id: WidgetId) -> Self {
371 self.id = Some(id);
372 self
373 }
374
375 #[must_use]
377 pub fn header(mut self, cells: &'a [&'a str]) -> Self {
378 self.columns = self.columns.max(cells.len());
379 self.header = Some(cells);
380 self
381 }
382
383 #[must_use]
385 pub fn rows(mut self, rows: &'a [&'a [&'a str]]) -> Self {
386 self.body.clear();
387 for r in rows {
388 self.columns = self.columns.max(r.len());
389 self.body.push(r);
390 }
391 self
392 }
393
394 #[must_use]
396 pub fn row(mut self, cells: &'a [&'a str]) -> Self {
397 self.columns = self.columns.max(cells.len());
398 self.body.push(cells);
399 self
400 }
401
402 #[must_use]
405 pub fn columns(mut self, columns: usize) -> Self {
406 self.columns = columns;
407 self
408 }
409
410 #[must_use]
412 pub fn striped(mut self, on: bool) -> Self {
413 self.striped = on;
414 self
415 }
416
417 #[must_use]
420 pub fn selected(mut self, row: usize, col: usize) -> Self {
421 self.selected = Some((row, col));
422 self
423 }
424
425 #[must_use]
428 pub fn on_select<F>(mut self, f: F) -> Self
429 where
430 F: Fn(usize, usize) -> M + 'a,
431 {
432 self.on_select = Some(Rc::new(f));
433 self
434 }
435
436 #[must_use]
439 pub fn scrollable(mut self, dir: ScrollDirection) -> Self {
440 self.scroll_dir = Some(dir);
441 self
442 }
443
444 #[must_use]
447 pub fn scroll_state(mut self, state: &ScrollState) -> Self {
448 self.scroll_state = Some(*state);
449 if self.scroll_dir.is_none() {
450 self.scroll_dir = Some(ScrollDirection::Vertical);
451 }
452 self
453 }
454
455 #[must_use]
457 pub fn scrollbar(mut self, mode: ScrollbarMode) -> Self {
458 self.scrollbar = Some(mode);
459 if self.scroll_dir.is_none() {
460 self.scroll_dir = Some(ScrollDirection::Vertical);
461 }
462 self
463 }
464
465 #[must_use]
467 pub fn snap(mut self, mode: SnapMode) -> Self {
468 self.snap = Some(mode);
469 if self.scroll_dir.is_none() {
470 self.scroll_dir = Some(ScrollDirection::Vertical);
471 }
472 self
473 }
474
475 #[must_use]
477 pub fn on_scroll<F>(mut self, f: F) -> Self
478 where
479 F: Fn(ScrollMsg) -> M + 'a,
480 {
481 self.on_scroll = Some(Box::new(f));
482 if self.scroll_dir.is_none() {
483 self.scroll_dir = Some(ScrollDirection::Vertical);
484 }
485 self
486 }
487
488 fn build_body(&mut self) -> Column<'a, C, M> {
491 let mut col = Column::new()
492 .width(self.width)
493 .height(Length::Fill)
494 .spacing(0);
495
496 if let Some(dir) = self.scroll_dir {
497 col = col.scrollable(dir);
498 if let Some(state) = self.scroll_state.as_ref() {
499 col = col.scroll_state(state);
500 }
501 if let Some(bar) = self.scrollbar {
502 col = col.scrollbar(bar);
503 }
504 if let Some(snap) = self.snap {
505 col = col.snap(snap);
506 }
507 if let Some(on_scroll) = self.on_scroll.take() {
508 col = col.on_scroll(move |sm| on_scroll(sm));
509 }
510 }
511
512 let columns = self.columns.max(1);
513 let striped = self.striped;
514 let selected = self.selected;
515 let on_select = self.on_select.clone();
516 for (i, cells) in self.body.iter().copied().enumerate() {
517 let mut row = TableRow::new(i, cells, columns);
518 row.base_id = self.row_base_id(i);
519 row.alternate = striped && (i % 2 == 1);
520 row.selected_col = match selected {
521 Some((r, c)) if r == i => Some(c),
522 _ => None,
523 };
524 row.on_select = on_select.clone();
525 col = col.push(row);
526 }
527 col
528 }
529
530 fn row_base_id(&self, row: usize) -> Option<WidgetId> {
531 let columns = self.columns.max(1) as u64;
532 self.id.map(|base| {
533 WidgetId::new(
534 base.raw()
535 .wrapping_add(1)
536 .wrapping_add(row as u64 * columns),
537 )
538 })
539 }
540
541 fn cell_id(&self, row: usize, col: usize) -> Option<WidgetId> {
542 self.row_base_id(row)
543 .map(|base| WidgetId::new(base.raw().wrapping_add(col as u64)))
544 }
545
546 fn coords_for(&self, target: WidgetId) -> Option<(usize, usize)> {
547 let columns = self.columns.max(1);
548 for row in 0..self.body.len() {
549 for col in 0..columns {
550 if self.cell_id(row, col) == Some(target) {
551 return Some((row, col));
552 }
553 }
554 }
555 None
556 }
557}
558
559impl<'a, C: PixelColor + 'a, M: Clone + 'a> Default for Table<'a, C, M> {
560 fn default() -> Self {
561 Self::new()
562 }
563}
564
565impl<'a, C: PixelColor + 'a, M: Clone + 'a> Widget<C, M> for Table<'a, C, M> {
566 fn measure(&mut self, constraints: Constraints) -> Size {
567 let w = self
568 .width
569 .resolve(constraints.max.width, constraints.max.width);
570 let h = self
571 .height
572 .resolve(constraints.max.height, constraints.max.height);
573 constraints.clamp(Size::new(w, h))
574 }
575
576 fn preferred_size(&self) -> (Length, Length) {
577 (self.width, self.height)
578 }
579
580 fn arrange(&mut self, rect: Rectangle) {
581 self.rect = rect;
582 let header_h = if self.header.is_some() {
584 TABLE_ROW_HEIGHT.min(rect.size.height)
585 } else {
586 0
587 };
588 self.header_rect = Rectangle::new(rect.top_left, Size::new(rect.size.width, header_h));
589 let body_rect = Rectangle::new(
590 Point::new(rect.top_left.x, rect.top_left.y + header_h as i32),
591 Size::new(rect.size.width, rect.size.height.saturating_sub(header_h)),
592 );
593
594 let mut body = self.build_body();
595 body.arrange(body_rect);
596 self.inner = Some(body);
597 }
598
599 fn rect(&self) -> Rectangle {
600 self.rect
601 }
602
603 fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
604 self.inner
605 .as_mut()
606 .and_then(|body| body.handle_touch(point, phase))
607 }
608
609 fn mark_pressed(&mut self, point: Point) {
610 if let Some(body) = self.inner.as_mut() {
611 body.mark_pressed(point);
612 }
613 }
614
615 fn collect_focusable(&self, out: &mut Vec<WidgetId>) {
616 if let Some(body) = self.inner.as_ref() {
617 body.collect_focusable(out);
618 }
619 }
620
621 fn sync_focus(&mut self, focused: Option<WidgetId>) {
622 if let Some(body) = self.inner.as_mut() {
623 body.sync_focus(focused);
624 }
625 }
626
627 fn route_action(&mut self, target: WidgetId, action: UiAction) -> Option<M> {
628 self.inner
629 .as_mut()
630 .and_then(|body| body.route_action(target, action))
631 }
632
633 fn navigate_focus(&self, target: WidgetId, action: UiAction) -> Option<WidgetId> {
634 let (row, col) = self.coords_for(target)?;
635 let columns = self.columns.max(1);
636 let (next_row, next_col) = match action {
637 UiAction::NavigateLeft => (row, col.saturating_sub(1)),
638 UiAction::NavigateRight => (row, (col + 1).min(columns.saturating_sub(1))),
639 UiAction::NavigateUp => (row.saturating_sub(1), col),
640 UiAction::NavigateDown => ((row + 1).min(self.body.len().saturating_sub(1)), col),
641 _ => return None,
642 };
643 self.cell_id(next_row, next_col)
644 }
645
646 fn focus_rect(&self, target: WidgetId) -> Option<Rectangle> {
647 self.inner.as_ref().and_then(|body| body.focus_rect(target))
648 }
649
650 fn focus_at(&self, point: Point) -> Option<WidgetId> {
651 self.inner.as_ref().and_then(|body| body.focus_at(point))
652 }
653
654 fn draw<'t>(
655 &self,
656 renderer: &mut dyn Renderer<C>,
657 theme: &Theme<'t, C>,
658 ) -> Result<(), RenderError> {
659 if let Some(body) = self.inner.as_ref() {
661 body.draw(renderer, theme)?;
662 }
663
664 if let Some(cells) = self.header {
666 let r = self.header_rect;
667 renderer.fill_rect(r, theme.accent.base)?;
668 let font = theme.default_font();
669 let cols = self.columns.max(1) as u32;
670 let col_w = r.size.width / cols;
671 let glyph_h = font.character_size.height as i32;
672 let baseline_y = r.top_left.y + r.size.height as i32 / 2 + glyph_h / 3;
673 for (col, text) in cells.iter().enumerate() {
674 let cell_x = r.top_left.x + (col_w * col as u32) as i32;
675 renderer.draw_text(
676 text,
677 Point::new(cell_x + CELL_PADDING_X as i32, baseline_y),
678 font,
679 theme.accent.on_base,
680 Alignment::Left,
681 )?;
682 if col > 0 {
683 let line = Rectangle::new(
684 Point::new(cell_x, r.top_left.y),
685 Size::new(1, r.size.height),
686 );
687 renderer.fill_rect(line, theme.accent.border)?;
688 }
689 }
690 let y = r.top_left.y + r.size.height as i32 - 1;
692 let edge = Rectangle::new(Point::new(r.top_left.x, y), Size::new(r.size.width, 1));
693 renderer.fill_rect(edge, theme.accent.border)?;
694 }
695
696 Ok(())
697 }
698}