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