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