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