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