Skip to main content

rlvgl_ui/
event_window.rs

1//! Overlay window that shows the most recent input events.
2//!
3//! Designed for hardware bring-up: renders a dark rounded-rect panel
4//! with a scrolling list of event descriptions. Appears on any input
5//! and auto-hides after all entries expire.
6
7use alloc::string::String;
8use alloc::vec::Vec;
9use core::cell::Cell;
10
11use rlvgl_core::bitmap_font::BitmapFont;
12use rlvgl_core::event::Event;
13use rlvgl_core::renderer::Renderer;
14use rlvgl_core::widget::{Color, Rect, Widget};
15
16use crate::draw_helpers::{draw_rounded_border, fill_rounded_rect};
17
18/// Default expiry if none specified (10 s at 6 Hz — legacy).
19const DEFAULT_EXPIRE_TICKS: u32 = 60;
20
21/// Maximum visible lines in the window.
22const MAX_LINES: usize = 10;
23
24/// Frames to keep clearing after hiding (double-buffer + 1 margin).
25const CLEAR_FRAMES: u8 = 3;
26
27/// A single event log entry.
28struct EventEntry {
29    text: String,
30    age: u32,
31}
32
33/// Themed overlay that displays recent input events.
34pub struct EventWindow {
35    bounds: Rect,
36    bg_color: Color,
37    border_color: Color,
38    border_width: u8,
39    radius: u8,
40    text_color: Color,
41    entries: Vec<EventEntry>,
42    visible: bool,
43    /// When `false`, `push_event` is a no-op (events are silently dropped).
44    enabled: bool,
45    /// Counts down after hiding to clear stale pixels from both framebuffers.
46    clear_countdown: u8,
47    padding: i32,
48    font: &'static BitmapFont,
49    /// Ticks before an entry expires (frame-rate dependent).
50    expire_ticks: u32,
51    /// Number of text lines rendered during the most recent draw pass.
52    last_draw_lines: Cell<u8>,
53    /// Monotonic draw sequence number for telemetry.
54    draw_seq: Cell<u32>,
55    /// When true, `handle_event(Tick)` is a no-op — entries don't age or
56    /// expire. Used during multi-frame dirty renders to ensure both
57    /// double-buffer frames show identical content.
58    frozen: bool,
59    /// When true, `draw()` is a no-op — the DMA2D overlay pipeline
60    /// handles rendering externally.
61    dma2d_mode: bool,
62}
63
64impl EventWindow {
65    /// Whether the window is currently visible.
66    pub fn is_visible(&self) -> bool {
67        self.visible
68    }
69
70    /// Number of entries currently in the list.
71    pub fn entry_count(&self) -> usize {
72        self.entries.len()
73    }
74
75    /// Whether event collection is enabled.
76    pub fn is_enabled(&self) -> bool {
77        self.enabled
78    }
79
80    /// Enable or disable event collection. When disabled, `push_event` is a no-op.
81    pub fn set_enabled(&mut self, val: bool) {
82        self.enabled = val;
83    }
84
85    /// Packed event-window diagnostic state.
86    pub fn diag_state(&self) -> u32 {
87        ((self.last_draw_lines.get() as u32) << 24)
88            | ((self.clear_countdown as u32) << 16)
89            | ((self.entries.len().min(0xFF) as u32) << 8)
90            | ((self.visible as u32) << 1)
91            | (self.enabled as u32)
92    }
93
94    /// Monotonic draw sequence number.
95    pub fn draw_seq(&self) -> u32 {
96        self.draw_seq.get()
97    }
98
99    /// Freeze event aging. While frozen, `handle_event(Tick)` is a no-op
100    /// so entries don't age or expire during multi-frame dirty renders.
101    pub fn set_frozen(&mut self, val: bool) {
102        self.frozen = val;
103    }
104
105    /// Whether event aging is currently frozen.
106    pub fn is_frozen(&self) -> bool {
107        self.frozen
108    }
109
110    /// Enable DMA2D rendering mode. When true, `draw()` becomes a no-op
111    /// because the DMA2D overlay pipeline handles rendering externally.
112    pub fn set_dma2d_mode(&mut self, val: bool) {
113        self.dma2d_mode = val;
114    }
115
116    /// Whether DMA2D rendering mode is active.
117    pub fn is_dma2d_mode(&self) -> bool {
118        self.dma2d_mode
119    }
120
121    /// Iterate visible entries, calling `f(line_index, text)` for each.
122    pub fn for_each_visible<F: FnMut(usize, &str)>(&self, mut f: F) {
123        let max_lines = MAX_LINES.min(self.entries.len());
124        let start = self.entries.len().saturating_sub(MAX_LINES);
125        for (i, entry) in self.entries[start..].iter().enumerate() {
126            if i >= max_lines {
127                break;
128            }
129            f(i, &entry.text);
130        }
131    }
132
133    /// Reference to the font used for text rendering.
134    pub fn font(&self) -> &'static BitmapFont {
135        self.font
136    }
137
138    /// Inner padding in pixels.
139    pub fn padding(&self) -> i32 {
140        self.padding
141    }
142
143    /// Line height: font scaled_height + gap.
144    pub fn line_height(&self) -> i32 {
145        self.font.scaled_height() + 4
146    }
147
148    /// Push a pre-formatted event string into the display list.
149    pub fn push_event(&mut self, text: String) {
150        if !self.enabled {
151            return;
152        }
153        self.entries.push(EventEntry { text, age: 0 });
154        // Cap total entries to prevent unbounded growth.
155        if self.entries.len() > MAX_LINES * 2 {
156            self.entries.remove(0);
157        }
158        self.visible = true;
159    }
160}
161
162impl Widget for EventWindow {
163    fn bounds(&self) -> Rect {
164        self.bounds
165    }
166
167    fn draw(&self, renderer: &mut dyn Renderer) {
168        if !self.visible || self.dma2d_mode {
169            return;
170        }
171
172        // Background + border
173        fill_rounded_rect(renderer, self.bounds, self.bg_color, self.radius);
174        draw_rounded_border(
175            renderer,
176            self.bounds,
177            self.border_color,
178            self.border_width,
179            self.radius,
180        );
181
182        // Text entries stacked vertically
183        let line_h = self.font.scaled_height() + 4;
184        let max_lines = MAX_LINES.min(self.entries.len());
185        let start = self.entries.len().saturating_sub(MAX_LINES);
186        let inner_x = self.bounds.x + self.padding;
187        let inner_y = self.bounds.y + self.padding;
188        self.last_draw_lines.set(max_lines as u8);
189        self.draw_seq.set(self.draw_seq.get().wrapping_add(1));
190
191        for (i, entry) in self.entries[start..].iter().enumerate() {
192            if i >= max_lines {
193                break;
194            }
195            let y = inner_y + i as i32 * line_h;
196            self.font
197                .draw_str(renderer, inner_x, y, &entry.text, self.text_color);
198        }
199    }
200
201    fn handle_event(&mut self, event: &Event) -> bool {
202        if event == &Event::Tick {
203            // Skip aging while frozen (multi-frame dirty render in progress).
204            if self.frozen {
205                return false;
206            }
207            // Age all entries and remove expired ones.
208            for entry in &mut self.entries {
209                entry.age += 1;
210            }
211            self.entries.retain(|e| e.age < self.expire_ticks);
212            if self.entries.is_empty() && self.visible {
213                // Start clearing stale pixels from both framebuffers.
214                // The Compositor calls clear_region() to drive the countdown.
215                self.clear_countdown = CLEAR_FRAMES;
216                self.visible = false;
217            }
218        }
219        // Input events are pushed by the application via push_event()
220        // so it can label the source (joystick vs button vs touch).
221        false // never consume — let other widgets see the event too
222    }
223
224    fn clear_region(&mut self) -> Option<Rect> {
225        if self.clear_countdown > 0 && !self.visible {
226            self.clear_countdown -= 1;
227            Some(self.bounds)
228        } else {
229            None
230        }
231    }
232}
233
234/// Builder for [`EventWindow`] with the dark-overlay theme.
235pub struct EventWindowBuilder {
236    window_w: i32,
237    window_h: i32,
238    pos_x: Option<i32>,
239    pos_y: Option<i32>,
240    bg_color: Color,
241    border_color: Color,
242    border_width: u8,
243    radius: u8,
244    text_color: Color,
245    font: &'static BitmapFont,
246    expire_ticks: u32,
247}
248
249impl EventWindowBuilder {
250    /// Create a builder with default dark-overlay theme values.
251    pub fn new(font: &'static BitmapFont) -> Self {
252        // Window sized to hold MAX_LINES of text at the font's scaled line height.
253        let line_h = font.scaled_height() + 4;
254        let padding = 12;
255        let window_h = MAX_LINES as i32 * line_h + padding * 2;
256        let window_w = 380;
257        Self {
258            window_w,
259            window_h,
260            pos_x: None,
261            pos_y: None,
262            bg_color: Color(25, 25, 25, 255),
263            border_color: Color(80, 80, 80, 255),
264            border_width: 2,
265            radius: 8,
266            text_color: Color(220, 220, 220, 255),
267            font,
268            expire_ticks: DEFAULT_EXPIRE_TICKS,
269        }
270    }
271
272    /// Set the number of ticks before entries expire.
273    ///
274    /// For frame-rate-independent timing, pass `frame_hz * desired_seconds`.
275    pub fn expire_ticks(mut self, ticks: u32) -> Self {
276        self.expire_ticks = ticks;
277        self
278    }
279
280    /// Override the background color.
281    pub fn bg_color(mut self, c: Color) -> Self {
282        self.bg_color = c;
283        self
284    }
285
286    /// Override the border color.
287    pub fn border_color(mut self, c: Color) -> Self {
288        self.border_color = c;
289        self
290    }
291
292    /// Override the corner radius.
293    pub fn radius(mut self, r: u8) -> Self {
294        self.radius = r;
295        self
296    }
297
298    /// Override the window width.
299    pub fn width(mut self, w: i32) -> Self {
300        self.window_w = w;
301        self
302    }
303
304    /// Center the window on a screen of the given dimensions.
305    pub fn center(mut self, screen_w: i32, screen_h: i32) -> Self {
306        self.pos_x = Some((screen_w - self.window_w) / 2);
307        self.pos_y = Some((screen_h - self.window_h) / 2);
308        self
309    }
310
311    /// Consume the builder and produce an [`EventWindow`].
312    pub fn build(self) -> EventWindow {
313        let margin = 10;
314        EventWindow {
315            bounds: Rect {
316                x: self.pos_x.unwrap_or(margin),
317                y: self.pos_y.unwrap_or(margin),
318                width: self.window_w,
319                height: self.window_h,
320            },
321            bg_color: self.bg_color,
322            border_color: self.border_color,
323            border_width: self.border_width,
324            radius: self.radius,
325            text_color: self.text_color,
326            entries: Vec::new(),
327            visible: false,
328            enabled: true,
329            clear_countdown: 0,
330            padding: 12,
331            font: self.font,
332            expire_ticks: self.expire_ticks,
333            last_draw_lines: Cell::new(0),
334            draw_seq: Cell::new(0),
335            frozen: false,
336            dma2d_mode: false,
337        }
338    }
339}