1use core::fmt::Write;
2
3use embedded_graphics_core::pixelcolor::{Rgb565, RgbColor};
4use heapless::String;
5
6#[cfg(not(feature = "std"))]
7use crate::math::F32Ext as _;
8use crate::{
9 block::Block,
10 geometry::{EdgeInsets, Rect},
11 image::{ImageFit, ImageRef, ReelPlayer},
12 render::{RenderCtx, StrokeStyle, TextAlign, TextStyle, TextWrap, VerticalAlign},
13 style::{Border, Style, VisualState, WidgetStyle},
14 widget::{FocusGroupId, StyleClassId, WidgetFlags, WidgetId},
15};
16
17pub const TEXTAREA_CAPACITY: usize = 128;
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum SurfaceState {
21 Ready,
22 Loading,
23 Empty,
24 Error,
25 Offline,
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum NotificationLevel {
30 Info,
31 Success,
32 Warning,
33 Error,
34}
35
36#[derive(Clone, Copy, Debug, Default, PartialEq)]
37pub enum WidgetKind<'a> {
38 Panel,
39 Label(&'a str),
40 Button(&'a str),
41 ProgressBar {
42 value: f32,
43 },
44 Toggle {
45 label: &'a str,
46 on: bool,
47 },
48 Checkbox {
49 label: &'a str,
50 checked: bool,
51 },
52 Slider {
53 value: f32,
54 min: f32,
55 max: f32,
56 },
57 ValueLabel {
58 label: &'a str,
59 value: i32,
60 },
61 IconButton {
62 icon: char,
63 label: &'a str,
64 },
65 List {
66 items: &'a [&'a str],
67 selected: usize,
68 offset: usize,
69 visible_rows: usize,
70 },
71 ScrollView {
72 offset_y: i32,
73 content_h: u32,
74 },
75 Tabs {
76 labels: &'a [&'a str],
77 selected: usize,
78 },
79 Dialog {
80 title: &'a str,
81 body: &'a str,
82 },
83 Toast {
84 text: &'a str,
85 ttl_ms: u32,
86 },
87 Meter {
88 value: f32,
89 min: f32,
90 max: f32,
91 },
92 ArcGauge {
93 value: f32,
94 min: f32,
95 max: f32,
96 start_deg: i32,
97 end_deg: i32,
98 thickness: u8,
99 antialias: bool,
100 major_ticks: u8,
101 minor_ticks: u8,
102 show_value: bool,
103 },
104 Gauge {
105 value: f32,
106 min: f32,
107 max: f32,
108 major_ticks: u8,
109 minor_ticks: u8,
110 show_value: bool,
111 },
112 GaugeNeedle {
113 value: f32,
114 min: f32,
115 max: f32,
116 start_deg: i32,
117 end_deg: i32,
118 },
119 Chart {
120 values: &'a [f32],
121 min: f32,
122 max: f32,
123 thickness: u8,
124 fill_under: bool,
125 markers: bool,
126 mode: ChartMode,
127 show_grid: bool,
128 show_axes: bool,
129 show_labels: bool,
130 },
131 Spinner {
132 phase: f32,
133 },
134 Dropdown {
135 items: &'a [&'a str],
136 selected: usize,
137 open: bool,
138 },
139 Roller {
140 items: &'a [&'a str],
141 selected: usize,
142 },
143 Table {
144 rows: &'a [&'a [&'a str]],
145 separators: bool,
146 cell_padding: u8,
147 align: TextAlign,
148 },
149 TextArea {
150 text_buf: [u8; TEXTAREA_CAPACITY],
151 text_len: u8,
152 cursor: usize,
153 placeholder: &'a str,
154 selection: Option<(usize, usize)>,
155 cursor_visible: bool,
156 read_only: bool,
157 single_line: bool,
158 accept_newline: bool,
159 },
160 Keyboard {
161 keys: &'a [char],
162 selected: usize,
163 cols: u8,
164 alt_keys: Option<&'a [char]>,
165 layout: KeyboardLayout,
166 target: Option<WidgetId>,
167 },
168 Image {
169 image: ImageRef<'a>,
170 fit: ImageFit,
171 },
172 Border,
173 #[default]
174 Spacer,
175 Menu {
176 items: &'a [&'a str],
177 selected: usize,
178 },
179 PeekReveal {
180 icon: ImageRef<'a>,
181 title: &'a str,
182 subtitle: &'a str,
183 progress: f32,
184 },
185 GlanceTile {
186 icon: char,
187 title: &'a str,
188 subtitle: &'a str,
189 highlighted: bool,
190 },
191 CardDeck {
192 titles: &'a [&'a str],
193 selected: usize,
194 },
195 Reel {
196 player: ReelPlayer<'a>,
197 fit: ImageFit,
198 },
199 StateSurface {
200 state: SurfaceState,
201 title: &'a str,
202 message: &'a str,
203 action: Option<&'a str>,
204 busy_phase: f32,
205 },
206 HeadsUpBanner {
207 level: NotificationLevel,
208 text: &'a str,
209 ttl_ms: u32,
210 },
211 NotificationActionSheet {
212 level: NotificationLevel,
213 title: &'a str,
214 body: &'a str,
215 actions: &'a [&'a str],
216 selected: usize,
217 open: bool,
218 },
219 FeedTimeline {
220 items: &'a [&'a str],
221 selected: usize,
222 offset: usize,
223 visible_rows: usize,
224 expanded: bool,
225 },
226}
227
228#[derive(Clone, Copy, Debug, PartialEq, Eq)]
229pub enum ChartMode {
230 Line,
231 Bars,
232}
233
234#[derive(Clone, Copy, Debug, PartialEq, Eq)]
235pub enum KeyboardLayout {
236 Normal,
237 Shift,
238 Symbols,
239}
240
241impl WidgetKind<'_> {
242 pub const fn focusable(self) -> bool {
243 matches!(
244 self,
245 Self::Button(_)
246 | Self::Toggle { .. }
247 | Self::Checkbox { .. }
248 | Self::Slider { .. }
249 | Self::IconButton { .. }
250 | Self::List { .. }
251 | Self::ScrollView { .. }
252 | Self::Tabs { .. }
253 | Self::Dropdown { .. }
254 | Self::Roller { .. }
255 | Self::TextArea { .. }
256 | Self::Keyboard { .. }
257 | Self::Menu { .. }
258 | Self::FeedTimeline { .. }
259 )
260 }
261}
262
263#[derive(Clone, Copy, Debug, PartialEq)]
264pub struct WidgetNode<'a> {
265 pub id: WidgetId,
266 pub parent: Option<WidgetId>,
267 pub style_class: Option<StyleClassId>,
268 pub focus_group: FocusGroupId,
269 pub rect: Rect,
270 pub style: WidgetStyle,
271 pub kind: WidgetKind<'a>,
272 pub flags: WidgetFlags,
273}
274
275impl<'a> WidgetNode<'a> {
276 pub fn new<S>(id: WidgetId, rect: Rect, kind: WidgetKind<'a>, style: S) -> Self
277 where
278 S: Into<WidgetStyle>,
279 {
280 Self {
281 id,
282 parent: None,
283 style_class: None,
284 focus_group: FocusGroupId::ROOT,
285 rect,
286 style: style.into(),
287 kind,
288 flags: default_flags(kind),
289 }
290 }
291
292 pub const fn hidden(&self) -> bool {
293 self.flags.contains(WidgetFlags::HIDDEN)
294 }
295
296 pub const fn disabled(&self) -> bool {
297 self.flags.contains(WidgetFlags::DISABLED)
298 }
299
300 pub const fn clickable(&self) -> bool {
301 self.flags.contains(WidgetFlags::CLICKABLE)
302 }
303
304 pub const fn scrollable(&self) -> bool {
305 self.flags.contains(WidgetFlags::SCROLLABLE)
306 }
307
308 pub const fn clips_children(&self) -> bool {
309 self.flags.contains(WidgetFlags::CLIP_CHILDREN)
310 }
311
312 pub const fn focusable(&self) -> bool {
313 !self.hidden() && !self.disabled() && self.flags.contains(WidgetFlags::FOCUSABLE)
314 }
315
316 pub fn render<D>(&self, ctx: &mut RenderCtx<'_, D>, state: VisualState) -> Result<(), D::Error>
317 where
318 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
319 {
320 self.render_at(ctx, self.rect, state)
321 }
322
323 pub fn render_at<D>(
324 &self,
325 ctx: &mut RenderCtx<'_, D>,
326 rect: Rect,
327 state: VisualState,
328 ) -> Result<(), D::Error>
329 where
330 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
331 {
332 if self.hidden() {
333 return Ok(());
334 }
335
336 match self.kind {
337 WidgetKind::Panel => render_panel(ctx, rect, self.style, state),
338 WidgetKind::Label(text) => render_label(ctx, rect, text, self.style),
339 WidgetKind::Button(text) => render_button(ctx, rect, text, self.style, state),
340 WidgetKind::ProgressBar { value } => {
341 render_progress(ctx, rect, value, self.style, state)
342 }
343 WidgetKind::Toggle { label, on } => {
344 render_toggle(ctx, rect, label, on, self.style, state)
345 }
346 WidgetKind::Checkbox { label, checked } => {
347 render_checkbox(ctx, rect, label, checked, self.style, state)
348 }
349 WidgetKind::Slider { value, min, max } => {
350 render_slider(ctx, rect, value, min, max, self.style, state)
351 }
352 WidgetKind::ValueLabel { label, value } => {
353 render_value_label(ctx, rect, label, value, self.style, state)
354 }
355 WidgetKind::IconButton { icon, label } => {
356 render_icon_button(ctx, rect, icon, label, self.style, state)
357 }
358 WidgetKind::List {
359 items,
360 selected,
361 offset,
362 visible_rows,
363 } => render_list(
364 ctx,
365 rect,
366 items,
367 selected,
368 offset,
369 visible_rows,
370 self.style,
371 state,
372 ),
373 WidgetKind::ScrollView {
374 offset_y,
375 content_h,
376 } => render_scroll_view(ctx, rect, offset_y, content_h, self.style, state),
377 WidgetKind::Tabs { labels, selected } => {
378 render_tabs(ctx, rect, labels, selected, self.style, state)
379 }
380 WidgetKind::Dialog { title, body } => {
381 render_dialog(ctx, rect, title, body, self.style, state)
382 }
383 WidgetKind::Toast { text, ttl_ms } => {
384 render_toast(ctx, rect, text, ttl_ms, self.style, state)
385 }
386 WidgetKind::Meter { value, min, max } => {
387 render_meter(ctx, rect, value, min, max, self.style, state)
388 }
389 WidgetKind::ArcGauge {
390 value,
391 min,
392 max,
393 start_deg,
394 end_deg,
395 thickness,
396 antialias,
397 major_ticks,
398 minor_ticks,
399 show_value,
400 } => render_arc_gauge(
401 ctx,
402 rect,
403 value,
404 min,
405 max,
406 start_deg,
407 end_deg,
408 thickness,
409 antialias,
410 major_ticks,
411 minor_ticks,
412 show_value,
413 self.style,
414 state,
415 ),
416 WidgetKind::Gauge {
417 value,
418 min,
419 max,
420 major_ticks,
421 minor_ticks,
422 show_value,
423 } => render_gauge(
424 ctx,
425 rect,
426 value,
427 min,
428 max,
429 major_ticks,
430 minor_ticks,
431 show_value,
432 self.style,
433 state,
434 ),
435 WidgetKind::GaugeNeedle {
436 value,
437 min,
438 max,
439 start_deg,
440 end_deg,
441 } => render_gauge_needle(
442 ctx, rect, value, min, max, start_deg, end_deg, self.style, state,
443 ),
444 WidgetKind::Chart {
445 values,
446 min,
447 max,
448 thickness,
449 fill_under,
450 markers,
451 mode,
452 show_grid,
453 show_axes,
454 show_labels,
455 } => render_chart(
456 ctx,
457 rect,
458 values,
459 min,
460 max,
461 thickness,
462 fill_under,
463 markers,
464 mode,
465 show_grid,
466 show_axes,
467 show_labels,
468 self.style,
469 state,
470 ),
471 WidgetKind::Spinner { phase } => render_spinner(ctx, rect, phase, self.style, state),
472 WidgetKind::Dropdown {
473 items,
474 selected,
475 open,
476 } => render_dropdown(ctx, rect, items, selected, open, self.style, state),
477 WidgetKind::Roller { items, selected } => {
478 render_roller(ctx, rect, items, selected, self.style, state)
479 }
480 WidgetKind::Table {
481 rows,
482 separators,
483 cell_padding,
484 align,
485 } => render_table(
486 ctx,
487 rect,
488 rows,
489 separators,
490 cell_padding,
491 align,
492 self.style,
493 state,
494 ),
495 WidgetKind::TextArea {
496 text_buf,
497 text_len,
498 cursor,
499 placeholder,
500 selection,
501 cursor_visible,
502 ..
503 } => render_textarea(
504 ctx,
505 rect,
506 textarea_text(&text_buf, text_len),
507 cursor,
508 placeholder,
509 selection,
510 cursor_visible,
511 self.style,
512 state,
513 ),
514 WidgetKind::Keyboard {
515 keys,
516 selected,
517 cols,
518 alt_keys,
519 layout,
520 ..
521 } => render_keyboard(
522 ctx, rect, keys, selected, cols, alt_keys, layout, self.style, state,
523 ),
524 WidgetKind::Image { image, fit } => {
525 render_image(ctx, rect, image, fit, self.style, state)
526 }
527 WidgetKind::Border => ctx.stroke_rect(rect, self.style.resolve(state).border),
528 WidgetKind::Spacer => Ok(()),
529 WidgetKind::Menu { items, selected } => {
530 render_menu(ctx, rect, items, selected, self.style, state)
531 }
532 WidgetKind::PeekReveal {
533 icon,
534 title,
535 subtitle,
536 progress,
537 } => render_peek_reveal(
538 ctx, rect, icon, title, subtitle, progress, self.style, state,
539 ),
540 WidgetKind::GlanceTile {
541 icon,
542 title,
543 subtitle,
544 highlighted,
545 } => render_glance_tile(
546 ctx,
547 rect,
548 icon,
549 title,
550 subtitle,
551 highlighted,
552 self.style,
553 state,
554 ),
555 WidgetKind::CardDeck { titles, selected } => {
556 render_card_deck(ctx, rect, titles, selected, self.style, state)
557 }
558 WidgetKind::Reel { player, fit } => {
559 render_reel(ctx, rect, player, fit, self.style, state)
560 }
561 WidgetKind::StateSurface {
562 state: surface_state,
563 title,
564 message,
565 action,
566 busy_phase,
567 } => render_state_surface(
568 ctx,
569 rect,
570 surface_state,
571 title,
572 message,
573 action,
574 busy_phase,
575 self.style,
576 state,
577 ),
578 WidgetKind::HeadsUpBanner {
579 level,
580 text,
581 ttl_ms,
582 } => render_heads_up_banner(ctx, rect, level, text, ttl_ms, self.style, state),
583 WidgetKind::NotificationActionSheet {
584 level,
585 title,
586 body,
587 actions,
588 selected,
589 open,
590 } => render_notification_action_sheet(
591 ctx, rect, level, title, body, actions, selected, open, self.style, state,
592 ),
593 WidgetKind::FeedTimeline {
594 items,
595 selected,
596 offset,
597 visible_rows,
598 expanded,
599 } => render_feed_timeline(
600 ctx,
601 rect,
602 items,
603 selected,
604 offset,
605 visible_rows,
606 expanded,
607 self.style,
608 state,
609 ),
610 }
611 }
612}
613
614const fn default_flags(kind: WidgetKind<'_>) -> WidgetFlags {
615 let mut flags = WidgetFlags::from_bits(
616 WidgetFlags::CLIP_CHILDREN.bits() | WidgetFlags::EVENT_BUBBLE.bits(),
617 );
618 if kind.focusable() {
619 flags = WidgetFlags::from_bits(
620 flags.bits() | WidgetFlags::FOCUSABLE.bits() | WidgetFlags::CLICKABLE.bits(),
621 );
622 }
623 if matches!(kind, WidgetKind::ScrollView { .. }) {
624 flags = WidgetFlags::from_bits(flags.bits() | WidgetFlags::SCROLLABLE.bits());
625 }
626 flags
627}
628
629fn render_panel<D>(
630 ctx: &mut RenderCtx<'_, D>,
631 rect: Rect,
632 style: WidgetStyle,
633 state: VisualState,
634) -> Result<(), D::Error>
635where
636 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
637{
638 let style = style.resolve(state);
639 Block::styled(style).render(rect, ctx)
640}
641
642fn render_label<D>(
643 ctx: &mut RenderCtx<'_, D>,
644 rect: Rect,
645 text: &str,
646 style: WidgetStyle,
647) -> Result<(), D::Error>
648where
649 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
650{
651 let style = style.resolve(VisualState::Normal);
652 let block = Block::styled(style);
653 block.render(rect, ctx)?;
654 let inner = block.inner(rect);
655 ctx.draw_text_in(
656 inner,
657 text,
658 TextStyle::new(style.text).with_font(style.font),
659 )
660}
661
662fn render_button<D>(
663 ctx: &mut RenderCtx<'_, D>,
664 rect: Rect,
665 text: &str,
666 style: WidgetStyle,
667 state: VisualState,
668) -> Result<(), D::Error>
669where
670 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
671{
672 let active_style = style.resolve(state);
673 let block = Block::styled(active_style);
674 block.render(rect, ctx)?;
675 let inner = block.inner(rect);
676 ctx.draw_text_in(
677 inner,
678 text,
679 TextStyle::new(active_style.text)
680 .with_font(active_style.font)
681 .centered(),
682 )
683}
684
685fn render_progress<D>(
686 ctx: &mut RenderCtx<'_, D>,
687 rect: Rect,
688 value: f32,
689 style: WidgetStyle,
690 state: VisualState,
691) -> Result<(), D::Error>
692where
693 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
694{
695 let style = style.resolve(state);
696 let block = Block::styled(style);
697 block.render(rect, ctx)?;
698 let inner = block.inner(rect);
699 let fill_w = ((inner.w as f32 * value.clamp(0.0, 1.0)) as u32).min(inner.w);
700 if fill_w > 0 {
701 let color = if matches!(state, VisualState::Focused) {
702 style.accent
703 } else {
704 style.foreground
705 };
706 ctx.fill_rect(Rect::new(inner.x, inner.y, fill_w, inner.h), color)?;
707 }
708 Ok(())
709}
710
711fn render_toggle<D>(
712 ctx: &mut RenderCtx<'_, D>,
713 rect: Rect,
714 label: &str,
715 on: bool,
716 style: WidgetStyle,
717 state: VisualState,
718) -> Result<(), D::Error>
719where
720 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
721{
722 let style = style.resolve(state);
723 let block = Block::styled(style);
724 block.render(rect, ctx)?;
725 let inner = block.inner(rect);
726 let knob_w = (inner.w / 4).max(8).min(inner.w);
727 let track = Rect::new(
728 inner.right() - knob_w as i32 - 2,
729 inner.y + 1,
730 knob_w,
731 inner.h.saturating_sub(2),
732 );
733 ctx.fill_rect(
734 track,
735 if on {
736 style.accent
737 } else {
738 Rgb565::new(7, 10, 10)
739 },
740 )?;
741 ctx.draw_text_in(
742 Rect::new(
743 inner.x,
744 inner.y,
745 inner.w.saturating_sub(knob_w + 4),
746 inner.h,
747 ),
748 label,
749 TextStyle::new(style.text).with_font(style.font),
750 )
751}
752
753fn render_checkbox<D>(
754 ctx: &mut RenderCtx<'_, D>,
755 rect: Rect,
756 label: &str,
757 checked: bool,
758 style: WidgetStyle,
759 state: VisualState,
760) -> Result<(), D::Error>
761where
762 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
763{
764 let style = style.resolve(state);
765 let block = Block::styled(style);
766 block.render(rect, ctx)?;
767 let inner = block.inner(rect);
768 let box_size = inner.h.min(8);
769 let box_rect = Rect::new(
770 inner.x,
771 inner.y + (inner.h.saturating_sub(box_size) as i32 / 2),
772 box_size,
773 box_size,
774 );
775 ctx.stroke_rect(box_rect, Border::one(style.text))?;
776 if checked && box_size > 4 {
777 ctx.fill_rect(
778 box_rect.inset(crate::geometry::EdgeInsets::all(2)),
779 style.accent,
780 )?;
781 }
782 ctx.draw_text_in(
783 Rect::new(
784 inner.x + box_size as i32 + 3,
785 inner.y,
786 inner.w.saturating_sub(box_size + 3),
787 inner.h,
788 ),
789 label,
790 TextStyle::new(style.text).with_font(style.font),
791 )
792}
793
794fn render_slider<D>(
795 ctx: &mut RenderCtx<'_, D>,
796 rect: Rect,
797 value: f32,
798 min: f32,
799 max: f32,
800 style: WidgetStyle,
801 state: VisualState,
802) -> Result<(), D::Error>
803where
804 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
805{
806 let style = style.resolve(state);
807 let block = Block::styled(style);
808 block.render(rect, ctx)?;
809 let inner = block.inner(rect);
810 let range = (max - min).max(f32::EPSILON);
811 let t = ((value - min) / range).clamp(0.0, 1.0);
812 let track_y = inner.y + inner.h as i32 / 2;
813 ctx.fill_rect(Rect::new(inner.x, track_y, inner.w, 1), style.text)?;
814 let knob_x = inner.x + ((inner.w.saturating_sub(3) as f32 * t) as i32);
815 ctx.fill_rect(Rect::new(knob_x, track_y - 2, 3, 5), style.accent)
816}
817
818fn render_value_label<D>(
819 ctx: &mut RenderCtx<'_, D>,
820 rect: Rect,
821 label: &str,
822 value: i32,
823 style: WidgetStyle,
824 state: VisualState,
825) -> Result<(), D::Error>
826where
827 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
828{
829 let style = style.resolve(state);
830 let block = Block::styled(style);
831 block.render(rect, ctx)?;
832 let inner = block.inner(rect);
833 ctx.draw_text_in(
834 Rect::new(inner.x, inner.y, inner.w / 2, inner.h),
835 label,
836 TextStyle::new(style.text).with_font(style.font),
837 )?;
838 draw_i32_right(
839 ctx,
840 Rect::new(
841 inner.x + (inner.w / 2) as i32,
842 inner.y,
843 inner.w - inner.w / 2,
844 inner.h,
845 ),
846 value,
847 style.accent,
848 )
849}
850
851fn render_icon_button<D>(
852 ctx: &mut RenderCtx<'_, D>,
853 rect: Rect,
854 icon: char,
855 label: &str,
856 style: WidgetStyle,
857 state: VisualState,
858) -> Result<(), D::Error>
859where
860 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
861{
862 let style = style.resolve(state);
863 let block = Block::styled(style);
864 block.render(rect, ctx)?;
865 let inner = block.inner(rect);
866 let mut icon_buf = [0u8; 4];
867 let icon_str = icon.encode_utf8(&mut icon_buf);
868 ctx.draw_text_in(
869 Rect::new(inner.x, inner.y, 8, inner.h),
870 icon_str,
871 TextStyle::new(style.accent)
872 .with_font(style.font)
873 .centered(),
874 )?;
875 ctx.draw_text_in(
876 Rect::new(inner.x + 10, inner.y, inner.w.saturating_sub(10), inner.h),
877 label,
878 TextStyle::new(style.text).with_font(style.font),
879 )
880}
881
882#[allow(clippy::too_many_arguments)]
883fn render_list<D>(
884 ctx: &mut RenderCtx<'_, D>,
885 rect: Rect,
886 items: &[&str],
887 selected: usize,
888 offset: usize,
889 visible_rows: usize,
890 style: WidgetStyle,
891 state: VisualState,
892) -> Result<(), D::Error>
893where
894 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
895{
896 let style = style.resolve(state);
897 let block = Block::styled(style);
898 block.render(rect, ctx)?;
899 if items.is_empty() {
900 return Ok(());
901 }
902 let inner = block.inner(rect);
903 let rows = visible_rows.max(1).min(items.len());
904 let row_h = (inner.h / rows as u32).max(1);
905 for row_idx in 0..rows {
906 let item_idx = offset.saturating_add(row_idx);
907 if item_idx >= items.len() {
908 break;
909 }
910 let row = Rect::new(
911 inner.x,
912 inner.y + (row_idx as u32 * row_h) as i32,
913 inner.w,
914 row_h,
915 );
916 if item_idx == selected {
917 ctx.fill_rect(row, style.accent)?;
918 }
919 ctx.draw_text_in(
920 row.inset(crate::geometry::EdgeInsets::symmetric(2, 1)),
921 items[item_idx],
922 TextStyle {
923 color: style.text,
924 font: style.font,
925 opacity: style.opacity,
926 align: TextAlign::Left,
927 vertical_align: VerticalAlign::Middle,
928 wrap: TextWrap::None,
929 overflow: crate::render::TextOverflow::Clip,
930 overflow_policy: crate::render::TextOverflowPolicy::Global(
931 crate::render::TextOverflow::Clip,
932 ),
933 kerning: false,
934 max_lines: None,
935 ellipsis: crate::render::EllipsisMode::ThreeDots,
936 line_spacing: 0,
937 },
938 )?;
939 }
940 Ok(())
941}
942
943fn render_scroll_view<D>(
944 ctx: &mut RenderCtx<'_, D>,
945 rect: Rect,
946 offset_y: i32,
947 content_h: u32,
948 style: WidgetStyle,
949 state: VisualState,
950) -> Result<(), D::Error>
951where
952 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
953{
954 let style = style.resolve(state);
955 let block = Block::styled(style);
956 block.render(rect, ctx)?;
957 if content_h > rect.h {
958 let inner = block.inner(rect);
959 let thumb_h = ((inner.h as u64 * inner.h as u64) / content_h.max(1) as u64)
960 .max(4)
961 .min(inner.h as u64) as u32;
962 let max_offset = content_h.saturating_sub(inner.h).max(1) as i32;
963 let y = inner.y
964 + ((inner.h.saturating_sub(thumb_h) as i32 * offset_y.clamp(0, max_offset))
965 / max_offset);
966 ctx.fill_rect(Rect::new(inner.right() - 3, y, 2, thumb_h), style.accent)?;
967 }
968 Ok(())
969}
970
971fn render_tabs<D>(
972 ctx: &mut RenderCtx<'_, D>,
973 rect: Rect,
974 labels: &[&str],
975 selected: usize,
976 style: WidgetStyle,
977 state: VisualState,
978) -> Result<(), D::Error>
979where
980 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
981{
982 let style = style.resolve(state);
983 let block = Block::styled(style);
984 block.render(rect, ctx)?;
985 if labels.is_empty() {
986 return Ok(());
987 }
988 let inner = block.inner(rect);
989 let tab_w = (inner.w / labels.len() as u32).max(1);
990 for (idx, label) in labels.iter().enumerate() {
991 let tab = Rect::new(
992 inner.x + (idx as u32 * tab_w) as i32,
993 inner.y,
994 tab_w,
995 inner.h,
996 );
997 if idx == selected {
998 ctx.fill_rect(tab, style.accent)?;
999 }
1000 ctx.draw_text_in(
1001 tab.inset(EdgeInsets::all(1)),
1002 label,
1003 TextStyle::new(style.text).with_font(style.font).centered(),
1004 )?;
1005 }
1006 Ok(())
1007}
1008
1009fn render_dialog<D>(
1010 ctx: &mut RenderCtx<'_, D>,
1011 rect: Rect,
1012 title: &str,
1013 body: &str,
1014 style: WidgetStyle,
1015 state: VisualState,
1016) -> Result<(), D::Error>
1017where
1018 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1019{
1020 let style = style.resolve(state);
1021 let block = Block::styled(style)
1022 .title(title)
1023 .title_align(TextAlign::Center);
1024 block.render(rect, ctx)?;
1025 let inner = block.content_area(rect);
1026 ctx.draw_text_in(
1027 inner,
1028 body,
1029 TextStyle {
1030 color: style.text,
1031 font: style.font,
1032 opacity: style.opacity,
1033 align: TextAlign::Center,
1034 vertical_align: VerticalAlign::Middle,
1035 wrap: TextWrap::Character,
1036 overflow: crate::render::TextOverflow::Clip,
1037 overflow_policy: crate::render::TextOverflowPolicy::Global(
1038 crate::render::TextOverflow::Clip,
1039 ),
1040 kerning: false,
1041 max_lines: None,
1042 ellipsis: crate::render::EllipsisMode::ThreeDots,
1043 line_spacing: 1,
1044 },
1045 )
1046}
1047
1048fn render_toast<D>(
1049 ctx: &mut RenderCtx<'_, D>,
1050 rect: Rect,
1051 text: &str,
1052 ttl_ms: u32,
1053 style: WidgetStyle,
1054 state: VisualState,
1055) -> Result<(), D::Error>
1056where
1057 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1058{
1059 if ttl_ms == 0 {
1060 return Ok(());
1061 }
1062 let style = style.resolve(state);
1063 let block = Block::styled(style);
1064 block.render(rect, ctx)?;
1065 ctx.draw_text_in(
1066 block.inner(rect),
1067 text,
1068 TextStyle {
1069 color: style.text,
1070 font: style.font,
1071 opacity: style.opacity,
1072 align: TextAlign::Center,
1073 vertical_align: VerticalAlign::Middle,
1074 wrap: TextWrap::Character,
1075 overflow: crate::render::TextOverflow::Clip,
1076 overflow_policy: crate::render::TextOverflowPolicy::Global(
1077 crate::render::TextOverflow::Clip,
1078 ),
1079 kerning: false,
1080 max_lines: None,
1081 ellipsis: crate::render::EllipsisMode::ThreeDots,
1082 line_spacing: 0,
1083 },
1084 )
1085}
1086
1087fn render_meter<D>(
1088 ctx: &mut RenderCtx<'_, D>,
1089 rect: Rect,
1090 value: f32,
1091 min: f32,
1092 max: f32,
1093 style: WidgetStyle,
1094 state: VisualState,
1095) -> Result<(), D::Error>
1096where
1097 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1098{
1099 let style = style.resolve(state);
1100 let block = Block::styled(style);
1101 block.render(rect, ctx)?;
1102 let inner = block.inner(rect);
1103 let range = (max - min).max(f32::EPSILON);
1104 let t = ((value - min) / range).clamp(0.0, 1.0);
1105 let bars = 10usize;
1106 let gap = 1u32;
1107 let bar_w = inner
1108 .w
1109 .saturating_sub(gap * (bars as u32 - 1))
1110 .max(bars as u32)
1111 / bars as u32;
1112 for i in 0..bars {
1113 let x = inner.x + (i as u32 * (bar_w + gap)) as i32;
1114 let active = (i as f32) < t * bars as f32;
1115 let h = ((inner.h as f32 * (i + 1) as f32 / bars as f32) as u32).max(1);
1116 let y = inner.bottom() - h as i32;
1117 ctx.fill_rect(
1118 Rect::new(x, y, bar_w, h),
1119 if active {
1120 style.accent
1121 } else {
1122 Rgb565::new(5, 8, 8)
1123 },
1124 )?;
1125 }
1126 Ok(())
1127}
1128
1129#[allow(clippy::too_many_arguments)]
1130fn render_arc_gauge<D>(
1131 ctx: &mut RenderCtx<'_, D>,
1132 rect: Rect,
1133 value: f32,
1134 min: f32,
1135 max: f32,
1136 start_deg: i32,
1137 end_deg: i32,
1138 thickness: u8,
1139 antialias: bool,
1140 major_ticks: u8,
1141 minor_ticks: u8,
1142 show_value: bool,
1143 style: WidgetStyle,
1144 state: VisualState,
1145) -> Result<(), D::Error>
1146where
1147 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1148{
1149 let style = style.resolve(state);
1150 let block = Block::styled(style);
1151 block.render(rect, ctx)?;
1152 let inner = block.inner(rect);
1153 let cx = inner.x + inner.w as i32 / 2;
1154 let cy = inner.y + inner.h as i32 / 2;
1155 let radius = (inner.w.min(inner.h) / 2).saturating_sub(1);
1156 let track = Rgb565::new(5, 8, 8);
1157 draw_arc_ticks(
1158 ctx,
1159 cx,
1160 cy,
1161 radius.saturating_sub((thickness.max(1) / 2) as u32),
1162 start_deg,
1163 end_deg,
1164 major_ticks,
1165 minor_ticks,
1166 track,
1167 )?;
1168 ctx.stroke_arc_styled(
1169 cx,
1170 cy,
1171 radius,
1172 start_deg,
1173 end_deg,
1174 StrokeStyle::new(track)
1175 .with_width(thickness)
1176 .with_antialias(antialias),
1177 )?;
1178 let range = (max - min).max(f32::EPSILON);
1179 let t = ((value - min) / range).clamp(0.0, 1.0);
1180 let active_end = start_deg + (((end_deg - start_deg) as f32) * t) as i32;
1181 ctx.stroke_arc_styled(
1182 cx,
1183 cy,
1184 radius,
1185 start_deg,
1186 active_end,
1187 StrokeStyle::new(style.accent)
1188 .with_width(thickness)
1189 .with_antialias(antialias),
1190 )?;
1191 if show_value {
1192 draw_gauge_value_label(ctx, inner, value, min, max, style)?;
1193 }
1194 Ok(())
1195}
1196
1197#[allow(clippy::too_many_arguments)]
1198fn render_gauge<D>(
1199 ctx: &mut RenderCtx<'_, D>,
1200 rect: Rect,
1201 value: f32,
1202 min: f32,
1203 max: f32,
1204 major_ticks: u8,
1205 minor_ticks: u8,
1206 show_value: bool,
1207 style: WidgetStyle,
1208 state: VisualState,
1209) -> Result<(), D::Error>
1210where
1211 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1212{
1213 render_arc_gauge(
1214 ctx,
1215 rect,
1216 value,
1217 min,
1218 max,
1219 135,
1220 405,
1221 2,
1222 true,
1223 major_ticks,
1224 minor_ticks,
1225 show_value,
1226 style,
1227 state,
1228 )
1229}
1230
1231#[allow(clippy::too_many_arguments)]
1232fn render_gauge_needle<D>(
1233 ctx: &mut RenderCtx<'_, D>,
1234 rect: Rect,
1235 value: f32,
1236 min: f32,
1237 max: f32,
1238 start_deg: i32,
1239 end_deg: i32,
1240 style: WidgetStyle,
1241 state: VisualState,
1242) -> Result<(), D::Error>
1243where
1244 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1245{
1246 let style = style.resolve(state);
1247 let block = Block::styled(style);
1248 block.render(rect, ctx)?;
1249 let inner = block.inner(rect);
1250 let cx = inner.x + inner.w as i32 / 2;
1251 let cy = inner.y + inner.h as i32 / 2;
1252 let radius = (inner.w.min(inner.h) / 2).saturating_sub(2);
1253 ctx.stroke_arc_styled(
1254 cx,
1255 cy,
1256 radius,
1257 start_deg,
1258 end_deg,
1259 StrokeStyle::new(Rgb565::new(8, 10, 10)).with_width(1),
1260 )?;
1261 let range = (max - min).max(f32::EPSILON);
1262 let t = ((value - min) / range).clamp(0.0, 1.0);
1263 let angle = (start_deg as f32 + (end_deg - start_deg) as f32 * t).to_radians();
1264 let nx = cx + (radius as f32 * angle.cos()) as i32;
1265 let ny = cy + (radius as f32 * angle.sin()) as i32;
1266 ctx.draw_line_styled(
1267 cx,
1268 cy,
1269 nx,
1270 ny,
1271 StrokeStyle::new(style.accent)
1272 .with_width(2)
1273 .with_antialias(true)
1274 .with_cap(crate::render::StrokeCap::Round),
1275 )?;
1276 ctx.fill_circle(cx, cy, 2, style.accent)
1277}
1278
1279#[allow(clippy::too_many_arguments)]
1280fn render_chart<D>(
1281 ctx: &mut RenderCtx<'_, D>,
1282 rect: Rect,
1283 values: &[f32],
1284 min: f32,
1285 max: f32,
1286 thickness: u8,
1287 fill_under: bool,
1288 markers: bool,
1289 mode: ChartMode,
1290 show_grid: bool,
1291 show_axes: bool,
1292 show_labels: bool,
1293 style: WidgetStyle,
1294 state: VisualState,
1295) -> Result<(), D::Error>
1296where
1297 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1298{
1299 let style = style.resolve(state);
1300 let block = Block::styled(style);
1301 block.render(rect, ctx)?;
1302 if values.len() < 2 {
1303 return Ok(());
1304 }
1305 let inner = block.inner(rect);
1306 if show_grid {
1307 for row in [1u32, 2, 3] {
1308 let y = inner.y + ((inner.h.saturating_sub(1) * row) / 4) as i32;
1309 ctx.draw_line_styled(
1310 inner.x,
1311 y,
1312 inner.right().saturating_sub(1),
1313 y,
1314 StrokeStyle::new(Rgb565::new(6, 10, 10)).with_width(1),
1315 )?;
1316 }
1317 }
1318 if show_axes {
1319 let axis = Rgb565::new(12, 18, 18);
1320 ctx.draw_line_styled(
1321 inner.x,
1322 inner.y,
1323 inner.x,
1324 inner.bottom().saturating_sub(1),
1325 StrokeStyle::new(axis).with_width(1),
1326 )?;
1327 ctx.draw_line_styled(
1328 inner.x,
1329 inner.bottom().saturating_sub(1),
1330 inner.right().saturating_sub(1),
1331 inner.bottom().saturating_sub(1),
1332 StrokeStyle::new(axis).with_width(1),
1333 )?;
1334 }
1335 if show_labels {
1336 let mut max_label: String<12> = String::new();
1337 let _ = write!(&mut max_label, "{:.1}", max);
1338 let mut min_label: String<12> = String::new();
1339 let _ = write!(&mut min_label, "{:.1}", min);
1340 ctx.draw_text_in(
1341 Rect::new(
1342 inner.x + 1,
1343 inner.y,
1344 inner.w.saturating_sub(2),
1345 style.font.line_height(),
1346 ),
1347 max_label.as_str(),
1348 TextStyle::new(style.text).with_font(style.font),
1349 )?;
1350 ctx.draw_text_in(
1351 Rect::new(
1352 inner.x + 1,
1353 inner
1354 .bottom()
1355 .saturating_sub(style.font.line_height() as i32),
1356 inner.w.saturating_sub(2),
1357 style.font.line_height(),
1358 ),
1359 min_label.as_str(),
1360 TextStyle::new(style.text).with_font(style.font),
1361 )?;
1362 }
1363 let range = (max - min).max(f32::EPSILON);
1364 match mode {
1365 ChartMode::Line => {
1366 let dx = (inner.w.saturating_sub(1) as f32) / (values.len().saturating_sub(1) as f32);
1367 for i in 1..values.len() {
1368 let v0 = ((values[i - 1] - min) / range).clamp(0.0, 1.0);
1369 let v1 = ((values[i] - min) / range).clamp(0.0, 1.0);
1370 let x0 = inner.x + ((i - 1) as f32 * dx) as i32;
1371 let x1 = inner.x + (i as f32 * dx) as i32;
1372 let y0 = inner.bottom() - 1 - (v0 * (inner.h.saturating_sub(1)) as f32) as i32;
1373 let y1 = inner.bottom() - 1 - (v1 * (inner.h.saturating_sub(1)) as f32) as i32;
1374 if fill_under {
1375 let base = inner.bottom() - 1;
1376 ctx.fill_polygon(
1377 &[
1378 embedded_graphics_core::geometry::Point::new(x0, base),
1379 embedded_graphics_core::geometry::Point::new(x0, y0),
1380 embedded_graphics_core::geometry::Point::new(x1, y1),
1381 embedded_graphics_core::geometry::Point::new(x1, base),
1382 ],
1383 Rgb565::new(2, 8, 2),
1384 )?;
1385 }
1386 ctx.draw_line_styled(
1387 x0,
1388 y0,
1389 x1,
1390 y1,
1391 StrokeStyle::new(style.accent)
1392 .with_width(thickness.max(1))
1393 .with_antialias(true),
1394 )?;
1395 if markers {
1396 ctx.fill_circle(x0, y0, 1, style.accent)?;
1397 ctx.fill_circle(x1, y1, 1, style.accent)?;
1398 }
1399 }
1400 }
1401 ChartMode::Bars => {
1402 let count = values.len() as u32;
1403 let gap = 1u32;
1404 let bar_w = inner
1405 .w
1406 .saturating_sub(gap.saturating_mul(count.saturating_sub(1)))
1407 .max(count)
1408 / count;
1409 for (i, value) in values.iter().copied().enumerate() {
1410 let t = ((value - min) / range).clamp(0.0, 1.0);
1411 let h = (t * inner.h.saturating_sub(1) as f32) as u32;
1412 let x = inner.x + (i as u32 * (bar_w + gap)) as i32;
1413 let y = inner.bottom().saturating_sub(h as i32 + 1);
1414 let bar = Rect::new(x, y, bar_w.max(1), h.max(1));
1415 ctx.fill_rect(bar, style.accent)?;
1416 if markers {
1417 ctx.fill_circle(x + (bar_w / 2) as i32, y, 1, style.text)?;
1418 }
1419 }
1420 }
1421 }
1422 Ok(())
1423}
1424
1425fn render_spinner<D>(
1426 ctx: &mut RenderCtx<'_, D>,
1427 rect: Rect,
1428 phase: f32,
1429 style: WidgetStyle,
1430 state: VisualState,
1431) -> Result<(), D::Error>
1432where
1433 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1434{
1435 let style = style.resolve(state);
1436 let block = Block::styled(style);
1437 block.render(rect, ctx)?;
1438 let inner = block.inner(rect);
1439 let cx = inner.x + inner.w as i32 / 2;
1440 let cy = inner.y + inner.h as i32 / 2;
1441 let radius = (inner.w.min(inner.h) / 2).saturating_sub(1);
1442 let base = ((phase.fract() * 360.0) as i32).rem_euclid(360);
1443 ctx.stroke_arc_styled(
1444 cx,
1445 cy,
1446 radius,
1447 base,
1448 base + 120,
1449 StrokeStyle::new(style.accent)
1450 .with_width(2)
1451 .with_antialias(true),
1452 )
1453}
1454
1455fn render_dropdown<D>(
1456 ctx: &mut RenderCtx<'_, D>,
1457 rect: Rect,
1458 items: &[&str],
1459 selected: usize,
1460 open: bool,
1461 style: WidgetStyle,
1462 state: VisualState,
1463) -> Result<(), D::Error>
1464where
1465 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1466{
1467 let style = style.resolve(state);
1468 let block = Block::styled(style);
1469 block.render(rect, ctx)?;
1470 let inner = block.inner(rect);
1471 let text = items.get(selected).copied().unwrap_or("-");
1472 ctx.draw_text_in(
1473 Rect::new(inner.x, inner.y, inner.w.saturating_sub(8), inner.h),
1474 text,
1475 TextStyle::new(style.text).with_font(style.font),
1476 )?;
1477 ctx.draw_text_in(
1478 Rect::new(inner.right() - 7, inner.y, 7, inner.h),
1479 if open { "^" } else { "v" },
1480 TextStyle::new(style.accent)
1481 .with_font(style.font)
1482 .centered(),
1483 )?;
1484 if open {
1485 let row_h = style.font.line_height().max(6);
1486 let popup_h = (row_h.saturating_mul(items.len() as u32))
1487 .min(40)
1488 .max(row_h);
1489 let popup = Rect::new(inner.x, inner.bottom() + 1, inner.w, popup_h);
1490 ctx.fill_rect(popup, style.background.unwrap_or(Rgb565::new(8, 12, 16)))?;
1491 ctx.stroke_rect(popup, Border::one(style.border.color))?;
1492 let visible = (popup_h / row_h).max(1) as usize;
1493 let start = selected
1494 .saturating_sub(visible / 2)
1495 .min(items.len().saturating_sub(visible));
1496 for (i, item) in items.iter().enumerate().skip(start).take(visible) {
1497 let row = Rect::new(
1498 popup.x + 1,
1499 popup.y + ((i - start) as u32 * row_h) as i32,
1500 popup.w.saturating_sub(2),
1501 row_h,
1502 );
1503 if i == selected {
1504 ctx.fill_rect(row, style.accent)?;
1505 }
1506 ctx.draw_text_in(
1507 row.inset(EdgeInsets::all(1)),
1508 item,
1509 TextStyle::new(style.text).with_font(style.font),
1510 )?;
1511 }
1512 }
1513 Ok(())
1514}
1515
1516fn render_roller<D>(
1517 ctx: &mut RenderCtx<'_, D>,
1518 rect: Rect,
1519 items: &[&str],
1520 selected: usize,
1521 style: WidgetStyle,
1522 state: VisualState,
1523) -> Result<(), D::Error>
1524where
1525 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1526{
1527 let style = style.resolve(state);
1528 let block = Block::styled(style);
1529 block.render(rect, ctx)?;
1530 if items.is_empty() {
1531 return Ok(());
1532 }
1533 let inner = block.inner(rect);
1534 let prev = items[(selected + items.len() - 1) % items.len()];
1535 let cur = items[selected];
1536 let next = items[(selected + 1) % items.len()];
1537 let row_h = (inner.h / 3).max(1);
1538 let rows = [prev, cur, next];
1539 for (idx, text) in rows.iter().enumerate() {
1540 let row = Rect::new(
1541 inner.x,
1542 inner.y + (idx as u32 * row_h) as i32,
1543 inner.w,
1544 row_h,
1545 );
1546 if idx == 1 {
1547 ctx.fill_rect(row, style.accent)?;
1548 }
1549 ctx.draw_text_in(
1550 row,
1551 text,
1552 TextStyle::new(style.text).with_font(style.font).centered(),
1553 )?;
1554 }
1555 Ok(())
1556}
1557
1558#[allow(clippy::too_many_arguments)]
1559fn render_table<D>(
1560 ctx: &mut RenderCtx<'_, D>,
1561 rect: Rect,
1562 rows: &[&[&str]],
1563 separators: bool,
1564 cell_padding: u8,
1565 align: TextAlign,
1566 style: WidgetStyle,
1567 state: VisualState,
1568) -> Result<(), D::Error>
1569where
1570 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1571{
1572 let style = style.resolve(state);
1573 let block = Block::styled(style);
1574 block.render(rect, ctx)?;
1575 if rows.is_empty() {
1576 return Ok(());
1577 }
1578 let inner = block.inner(rect);
1579 let row_h = (inner.h / rows.len() as u32).max(1);
1580 let max_cols = rows.iter().map(|row| row.len()).max().unwrap_or(1).max(1);
1581 let col_w = (inner.w / max_cols as u32).max(1);
1582 for (r, cols) in rows.iter().enumerate() {
1583 for c in 0..max_cols {
1584 let text = cols.get(c).copied().unwrap_or("");
1585 let cell = Rect::new(
1586 inner.x + (c as u32 * col_w) as i32,
1587 inner.y + (r as u32 * row_h) as i32,
1588 col_w,
1589 row_h,
1590 );
1591 if separators {
1592 ctx.stroke_rect(cell, Border::one(style.border.color))?;
1593 }
1594 ctx.draw_text_in(
1595 cell.inset(EdgeInsets::all(cell_padding as i16)),
1596 text,
1597 TextStyle::new(style.text)
1598 .with_font(style.font)
1599 .with_align(align),
1600 )?;
1601 }
1602 }
1603 Ok(())
1604}
1605
1606#[allow(clippy::too_many_arguments)]
1607fn draw_arc_ticks<D>(
1608 ctx: &mut RenderCtx<'_, D>,
1609 cx: i32,
1610 cy: i32,
1611 radius: u32,
1612 start_deg: i32,
1613 end_deg: i32,
1614 major_ticks: u8,
1615 minor_ticks: u8,
1616 color: Rgb565,
1617) -> Result<(), D::Error>
1618where
1619 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1620{
1621 let major_ticks = major_ticks.max(1);
1622 let minor_ticks = minor_ticks.max(1);
1623 let total_steps = (major_ticks as u32).saturating_mul(minor_ticks as u32);
1624 for step in 0..=total_steps {
1625 let t = if total_steps == 0 {
1626 0.0
1627 } else {
1628 step as f32 / total_steps as f32
1629 };
1630 let angle = (start_deg as f32 + (end_deg - start_deg) as f32 * t).to_radians();
1631 let is_major = step % minor_ticks as u32 == 0;
1632 let tick_len = if is_major { 4 } else { 2 };
1633 let outer_x = cx + (radius as f32 * angle.cos()) as i32;
1634 let outer_y = cy + (radius as f32 * angle.sin()) as i32;
1635 let inner_x = cx + ((radius.saturating_sub(tick_len)) as f32 * angle.cos()) as i32;
1636 let inner_y = cy + ((radius.saturating_sub(tick_len)) as f32 * angle.sin()) as i32;
1637 ctx.draw_line_styled(
1638 inner_x,
1639 inner_y,
1640 outer_x,
1641 outer_y,
1642 StrokeStyle::new(color).with_width(1),
1643 )?;
1644 }
1645 Ok(())
1646}
1647
1648fn draw_gauge_value_label<D>(
1649 ctx: &mut RenderCtx<'_, D>,
1650 inner: Rect,
1651 value: f32,
1652 min: f32,
1653 max: f32,
1654 style: Style,
1655) -> Result<(), D::Error>
1656where
1657 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1658{
1659 let range = (max - min).max(f32::EPSILON);
1660 let percent = (((value - min) / range).clamp(0.0, 1.0) * 100.0).round() as i32;
1661 let mut label: String<8> = String::new();
1662 let _ = write!(&mut label, "{}%", percent);
1663 ctx.draw_text_in(
1664 Rect::new(
1665 inner.x,
1666 inner.y + (inner.h as i32 / 2) - (style.font.line_height() as i32 / 2),
1667 inner.w,
1668 style.font.line_height(),
1669 ),
1670 label.as_str(),
1671 TextStyle::new(style.text)
1672 .with_font(style.font)
1673 .with_align(TextAlign::Center),
1674 )
1675}
1676
1677#[allow(clippy::too_many_arguments)]
1678fn render_textarea<D>(
1679 ctx: &mut RenderCtx<'_, D>,
1680 rect: Rect,
1681 text: &str,
1682 cursor: usize,
1683 placeholder: &str,
1684 selection: Option<(usize, usize)>,
1685 cursor_visible: bool,
1686 style: WidgetStyle,
1687 state: VisualState,
1688) -> Result<(), D::Error>
1689where
1690 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1691{
1692 let style = style.resolve(state);
1693 let block = Block::styled(style);
1694 block.render(rect, ctx)?;
1695 let inner = block.inner(rect).inset(EdgeInsets::all(1));
1696 let max_chars = (inner.w / style.font.advance()).max(1) as usize;
1697 let shown = if text.is_empty() { placeholder } else { text };
1698 let color = if text.is_empty() {
1699 Rgb565::new(
1700 style.text.r().saturating_sub(8),
1701 style.text.g().saturating_sub(10),
1702 style.text.b().saturating_sub(8),
1703 )
1704 } else {
1705 style.text
1706 };
1707 if !text.is_empty() {
1708 if let Some((start, end)) = selection {
1709 let start = start.min(end).min(text.chars().count());
1710 let end = end.max(start).min(text.chars().count());
1711 for idx in start..end {
1712 let (col, row) = textarea_grid_position(text, idx, max_chars);
1713 let sel_rect = Rect::new(
1714 inner.x + (col as u32 * style.font.advance()) as i32,
1715 inner.y + (row as u32 * style.font.line_height()) as i32,
1716 style.font.advance(),
1717 style.font.line_height().min(inner.h),
1718 );
1719 ctx.fill_rect(sel_rect, style.accent)?;
1720 }
1721 }
1722 }
1723 ctx.draw_text_in(
1724 inner,
1725 shown,
1726 TextStyle::new(color)
1727 .with_font(style.font)
1728 .with_wrap(TextWrap::Character),
1729 )?;
1730 let chars = text.chars().count();
1731 let cursor = cursor.min(chars);
1732 if state == VisualState::Focused && cursor_visible {
1733 let (col, row) = textarea_grid_position(text, cursor, max_chars);
1734 let x = inner.x + (col as u32 * style.font.advance()) as i32;
1735 let y = inner.y + (row as u32 * style.font.line_height()) as i32;
1736 let caret = Rect::new(x, y, 1, style.font.line_height().min(inner.h));
1737 ctx.fill_rect(caret, style.accent)?;
1738 }
1739 Ok(())
1740}
1741
1742fn textarea_grid_position(text: &str, cursor: usize, max_chars: usize) -> (usize, usize) {
1743 let mut row = 0usize;
1744 let mut col = 0usize;
1745 for ch in text.chars().take(cursor) {
1746 if ch == '\n' {
1747 row += 1;
1748 col = 0;
1749 continue;
1750 }
1751 col += 1;
1752 if col >= max_chars {
1753 row += 1;
1754 col = 0;
1755 }
1756 }
1757 (col, row)
1758}
1759
1760fn textarea_text(buf: &[u8; TEXTAREA_CAPACITY], len: u8) -> &str {
1761 let used = (len as usize).min(TEXTAREA_CAPACITY);
1762 core::str::from_utf8(&buf[..used]).unwrap_or("")
1763}
1764
1765#[allow(clippy::too_many_arguments)]
1766fn render_keyboard<D>(
1767 ctx: &mut RenderCtx<'_, D>,
1768 rect: Rect,
1769 keys: &[char],
1770 selected: usize,
1771 cols: u8,
1772 alt_keys: Option<&[char]>,
1773 layout: KeyboardLayout,
1774 style: WidgetStyle,
1775 state: VisualState,
1776) -> Result<(), D::Error>
1777where
1778 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1779{
1780 let style = style.resolve(state);
1781 let block = Block::styled(style);
1782 block.render(rect, ctx)?;
1783 if keys.is_empty() {
1784 return Ok(());
1785 }
1786 let inner = block.inner(rect).inset(EdgeInsets::all(1));
1787 let cols = cols.max(1) as usize;
1788 let rows = keys.len().div_ceil(cols).max(1);
1789 let cell_w = (inner.w / cols as u32).max(1);
1790 let cell_h = (inner.h / rows as u32).max(1);
1791 for (idx, key) in keys.iter().copied().enumerate() {
1792 let col = idx % cols;
1793 let row = idx / cols;
1794 let cell = Rect::new(
1795 inner.x + (col as u32 * cell_w) as i32,
1796 inner.y + (row as u32 * cell_h) as i32,
1797 cell_w,
1798 cell_h,
1799 );
1800 if idx == selected.min(keys.len() - 1) {
1801 ctx.fill_rect(cell, style.accent)?;
1802 }
1803 let rendered = keyboard_key_for_layout(key, idx, keys, alt_keys, layout);
1804 let mut label = [0u8; 4];
1805 let text = rendered.encode_utf8(&mut label);
1806 ctx.draw_text_in(
1807 cell.inset(EdgeInsets::all(1)),
1808 text,
1809 TextStyle::new(style.text).with_font(style.font).centered(),
1810 )?;
1811 }
1812 Ok(())
1813}
1814
1815fn keyboard_key_for_layout(
1816 base: char,
1817 idx: usize,
1818 base_keys: &[char],
1819 alt_keys: Option<&[char]>,
1820 layout: KeyboardLayout,
1821) -> char {
1822 match layout {
1823 KeyboardLayout::Normal => base,
1824 KeyboardLayout::Shift => {
1825 if base.is_ascii_alphabetic() {
1826 base.to_ascii_uppercase()
1827 } else {
1828 base
1829 }
1830 }
1831 KeyboardLayout::Symbols => alt_keys
1832 .and_then(|keys| keys.get(idx).copied())
1833 .or_else(|| {
1834 const FALLBACK: [char; 10] = ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')'];
1835 FALLBACK.get(idx % FALLBACK.len()).copied()
1836 })
1837 .unwrap_or_else(|| base_keys.get(idx).copied().unwrap_or(base)),
1838 }
1839}
1840
1841fn render_menu<D>(
1842 ctx: &mut RenderCtx<'_, D>,
1843 rect: Rect,
1844 items: &[&str],
1845 selected: usize,
1846 style: WidgetStyle,
1847 state: VisualState,
1848) -> Result<(), D::Error>
1849where
1850 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1851{
1852 let style = style.resolve(state);
1853 let block = Block::styled(style);
1854 block.render(rect, ctx)?;
1855
1856 if items.is_empty() {
1857 return Ok(());
1858 }
1859
1860 let inner = block.inner(rect);
1861 let row_h = (inner.h / items.len() as u32).max(1);
1862 for (i, item) in items.iter().enumerate() {
1863 let row = Rect::new(inner.x, inner.y + (i as u32 * row_h) as i32, inner.w, row_h);
1864 let is_selected = i == selected;
1865 if is_selected {
1866 ctx.fill_rect(row, style.accent)?;
1867 }
1868 ctx.draw_text_in(
1869 row.inset(crate::geometry::EdgeInsets::symmetric(2, 1)),
1870 item,
1871 TextStyle {
1872 color: style.text,
1873 font: style.font,
1874 opacity: style.opacity,
1875 align: TextAlign::Left,
1876 vertical_align: VerticalAlign::Middle,
1877 wrap: TextWrap::None,
1878 overflow: crate::render::TextOverflow::Clip,
1879 overflow_policy: crate::render::TextOverflowPolicy::Global(
1880 crate::render::TextOverflow::Clip,
1881 ),
1882 kerning: false,
1883 max_lines: None,
1884 ellipsis: crate::render::EllipsisMode::ThreeDots,
1885 line_spacing: 0,
1886 },
1887 )?;
1888 }
1889 Ok(())
1890}
1891
1892fn render_image<D>(
1893 ctx: &mut RenderCtx<'_, D>,
1894 rect: Rect,
1895 image: ImageRef<'_>,
1896 fit: ImageFit,
1897 style: WidgetStyle,
1898 state: VisualState,
1899) -> Result<(), D::Error>
1900where
1901 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1902{
1903 let style = style.resolve(state);
1904 let block = Block::styled(style);
1905 block.render(rect, ctx)?;
1906 ctx.draw_image(block.inner(rect), image, fit)
1907}
1908
1909#[allow(clippy::too_many_arguments)]
1910fn render_peek_reveal<D>(
1911 ctx: &mut RenderCtx<'_, D>,
1912 rect: Rect,
1913 icon: ImageRef<'_>,
1914 title: &str,
1915 subtitle: &str,
1916 progress: f32,
1917 style: WidgetStyle,
1918 state: VisualState,
1919) -> Result<(), D::Error>
1920where
1921 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1922{
1923 let style = style.resolve(state);
1924 let block = Block::styled(style);
1925 block.render(rect, ctx)?;
1926 let inner = block.inner(rect);
1927 let t = progress.clamp(0.0, 1.0);
1928 let icon_size = ((inner.h.min(inner.w / 3) as f32) * (0.2 + 0.8 * t))
1929 .max(2.0)
1930 .round() as u32;
1931 let icon_rect = Rect::new(inner.x + 1, inner.y + 1, icon_size, icon_size);
1932 ctx.draw_image(icon_rect, icon, ImageFit::Stretch)?;
1933 if t > 0.25 {
1934 ctx.draw_text_in(
1935 Rect::new(
1936 inner.x + icon_size as i32 + 2,
1937 inner.y,
1938 inner.w.saturating_sub(icon_size + 2),
1939 inner.h / 2,
1940 ),
1941 title,
1942 TextStyle::new(style.text).with_font(style.font),
1943 )?;
1944 }
1945 if t > 0.5 {
1946 ctx.draw_text_in(
1947 Rect::new(
1948 inner.x + icon_size as i32 + 2,
1949 inner.y + (inner.h / 2) as i32,
1950 inner.w.saturating_sub(icon_size + 2),
1951 inner.h / 2,
1952 ),
1953 subtitle,
1954 TextStyle::new(style.accent).with_font(style.font),
1955 )?;
1956 }
1957 Ok(())
1958}
1959
1960#[allow(clippy::too_many_arguments)]
1961fn render_glance_tile<D>(
1962 ctx: &mut RenderCtx<'_, D>,
1963 rect: Rect,
1964 icon: char,
1965 title: &str,
1966 subtitle: &str,
1967 highlighted: bool,
1968 style: WidgetStyle,
1969 state: VisualState,
1970) -> Result<(), D::Error>
1971where
1972 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1973{
1974 let style = style.resolve(state);
1975 let block = Block::styled(style);
1976 block.render(rect, ctx)?;
1977 let inner = block.inner(rect);
1978 if highlighted {
1979 ctx.fill_rect(Rect::new(inner.x, inner.y, inner.w, 2), style.accent)?;
1980 }
1981 let mut icon_buf = [0u8; 4];
1982 let icon_str = icon.encode_utf8(&mut icon_buf);
1983 ctx.draw_text_in(
1984 Rect::new(inner.x, inner.y, 10, inner.h),
1985 icon_str,
1986 TextStyle::new(style.accent)
1987 .with_font(style.font)
1988 .centered(),
1989 )?;
1990 ctx.draw_text_in(
1991 Rect::new(
1992 inner.x + 12,
1993 inner.y,
1994 inner.w.saturating_sub(12),
1995 inner.h / 2,
1996 ),
1997 title,
1998 TextStyle::new(style.text).with_font(style.font),
1999 )?;
2000 ctx.draw_text_in(
2001 Rect::new(
2002 inner.x + 12,
2003 inner.y + (inner.h / 2) as i32,
2004 inner.w.saturating_sub(12),
2005 inner.h / 2,
2006 ),
2007 subtitle,
2008 TextStyle::new(style.accent).with_font(style.font),
2009 )?;
2010 Ok(())
2011}
2012
2013fn render_card_deck<D>(
2014 ctx: &mut RenderCtx<'_, D>,
2015 rect: Rect,
2016 titles: &[&str],
2017 selected: usize,
2018 style: WidgetStyle,
2019 state: VisualState,
2020) -> Result<(), D::Error>
2021where
2022 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2023{
2024 let style = style.resolve(state);
2025 let block = Block::styled(style);
2026 block.render(rect, ctx)?;
2027 let inner = block.inner(rect);
2028 if titles.is_empty() {
2029 return Ok(());
2030 }
2031 let active = titles[selected.min(titles.len() - 1)];
2032 ctx.draw_text_in(
2033 inner,
2034 active,
2035 TextStyle::new(style.text).with_font(style.font).centered(),
2036 )?;
2037 Ok(())
2038}
2039
2040fn render_reel<D>(
2041 ctx: &mut RenderCtx<'_, D>,
2042 rect: Rect,
2043 player: ReelPlayer<'_>,
2044 fit: ImageFit,
2045 style: WidgetStyle,
2046 state: VisualState,
2047) -> Result<(), D::Error>
2048where
2049 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2050{
2051 let style = style.resolve(state);
2052 let block = Block::styled(style);
2053 block.render(rect, ctx)?;
2054 if let Some(src) = player.current_sprite_rect() {
2055 let inner = block.inner(rect);
2056 let frame_index = (src.x / player.sheet.sprite_w.max(1) as i32) as u8
2057 + ((src.y / player.sheet.sprite_h.max(1) as i32) as u8) * 2;
2058 let accent = match frame_index & 0x03 {
2059 0 => Rgb565::new(0, 40, 31),
2060 1 => Rgb565::new(31, 20, 0),
2061 2 => Rgb565::new(20, 0, 31),
2062 _ => Rgb565::new(31, 40, 0),
2063 };
2064 ctx.stroke_rect(inner, Border::one(accent))?;
2065 let w = inner.w.saturating_sub(4);
2066 let h = inner.h.saturating_sub(4);
2067 let bar_w = (w / 4).max(1);
2068 for i in 0..4u32 {
2069 let x = inner.x + 2 + (i * bar_w) as i32;
2070 let bar = Rect::new(x, inner.y + 2, bar_w.saturating_sub(1), h);
2071 let active = i as u8 <= (frame_index & 0x03);
2072 ctx.fill_rect(bar, if active { accent } else { Rgb565::new(4, 6, 6) })?;
2073 }
2074 if matches!(fit, ImageFit::Stretch | ImageFit::Center) {
2075 }
2077 }
2078 Ok(())
2079}
2080
2081#[allow(clippy::too_many_arguments)]
2082fn render_state_surface<D>(
2083 ctx: &mut RenderCtx<'_, D>,
2084 rect: Rect,
2085 surface: SurfaceState,
2086 title: &str,
2087 message: &str,
2088 action: Option<&str>,
2089 busy_phase: f32,
2090 style: WidgetStyle,
2091 state: VisualState,
2092) -> Result<(), D::Error>
2093where
2094 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2095{
2096 let style = style.resolve(state);
2097 let block = Block::styled(style)
2098 .title(title)
2099 .title_align(TextAlign::Center);
2100 block.render(rect, ctx)?;
2101 let inner = block.content_area(rect);
2102
2103 let badge = match surface {
2104 SurfaceState::Ready => "READY",
2105 SurfaceState::Loading => "LOADING",
2106 SurfaceState::Empty => "EMPTY",
2107 SurfaceState::Error => "ERROR",
2108 SurfaceState::Offline => "OFFLINE",
2109 };
2110 ctx.draw_text_in(
2111 Rect::new(inner.x, inner.y, inner.w, style.font.line_height()),
2112 badge,
2113 TextStyle::new(style.accent)
2114 .with_font(style.font)
2115 .centered(),
2116 )?;
2117
2118 if matches!(surface, SurfaceState::Loading) {
2119 let y = inner.y + style.font.line_height() as i32 + 3;
2120 let w = inner.w.saturating_sub(10);
2121 let x = inner.x + 5;
2122 ctx.stroke_rect(Rect::new(x, y, w, 5), Border::one(style.border.color))?;
2123 let t = busy_phase.fract().abs();
2124 let pulse = ((w as f32 * 0.2) as u32).max(2);
2125 let offset = ((w.saturating_sub(pulse) as f32) * t) as i32;
2126 ctx.fill_rect(Rect::new(x + offset, y + 1, pulse, 3), style.accent)?;
2127 }
2128
2129 ctx.draw_text_in(
2130 Rect::new(
2131 inner.x + 2,
2132 inner.y + style.font.line_height() as i32 + 10,
2133 inner.w.saturating_sub(4),
2134 inner.h.saturating_sub(style.font.line_height() + 20),
2135 ),
2136 message,
2137 TextStyle::new(style.text)
2138 .with_font(style.font)
2139 .with_align(TextAlign::Center)
2140 .with_wrap(TextWrap::Character),
2141 )?;
2142
2143 if let Some(action_label) = action {
2144 let action_h = style.font.line_height() + 3;
2145 let action_rect = Rect::new(
2146 inner.x + 4,
2147 inner.bottom() - action_h as i32 - 2,
2148 inner.w.saturating_sub(8),
2149 action_h,
2150 );
2151 ctx.stroke_rect(action_rect, Border::one(style.accent))?;
2152 ctx.draw_text_in(
2153 action_rect,
2154 action_label,
2155 TextStyle::new(style.accent)
2156 .with_font(style.font)
2157 .with_align(TextAlign::Center),
2158 )?;
2159 }
2160
2161 Ok(())
2162}
2163
2164fn render_heads_up_banner<D>(
2165 ctx: &mut RenderCtx<'_, D>,
2166 rect: Rect,
2167 level: NotificationLevel,
2168 text: &str,
2169 ttl_ms: u32,
2170 style: WidgetStyle,
2171 state: VisualState,
2172) -> Result<(), D::Error>
2173where
2174 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2175{
2176 if ttl_ms == 0 {
2177 return Ok(());
2178 }
2179 let mut style = style.resolve(state);
2180 style.accent = match level {
2181 NotificationLevel::Info => Rgb565::new(0, 32, 31),
2182 NotificationLevel::Success => Rgb565::new(0, 50, 0),
2183 NotificationLevel::Warning => Rgb565::new(31, 40, 0),
2184 NotificationLevel::Error => Rgb565::new(31, 0, 0),
2185 };
2186 let block = Block::styled(style);
2187 block.render(rect, ctx)?;
2188 ctx.draw_text_in(
2189 block.inner(rect),
2190 text,
2191 TextStyle::new(style.text)
2192 .with_font(style.font)
2193 .with_align(TextAlign::Center),
2194 )
2195}
2196
2197#[allow(clippy::too_many_arguments)]
2198fn render_notification_action_sheet<D>(
2199 ctx: &mut RenderCtx<'_, D>,
2200 rect: Rect,
2201 level: NotificationLevel,
2202 title: &str,
2203 body: &str,
2204 actions: &[&str],
2205 selected: usize,
2206 open: bool,
2207 style: WidgetStyle,
2208 state: VisualState,
2209) -> Result<(), D::Error>
2210where
2211 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2212{
2213 if !open {
2214 return Ok(());
2215 }
2216 let mut style = style.resolve(state);
2217 style.accent = match level {
2218 NotificationLevel::Info => Rgb565::new(0, 32, 31),
2219 NotificationLevel::Success => Rgb565::new(0, 50, 0),
2220 NotificationLevel::Warning => Rgb565::new(31, 40, 0),
2221 NotificationLevel::Error => Rgb565::new(31, 0, 0),
2222 };
2223 let block = Block::styled(style)
2224 .title(title)
2225 .title_align(TextAlign::Center);
2226 block.render(rect, ctx)?;
2227 let inner = block.content_area(rect);
2228 let body_h = inner.h.saturating_sub(style.font.line_height() + 12);
2229 ctx.draw_text_in(
2230 Rect::new(inner.x + 2, inner.y + 2, inner.w.saturating_sub(4), body_h),
2231 body,
2232 TextStyle::new(style.text)
2233 .with_font(style.font)
2234 .with_wrap(TextWrap::Character),
2235 )?;
2236 if actions.is_empty() {
2237 return Ok(());
2238 }
2239 let action_h = style.font.line_height() + 2;
2240 let y = inner.bottom() - action_h as i32 - 2;
2241 let action_w = (inner.w / actions.len() as u32).max(1);
2242 for (i, action) in actions.iter().enumerate() {
2243 let cell = Rect::new(
2244 inner.x + (i as u32 * action_w) as i32,
2245 y,
2246 action_w,
2247 action_h,
2248 );
2249 if i == selected.min(actions.len() - 1) {
2250 ctx.fill_rect(cell, style.accent)?;
2251 } else {
2252 ctx.stroke_rect(cell, Border::one(style.border.color))?;
2253 }
2254 ctx.draw_text_in(
2255 cell,
2256 action,
2257 TextStyle::new(style.text)
2258 .with_font(style.font)
2259 .with_align(TextAlign::Center),
2260 )?;
2261 }
2262 Ok(())
2263}
2264
2265#[allow(clippy::too_many_arguments)]
2266fn render_feed_timeline<D>(
2267 ctx: &mut RenderCtx<'_, D>,
2268 rect: Rect,
2269 items: &[&str],
2270 selected: usize,
2271 offset: usize,
2272 visible_rows: usize,
2273 expanded: bool,
2274 style: WidgetStyle,
2275 state: VisualState,
2276) -> Result<(), D::Error>
2277where
2278 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2279{
2280 let style = style.resolve(state);
2281 let block = Block::styled(style);
2282 block.render(rect, ctx)?;
2283 if items.is_empty() {
2284 return Ok(());
2285 }
2286 let inner = block.inner(rect);
2287 let rows = visible_rows.max(1).min(items.len());
2288 let row_h = (inner.h / rows as u32).max(1);
2289 for row_idx in 0..rows {
2290 let item_idx = offset.saturating_add(row_idx);
2291 if item_idx >= items.len() {
2292 break;
2293 }
2294 let row = Rect::new(
2295 inner.x,
2296 inner.y + (row_idx as u32 * row_h) as i32,
2297 inner.w,
2298 row_h,
2299 );
2300 let is_selected = item_idx == selected;
2301 if is_selected {
2302 ctx.fill_rect(row, style.accent)?;
2303 }
2304 ctx.draw_text_in(
2305 row.inset(EdgeInsets::symmetric(2, 1)),
2306 items[item_idx],
2307 TextStyle::new(style.text)
2308 .with_font(style.font)
2309 .with_wrap(TextWrap::Character),
2310 )?;
2311 if expanded && is_selected && row_h > style.font.line_height() + 4 {
2312 let detail = Rect::new(
2313 row.x + 2,
2314 row.y + style.font.line_height() as i32,
2315 row.w.saturating_sub(4),
2316 row.h.saturating_sub(style.font.line_height()),
2317 );
2318 ctx.draw_text_in(
2319 detail,
2320 "details...",
2321 TextStyle::new(style.text).with_font(style.font),
2322 )?;
2323 }
2324 }
2325 Ok(())
2326}
2327
2328fn draw_i32_right<D>(
2329 ctx: &mut RenderCtx<'_, D>,
2330 rect: Rect,
2331 value: i32,
2332 color: Rgb565,
2333) -> Result<(), D::Error>
2334where
2335 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2336{
2337 let mut buf = [0u8; 12];
2338 let mut n = value.unsigned_abs();
2339 let negative = value < 0;
2340 let mut pos = buf.len();
2341 if n == 0 {
2342 pos -= 1;
2343 buf[pos] = b'0';
2344 } else {
2345 while n > 0 && pos > usize::from(negative) {
2346 pos -= 1;
2347 buf[pos] = b'0' + (n % 10) as u8;
2348 n /= 10;
2349 }
2350 }
2351 if negative && pos > 0 {
2352 pos -= 1;
2353 buf[pos] = b'-';
2354 }
2355 let text = core::str::from_utf8(&buf[pos..]).unwrap_or("?");
2356 ctx.draw_text_in(
2357 rect,
2358 text,
2359 TextStyle {
2360 color,
2361 font: crate::font::FontId::Tiny3x5,
2362 opacity: 255,
2363 align: TextAlign::Right,
2364 vertical_align: VerticalAlign::Middle,
2365 wrap: TextWrap::None,
2366 overflow: crate::render::TextOverflow::Clip,
2367 overflow_policy: crate::render::TextOverflowPolicy::Global(
2368 crate::render::TextOverflow::Clip,
2369 ),
2370 kerning: false,
2371 max_lines: None,
2372 ellipsis: crate::render::EllipsisMode::ThreeDots,
2373 line_spacing: 0,
2374 },
2375 )
2376}
2377
2378impl Default for WidgetNode<'_> {
2379 fn default() -> Self {
2380 Self::new(
2381 WidgetId::new(0),
2382 Rect::empty(),
2383 WidgetKind::Spacer,
2384 WidgetStyle::new(Style {
2385 background: None,
2386 gradient: None,
2387 font: crate::font::FontId::Tiny3x5,
2388 foreground: Rgb565::WHITE,
2389 text: Rgb565::WHITE,
2390 accent: Rgb565::WHITE,
2391 opacity: 255,
2392 corner_radius: 0,
2393 shadow: None,
2394 border: Border::none(),
2395 padding: crate::geometry::EdgeInsets::all(0),
2396 }),
2397 )
2398 }
2399}