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