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