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