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