Skip to main content

embedded_gui/widgets/
mod.rs

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