Skip to main content

embedded_gui/widgets/
mod.rs

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