Skip to main content

embedded_gui/widgets/
timeline.rs

1//! Timeline Connective RelBar and Peek Layer widgets.
2//!
3//! Provides `TimelineNodeWidget` (relationship connector bar between chronological pins)
4//! and `PeekBannerWidget` (reactive peek overlay for heads-up alerts and upcoming events).
5
6use embedded_graphics_core::{draw_target::DrawTarget, pixelcolor::Rgb565};
7
8use crate::{
9    geometry::Rect,
10    render::{RenderCtx, StrokeStyle},
11    round::UnobstructedArea,
12    style::Style,
13    widget::{PropertyKey, PropertyValue, Widget},
14};
15
16/// Temporal state of a timeline pin node.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum TimelineNodeState {
19    Past,
20    ActiveNow,
21    Upcoming,
22    Future,
23}
24
25/// A vertical relationship connector bar and node dot linking chronological events.
26#[derive(Clone, Copy, Debug, PartialEq)]
27pub struct TimelineNodeWidget {
28    pub state: TimelineNodeState,
29    pub node_radius: u8,
30    pub bar_width: u8,
31    pub has_top_bar: bool,
32    pub has_bottom_bar: bool,
33    pub active_color: Rgb565,
34    pub inactive_color: Rgb565,
35    pub line_color: Rgb565,
36}
37
38impl TimelineNodeWidget {
39    pub const fn new(state: TimelineNodeState) -> Self {
40        Self {
41            state,
42            node_radius: 4,
43            bar_width: 2,
44            has_top_bar: true,
45            has_bottom_bar: true,
46            active_color: Rgb565::new(31, 40, 0), // Amber / Gold
47            inactive_color: Rgb565::new(10, 20, 10),
48            line_color: Rgb565::new(8, 16, 8),
49        }
50    }
51
52    pub fn render<D, C>(&self, ctx: &mut RenderCtx<'_, D, C>, bounds: Rect) -> Result<(), D::Error>
53    where
54        D: DrawTarget<Color = Rgb565>,
55        C: crate::render::Compositor<D>,
56    {
57        let cx = bounds.x + (bounds.w as i32 / 2);
58        let cy = bounds.y + (bounds.h as i32 / 2);
59        let r = self.node_radius as u32;
60
61        // Top connector line
62        if self.has_top_bar {
63            let stroke = StrokeStyle::new(self.line_color).with_width(self.bar_width);
64            ctx.draw_line_styled(cx, bounds.y, cx, cy - r as i32, stroke)?;
65        }
66
67        // Bottom connector line
68        if self.has_bottom_bar {
69            let stroke = StrokeStyle::new(self.line_color).with_width(self.bar_width);
70            ctx.draw_line_styled(cx, cy + r as i32, cx, bounds.bottom(), stroke)?;
71        }
72
73        // Center Node Dot
74        let (dot_color, dot_r) = match self.state {
75            TimelineNodeState::ActiveNow => (self.active_color, r + 1),
76            TimelineNodeState::Upcoming => (self.active_color, r),
77            TimelineNodeState::Past | TimelineNodeState::Future => (self.inactive_color, r),
78        };
79
80        ctx.fill_circle(cx, cy, dot_r, dot_color)?;
81        if matches!(self.state, TimelineNodeState::ActiveNow) {
82            ctx.stroke_circle(cx, cy, dot_r + 2, Rgb565::new(31, 63, 31))?;
83        }
84
85        Ok(())
86    }
87}
88
89impl Widget for TimelineNodeWidget {
90    fn render_widget_bounds(&self, _bounds: Rect, _style: &Style) {}
91
92    fn get_property(&self, key: PropertyKey) -> Option<PropertyValue<'_>> {
93        match key {
94            PropertyKey::Selected => Some(PropertyValue::Int(self.state as i32)),
95            _ => None,
96        }
97    }
98}
99
100/// Reactive Peek banner ribbon for upcoming pins, alerts, or status hints.
101#[derive(Clone, Copy, Debug, PartialEq)]
102pub struct PeekBannerWidget<'a> {
103    pub title: &'a str,
104    pub subtitle: Option<&'a str>,
105    pub icon_char: Option<char>,
106    pub height: u16,
107    pub is_expanded: bool,
108    pub background_color: Rgb565,
109    pub text_color: Rgb565,
110    pub accent_color: Rgb565,
111}
112
113impl<'a> PeekBannerWidget<'a> {
114    pub const fn new(title: &'a str) -> Self {
115        Self {
116            title,
117            subtitle: None,
118            icon_char: None,
119            height: 28,
120            is_expanded: false,
121            background_color: Rgb565::new(4, 8, 12),
122            text_color: Rgb565::new(31, 63, 31),
123            accent_color: Rgb565::new(0, 45, 25), // Teal / Emerald
124        }
125    }
126
127    /// Adapts an [`UnobstructedArea`] to account for this peek banner.
128    pub fn apply_to_unobstructed_area(&self, area: &mut UnobstructedArea) {
129        let h = if self.is_expanded {
130            self.height * 2
131        } else {
132            self.height
133        };
134        area.inset_top = h;
135    }
136
137    pub fn render<D, C>(&self, ctx: &mut RenderCtx<'_, D, C>, bounds: Rect) -> Result<(), D::Error>
138    where
139        D: DrawTarget<Color = Rgb565>,
140        C: crate::render::Compositor<D>,
141    {
142        // Background banner card
143        ctx.fill_rounded_rect(bounds, 4, self.background_color)?;
144
145        // Left accent indicator stripe
146        let stripe = Rect::new(bounds.x, bounds.y, 4, bounds.h);
147        ctx.fill_rounded_rect(stripe, 2, self.accent_color)?;
148
149        // Title and icon
150        let text_x = bounds.x + 10;
151        let text_y = bounds.y + (bounds.h as i32 / 2) - 4;
152        ctx.draw_text(text_x, text_y, self.title, self.text_color)?;
153
154        if let Some(sub) = self.subtitle {
155            if self.is_expanded {
156                ctx.draw_text(text_x, text_y + 12, sub, Rgb565::new(20, 40, 20))?;
157            }
158        }
159
160        Ok(())
161    }
162}
163
164impl<'a> Widget for PeekBannerWidget<'a> {
165    fn render_widget_bounds(&self, _bounds: Rect, _style: &Style) {}
166
167    fn get_property(&self, key: PropertyKey) -> Option<PropertyValue<'_>> {
168        match key {
169            PropertyKey::Text => Some(PropertyValue::Str(self.title)),
170            PropertyKey::Expanded => Some(PropertyValue::Bool(self.is_expanded)),
171            _ => None,
172        }
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::framebuffer::Framebuffer;
180
181    #[test]
182    fn test_timeline_node_render() {
183        let node = TimelineNodeWidget::new(TimelineNodeState::ActiveNow);
184        let mut fb = Framebuffer::<400>::new(20, 20);
185        let mut ctx = RenderCtx::new(&mut fb, Rect::new(0, 0, 20, 20));
186        assert!(node.render(&mut ctx, Rect::new(0, 0, 20, 20)).is_ok());
187    }
188
189    #[test]
190    fn test_peek_banner_render() {
191        let peek = PeekBannerWidget::new("Meeting in 10m");
192        let mut fb = Framebuffer::<1200>::new(100, 12);
193        let mut ctx = RenderCtx::new(&mut fb, Rect::new(0, 0, 100, 12));
194        assert!(peek.render(&mut ctx, Rect::new(0, 0, 100, 12)).is_ok());
195    }
196}