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::{Compositor, 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 #[cfg(feature = "rich-widgets")]
45 Toggle {
46 label: &'a str,
47 on: bool,
48 },
49 #[cfg(feature = "rich-widgets")]
50 Checkbox {
51 label: &'a str,
52 checked: bool,
53 },
54 #[cfg(feature = "rich-widgets")]
55 Slider {
56 value: f32,
57 min: f32,
58 max: f32,
59 },
60 #[cfg(feature = "rich-widgets")]
61 ValueLabel {
62 label: &'a str,
63 value: i32,
64 },
65 #[cfg(feature = "rich-widgets")]
66 IconButton {
67 icon: char,
68 label: &'a str,
69 },
70 #[cfg(feature = "rich-widgets")]
71 List {
72 items: &'a [&'a str],
73 selected: usize,
74 offset: usize,
75 visible_rows: usize,
76 },
77 #[cfg(feature = "rich-widgets")]
78 ScrollView {
79 offset_y: i32,
80 content_h: u32,
81 },
82 #[cfg(feature = "rich-widgets")]
83 Tabs {
84 labels: &'a [&'a str],
85 selected: usize,
86 },
87 #[cfg(feature = "rich-widgets")]
88 Dialog {
89 title: &'a str,
90 body: &'a str,
91 },
92 #[cfg(feature = "rich-widgets")]
93 Toast {
94 text: &'a str,
95 ttl_ms: u32,
96 },
97 #[cfg(feature = "rich-widgets")]
98 Meter {
99 value: f32,
100 min: f32,
101 max: f32,
102 },
103 #[cfg(feature = "rich-widgets")]
104 ArcGauge {
105 value: f32,
106 min: f32,
107 max: f32,
108 start_deg: i32,
109 end_deg: i32,
110 thickness: u8,
111 antialias: bool,
112 major_ticks: u8,
113 minor_ticks: u8,
114 show_value: bool,
115 },
116 #[cfg(feature = "rich-widgets")]
117 Gauge {
118 value: f32,
119 min: f32,
120 max: f32,
121 major_ticks: u8,
122 minor_ticks: u8,
123 show_value: bool,
124 },
125 #[cfg(feature = "rich-widgets")]
126 GaugeNeedle {
127 value: f32,
128 min: f32,
129 max: f32,
130 start_deg: i32,
131 end_deg: i32,
132 },
133 SweepingArc {
140 progress: f32,
141 clockwise: bool,
144 arc_radius: u32,
145 frame_inset: u16,
146 corner_radius: u8,
147 bg_color: Rgb565,
148 arc_color: Rgb565,
149 frame_color: Rgb565,
150 },
151 #[cfg(feature = "rich-widgets")]
152 Chart {
153 values: &'a [f32],
154 min: f32,
155 max: f32,
156 thickness: u8,
157 fill_under: bool,
158 markers: bool,
159 mode: ChartMode,
160 show_grid: bool,
161 show_axes: bool,
162 show_labels: bool,
163 },
164 Plotter {
165 values: &'a [f32],
166 head: usize,
167 min: f32,
168 max: f32,
169 thickness: u8,
170 show_grid: bool,
171 show_axes: bool,
172 },
173 CircularList {
174 items: &'a [&'a str],
175 selected: usize,
176 offset: usize,
177 visible_rows: usize,
178 },
179 Spinner {
180 phase: f32,
181 },
182 #[cfg(feature = "rich-widgets")]
183 Dropdown {
184 items: &'a [&'a str],
185 selected: usize,
186 open: bool,
187 },
188 #[cfg(feature = "rich-widgets")]
189 Roller {
190 items: &'a [&'a str],
191 selected: usize,
192 },
193 #[cfg(feature = "rich-widgets")]
194 Table {
195 rows: &'a [&'a [&'a str]],
196 separators: bool,
197 cell_padding: u8,
198 align: TextAlign,
199 },
200 #[cfg(feature = "rich-widgets")]
201 TextArea {
202 text_buf: [u8; TEXTAREA_CAPACITY],
203 text_len: u8,
204 cursor: usize,
205 placeholder: &'a str,
206 selection: Option<(usize, usize)>,
207 cursor_visible: bool,
208 read_only: bool,
209 single_line: bool,
210 accept_newline: bool,
211 },
212 #[cfg(feature = "rich-widgets")]
213 Keyboard {
214 keys: &'a [char],
215 selected: usize,
216 cols: u8,
217 alt_keys: Option<&'a [char]>,
218 layout: KeyboardLayout,
219 target: Option<WidgetId>,
220 },
221 Image {
222 image: ImageRef<'a>,
223 fit: ImageFit,
224 },
225 Border,
226 #[default]
227 Spacer,
228 #[cfg(feature = "rich-widgets")]
229 Menu {
230 items: &'a [&'a str],
231 selected: usize,
232 },
233 #[cfg(feature = "rich-widgets")]
234 PeekReveal {
235 icon: ImageRef<'a>,
236 title: &'a str,
237 subtitle: &'a str,
238 progress: f32,
239 },
240 #[cfg(feature = "rich-widgets")]
241 GlanceTile {
242 icon: char,
243 title: &'a str,
244 subtitle: &'a str,
245 highlighted: bool,
246 },
247 #[cfg(feature = "rich-widgets")]
248 CardDeck {
249 titles: &'a [&'a str],
250 selected: usize,
251 },
252 #[cfg(feature = "rich-widgets")]
253 Reel {
254 player: ReelPlayer<'a>,
255 fit: ImageFit,
256 },
257 #[cfg(feature = "rich-widgets")]
258 StateSurface {
259 state: SurfaceState,
260 title: &'a str,
261 message: &'a str,
262 action: Option<&'a str>,
263 busy_phase: f32,
264 },
265 #[cfg(feature = "rich-widgets")]
266 HeadsUpBanner {
267 level: NotificationLevel,
268 text: &'a str,
269 ttl_ms: u32,
270 },
271 #[cfg(feature = "rich-widgets")]
272 NotificationActionSheet {
273 level: NotificationLevel,
274 title: &'a str,
275 body: &'a str,
276 actions: &'a [&'a str],
277 selected: usize,
278 open: bool,
279 },
280 #[cfg(feature = "rich-widgets")]
281 FeedTimeline {
282 items: &'a [&'a str],
283 selected: usize,
284 offset: usize,
285 visible_rows: usize,
286 expanded: bool,
287 },
288 Dial {
289 value: f32,
290 min: f32,
291 max: f32,
292 },
293 RlePlayer {
294 rle_data: &'static [u8],
295 frame_width: u16,
296 frame_height: u16,
297 total_frames: usize,
298 current_frame: usize,
299 elapsed_ms: u32,
300 frame_duration_ms: u32,
301 },
302 AutoComplete {
303 text_buf: [u8; 32],
304 text_len: u8,
305 suggestions: &'a [&'a str],
306 filtered: [Option<&'a str>; 8],
307 filter_count: u8,
308 selected: Option<usize>,
309 expanded: bool,
310 },
311}
312
313#[derive(Clone, Copy, Debug, PartialEq, Eq)]
314pub enum ChartMode {
315 Line,
316 Bars,
317}
318
319#[derive(Clone, Copy, Debug, PartialEq, Eq)]
320pub enum KeyboardLayout {
321 Normal,
322 Shift,
323 Symbols,
324}
325
326impl WidgetKind<'_> {
327 pub const fn focusable(self) -> bool {
328 #[cfg(feature = "rich-widgets")]
329 if matches!(
330 self,
331 Self::Toggle { .. }
332 | Self::Checkbox { .. }
333 | Self::Slider { .. }
334 | Self::IconButton { .. }
335 | Self::List { .. }
336 | Self::CircularList { .. }
337 | Self::ScrollView { .. }
338 | Self::Tabs { .. }
339 | Self::Dropdown { .. }
340 | Self::Roller { .. }
341 | Self::TextArea { .. }
342 | Self::Keyboard { .. }
343 | Self::Menu { .. }
344 | Self::FeedTimeline { .. }
345 | Self::Dial { .. }
346 | Self::AutoComplete { .. }
347 ) {
348 return true;
349 }
350 matches!(self, Self::Button { .. } | Self::RlePlayer { .. })
351 }
352}
353
354#[derive(Clone, Copy, Debug, PartialEq)]
355pub struct WidgetNode<'a> {
356 pub id: WidgetId,
357 pub parent: Option<WidgetId>,
358 pub style_class: Option<StyleClassId>,
359 pub focus_group: FocusGroupId,
360 pub rect: Rect,
361 pub style: WidgetStyle,
362 pub kind: WidgetKind<'a>,
363 pub flags: WidgetFlags,
364}
365
366impl<'a> WidgetNode<'a> {
367 pub fn new<S>(id: WidgetId, rect: impl Into<Rect>, kind: WidgetKind<'a>, style: S) -> Self
368 where
369 S: Into<WidgetStyle>,
370 {
371 Self {
372 id,
373 parent: None,
374 style_class: None,
375 focus_group: FocusGroupId::ROOT,
376 rect: rect.into(),
377 style: style.into(),
378 kind,
379 flags: default_flags(kind),
380 }
381 }
382
383 pub const fn hidden(&self) -> bool {
384 self.flags.contains(WidgetFlags::HIDDEN)
385 }
386
387 pub const fn disabled(&self) -> bool {
388 self.flags.contains(WidgetFlags::DISABLED)
389 }
390
391 pub const fn clickable(&self) -> bool {
392 self.flags.contains(WidgetFlags::CLICKABLE)
393 }
394
395 pub const fn scrollable(&self) -> bool {
396 self.flags.contains(WidgetFlags::SCROLLABLE)
397 }
398
399 pub const fn clips_children(&self) -> bool {
400 self.flags.contains(WidgetFlags::CLIP_CHILDREN)
401 }
402
403 pub const fn focusable(&self) -> bool {
404 !self.hidden() && !self.disabled() && self.flags.contains(WidgetFlags::FOCUSABLE)
405 }
406
407 pub fn render<D, C>(
408 &self,
409 ctx: &mut RenderCtx<'_, D, C>,
410 state: VisualState,
411 ) -> Result<(), D::Error>
412 where
413 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
414 C: Compositor<D>,
415 {
416 self.render_at(ctx, self.rect, state)
417 }
418
419 pub fn render_at<D, C>(
420 &self,
421 ctx: &mut RenderCtx<'_, D, C>,
422 rect: Rect,
423 state: VisualState,
424 ) -> Result<(), D::Error>
425 where
426 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
427 C: Compositor<D>,
428 {
429 if self.hidden() {
430 return Ok(());
431 }
432
433 match self.kind {
434 WidgetKind::Panel => render_panel(ctx, rect, self.style, state),
435 WidgetKind::Label(text) => render_label(ctx, rect, text, self.style),
436 WidgetKind::Button(text) => render_button(ctx, rect, text, self.style, state),
437 WidgetKind::ProgressBar { value } => {
438 render_progress(ctx, rect, value, self.style, state)
439 }
440 #[cfg(feature = "rich-widgets")]
441 WidgetKind::Toggle { label, on } => {
442 render_toggle(ctx, rect, label, on, self.style, state)
443 }
444 #[cfg(feature = "rich-widgets")]
445 WidgetKind::Checkbox { label, checked } => {
446 render_checkbox(ctx, rect, label, checked, self.style, state)
447 }
448 #[cfg(feature = "rich-widgets")]
449 WidgetKind::Slider { value, min, max } => {
450 render_slider(ctx, rect, value, min, max, self.style, state)
451 }
452 #[cfg(feature = "rich-widgets")]
453 WidgetKind::ValueLabel { label, value } => {
454 render_value_label(ctx, rect, label, value, self.style, state)
455 }
456 #[cfg(feature = "rich-widgets")]
457 WidgetKind::IconButton { icon, label } => {
458 render_icon_button(ctx, rect, icon, label, self.style, state)
459 }
460 #[cfg(feature = "rich-widgets")]
461 WidgetKind::List {
462 items,
463 selected,
464 offset,
465 visible_rows,
466 } => render_list(
467 ctx,
468 rect,
469 items,
470 selected,
471 offset,
472 visible_rows,
473 self.style,
474 state,
475 ),
476 WidgetKind::CircularList {
477 items,
478 selected,
479 offset,
480 visible_rows,
481 } => render_circular_list(
482 ctx,
483 rect,
484 items,
485 selected,
486 offset,
487 visible_rows,
488 self.style,
489 state,
490 ),
491 #[cfg(feature = "rich-widgets")]
492 WidgetKind::ScrollView {
493 offset_y,
494 content_h,
495 } => render_scroll_view(ctx, rect, offset_y, content_h, self.style, state),
496 #[cfg(feature = "rich-widgets")]
497 WidgetKind::Tabs { labels, selected } => {
498 render_tabs(ctx, rect, labels, selected, self.style, state)
499 }
500 #[cfg(feature = "rich-widgets")]
501 WidgetKind::Dialog { title, body } => {
502 render_dialog(ctx, rect, title, body, self.style, state)
503 }
504 #[cfg(feature = "rich-widgets")]
505 WidgetKind::Toast { text, ttl_ms } => {
506 render_toast(ctx, rect, text, ttl_ms, self.style, state)
507 }
508 #[cfg(feature = "rich-widgets")]
509 WidgetKind::Meter { value, min, max } => {
510 render_meter(ctx, rect, value, min, max, self.style, state)
511 }
512 #[cfg(feature = "rich-widgets")]
513 WidgetKind::ArcGauge {
514 value,
515 min,
516 max,
517 start_deg,
518 end_deg,
519 thickness,
520 antialias,
521 major_ticks,
522 minor_ticks,
523 show_value,
524 } => render_arc_gauge(
525 ctx,
526 rect,
527 value,
528 min,
529 max,
530 start_deg,
531 end_deg,
532 thickness,
533 antialias,
534 major_ticks,
535 minor_ticks,
536 show_value,
537 self.style,
538 state,
539 ),
540 #[cfg(feature = "rich-widgets")]
541 WidgetKind::Gauge {
542 value,
543 min,
544 max,
545 major_ticks,
546 minor_ticks,
547 show_value,
548 } => render_gauge(
549 ctx,
550 rect,
551 value,
552 min,
553 max,
554 major_ticks,
555 minor_ticks,
556 show_value,
557 self.style,
558 state,
559 ),
560 #[cfg(feature = "rich-widgets")]
561 WidgetKind::GaugeNeedle {
562 value,
563 min,
564 max,
565 start_deg,
566 end_deg,
567 } => render_gauge_needle(
568 ctx, rect, value, min, max, start_deg, end_deg, self.style, state,
569 ),
570 WidgetKind::SweepingArc {
571 progress,
572 clockwise,
573 arc_radius,
574 frame_inset,
575 corner_radius,
576 bg_color,
577 arc_color,
578 frame_color,
579 } => render_sweeping_arc(
580 ctx,
581 rect,
582 progress,
583 clockwise,
584 arc_radius,
585 frame_inset,
586 corner_radius,
587 bg_color,
588 arc_color,
589 frame_color,
590 ),
591 #[cfg(feature = "rich-widgets")]
592 WidgetKind::Chart {
593 values,
594 min,
595 max,
596 thickness,
597 fill_under,
598 markers,
599 mode,
600 show_grid,
601 show_axes,
602 show_labels,
603 } => render_chart(
604 ctx,
605 rect,
606 values,
607 min,
608 max,
609 thickness,
610 fill_under,
611 markers,
612 mode,
613 show_grid,
614 show_axes,
615 show_labels,
616 self.style,
617 state,
618 ),
619 WidgetKind::Plotter {
620 values,
621 head,
622 min,
623 max,
624 thickness,
625 show_grid,
626 show_axes,
627 } => render_plotter(
628 ctx, rect, values, head, min, max, thickness, show_grid, show_axes, self.style,
629 state,
630 ),
631 WidgetKind::Spinner { phase } => render_spinner(ctx, rect, phase, self.style, state),
632 #[cfg(feature = "rich-widgets")]
633 WidgetKind::Dropdown {
634 items,
635 selected,
636 open,
637 } => render_dropdown(ctx, rect, items, selected, open, self.style, state),
638 #[cfg(feature = "rich-widgets")]
639 WidgetKind::Roller { items, selected } => {
640 render_roller(ctx, rect, items, selected, self.style, state)
641 }
642 #[cfg(feature = "rich-widgets")]
643 WidgetKind::Table {
644 rows,
645 separators,
646 cell_padding,
647 align,
648 } => render_table(
649 ctx,
650 rect,
651 rows,
652 separators,
653 cell_padding,
654 align,
655 self.style,
656 state,
657 ),
658 #[cfg(feature = "rich-widgets")]
659 WidgetKind::TextArea {
660 text_buf,
661 text_len,
662 cursor,
663 placeholder,
664 selection,
665 cursor_visible,
666 ..
667 } => render_textarea(
668 ctx,
669 rect,
670 textarea_text(&text_buf, text_len),
671 cursor,
672 placeholder,
673 selection,
674 cursor_visible,
675 self.style,
676 state,
677 ),
678 #[cfg(feature = "rich-widgets")]
679 WidgetKind::Keyboard {
680 keys,
681 selected,
682 cols,
683 alt_keys,
684 layout,
685 ..
686 } => render_keyboard(
687 ctx, rect, keys, selected, cols, alt_keys, layout, self.style, state,
688 ),
689 WidgetKind::Image { image, fit } => {
690 render_image(ctx, rect, image, fit, self.style, state)
691 }
692 WidgetKind::Border => ctx.stroke_rect(rect, self.style.resolve(state).border),
693 WidgetKind::Spacer => Ok(()),
694 #[cfg(feature = "rich-widgets")]
695 WidgetKind::Menu { items, selected } => {
696 render_menu(ctx, rect, items, selected, self.style, state)
697 }
698 #[cfg(feature = "rich-widgets")]
699 WidgetKind::PeekReveal {
700 icon,
701 title,
702 subtitle,
703 progress,
704 } => render_peek_reveal(
705 ctx, rect, icon, title, subtitle, progress, self.style, state,
706 ),
707 #[cfg(feature = "rich-widgets")]
708 WidgetKind::GlanceTile {
709 icon,
710 title,
711 subtitle,
712 highlighted,
713 } => render_glance_tile(
714 ctx,
715 rect,
716 icon,
717 title,
718 subtitle,
719 highlighted,
720 self.style,
721 state,
722 ),
723 #[cfg(feature = "rich-widgets")]
724 WidgetKind::CardDeck { titles, selected } => {
725 render_card_deck(ctx, rect, titles, selected, self.style, state)
726 }
727 #[cfg(feature = "rich-widgets")]
728 WidgetKind::Reel { player, fit } => {
729 render_reel(ctx, rect, player, fit, self.style, state)
730 }
731 #[cfg(feature = "rich-widgets")]
732 WidgetKind::StateSurface {
733 state: surface_state,
734 title,
735 message,
736 action,
737 busy_phase,
738 } => render_state_surface(
739 ctx,
740 rect,
741 surface_state,
742 title,
743 message,
744 action,
745 busy_phase,
746 self.style,
747 state,
748 ),
749 #[cfg(feature = "rich-widgets")]
750 WidgetKind::HeadsUpBanner {
751 level,
752 text,
753 ttl_ms,
754 } => render_heads_up_banner(ctx, rect, level, text, ttl_ms, self.style, state),
755 #[cfg(feature = "rich-widgets")]
756 WidgetKind::NotificationActionSheet {
757 level,
758 title,
759 body,
760 actions,
761 selected,
762 open,
763 } => render_notification_action_sheet(
764 ctx, rect, level, title, body, actions, selected, open, self.style, state,
765 ),
766 #[cfg(feature = "rich-widgets")]
767 WidgetKind::FeedTimeline {
768 items,
769 selected,
770 offset,
771 visible_rows,
772 expanded,
773 } => render_feed_timeline(
774 ctx,
775 rect,
776 items,
777 selected,
778 offset,
779 visible_rows,
780 expanded,
781 self.style,
782 state,
783 ),
784 WidgetKind::Dial { value, min, max } => {
785 render_dial(ctx, rect, value, min, max, self.style, state)
786 }
787 WidgetKind::RlePlayer {
788 rle_data,
789 frame_width,
790 frame_height,
791 current_frame,
792 ..
793 } => render_rle_player(
794 ctx,
795 rect,
796 rle_data,
797 current_frame,
798 frame_width,
799 frame_height,
800 self.style,
801 state,
802 ),
803 WidgetKind::AutoComplete {
804 text_buf,
805 text_len,
806 filtered,
807 filter_count,
808 selected,
809 expanded,
810 ..
811 } => render_autocomplete(
812 ctx,
813 rect,
814 &text_buf,
815 text_len,
816 &filtered,
817 filter_count,
818 selected,
819 expanded,
820 self.style,
821 state,
822 ),
823 }
824 }
825}
826
827const fn default_flags(kind: WidgetKind<'_>) -> WidgetFlags {
828 let mut flags = WidgetFlags::from_bits(
829 WidgetFlags::CLIP_CHILDREN.bits() | WidgetFlags::EVENT_BUBBLE.bits(),
830 );
831 if kind.focusable() {
832 flags = WidgetFlags::from_bits(
833 flags.bits() | WidgetFlags::FOCUSABLE.bits() | WidgetFlags::CLICKABLE.bits(),
834 );
835 }
836 #[cfg(feature = "rich-widgets")]
837 if matches!(kind, WidgetKind::ScrollView { .. }) {
838 flags = WidgetFlags::from_bits(flags.bits() | WidgetFlags::SCROLLABLE.bits());
839 }
840 flags
841}
842
843fn render_panel<D, C>(
844 ctx: &mut RenderCtx<'_, D, C>,
845 rect: Rect,
846 style: WidgetStyle,
847 state: VisualState,
848) -> Result<(), D::Error>
849where
850 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
851 C: Compositor<D>,
852{
853 let style = style.resolve(state);
854 Block::styled(style).render(rect, ctx)
855}
856
857fn render_label<D, C>(
858 ctx: &mut RenderCtx<'_, D, C>,
859 rect: Rect,
860 text: &str,
861 style: WidgetStyle,
862) -> Result<(), D::Error>
863where
864 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
865 C: Compositor<D>,
866{
867 let style = style.resolve(VisualState::Normal);
868 let block = Block::styled(style);
869 block.render(rect, ctx)?;
870 let inner = block.inner(rect);
871 ctx.draw_text_in(
872 inner,
873 text,
874 TextStyle::new(style.text).with_font(style.font),
875 )
876}
877
878fn render_button<D, C>(
879 ctx: &mut RenderCtx<'_, D, C>,
880 rect: Rect,
881 text: &str,
882 style: WidgetStyle,
883 state: VisualState,
884) -> Result<(), D::Error>
885where
886 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
887 C: Compositor<D>,
888{
889 let active_style = style.resolve(state);
890 let block = Block::styled(active_style);
891 block.render(rect, ctx)?;
892 let inner = block.inner(rect);
893 ctx.draw_text_in(
894 inner,
895 text,
896 TextStyle::new(active_style.text)
897 .with_font(active_style.font)
898 .centered(),
899 )
900}
901
902fn render_progress<D, C>(
903 ctx: &mut RenderCtx<'_, D, C>,
904 rect: Rect,
905 value: f32,
906 style: WidgetStyle,
907 state: VisualState,
908) -> Result<(), D::Error>
909where
910 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
911 C: Compositor<D>,
912{
913 let style = style.resolve(state);
914 let block = Block::styled(style);
915 block.render(rect, ctx)?;
916 let inner = block.inner(rect);
917 let fill_w = ((inner.w as f32 * value.clamp(0.0, 1.0)) as u32).min(inner.w);
918 if fill_w > 0 {
919 let color = if matches!(state, VisualState::Focused) {
920 style.accent
921 } else {
922 style.foreground
923 };
924 ctx.fill_rect(Rect::new(inner.x, inner.y, fill_w, inner.h), color)?;
925 }
926 Ok(())
927}
928
929#[cfg(feature = "rich-widgets")]
930fn render_toggle<D, C>(
931 ctx: &mut RenderCtx<'_, D, C>,
932 rect: Rect,
933 label: &str,
934 on: bool,
935 style: WidgetStyle,
936 state: VisualState,
937) -> Result<(), D::Error>
938where
939 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
940 C: Compositor<D>,
941{
942 let style = style.resolve(state);
943 let block = Block::styled(style);
944 block.render(rect, ctx)?;
945 let inner = block.inner(rect);
946 let knob_w = (inner.w / 4).max(8).min(inner.w);
947 let track = Rect::new(
948 inner.right() - knob_w as i32 - 2,
949 inner.y + 1,
950 knob_w,
951 inner.h.saturating_sub(2),
952 );
953 ctx.fill_rect(
954 track,
955 if on {
956 style.accent
957 } else {
958 Rgb565::new(7, 10, 10)
959 },
960 )?;
961 ctx.draw_text_in(
962 Rect::new(
963 inner.x,
964 inner.y,
965 inner.w.saturating_sub(knob_w + 4),
966 inner.h,
967 ),
968 label,
969 TextStyle::new(style.text).with_font(style.font),
970 )
971}
972
973#[cfg(feature = "rich-widgets")]
974fn render_checkbox<D, C>(
975 ctx: &mut RenderCtx<'_, D, C>,
976 rect: Rect,
977 label: &str,
978 checked: bool,
979 style: WidgetStyle,
980 state: VisualState,
981) -> Result<(), D::Error>
982where
983 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
984 C: Compositor<D>,
985{
986 let style = style.resolve(state);
987 let block = Block::styled(style);
988 block.render(rect, ctx)?;
989 let inner = block.inner(rect);
990 let box_size = inner.h.min(8);
991 let box_rect = Rect::new(
992 inner.x,
993 inner.y + (inner.h.saturating_sub(box_size) as i32 / 2),
994 box_size,
995 box_size,
996 );
997 ctx.stroke_rect(box_rect, Border::one(style.text))?;
998 if checked && box_size > 4 {
999 ctx.fill_rect(
1000 box_rect.inset(crate::geometry::EdgeInsets::all(2)),
1001 style.accent,
1002 )?;
1003 }
1004 ctx.draw_text_in(
1005 Rect::new(
1006 inner.x + box_size as i32 + 3,
1007 inner.y,
1008 inner.w.saturating_sub(box_size + 3),
1009 inner.h,
1010 ),
1011 label,
1012 TextStyle::new(style.text).with_font(style.font),
1013 )
1014}
1015
1016#[cfg(feature = "rich-widgets")]
1017fn render_slider<D, C>(
1018 ctx: &mut RenderCtx<'_, D, C>,
1019 rect: Rect,
1020 value: f32,
1021 min: f32,
1022 max: f32,
1023 style: WidgetStyle,
1024 state: VisualState,
1025) -> Result<(), D::Error>
1026where
1027 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1028 C: Compositor<D>,
1029{
1030 let style = style.resolve(state);
1031 let block = Block::styled(style);
1032 block.render(rect, ctx)?;
1033 let inner = block.inner(rect);
1034 let range = (max - min).max(f32::EPSILON);
1035 let t = ((value - min) / range).clamp(0.0, 1.0);
1036 let track_y = inner.y + inner.h as i32 / 2;
1037 ctx.fill_rect(Rect::new(inner.x, track_y, inner.w, 1), style.text)?;
1038 let knob_x = inner.x + ((inner.w.saturating_sub(3) as f32 * t) as i32);
1039 ctx.fill_rect(Rect::new(knob_x, track_y - 2, 3, 5), style.accent)
1040}
1041
1042fn render_dial<D, C>(
1043 ctx: &mut RenderCtx<'_, D, C>,
1044 rect: Rect,
1045 value: f32,
1046 min: f32,
1047 max: f32,
1048 style: WidgetStyle,
1049 state: VisualState,
1050) -> Result<(), D::Error>
1051where
1052 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1053 C: Compositor<D>,
1054{
1055 let style = style.resolve(state);
1056 let block = Block::styled(style);
1057 block.render(rect, ctx)?;
1058 let inner = block.inner(rect);
1059
1060 let cx = inner.x + inner.w as i32 / 2;
1061 let cy = inner.y + inner.h as i32 / 2;
1062 let radius = (inner.w.min(inner.h) as i32 / 2).saturating_sub(2);
1063
1064 if radius > 0 {
1065 ctx.stroke_circle(cx, cy, radius as u32, style.text)?;
1066
1067 let range = (max - min).max(f32::EPSILON);
1068 let t = ((value - min) / range).clamp(0.0, 1.0);
1069
1070 #[cfg(not(feature = "std"))]
1071 use crate::math::F32Ext as _;
1072
1073 let angle = t * 2.0 * core::f32::consts::PI - (core::f32::consts::PI / 2.0);
1074 let cos_val = angle.cos();
1075 let sin_val = angle.sin();
1076
1077 let px = cx + (radius as f32 * cos_val).round() as i32;
1078 let py = cy + (radius as f32 * sin_val).round() as i32;
1079
1080 ctx.draw_line(cx, cy, px, py, style.accent)?;
1081 ctx.fill_circle(cx, cy, 2, style.accent)?;
1082 }
1083
1084 Ok(())
1085}
1086
1087#[allow(clippy::too_many_arguments, clippy::needless_range_loop)]
1088fn render_rle_player<D, C>(
1089 ctx: &mut RenderCtx<'_, D, C>,
1090 rect: Rect,
1091 rle_data: &[u8],
1092 current_frame: usize,
1093 frame_w: u16,
1094 frame_h: u16,
1095 style: WidgetStyle,
1096 state: VisualState,
1097) -> Result<(), D::Error>
1098where
1099 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1100 C: Compositor<D>,
1101{
1102 let style = style.resolve(state);
1103 let block = Block::styled(style);
1104 block.render(rect, ctx)?;
1105 let inner = block.inner(rect);
1106
1107 if rle_data.len() < 3 {
1108 return Ok(());
1109 }
1110 let total_frames = u16::from_le_bytes([rle_data[0], rle_data[1]]) as usize;
1111 if current_frame >= total_frames {
1112 return Ok(());
1113 }
1114 let offset_start = 2 + current_frame * 4;
1115 if offset_start + 4 > rle_data.len() {
1116 return Ok(());
1117 }
1118 let frame_offset = u32::from_le_bytes([
1119 rle_data[offset_start],
1120 rle_data[offset_start + 1],
1121 rle_data[offset_start + 2],
1122 rle_data[offset_start + 3],
1123 ]) as usize;
1124
1125 if frame_offset >= rle_data.len() {
1126 return Ok(());
1127 }
1128 let pal_size = rle_data[frame_offset] as usize;
1129 let mut pal_colors = [Rgb565::BLACK; 256];
1130 let pal_colors_start = frame_offset + 1;
1131 for i in 0..pal_size {
1132 let idx = pal_colors_start + i * 2;
1133 if idx + 2 <= rle_data.len() {
1134 let color_u16 = u16::from_le_bytes([rle_data[idx], rle_data[idx + 1]]);
1135 let r = ((color_u16 >> 11) & 0x1F) as u8;
1136 let g = ((color_u16 >> 5) & 0x3F) as u8;
1137 let b = (color_u16 & 0x1F) as u8;
1138 pal_colors[i] = Rgb565::new(r, g, b);
1139 }
1140 }
1141
1142 let runs_start = pal_colors_start + pal_size * 2;
1143 let mut cur_x = 0i32;
1144 let mut cur_y = 0i32;
1145 let mut idx = runs_start;
1146
1147 while idx + 2 <= rle_data.len() {
1148 let run_len = rle_data[idx] as i32;
1149 let pal_idx = rle_data[idx + 1] as usize;
1150 idx += 2;
1151
1152 if run_len == 0 {
1153 break;
1154 }
1155
1156 let color = if pal_idx < pal_size {
1157 pal_colors[pal_idx]
1158 } else {
1159 Rgb565::BLACK
1160 };
1161
1162 for _ in 0..run_len {
1163 if cur_y >= frame_h as i32 {
1164 break;
1165 }
1166 let px = inner.x + cur_x;
1167 let py = inner.y + cur_y;
1168 if inner.contains(px, py) {
1169 ctx.fill_rect(Rect::new(px, py, 1, 1), color)?;
1170 }
1171
1172 cur_x += 1;
1173 if cur_x >= frame_w as i32 {
1174 cur_x = 0;
1175 cur_y += 1;
1176 }
1177 }
1178 }
1179
1180 Ok(())
1181}
1182
1183#[allow(clippy::too_many_arguments, clippy::needless_range_loop)]
1184fn render_autocomplete<D, C>(
1185 ctx: &mut RenderCtx<'_, D, C>,
1186 rect: Rect,
1187 text_buf: &[u8; 32],
1188 text_len: u8,
1189 filtered: &[Option<&str>; 8],
1190 filter_count: u8,
1191 selected: Option<usize>,
1192 expanded: bool,
1193 style: WidgetStyle,
1194 state: VisualState,
1195) -> Result<(), D::Error>
1196where
1197 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1198 C: Compositor<D>,
1199{
1200 let style = style.resolve(state);
1201 let block = Block::styled(style);
1202
1203 let row_h = style.font.line_height();
1204 let input_h = row_h.saturating_add(4);
1205 let input_rect = Rect::new(rect.x, rect.y, rect.w, input_h);
1206
1207 block.render(input_rect, ctx)?;
1208 let inner = block.inner(input_rect);
1209
1210 let current_text = core::str::from_utf8(&text_buf[..text_len as usize]).unwrap_or("");
1211 if text_len == 0 {
1212 ctx.draw_text_in(
1213 inner,
1214 "Search...",
1215 TextStyle::new(Rgb565::new(16, 32, 16)).with_font(style.font),
1216 )?;
1217 } else {
1218 ctx.draw_text_in(
1219 inner,
1220 current_text,
1221 TextStyle::new(style.text).with_font(style.font),
1222 )?;
1223
1224 if state == VisualState::Focused {
1225 let cursor_x =
1226 inner.x + current_text.chars().count() as i32 * style.font.advance() as i32;
1227 if cursor_x < inner.right() {
1228 ctx.fill_rect(Rect::new(cursor_x, inner.y, 1, inner.h), style.accent)?;
1229 }
1230 }
1231 }
1232
1233 ctx.draw_text_in(
1234 Rect::new(inner.right() - 7, inner.y, 7, inner.h),
1235 if expanded { "^" } else { "v" },
1236 TextStyle::new(style.accent)
1237 .with_font(style.font)
1238 .centered(),
1239 )?;
1240
1241 if expanded && filter_count > 0 {
1242 let popup_h = (row_h.saturating_add(2))
1243 .saturating_mul(filter_count as u32)
1244 .min(100);
1245 let popup = Rect::new(rect.x, input_rect.bottom() + 1, rect.w, popup_h);
1246 ctx.fill_rect(popup, style.background.unwrap_or(Rgb565::new(4, 6, 8)))?;
1247 ctx.stroke_rect(popup, Border::one(style.border.color))?;
1248
1249 for i in 0..filter_count as usize {
1250 if let Some(s) = filtered[i] {
1251 let row_y = popup.y + i as i32 * (row_h as i32 + 2);
1252 let row_rect = Rect::new(popup.x + 1, row_y + 1, popup.w.saturating_sub(2), row_h);
1253
1254 if selected == Some(i) {
1255 ctx.fill_rect(row_rect, style.accent)?;
1256 ctx.draw_text_in(
1257 Rect::new(row_rect.x + 2, row_rect.y, row_rect.w - 2, row_rect.h),
1258 s,
1259 TextStyle::new(style.foreground).with_font(style.font),
1260 )?;
1261 } else {
1262 ctx.draw_text_in(
1263 Rect::new(row_rect.x + 2, row_rect.y, row_rect.w - 2, row_rect.h),
1264 s,
1265 TextStyle::new(style.text).with_font(style.font),
1266 )?;
1267 }
1268 }
1269 }
1270 }
1271
1272 Ok(())
1273}
1274
1275#[cfg(feature = "rich-widgets")]
1276fn render_value_label<D, C>(
1277 ctx: &mut RenderCtx<'_, D, C>,
1278 rect: Rect,
1279 label: &str,
1280 value: i32,
1281 style: WidgetStyle,
1282 state: VisualState,
1283) -> Result<(), D::Error>
1284where
1285 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1286 C: Compositor<D>,
1287{
1288 let style = style.resolve(state);
1289 let block = Block::styled(style);
1290 block.render(rect, ctx)?;
1291 let inner = block.inner(rect);
1292 ctx.draw_text_in(
1293 Rect::new(inner.x, inner.y, inner.w / 2, inner.h),
1294 label,
1295 TextStyle::new(style.text).with_font(style.font),
1296 )?;
1297 draw_i32_right(
1298 ctx,
1299 Rect::new(
1300 inner.x + (inner.w / 2) as i32,
1301 inner.y,
1302 inner.w - inner.w / 2,
1303 inner.h,
1304 ),
1305 value,
1306 style.accent,
1307 )
1308}
1309
1310#[cfg(feature = "rich-widgets")]
1311fn render_icon_button<D, C>(
1312 ctx: &mut RenderCtx<'_, D, C>,
1313 rect: Rect,
1314 icon: char,
1315 label: &str,
1316 style: WidgetStyle,
1317 state: VisualState,
1318) -> Result<(), D::Error>
1319where
1320 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1321 C: Compositor<D>,
1322{
1323 let style = style.resolve(state);
1324 let block = Block::styled(style);
1325 block.render(rect, ctx)?;
1326 let inner = block.inner(rect);
1327 let mut icon_buf = [0u8; 4];
1328 let icon_str = icon.encode_utf8(&mut icon_buf);
1329 ctx.draw_text_in(
1330 Rect::new(inner.x, inner.y, 8, inner.h),
1331 icon_str,
1332 TextStyle::new(style.accent)
1333 .with_font(style.font)
1334 .centered(),
1335 )?;
1336 ctx.draw_text_in(
1337 Rect::new(inner.x + 10, inner.y, inner.w.saturating_sub(10), inner.h),
1338 label,
1339 TextStyle::new(style.text).with_font(style.font),
1340 )
1341}
1342
1343#[allow(clippy::too_many_arguments)]
1344#[cfg(feature = "rich-widgets")]
1345fn render_list<D, C>(
1346 ctx: &mut RenderCtx<'_, D, C>,
1347 rect: Rect,
1348 items: &[&str],
1349 selected: usize,
1350 offset: usize,
1351 visible_rows: usize,
1352 style: WidgetStyle,
1353 state: VisualState,
1354) -> Result<(), D::Error>
1355where
1356 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1357 C: Compositor<D>,
1358{
1359 let style = style.resolve(state);
1360 let block = Block::styled(style);
1361 block.render(rect, ctx)?;
1362 if items.is_empty() {
1363 return Ok(());
1364 }
1365 let inner = block.inner(rect);
1366 let rows = visible_rows.max(1).min(items.len());
1367 let row_h = (inner.h / rows as u32).max(1);
1368 for row_idx in 0..rows {
1369 let item_idx = offset.saturating_add(row_idx);
1370 if item_idx >= items.len() {
1371 break;
1372 }
1373 let row = Rect::new(
1374 inner.x,
1375 inner.y + (row_idx as u32 * row_h) as i32,
1376 inner.w,
1377 row_h,
1378 );
1379 if item_idx == selected {
1380 ctx.fill_rect(row, style.accent)?;
1381 }
1382 ctx.draw_text_in(
1383 row.inset(crate::geometry::EdgeInsets::symmetric(2, 1)),
1384 items[item_idx],
1385 TextStyle {
1386 color: style.text,
1387 font: style.font,
1388 opacity: style.opacity,
1389 align: TextAlign::Left,
1390 vertical_align: VerticalAlign::Middle,
1391 wrap: TextWrap::None,
1392 overflow: crate::render::TextOverflow::Clip,
1393 overflow_policy: crate::render::TextOverflowPolicy::Global(
1394 crate::render::TextOverflow::Clip,
1395 ),
1396 kerning: false,
1397 max_lines: None,
1398 ellipsis: crate::render::EllipsisMode::ThreeDots,
1399 line_spacing: 0,
1400 },
1401 )?;
1402 }
1403 Ok(())
1404}
1405
1406#[allow(clippy::too_many_arguments)]
1407fn render_circular_list<D, C>(
1408 ctx: &mut RenderCtx<'_, D, C>,
1409 rect: Rect,
1410 items: &[&str],
1411 selected: usize,
1412 offset: usize,
1413 visible_rows: usize,
1414 style: WidgetStyle,
1415 state: VisualState,
1416) -> Result<(), D::Error>
1417where
1418 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1419 C: Compositor<D>,
1420{
1421 let style = style.resolve(state);
1422 let block = Block::styled(style);
1423 block.render(rect, ctx)?;
1424 if items.is_empty() {
1425 return Ok(());
1426 }
1427 let inner = block.inner(rect);
1428 let rows = visible_rows.max(1).min(items.len());
1429 let row_h = (inner.h / rows as u32).max(1);
1430
1431 let center_y = inner.y + (inner.h as i32) / 2;
1432 let half_h = (inner.h as f32 / 2.0).max(1.0);
1433 let max_shift = (inner.w as f32 * 0.25).max(8.0);
1434
1435 for row_idx in 0..rows {
1436 let item_idx = offset.saturating_add(row_idx);
1437 if item_idx >= items.len() {
1438 break;
1439 }
1440
1441 let item_center_y = inner.y + (row_idx as u32 * row_h + row_h / 2) as i32;
1442 let dy = (item_center_y - center_y) as f32;
1443
1444 let normalized_dist = dy / half_h;
1445 let x_shift = (normalized_dist * normalized_dist * max_shift) as i32;
1446
1447 let row = Rect::new(
1448 inner.x + x_shift,
1449 inner.y + (row_idx as u32 * row_h) as i32,
1450 inner.w.saturating_sub(x_shift as u32),
1451 row_h,
1452 );
1453
1454 if item_idx == selected {
1455 ctx.fill_rect(row, style.accent)?;
1456 }
1457
1458 ctx.draw_text_in(
1459 row.inset(crate::geometry::EdgeInsets::symmetric(2, 4)),
1460 items[item_idx],
1461 TextStyle {
1462 color: if item_idx == selected {
1463 style.background.unwrap_or(style.text)
1464 } else {
1465 style.text
1466 },
1467 font: style.font,
1468 opacity: style.opacity,
1469 align: TextAlign::Left,
1470 vertical_align: VerticalAlign::Middle,
1471 wrap: TextWrap::None,
1472 overflow: crate::render::TextOverflow::Clip,
1473 overflow_policy: crate::render::TextOverflowPolicy::Global(
1474 crate::render::TextOverflow::Clip,
1475 ),
1476 kerning: false,
1477 max_lines: None,
1478 ellipsis: crate::render::EllipsisMode::ThreeDots,
1479 line_spacing: 0,
1480 },
1481 )?;
1482 }
1483 Ok(())
1484}
1485
1486#[cfg(feature = "rich-widgets")]
1487fn render_scroll_view<D, C>(
1488 ctx: &mut RenderCtx<'_, D, C>,
1489 rect: Rect,
1490 offset_y: i32,
1491 content_h: u32,
1492 style: WidgetStyle,
1493 state: VisualState,
1494) -> Result<(), D::Error>
1495where
1496 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1497 C: Compositor<D>,
1498{
1499 let style = style.resolve(state);
1500 let block = Block::styled(style);
1501 block.render(rect, ctx)?;
1502 if content_h > rect.h {
1503 let inner = block.inner(rect);
1504 let thumb_h = ((inner.h as u64 * inner.h as u64) / content_h.max(1) as u64)
1505 .max(4)
1506 .min(inner.h as u64) as u32;
1507 let max_offset = content_h.saturating_sub(inner.h).max(1) as i32;
1508 let y = inner.y
1509 + ((inner.h.saturating_sub(thumb_h) as i32 * offset_y.clamp(0, max_offset))
1510 / max_offset);
1511 ctx.fill_rect(Rect::new(inner.right() - 3, y, 2, thumb_h), style.accent)?;
1512 }
1513 Ok(())
1514}
1515
1516#[cfg(feature = "rich-widgets")]
1517fn render_tabs<D, C>(
1518 ctx: &mut RenderCtx<'_, D, C>,
1519 rect: Rect,
1520 labels: &[&str],
1521 selected: usize,
1522 style: WidgetStyle,
1523 state: VisualState,
1524) -> Result<(), D::Error>
1525where
1526 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1527 C: Compositor<D>,
1528{
1529 let style = style.resolve(state);
1530 let block = Block::styled(style);
1531 block.render(rect, ctx)?;
1532 if labels.is_empty() {
1533 return Ok(());
1534 }
1535 let inner = block.inner(rect);
1536 let tab_w = (inner.w / labels.len() as u32).max(1);
1537 for (idx, label) in labels.iter().enumerate() {
1538 let tab = Rect::new(
1539 inner.x + (idx as u32 * tab_w) as i32,
1540 inner.y,
1541 tab_w,
1542 inner.h,
1543 );
1544 if idx == selected {
1545 ctx.fill_rect(tab, style.accent)?;
1546 }
1547 ctx.draw_text_in(
1548 tab.inset(EdgeInsets::all(1)),
1549 label,
1550 TextStyle::new(style.text).with_font(style.font).centered(),
1551 )?;
1552 }
1553 Ok(())
1554}
1555
1556#[cfg(feature = "rich-widgets")]
1557fn render_dialog<D, C>(
1558 ctx: &mut RenderCtx<'_, D, C>,
1559 rect: Rect,
1560 title: &str,
1561 body: &str,
1562 style: WidgetStyle,
1563 state: VisualState,
1564) -> Result<(), D::Error>
1565where
1566 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1567 C: Compositor<D>,
1568{
1569 let style = style.resolve(state);
1570 let block = Block::styled(style)
1571 .title(title)
1572 .title_align(TextAlign::Center);
1573 block.render(rect, ctx)?;
1574 let inner = block.content_area(rect);
1575 ctx.draw_text_in(
1576 inner,
1577 body,
1578 TextStyle {
1579 color: style.text,
1580 font: style.font,
1581 opacity: style.opacity,
1582 align: TextAlign::Center,
1583 vertical_align: VerticalAlign::Middle,
1584 wrap: TextWrap::Character,
1585 overflow: crate::render::TextOverflow::Clip,
1586 overflow_policy: crate::render::TextOverflowPolicy::Global(
1587 crate::render::TextOverflow::Clip,
1588 ),
1589 kerning: false,
1590 max_lines: None,
1591 ellipsis: crate::render::EllipsisMode::ThreeDots,
1592 line_spacing: 1,
1593 },
1594 )
1595}
1596
1597#[cfg(feature = "rich-widgets")]
1598fn render_toast<D, C>(
1599 ctx: &mut RenderCtx<'_, D, C>,
1600 rect: Rect,
1601 text: &str,
1602 ttl_ms: u32,
1603 style: WidgetStyle,
1604 state: VisualState,
1605) -> Result<(), D::Error>
1606where
1607 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1608 C: Compositor<D>,
1609{
1610 if ttl_ms == 0 {
1611 return Ok(());
1612 }
1613 let style = style.resolve(state);
1614 let block = Block::styled(style);
1615 block.render(rect, ctx)?;
1616 ctx.draw_text_in(
1617 block.inner(rect),
1618 text,
1619 TextStyle {
1620 color: style.text,
1621 font: style.font,
1622 opacity: style.opacity,
1623 align: TextAlign::Center,
1624 vertical_align: VerticalAlign::Middle,
1625 wrap: TextWrap::Character,
1626 overflow: crate::render::TextOverflow::Clip,
1627 overflow_policy: crate::render::TextOverflowPolicy::Global(
1628 crate::render::TextOverflow::Clip,
1629 ),
1630 kerning: false,
1631 max_lines: None,
1632 ellipsis: crate::render::EllipsisMode::ThreeDots,
1633 line_spacing: 0,
1634 },
1635 )
1636}
1637
1638#[cfg(feature = "rich-widgets")]
1639fn render_meter<D, C>(
1640 ctx: &mut RenderCtx<'_, D, C>,
1641 rect: Rect,
1642 value: f32,
1643 min: f32,
1644 max: f32,
1645 style: WidgetStyle,
1646 state: VisualState,
1647) -> Result<(), D::Error>
1648where
1649 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1650 C: Compositor<D>,
1651{
1652 let style = style.resolve(state);
1653 let block = Block::styled(style);
1654 block.render(rect, ctx)?;
1655 let inner = block.inner(rect);
1656 let range = (max - min).max(f32::EPSILON);
1657 let t = ((value - min) / range).clamp(0.0, 1.0);
1658 let bars = 10usize;
1659 let gap = 1u32;
1660 let bar_w = inner
1661 .w
1662 .saturating_sub(gap * (bars as u32 - 1))
1663 .max(bars as u32)
1664 / bars as u32;
1665 for i in 0..bars {
1666 let x = inner.x + (i as u32 * (bar_w + gap)) as i32;
1667 let active = (i as f32) < t * bars as f32;
1668 let h = ((inner.h as f32 * (i + 1) as f32 / bars as f32) as u32).max(1);
1669 let y = inner.bottom() - h as i32;
1670 ctx.fill_rect(
1671 Rect::new(x, y, bar_w, h),
1672 if active {
1673 style.accent
1674 } else {
1675 Rgb565::new(5, 8, 8)
1676 },
1677 )?;
1678 }
1679 Ok(())
1680}
1681
1682#[allow(clippy::too_many_arguments)]
1683#[cfg(feature = "rich-widgets")]
1684fn render_arc_gauge<D, C>(
1685 ctx: &mut RenderCtx<'_, D, C>,
1686 rect: Rect,
1687 value: f32,
1688 min: f32,
1689 max: f32,
1690 start_deg: i32,
1691 end_deg: i32,
1692 thickness: u8,
1693 antialias: bool,
1694 major_ticks: u8,
1695 minor_ticks: u8,
1696 show_value: bool,
1697 style: WidgetStyle,
1698 state: VisualState,
1699) -> Result<(), D::Error>
1700where
1701 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1702 C: Compositor<D>,
1703{
1704 let style = style.resolve(state);
1705 let block = Block::styled(style);
1706 block.render(rect, ctx)?;
1707 let inner = block.inner(rect);
1708 let cx = inner.x + inner.w as i32 / 2;
1709 let cy = inner.y + inner.h as i32 / 2;
1710 let radius = (inner.w.min(inner.h) / 2).saturating_sub(1);
1711 let track = Rgb565::new(5, 8, 8);
1712 draw_arc_ticks(
1713 ctx,
1714 cx,
1715 cy,
1716 radius.saturating_sub((thickness.max(1) / 2) as u32),
1717 start_deg,
1718 end_deg,
1719 major_ticks,
1720 minor_ticks,
1721 track,
1722 )?;
1723 ctx.stroke_arc_styled(
1724 cx,
1725 cy,
1726 radius,
1727 start_deg,
1728 end_deg,
1729 StrokeStyle::new(track)
1730 .with_width(thickness)
1731 .with_antialias(antialias),
1732 )?;
1733 let range = (max - min).max(f32::EPSILON);
1734 let t = ((value - min) / range).clamp(0.0, 1.0);
1735 let active_end = start_deg + (((end_deg - start_deg) as f32) * t) as i32;
1736 ctx.stroke_arc_styled(
1737 cx,
1738 cy,
1739 radius,
1740 start_deg,
1741 active_end,
1742 StrokeStyle::new(style.accent)
1743 .with_width(thickness)
1744 .with_antialias(antialias),
1745 )?;
1746 if show_value {
1747 draw_gauge_value_label(ctx, inner, value, min, max, style)?;
1748 }
1749 Ok(())
1750}
1751
1752#[allow(clippy::too_many_arguments)]
1753fn render_sweeping_arc<D, C>(
1754 ctx: &mut RenderCtx<'_, D, C>,
1755 rect: Rect,
1756 progress: f32,
1757 clockwise: bool,
1758 arc_radius: u32,
1759 frame_inset: u16,
1760 corner_radius: u8,
1761 bg_color: Rgb565,
1762 arc_color: Rgb565,
1763 frame_color: Rgb565,
1764) -> Result<(), D::Error>
1765where
1766 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1767 C: Compositor<D>,
1768{
1769 ctx.fill_rect(rect, bg_color)?;
1771 let cx = rect.x + rect.w as i32 / 2;
1773 let cy = rect.y + rect.h as i32 / 2;
1774 let sweep = progress.clamp(0.0, 1.0) * 360.0;
1775 let signed_sweep = if clockwise { -sweep } else { sweep };
1776 ctx.fill_sector_sweep(cx, cy, arc_radius, -90.0, signed_sweep, arc_color)?;
1777 let inset = frame_inset as i32;
1779 let fw = (rect.w as i32 - 2 * inset).max(0) as u32;
1780 let fh = (rect.h as i32 - 2 * inset).max(0) as u32;
1781 let frame = Rect::new(rect.x + inset, rect.y + inset, fw, fh);
1782 ctx.fill_rounded_rect(frame, corner_radius, frame_color)?;
1783 ctx.stroke_rounded_rect(frame, corner_radius, Border::one(frame_color))?;
1784 Ok(())
1785}
1786
1787#[allow(clippy::too_many_arguments)]
1788#[cfg(feature = "rich-widgets")]
1789fn render_gauge<D, C>(
1790 ctx: &mut RenderCtx<'_, D, C>,
1791 rect: Rect,
1792 value: f32,
1793 min: f32,
1794 max: f32,
1795 major_ticks: u8,
1796 minor_ticks: u8,
1797 show_value: bool,
1798 style: WidgetStyle,
1799 state: VisualState,
1800) -> Result<(), D::Error>
1801where
1802 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1803 C: Compositor<D>,
1804{
1805 render_arc_gauge(
1806 ctx,
1807 rect,
1808 value,
1809 min,
1810 max,
1811 135,
1812 405,
1813 2,
1814 true,
1815 major_ticks,
1816 minor_ticks,
1817 show_value,
1818 style,
1819 state,
1820 )
1821}
1822
1823#[allow(clippy::too_many_arguments)]
1824#[cfg(feature = "rich-widgets")]
1825fn render_gauge_needle<D, C>(
1826 ctx: &mut RenderCtx<'_, D, C>,
1827 rect: Rect,
1828 value: f32,
1829 min: f32,
1830 max: f32,
1831 start_deg: i32,
1832 end_deg: i32,
1833 style: WidgetStyle,
1834 state: VisualState,
1835) -> Result<(), D::Error>
1836where
1837 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1838 C: Compositor<D>,
1839{
1840 let style = style.resolve(state);
1841 let block = Block::styled(style);
1842 block.render(rect, ctx)?;
1843 let inner = block.inner(rect);
1844 let cx = inner.x + inner.w as i32 / 2;
1845 let cy = inner.y + inner.h as i32 / 2;
1846 let radius = (inner.w.min(inner.h) / 2).saturating_sub(2);
1847 ctx.stroke_arc_styled(
1848 cx,
1849 cy,
1850 radius,
1851 start_deg,
1852 end_deg,
1853 StrokeStyle::new(Rgb565::new(8, 10, 10)).with_width(1),
1854 )?;
1855 let range = (max - min).max(f32::EPSILON);
1856 let t = ((value - min) / range).clamp(0.0, 1.0);
1857 let angle = (start_deg as f32 + (end_deg - start_deg) as f32 * t).to_radians();
1858 let nx = cx + (radius as f32 * angle.cos()) as i32;
1859 let ny = cy + (radius as f32 * angle.sin()) as i32;
1860 ctx.draw_line_styled(
1861 cx,
1862 cy,
1863 nx,
1864 ny,
1865 StrokeStyle::new(style.accent)
1866 .with_width(2)
1867 .with_antialias(true)
1868 .with_cap(crate::render::StrokeCap::Round),
1869 )?;
1870 ctx.fill_circle(cx, cy, 2, style.accent)
1871}
1872
1873#[allow(clippy::too_many_arguments)]
1874#[cfg(feature = "rich-widgets")]
1875fn render_chart<D, C>(
1876 ctx: &mut RenderCtx<'_, D, C>,
1877 rect: Rect,
1878 values: &[f32],
1879 min: f32,
1880 max: f32,
1881 thickness: u8,
1882 fill_under: bool,
1883 markers: bool,
1884 mode: ChartMode,
1885 show_grid: bool,
1886 show_axes: bool,
1887 show_labels: bool,
1888 style: WidgetStyle,
1889 state: VisualState,
1890) -> Result<(), D::Error>
1891where
1892 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
1893 C: Compositor<D>,
1894{
1895 let style = style.resolve(state);
1896 let block = Block::styled(style);
1897 block.render(rect, ctx)?;
1898 if values.len() < 2 {
1899 return Ok(());
1900 }
1901 let inner = block.inner(rect);
1902 if show_grid {
1903 for row in [1u32, 2, 3] {
1904 let y = inner.y + ((inner.h.saturating_sub(1) * row) / 4) as i32;
1905 ctx.draw_line_styled(
1906 inner.x,
1907 y,
1908 inner.right().saturating_sub(1),
1909 y,
1910 StrokeStyle::new(Rgb565::new(6, 10, 10)).with_width(1),
1911 )?;
1912 }
1913 }
1914 if show_axes {
1915 let axis = Rgb565::new(12, 18, 18);
1916 ctx.draw_line_styled(
1917 inner.x,
1918 inner.y,
1919 inner.x,
1920 inner.bottom().saturating_sub(1),
1921 StrokeStyle::new(axis).with_width(1),
1922 )?;
1923 ctx.draw_line_styled(
1924 inner.x,
1925 inner.bottom().saturating_sub(1),
1926 inner.right().saturating_sub(1),
1927 inner.bottom().saturating_sub(1),
1928 StrokeStyle::new(axis).with_width(1),
1929 )?;
1930 }
1931 if show_labels {
1932 let mut max_label: String<12> = String::new();
1933 let _ = write!(&mut max_label, "{:.1}", max);
1934 let mut min_label: String<12> = String::new();
1935 let _ = write!(&mut min_label, "{:.1}", min);
1936 ctx.draw_text_in(
1937 Rect::new(
1938 inner.x + 1,
1939 inner.y,
1940 inner.w.saturating_sub(2),
1941 style.font.line_height(),
1942 ),
1943 max_label.as_str(),
1944 TextStyle::new(style.text).with_font(style.font),
1945 )?;
1946 ctx.draw_text_in(
1947 Rect::new(
1948 inner.x + 1,
1949 inner
1950 .bottom()
1951 .saturating_sub(style.font.line_height() as i32),
1952 inner.w.saturating_sub(2),
1953 style.font.line_height(),
1954 ),
1955 min_label.as_str(),
1956 TextStyle::new(style.text).with_font(style.font),
1957 )?;
1958 }
1959 let range = (max - min).max(f32::EPSILON);
1960 match mode {
1961 ChartMode::Line => {
1962 let dx = (inner.w.saturating_sub(1) as f32) / (values.len().saturating_sub(1) as f32);
1963 for i in 1..values.len() {
1964 let v0 = ((values[i - 1] - min) / range).clamp(0.0, 1.0);
1965 let v1 = ((values[i] - min) / range).clamp(0.0, 1.0);
1966 let x0 = inner.x + ((i - 1) as f32 * dx) as i32;
1967 let x1 = inner.x + (i as f32 * dx) as i32;
1968 let y0 = inner.bottom() - 1 - (v0 * (inner.h.saturating_sub(1)) as f32) as i32;
1969 let y1 = inner.bottom() - 1 - (v1 * (inner.h.saturating_sub(1)) as f32) as i32;
1970 if fill_under {
1971 let base = inner.bottom() - 1;
1972 ctx.fill_polygon(
1973 &[
1974 embedded_graphics_core::geometry::Point::new(x0, base),
1975 embedded_graphics_core::geometry::Point::new(x0, y0),
1976 embedded_graphics_core::geometry::Point::new(x1, y1),
1977 embedded_graphics_core::geometry::Point::new(x1, base),
1978 ],
1979 Rgb565::new(2, 8, 2),
1980 )?;
1981 }
1982 ctx.draw_line_styled(
1983 x0,
1984 y0,
1985 x1,
1986 y1,
1987 StrokeStyle::new(style.accent)
1988 .with_width(thickness.max(1))
1989 .with_antialias(true),
1990 )?;
1991 if markers {
1992 ctx.fill_circle(x0, y0, 1, style.accent)?;
1993 ctx.fill_circle(x1, y1, 1, style.accent)?;
1994 }
1995 }
1996 }
1997 ChartMode::Bars => {
1998 let count = values.len() as u32;
1999 let gap = 1u32;
2000 let bar_w = inner
2001 .w
2002 .saturating_sub(gap.saturating_mul(count.saturating_sub(1)))
2003 .max(count)
2004 / count;
2005 for (i, value) in values.iter().copied().enumerate() {
2006 let t = ((value - min) / range).clamp(0.0, 1.0);
2007 let h = (t * inner.h.saturating_sub(1) as f32) as u32;
2008 let x = inner.x + (i as u32 * (bar_w + gap)) as i32;
2009 let y = inner.bottom().saturating_sub(h as i32 + 1);
2010 let bar = Rect::new(x, y, bar_w.max(1), h.max(1));
2011 ctx.fill_rect(bar, style.accent)?;
2012 if markers {
2013 ctx.fill_circle(x + (bar_w / 2) as i32, y, 1, style.text)?;
2014 }
2015 }
2016 }
2017 }
2018 Ok(())
2019}
2020
2021#[allow(clippy::too_many_arguments)]
2022fn render_plotter<D, C>(
2023 ctx: &mut RenderCtx<'_, D, C>,
2024 rect: Rect,
2025 values: &[f32],
2026 head: usize,
2027 min: f32,
2028 max: f32,
2029 thickness: u8,
2030 show_grid: bool,
2031 show_axes: bool,
2032 style: WidgetStyle,
2033 state: VisualState,
2034) -> Result<(), D::Error>
2035where
2036 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2037 C: Compositor<D>,
2038{
2039 let style = style.resolve(state);
2040 let block = Block::styled(style);
2041 block.render(rect, ctx)?;
2042 if values.len() < 2 {
2043 return Ok(());
2044 }
2045 let inner = block.inner(rect);
2046 if show_grid {
2047 for row in [1u32, 2, 3] {
2048 let y = inner.y + ((inner.h.saturating_sub(1) * row) / 4) as i32;
2049 ctx.draw_line_styled(
2050 inner.x,
2051 y,
2052 inner.right().saturating_sub(1),
2053 y,
2054 StrokeStyle::new(Rgb565::new(6, 10, 10)).with_width(1),
2055 )?;
2056 }
2057 }
2058 if show_axes {
2059 let axis = Rgb565::new(12, 18, 18);
2060 ctx.draw_line_styled(
2061 inner.x,
2062 inner.y,
2063 inner.x,
2064 inner.bottom().saturating_sub(1),
2065 StrokeStyle::new(axis).with_width(1),
2066 )?;
2067 ctx.draw_line_styled(
2068 inner.x,
2069 inner.bottom().saturating_sub(1),
2070 inner.right().saturating_sub(1),
2071 inner.bottom().saturating_sub(1),
2072 StrokeStyle::new(axis).with_width(1),
2073 )?;
2074 }
2075
2076 let range = (max - min).max(f32::EPSILON);
2077 let dx = (inner.w.saturating_sub(1) as f32) / (values.len().saturating_sub(1) as f32);
2078
2079 for i in 1..values.len() {
2080 let idx0 = (head + i - 1) % values.len();
2081 let idx1 = (head + i) % values.len();
2082
2083 let v0 = ((values[idx0] - min) / range).clamp(0.0, 1.0);
2084 let v1 = ((values[idx1] - min) / range).clamp(0.0, 1.0);
2085
2086 let x0 = inner.x + ((i - 1) as f32 * dx) as i32;
2087 let x1 = inner.x + (i as f32 * dx) as i32;
2088
2089 let y0 = inner.bottom() - 1 - (v0 * (inner.h.saturating_sub(1)) as f32) as i32;
2090 let y1 = inner.bottom() - 1 - (v1 * (inner.h.saturating_sub(1)) as f32) as i32;
2091
2092 ctx.draw_line_styled(
2093 x0,
2094 y0,
2095 x1,
2096 y1,
2097 StrokeStyle::new(style.accent)
2098 .with_width(thickness.max(1))
2099 .with_antialias(true),
2100 )?;
2101 }
2102
2103 Ok(())
2104}
2105
2106fn render_spinner<D, C>(
2107 ctx: &mut RenderCtx<'_, D, C>,
2108 rect: Rect,
2109 phase: f32,
2110 style: WidgetStyle,
2111 state: VisualState,
2112) -> Result<(), D::Error>
2113where
2114 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2115 C: Compositor<D>,
2116{
2117 let style = style.resolve(state);
2118 let block = Block::styled(style);
2119 block.render(rect, ctx)?;
2120 let inner = block.inner(rect);
2121 let cx = inner.x + inner.w as i32 / 2;
2122 let cy = inner.y + inner.h as i32 / 2;
2123 let radius = (inner.w.min(inner.h) / 2).saturating_sub(1);
2124 let base = ((phase.fract() * 360.0) as i32).rem_euclid(360);
2125 ctx.stroke_arc_styled(
2126 cx,
2127 cy,
2128 radius,
2129 base,
2130 base + 120,
2131 StrokeStyle::new(style.accent)
2132 .with_width(2)
2133 .with_antialias(true),
2134 )
2135}
2136
2137#[cfg(feature = "rich-widgets")]
2138fn render_dropdown<D, C>(
2139 ctx: &mut RenderCtx<'_, D, C>,
2140 rect: Rect,
2141 items: &[&str],
2142 selected: usize,
2143 open: bool,
2144 style: WidgetStyle,
2145 state: VisualState,
2146) -> Result<(), D::Error>
2147where
2148 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2149 C: Compositor<D>,
2150{
2151 let style = style.resolve(state);
2152 let block = Block::styled(style);
2153 block.render(rect, ctx)?;
2154 let inner = block.inner(rect);
2155 let text = items.get(selected).copied().unwrap_or("-");
2156 ctx.draw_text_in(
2157 Rect::new(inner.x, inner.y, inner.w.saturating_sub(8), inner.h),
2158 text,
2159 TextStyle::new(style.text).with_font(style.font),
2160 )?;
2161 ctx.draw_text_in(
2162 Rect::new(inner.right() - 7, inner.y, 7, inner.h),
2163 if open { "^" } else { "v" },
2164 TextStyle::new(style.accent)
2165 .with_font(style.font)
2166 .centered(),
2167 )?;
2168 if open {
2169 let row_h = style.font.line_height().max(6);
2170 let popup_h = (row_h.saturating_mul(items.len() as u32))
2171 .min(40)
2172 .max(row_h);
2173 let popup = Rect::new(inner.x, inner.bottom() + 1, inner.w, popup_h);
2174 ctx.fill_rect(popup, style.background.unwrap_or(Rgb565::new(8, 12, 16)))?;
2175 ctx.stroke_rect(popup, Border::one(style.border.color))?;
2176 let visible = (popup_h / row_h).max(1) as usize;
2177 let start = selected
2178 .saturating_sub(visible / 2)
2179 .min(items.len().saturating_sub(visible));
2180 for (i, item) in items.iter().enumerate().skip(start).take(visible) {
2181 let row = Rect::new(
2182 popup.x + 1,
2183 popup.y + ((i - start) as u32 * row_h) as i32,
2184 popup.w.saturating_sub(2),
2185 row_h,
2186 );
2187 if i == selected {
2188 ctx.fill_rect(row, style.accent)?;
2189 }
2190 ctx.draw_text_in(
2191 row.inset(EdgeInsets::all(1)),
2192 item,
2193 TextStyle::new(style.text).with_font(style.font),
2194 )?;
2195 }
2196 }
2197 Ok(())
2198}
2199
2200#[cfg(feature = "rich-widgets")]
2201fn render_roller<D, C>(
2202 ctx: &mut RenderCtx<'_, D, C>,
2203 rect: Rect,
2204 items: &[&str],
2205 selected: usize,
2206 style: WidgetStyle,
2207 state: VisualState,
2208) -> Result<(), D::Error>
2209where
2210 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2211 C: Compositor<D>,
2212{
2213 let style = style.resolve(state);
2214 let block = Block::styled(style);
2215 block.render(rect, ctx)?;
2216 if items.is_empty() {
2217 return Ok(());
2218 }
2219 let inner = block.inner(rect);
2220 let prev = items[(selected + items.len() - 1) % items.len()];
2221 let cur = items[selected];
2222 let next = items[(selected + 1) % items.len()];
2223 let row_h = (inner.h / 3).max(1);
2224 let rows = [prev, cur, next];
2225 for (idx, text) in rows.iter().enumerate() {
2226 let row = Rect::new(
2227 inner.x,
2228 inner.y + (idx as u32 * row_h) as i32,
2229 inner.w,
2230 row_h,
2231 );
2232 if idx == 1 {
2233 ctx.fill_rect(row, style.accent)?;
2234 }
2235 ctx.draw_text_in(
2236 row,
2237 text,
2238 TextStyle::new(style.text).with_font(style.font).centered(),
2239 )?;
2240 }
2241 Ok(())
2242}
2243
2244#[allow(clippy::too_many_arguments)]
2245#[cfg(feature = "rich-widgets")]
2246fn render_table<D, C>(
2247 ctx: &mut RenderCtx<'_, D, C>,
2248 rect: Rect,
2249 rows: &[&[&str]],
2250 separators: bool,
2251 cell_padding: u8,
2252 align: TextAlign,
2253 style: WidgetStyle,
2254 state: VisualState,
2255) -> Result<(), D::Error>
2256where
2257 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2258 C: Compositor<D>,
2259{
2260 let style = style.resolve(state);
2261 let block = Block::styled(style);
2262 block.render(rect, ctx)?;
2263 if rows.is_empty() {
2264 return Ok(());
2265 }
2266 let inner = block.inner(rect);
2267 let row_h = (inner.h / rows.len() as u32).max(1);
2268 let max_cols = rows.iter().map(|row| row.len()).max().unwrap_or(1).max(1);
2269 let col_w = (inner.w / max_cols as u32).max(1);
2270 for (r, cols) in rows.iter().enumerate() {
2271 for c in 0..max_cols {
2272 let text = cols.get(c).copied().unwrap_or("");
2273 let cell = Rect::new(
2274 inner.x + (c as u32 * col_w) as i32,
2275 inner.y + (r as u32 * row_h) as i32,
2276 col_w,
2277 row_h,
2278 );
2279 if separators {
2280 ctx.stroke_rect(cell, Border::one(style.border.color))?;
2281 }
2282 ctx.draw_text_in(
2283 cell.inset(EdgeInsets::all(cell_padding as i16)),
2284 text,
2285 TextStyle::new(style.text)
2286 .with_font(style.font)
2287 .with_align(align),
2288 )?;
2289 }
2290 }
2291 Ok(())
2292}
2293
2294#[allow(clippy::too_many_arguments)]
2295#[cfg(feature = "rich-widgets")]
2296fn draw_arc_ticks<D, C>(
2297 ctx: &mut RenderCtx<'_, D, C>,
2298 cx: i32,
2299 cy: i32,
2300 radius: u32,
2301 start_deg: i32,
2302 end_deg: i32,
2303 major_ticks: u8,
2304 minor_ticks: u8,
2305 color: Rgb565,
2306) -> Result<(), D::Error>
2307where
2308 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2309 C: Compositor<D>,
2310{
2311 let major_ticks = major_ticks.max(1);
2312 let minor_ticks = minor_ticks.max(1);
2313 let total_steps = (major_ticks as u32).saturating_mul(minor_ticks as u32);
2314 for step in 0..=total_steps {
2315 let t = if total_steps == 0 {
2316 0.0
2317 } else {
2318 step as f32 / total_steps as f32
2319 };
2320 let angle = (start_deg as f32 + (end_deg - start_deg) as f32 * t).to_radians();
2321 let is_major = step % minor_ticks as u32 == 0;
2322 let tick_len = if is_major { 4 } else { 2 };
2323 let outer_x = cx + (radius as f32 * angle.cos()) as i32;
2324 let outer_y = cy + (radius as f32 * angle.sin()) as i32;
2325 let inner_x = cx + ((radius.saturating_sub(tick_len)) as f32 * angle.cos()) as i32;
2326 let inner_y = cy + ((radius.saturating_sub(tick_len)) as f32 * angle.sin()) as i32;
2327 ctx.draw_line_styled(
2328 inner_x,
2329 inner_y,
2330 outer_x,
2331 outer_y,
2332 StrokeStyle::new(color).with_width(1),
2333 )?;
2334 }
2335 Ok(())
2336}
2337
2338#[cfg(feature = "rich-widgets")]
2339fn draw_gauge_value_label<D, C>(
2340 ctx: &mut RenderCtx<'_, D, C>,
2341 inner: Rect,
2342 value: f32,
2343 min: f32,
2344 max: f32,
2345 style: Style,
2346) -> Result<(), D::Error>
2347where
2348 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2349 C: Compositor<D>,
2350{
2351 let range = (max - min).max(f32::EPSILON);
2352 let percent = (((value - min) / range).clamp(0.0, 1.0) * 100.0).round() as i32;
2353 let mut label: String<8> = String::new();
2354 let _ = write!(&mut label, "{}%", percent);
2355 ctx.draw_text_in(
2356 Rect::new(
2357 inner.x,
2358 inner.y + (inner.h as i32 / 2) - (style.font.line_height() as i32 / 2),
2359 inner.w,
2360 style.font.line_height(),
2361 ),
2362 label.as_str(),
2363 TextStyle::new(style.text)
2364 .with_font(style.font)
2365 .with_align(TextAlign::Center),
2366 )
2367}
2368
2369#[allow(clippy::too_many_arguments)]
2370#[cfg(feature = "rich-widgets")]
2371fn render_textarea<D, C>(
2372 ctx: &mut RenderCtx<'_, D, C>,
2373 rect: Rect,
2374 text: &str,
2375 cursor: usize,
2376 placeholder: &str,
2377 selection: Option<(usize, usize)>,
2378 cursor_visible: bool,
2379 style: WidgetStyle,
2380 state: VisualState,
2381) -> Result<(), D::Error>
2382where
2383 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2384 C: Compositor<D>,
2385{
2386 let style = style.resolve(state);
2387 let block = Block::styled(style);
2388 block.render(rect, ctx)?;
2389 let inner = block.inner(rect).inset(EdgeInsets::all(1));
2390 let max_chars = (inner.w / style.font.advance()).max(1) as usize;
2391 let shown = if text.is_empty() { placeholder } else { text };
2392 let color = if text.is_empty() {
2393 Rgb565::new(
2394 style.text.r().saturating_sub(8),
2395 style.text.g().saturating_sub(10),
2396 style.text.b().saturating_sub(8),
2397 )
2398 } else {
2399 style.text
2400 };
2401 if !text.is_empty() {
2402 if let Some((start, end)) = selection {
2403 let start = start.min(end).min(text.chars().count());
2404 let end = end.max(start).min(text.chars().count());
2405 for idx in start..end {
2406 let (col, row) = textarea_grid_position(text, idx, max_chars);
2407 let sel_rect = Rect::new(
2408 inner.x + (col as u32 * style.font.advance()) as i32,
2409 inner.y + (row as u32 * style.font.line_height()) as i32,
2410 style.font.advance(),
2411 style.font.line_height().min(inner.h),
2412 );
2413 ctx.fill_rect(sel_rect, style.accent)?;
2414 }
2415 }
2416 }
2417 ctx.draw_text_in(
2418 inner,
2419 shown,
2420 TextStyle::new(color)
2421 .with_font(style.font)
2422 .with_wrap(TextWrap::Character),
2423 )?;
2424 let chars = text.chars().count();
2425 let cursor = cursor.min(chars);
2426 if state == VisualState::Focused && cursor_visible {
2427 let (col, row) = textarea_grid_position(text, cursor, max_chars);
2428 let x = inner.x + (col as u32 * style.font.advance()) as i32;
2429 let y = inner.y + (row as u32 * style.font.line_height()) as i32;
2430 let caret = Rect::new(x, y, 1, style.font.line_height().min(inner.h));
2431 ctx.fill_rect(caret, style.accent)?;
2432 }
2433 Ok(())
2434}
2435
2436#[cfg(feature = "rich-widgets")]
2437fn textarea_grid_position(text: &str, cursor: usize, max_chars: usize) -> (usize, usize) {
2438 let mut row = 0usize;
2439 let mut col = 0usize;
2440 for ch in text.chars().take(cursor) {
2441 if ch == '\n' {
2442 row += 1;
2443 col = 0;
2444 continue;
2445 }
2446 col += 1;
2447 if col >= max_chars {
2448 row += 1;
2449 col = 0;
2450 }
2451 }
2452 (col, row)
2453}
2454
2455#[cfg(feature = "rich-widgets")]
2456fn textarea_text(buf: &[u8; TEXTAREA_CAPACITY], len: u8) -> &str {
2457 let used = (len as usize).min(TEXTAREA_CAPACITY);
2458 core::str::from_utf8(&buf[..used]).unwrap_or("")
2459}
2460
2461#[allow(clippy::too_many_arguments)]
2462#[cfg(feature = "rich-widgets")]
2463fn render_keyboard<D, C>(
2464 ctx: &mut RenderCtx<'_, D, C>,
2465 rect: Rect,
2466 keys: &[char],
2467 selected: usize,
2468 cols: u8,
2469 alt_keys: Option<&[char]>,
2470 layout: KeyboardLayout,
2471 style: WidgetStyle,
2472 state: VisualState,
2473) -> Result<(), D::Error>
2474where
2475 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2476 C: Compositor<D>,
2477{
2478 let style = style.resolve(state);
2479 let block = Block::styled(style);
2480 block.render(rect, ctx)?;
2481 if keys.is_empty() {
2482 return Ok(());
2483 }
2484 let inner = block.inner(rect).inset(EdgeInsets::all(1));
2485 let cols = cols.max(1) as usize;
2486 let rows = keys.len().div_ceil(cols).max(1);
2487 let cell_w = (inner.w / cols as u32).max(1);
2488 let cell_h = (inner.h / rows as u32).max(1);
2489 for (idx, key) in keys.iter().copied().enumerate() {
2490 let col = idx % cols;
2491 let row = idx / cols;
2492 let cell = Rect::new(
2493 inner.x + (col as u32 * cell_w) as i32,
2494 inner.y + (row as u32 * cell_h) as i32,
2495 cell_w,
2496 cell_h,
2497 );
2498 if idx == selected.min(keys.len() - 1) {
2499 ctx.fill_rect(cell, style.accent)?;
2500 }
2501 let rendered = keyboard_key_for_layout(key, idx, keys, alt_keys, layout);
2502 let mut label = [0u8; 4];
2503 let text = rendered.encode_utf8(&mut label);
2504 ctx.draw_text_in(
2505 cell.inset(EdgeInsets::all(1)),
2506 text,
2507 TextStyle::new(style.text).with_font(style.font).centered(),
2508 )?;
2509 }
2510 Ok(())
2511}
2512
2513#[cfg(feature = "rich-widgets")]
2514fn keyboard_key_for_layout(
2515 base: char,
2516 idx: usize,
2517 base_keys: &[char],
2518 alt_keys: Option<&[char]>,
2519 layout: KeyboardLayout,
2520) -> char {
2521 match layout {
2522 KeyboardLayout::Normal => base,
2523 KeyboardLayout::Shift => {
2524 if base.is_ascii_alphabetic() {
2525 base.to_ascii_uppercase()
2526 } else {
2527 base
2528 }
2529 }
2530 KeyboardLayout::Symbols => alt_keys
2531 .and_then(|keys| keys.get(idx).copied())
2532 .or_else(|| {
2533 const FALLBACK: [char; 10] = ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')'];
2534 FALLBACK.get(idx % FALLBACK.len()).copied()
2535 })
2536 .unwrap_or_else(|| base_keys.get(idx).copied().unwrap_or(base)),
2537 }
2538}
2539
2540#[cfg(feature = "rich-widgets")]
2541fn render_menu<D, C>(
2542 ctx: &mut RenderCtx<'_, D, C>,
2543 rect: Rect,
2544 items: &[&str],
2545 selected: usize,
2546 style: WidgetStyle,
2547 state: VisualState,
2548) -> Result<(), D::Error>
2549where
2550 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2551 C: Compositor<D>,
2552{
2553 let style = style.resolve(state);
2554 let block = Block::styled(style);
2555 block.render(rect, ctx)?;
2556
2557 if items.is_empty() {
2558 return Ok(());
2559 }
2560
2561 let inner = block.inner(rect);
2562 let row_h = (inner.h / items.len() as u32).max(1);
2563 for (i, item) in items.iter().enumerate() {
2564 let row = Rect::new(inner.x, inner.y + (i as u32 * row_h) as i32, inner.w, row_h);
2565 let is_selected = i == selected;
2566 if is_selected {
2567 ctx.fill_rect(row, style.accent)?;
2568 }
2569 ctx.draw_text_in(
2570 row.inset(crate::geometry::EdgeInsets::symmetric(2, 1)),
2571 item,
2572 TextStyle {
2573 color: style.text,
2574 font: style.font,
2575 opacity: style.opacity,
2576 align: TextAlign::Left,
2577 vertical_align: VerticalAlign::Middle,
2578 wrap: TextWrap::None,
2579 overflow: crate::render::TextOverflow::Clip,
2580 overflow_policy: crate::render::TextOverflowPolicy::Global(
2581 crate::render::TextOverflow::Clip,
2582 ),
2583 kerning: false,
2584 max_lines: None,
2585 ellipsis: crate::render::EllipsisMode::ThreeDots,
2586 line_spacing: 0,
2587 },
2588 )?;
2589 }
2590 Ok(())
2591}
2592
2593fn render_image<D, C>(
2594 ctx: &mut RenderCtx<'_, D, C>,
2595 rect: Rect,
2596 image: ImageRef<'_>,
2597 fit: ImageFit,
2598 style: WidgetStyle,
2599 state: VisualState,
2600) -> Result<(), D::Error>
2601where
2602 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2603 C: Compositor<D>,
2604{
2605 let style = style.resolve(state);
2606 let block = Block::styled(style);
2607 block.render(rect, ctx)?;
2608 ctx.draw_image(block.inner(rect), image, fit)
2609}
2610
2611#[allow(clippy::too_many_arguments)]
2612#[cfg(feature = "rich-widgets")]
2613fn render_peek_reveal<D, C>(
2614 ctx: &mut RenderCtx<'_, D, C>,
2615 rect: Rect,
2616 icon: ImageRef<'_>,
2617 title: &str,
2618 subtitle: &str,
2619 progress: f32,
2620 style: WidgetStyle,
2621 state: VisualState,
2622) -> Result<(), D::Error>
2623where
2624 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2625 C: Compositor<D>,
2626{
2627 let style = style.resolve(state);
2628 let block = Block::styled(style);
2629 block.render(rect, ctx)?;
2630 let inner = block.inner(rect);
2631 let t = progress.clamp(0.0, 1.0);
2632 let icon_size = ((inner.h.min(inner.w / 3) as f32) * (0.2 + 0.8 * t))
2633 .max(2.0)
2634 .round() as u32;
2635 let icon_rect = Rect::new(inner.x + 1, inner.y + 1, icon_size, icon_size);
2636 ctx.draw_image(icon_rect, icon, ImageFit::Stretch)?;
2637 if t > 0.25 {
2638 ctx.draw_text_in(
2639 Rect::new(
2640 inner.x + icon_size as i32 + 2,
2641 inner.y,
2642 inner.w.saturating_sub(icon_size + 2),
2643 inner.h / 2,
2644 ),
2645 title,
2646 TextStyle::new(style.text).with_font(style.font),
2647 )?;
2648 }
2649 if t > 0.5 {
2650 ctx.draw_text_in(
2651 Rect::new(
2652 inner.x + icon_size as i32 + 2,
2653 inner.y + (inner.h / 2) as i32,
2654 inner.w.saturating_sub(icon_size + 2),
2655 inner.h / 2,
2656 ),
2657 subtitle,
2658 TextStyle::new(style.accent).with_font(style.font),
2659 )?;
2660 }
2661 Ok(())
2662}
2663
2664#[allow(clippy::too_many_arguments)]
2665#[cfg(feature = "rich-widgets")]
2666fn render_glance_tile<D, C>(
2667 ctx: &mut RenderCtx<'_, D, C>,
2668 rect: Rect,
2669 icon: char,
2670 title: &str,
2671 subtitle: &str,
2672 highlighted: bool,
2673 style: WidgetStyle,
2674 state: VisualState,
2675) -> Result<(), D::Error>
2676where
2677 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2678 C: Compositor<D>,
2679{
2680 let style = style.resolve(state);
2681 let block = Block::styled(style);
2682 block.render(rect, ctx)?;
2683 let inner = block.inner(rect);
2684 if highlighted {
2685 ctx.fill_rect(Rect::new(inner.x, inner.y, inner.w, 2), style.accent)?;
2686 }
2687 let mut icon_buf = [0u8; 4];
2688 let icon_str = icon.encode_utf8(&mut icon_buf);
2689 ctx.draw_text_in(
2690 Rect::new(inner.x, inner.y, 10, inner.h),
2691 icon_str,
2692 TextStyle::new(style.accent)
2693 .with_font(style.font)
2694 .centered(),
2695 )?;
2696 ctx.draw_text_in(
2697 Rect::new(
2698 inner.x + 12,
2699 inner.y,
2700 inner.w.saturating_sub(12),
2701 inner.h / 2,
2702 ),
2703 title,
2704 TextStyle::new(style.text).with_font(style.font),
2705 )?;
2706 ctx.draw_text_in(
2707 Rect::new(
2708 inner.x + 12,
2709 inner.y + (inner.h / 2) as i32,
2710 inner.w.saturating_sub(12),
2711 inner.h / 2,
2712 ),
2713 subtitle,
2714 TextStyle::new(style.accent).with_font(style.font),
2715 )?;
2716 Ok(())
2717}
2718
2719#[cfg(feature = "rich-widgets")]
2720fn render_card_deck<D, C>(
2721 ctx: &mut RenderCtx<'_, D, C>,
2722 rect: Rect,
2723 titles: &[&str],
2724 selected: usize,
2725 style: WidgetStyle,
2726 state: VisualState,
2727) -> Result<(), D::Error>
2728where
2729 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2730 C: Compositor<D>,
2731{
2732 let style = style.resolve(state);
2733 let block = Block::styled(style);
2734 block.render(rect, ctx)?;
2735 let inner = block.inner(rect);
2736 if titles.is_empty() {
2737 return Ok(());
2738 }
2739 let active = titles[selected.min(titles.len() - 1)];
2740 ctx.draw_text_in(
2741 inner,
2742 active,
2743 TextStyle::new(style.text).with_font(style.font).centered(),
2744 )?;
2745 Ok(())
2746}
2747
2748#[cfg(feature = "rich-widgets")]
2749fn render_reel<D, C>(
2750 ctx: &mut RenderCtx<'_, D, C>,
2751 rect: Rect,
2752 player: ReelPlayer<'_>,
2753 fit: ImageFit,
2754 style: WidgetStyle,
2755 state: VisualState,
2756) -> Result<(), D::Error>
2757where
2758 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2759 C: Compositor<D>,
2760{
2761 let style = style.resolve(state);
2762 let block = Block::styled(style);
2763 block.render(rect, ctx)?;
2764 if let Some(src) = player.current_sprite_rect() {
2765 let inner = block.inner(rect);
2766 let frame_index = (src.x / player.sheet.sprite_w.max(1) as i32) as u8
2767 + ((src.y / player.sheet.sprite_h.max(1) as i32) as u8) * 2;
2768 let accent = match frame_index & 0x03 {
2769 0 => Rgb565::new(0, 40, 31),
2770 1 => Rgb565::new(31, 20, 0),
2771 2 => Rgb565::new(20, 0, 31),
2772 _ => Rgb565::new(31, 40, 0),
2773 };
2774 ctx.stroke_rect(inner, Border::one(accent))?;
2775 let w = inner.w.saturating_sub(4);
2776 let h = inner.h.saturating_sub(4);
2777 let bar_w = (w / 4).max(1);
2778 for i in 0..4u32 {
2779 let x = inner.x + 2 + (i * bar_w) as i32;
2780 let bar = Rect::new(x, inner.y + 2, bar_w.saturating_sub(1), h);
2781 let active = i as u8 <= (frame_index & 0x03);
2782 ctx.fill_rect(bar, if active { accent } else { Rgb565::new(4, 6, 6) })?;
2783 }
2784 if matches!(fit, ImageFit::Stretch | ImageFit::Center) {
2785 }
2787 }
2788 Ok(())
2789}
2790
2791#[allow(clippy::too_many_arguments)]
2792#[cfg(feature = "rich-widgets")]
2793fn render_state_surface<D, C>(
2794 ctx: &mut RenderCtx<'_, D, C>,
2795 rect: Rect,
2796 surface: SurfaceState,
2797 title: &str,
2798 message: &str,
2799 action: Option<&str>,
2800 busy_phase: f32,
2801 style: WidgetStyle,
2802 state: VisualState,
2803) -> Result<(), D::Error>
2804where
2805 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2806 C: Compositor<D>,
2807{
2808 let style = style.resolve(state);
2809 let block = Block::styled(style)
2810 .title(title)
2811 .title_align(TextAlign::Center);
2812 block.render(rect, ctx)?;
2813 let inner = block.content_area(rect);
2814
2815 let badge = match surface {
2816 SurfaceState::Ready => "READY",
2817 SurfaceState::Loading => "LOADING",
2818 SurfaceState::Empty => "EMPTY",
2819 SurfaceState::Error => "ERROR",
2820 SurfaceState::Offline => "OFFLINE",
2821 };
2822 ctx.draw_text_in(
2823 Rect::new(inner.x, inner.y, inner.w, style.font.line_height()),
2824 badge,
2825 TextStyle::new(style.accent)
2826 .with_font(style.font)
2827 .centered(),
2828 )?;
2829
2830 if matches!(surface, SurfaceState::Loading) {
2831 let y = inner.y + style.font.line_height() as i32 + 3;
2832 let w = inner.w.saturating_sub(10);
2833 let x = inner.x + 5;
2834 ctx.stroke_rect(Rect::new(x, y, w, 5), Border::one(style.border.color))?;
2835 let t = busy_phase.fract().abs();
2836 let pulse = ((w as f32 * 0.2) as u32).max(2);
2837 let offset = ((w.saturating_sub(pulse) as f32) * t) as i32;
2838 ctx.fill_rect(Rect::new(x + offset, y + 1, pulse, 3), style.accent)?;
2839 }
2840
2841 ctx.draw_text_in(
2842 Rect::new(
2843 inner.x + 2,
2844 inner.y + style.font.line_height() as i32 + 10,
2845 inner.w.saturating_sub(4),
2846 inner.h.saturating_sub(style.font.line_height() + 20),
2847 ),
2848 message,
2849 TextStyle::new(style.text)
2850 .with_font(style.font)
2851 .with_align(TextAlign::Center)
2852 .with_wrap(TextWrap::Character),
2853 )?;
2854
2855 if let Some(action_label) = action {
2856 let action_h = style.font.line_height() + 3;
2857 let action_rect = Rect::new(
2858 inner.x + 4,
2859 inner.bottom() - action_h as i32 - 2,
2860 inner.w.saturating_sub(8),
2861 action_h,
2862 );
2863 ctx.stroke_rect(action_rect, Border::one(style.accent))?;
2864 ctx.draw_text_in(
2865 action_rect,
2866 action_label,
2867 TextStyle::new(style.accent)
2868 .with_font(style.font)
2869 .with_align(TextAlign::Center),
2870 )?;
2871 }
2872
2873 Ok(())
2874}
2875
2876#[cfg(feature = "rich-widgets")]
2877fn render_heads_up_banner<D, C>(
2878 ctx: &mut RenderCtx<'_, D, C>,
2879 rect: Rect,
2880 level: NotificationLevel,
2881 text: &str,
2882 ttl_ms: u32,
2883 style: WidgetStyle,
2884 state: VisualState,
2885) -> Result<(), D::Error>
2886where
2887 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2888 C: Compositor<D>,
2889{
2890 if ttl_ms == 0 {
2891 return Ok(());
2892 }
2893 let mut style = style.resolve(state);
2894 style.accent = match level {
2895 NotificationLevel::Info => Rgb565::new(0, 32, 31),
2896 NotificationLevel::Success => Rgb565::new(0, 50, 0),
2897 NotificationLevel::Warning => Rgb565::new(31, 40, 0),
2898 NotificationLevel::Error => Rgb565::new(31, 0, 0),
2899 };
2900 let block = Block::styled(style);
2901 block.render(rect, ctx)?;
2902 ctx.draw_text_in(
2903 block.inner(rect),
2904 text,
2905 TextStyle::new(style.text)
2906 .with_font(style.font)
2907 .with_align(TextAlign::Center),
2908 )
2909}
2910
2911#[allow(clippy::too_many_arguments)]
2912#[cfg(feature = "rich-widgets")]
2913fn render_notification_action_sheet<D, C>(
2914 ctx: &mut RenderCtx<'_, D, C>,
2915 rect: Rect,
2916 level: NotificationLevel,
2917 title: &str,
2918 body: &str,
2919 actions: &[&str],
2920 selected: usize,
2921 open: bool,
2922 style: WidgetStyle,
2923 state: VisualState,
2924) -> Result<(), D::Error>
2925where
2926 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2927 C: Compositor<D>,
2928{
2929 if !open {
2930 return Ok(());
2931 }
2932 let mut style = style.resolve(state);
2933 style.accent = match level {
2934 NotificationLevel::Info => Rgb565::new(0, 32, 31),
2935 NotificationLevel::Success => Rgb565::new(0, 50, 0),
2936 NotificationLevel::Warning => Rgb565::new(31, 40, 0),
2937 NotificationLevel::Error => Rgb565::new(31, 0, 0),
2938 };
2939 let block = Block::styled(style)
2940 .title(title)
2941 .title_align(TextAlign::Center);
2942 block.render(rect, ctx)?;
2943 let inner = block.content_area(rect);
2944 let body_h = inner.h.saturating_sub(style.font.line_height() + 12);
2945 ctx.draw_text_in(
2946 Rect::new(inner.x + 2, inner.y + 2, inner.w.saturating_sub(4), body_h),
2947 body,
2948 TextStyle::new(style.text)
2949 .with_font(style.font)
2950 .with_wrap(TextWrap::Character),
2951 )?;
2952 if actions.is_empty() {
2953 return Ok(());
2954 }
2955 let action_h = style.font.line_height() + 2;
2956 let y = inner.bottom() - action_h as i32 - 2;
2957 let action_w = (inner.w / actions.len() as u32).max(1);
2958 for (i, action) in actions.iter().enumerate() {
2959 let cell = Rect::new(
2960 inner.x + (i as u32 * action_w) as i32,
2961 y,
2962 action_w,
2963 action_h,
2964 );
2965 if i == selected.min(actions.len() - 1) {
2966 ctx.fill_rect(cell, style.accent)?;
2967 } else {
2968 ctx.stroke_rect(cell, Border::one(style.border.color))?;
2969 }
2970 ctx.draw_text_in(
2971 cell,
2972 action,
2973 TextStyle::new(style.text)
2974 .with_font(style.font)
2975 .with_align(TextAlign::Center),
2976 )?;
2977 }
2978 Ok(())
2979}
2980
2981#[allow(clippy::too_many_arguments)]
2982#[cfg(feature = "rich-widgets")]
2983fn render_feed_timeline<D, C>(
2984 ctx: &mut RenderCtx<'_, D, C>,
2985 rect: Rect,
2986 items: &[&str],
2987 selected: usize,
2988 offset: usize,
2989 visible_rows: usize,
2990 expanded: bool,
2991 style: WidgetStyle,
2992 state: VisualState,
2993) -> Result<(), D::Error>
2994where
2995 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
2996 C: Compositor<D>,
2997{
2998 let style = style.resolve(state);
2999 let block = Block::styled(style);
3000 block.render(rect, ctx)?;
3001 if items.is_empty() {
3002 return Ok(());
3003 }
3004 let inner = block.inner(rect);
3005 let rows = visible_rows.max(1).min(items.len());
3006 let row_h = (inner.h / rows as u32).max(1);
3007 for row_idx in 0..rows {
3008 let item_idx = offset.saturating_add(row_idx);
3009 if item_idx >= items.len() {
3010 break;
3011 }
3012 let row = Rect::new(
3013 inner.x,
3014 inner.y + (row_idx as u32 * row_h) as i32,
3015 inner.w,
3016 row_h,
3017 );
3018 let is_selected = item_idx == selected;
3019 if is_selected {
3020 ctx.fill_rect(row, style.accent)?;
3021 }
3022 ctx.draw_text_in(
3023 row.inset(EdgeInsets::symmetric(2, 1)),
3024 items[item_idx],
3025 TextStyle::new(style.text)
3026 .with_font(style.font)
3027 .with_wrap(TextWrap::Character),
3028 )?;
3029 if expanded && is_selected && row_h > style.font.line_height() + 4 {
3030 let detail = Rect::new(
3031 row.x + 2,
3032 row.y + style.font.line_height() as i32,
3033 row.w.saturating_sub(4),
3034 row.h.saturating_sub(style.font.line_height()),
3035 );
3036 ctx.draw_text_in(
3037 detail,
3038 "details...",
3039 TextStyle::new(style.text).with_font(style.font),
3040 )?;
3041 }
3042 }
3043 Ok(())
3044}
3045
3046#[cfg(feature = "rich-widgets")]
3047fn draw_i32_right<D, C>(
3048 ctx: &mut RenderCtx<'_, D, C>,
3049 rect: Rect,
3050 value: i32,
3051 color: Rgb565,
3052) -> Result<(), D::Error>
3053where
3054 D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
3055 C: Compositor<D>,
3056{
3057 let mut buf = [0u8; 12];
3058 let mut n = value.unsigned_abs();
3059 let negative = value < 0;
3060 let mut pos = buf.len();
3061 if n == 0 {
3062 pos -= 1;
3063 buf[pos] = b'0';
3064 } else {
3065 while n > 0 && pos > usize::from(negative) {
3066 pos -= 1;
3067 buf[pos] = b'0' + (n % 10) as u8;
3068 n /= 10;
3069 }
3070 }
3071 if negative && pos > 0 {
3072 pos -= 1;
3073 buf[pos] = b'-';
3074 }
3075 let text = core::str::from_utf8(&buf[pos..]).unwrap_or("?");
3076 ctx.draw_text_in(
3077 rect,
3078 text,
3079 TextStyle {
3080 color,
3081 font: crate::font::FontId::Tiny3x5,
3082 opacity: 255,
3083 align: TextAlign::Right,
3084 vertical_align: VerticalAlign::Middle,
3085 wrap: TextWrap::None,
3086 overflow: crate::render::TextOverflow::Clip,
3087 overflow_policy: crate::render::TextOverflowPolicy::Global(
3088 crate::render::TextOverflow::Clip,
3089 ),
3090 kerning: false,
3091 max_lines: None,
3092 ellipsis: crate::render::EllipsisMode::ThreeDots,
3093 line_spacing: 0,
3094 },
3095 )
3096}
3097
3098impl Default for WidgetNode<'_> {
3099 fn default() -> Self {
3100 Self::new(
3101 WidgetId::new(0),
3102 Rect::empty(),
3103 WidgetKind::Spacer,
3104 WidgetStyle::new(Style {
3105 background: None,
3106 gradient: None,
3107 font: crate::font::FontId::Tiny3x5,
3108 foreground: Rgb565::WHITE,
3109 text: Rgb565::WHITE,
3110 accent: Rgb565::WHITE,
3111 opacity: 255,
3112 corner_radius: 0,
3113 shadow: None,
3114 border: Border::none(),
3115 padding: crate::geometry::EdgeInsets::all(0),
3116 }),
3117 )
3118 }
3119}