Skip to main content

rlvgl_widgets/
window.rs

1//! LPAR-13 title-bar + content-area window widget.
2//!
3//! A [`Window`] is the LVGL `win` parity surface: a layout container split into
4//! a fixed header bar (title label + optional icon-button slots) and a content
5//! area. It coexists with `ui::Modal`, `ui::Drawer`, and `ui::EventWindow`
6//! without altering those surfaces (LPAR-13 §5.I / §5.A / §9).
7//!
8//! # Layout container role (LPAR-13 §5.I)
9//!
10//! `Window` overrides [`Widget::set_bounds`] to recompute [`header_bounds`]
11//! and [`content_bounds`] whenever the outer bounding rect changes, so
12//! layout-driven sizing is adopted (LPAR-10 §5.A contract, LPAR-12 precedent).
13//!
14//! # Header buttons
15//!
16//! Optional icon buttons can be added to the right side of the header bar via
17//! [`Window::add_header_button`]. Activation is tracked via
18//! [`Window::last_button_pressed`] (poll-slot pattern).
19
20extern crate alloc;
21
22use alloc::string::String;
23use alloc::vec::Vec;
24
25use rlvgl_core::draw::draw_widget_bg;
26use rlvgl_core::event::Event;
27use rlvgl_core::font::{FontMetrics, WidgetFont, shape_text_ltr};
28use rlvgl_core::renderer::Renderer;
29use rlvgl_core::style::Style;
30use rlvgl_core::widget::{Color, Rect, Widget};
31
32// ---------------------------------------------------------------------------
33// Constants
34// ---------------------------------------------------------------------------
35
36/// Default header height in pixels when none is specified.
37pub const DEFAULT_HEADER_HEIGHT: i32 = 24;
38/// Horizontal padding for the title text inside the header.
39const TITLE_PAD_X: i32 = 6;
40
41// ---------------------------------------------------------------------------
42// WindowButtonId
43// ---------------------------------------------------------------------------
44
45/// Opaque identifier for a header button within a [`Window`].
46///
47/// Assigned sequentially by [`Window::add_header_button`].
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub struct WindowButtonId(pub u16);
50
51// ---------------------------------------------------------------------------
52// Internal header button descriptor
53// ---------------------------------------------------------------------------
54
55struct HeaderButtonDesc {
56    /// Optional icon text drawn inside the button.
57    icon: Option<String>,
58    /// Button width in pixels.
59    width: i32,
60}
61
62// ---------------------------------------------------------------------------
63// Window
64// ---------------------------------------------------------------------------
65
66/// Title-bar + content-area window widget (LVGL `win` parity).
67///
68/// Create with [`Window::new`], set the title with [`Window::set_title`], add
69/// optional header buttons with [`Window::add_header_button`], and query
70/// geometry via [`Window::header_bounds`] and [`Window::content_bounds`].
71pub struct Window {
72    /// Outer bounding box.
73    bounds: Rect,
74    /// Title string shown in the header bar.
75    title: String,
76    /// Header bar height in pixels.
77    header_height: i32,
78    /// Registered header icon buttons (right-to-left order in the header).
79    buttons: Vec<HeaderButtonDesc>,
80    /// Next button id counter.
81    next_btn_id: u16,
82    /// Most recently activated button (poll-slot; drained by `last_button_pressed`).
83    last_pressed: Option<WindowButtonId>,
84    /// Overall window background style (Part::MAIN).
85    pub style: Style,
86    /// Background color for the header bar.
87    pub header_color: Color,
88    /// Header title text color.
89    pub title_color: Color,
90    /// Header button background color (Part::ITEMS).
91    pub button_color: Color,
92    /// Header button icon text color.
93    pub button_text_color: Color,
94    /// Font assignment for this widget (FONT-00 §5); resolves to `FONT_6X10`
95    /// when unset.
96    font: WidgetFont,
97}
98
99impl Window {
100    /// Create a window with the given outer bounds and header height.
101    ///
102    /// Use [`DEFAULT_HEADER_HEIGHT`] when no specific height is needed.
103    pub fn new(bounds: Rect, header_height: i32) -> Self {
104        let header_height = header_height.max(0);
105        Self {
106            bounds,
107            title: String::new(),
108            header_height,
109            buttons: Vec::new(),
110            next_btn_id: 0,
111            last_pressed: None,
112            style: Style::default(),
113            header_color: Color(50, 50, 80, 255),
114            title_color: Color(240, 240, 240, 255),
115            button_color: Color(70, 70, 100, 255),
116            button_text_color: Color(240, 240, 240, 255),
117            font: WidgetFont::new(),
118        }
119    }
120
121    /// Set the window title displayed in the header bar.
122    pub fn set_title(&mut self, text: &str) {
123        self.title = String::from(text);
124    }
125
126    /// Return the current window title.
127    pub fn title(&self) -> &str {
128        &self.title
129    }
130
131    /// Append an icon button to the header bar and return its [`WindowButtonId`].
132    ///
133    /// `icon` is an optional UTF-8 glyph or short string drawn inside the
134    /// button. `width` is the button width in pixels.
135    pub fn add_header_button(&mut self, icon: Option<&str>, width: i32) -> WindowButtonId {
136        let id = WindowButtonId(self.next_btn_id);
137        self.next_btn_id = self.next_btn_id.saturating_add(1);
138        self.buttons.push(HeaderButtonDesc {
139            icon: icon.map(String::from),
140            width: width.max(0),
141        });
142        id
143    }
144
145    /// Return and clear the most recently activated header button id.
146    ///
147    /// Returns `None` when no button was pressed since the last poll.
148    pub fn last_button_pressed(&mut self) -> Option<WindowButtonId> {
149        self.last_pressed.take()
150    }
151
152    /// Return the screen-space rect of the header bar.
153    pub fn header_bounds(&self) -> Rect {
154        Rect {
155            x: self.bounds.x,
156            y: self.bounds.y,
157            width: self.bounds.width,
158            height: self.header_height,
159        }
160    }
161
162    /// Return the screen-space rect of the content area (below the header).
163    pub fn content_bounds(&self) -> Rect {
164        let remaining = (self.bounds.height - self.header_height).max(0);
165        Rect {
166            x: self.bounds.x,
167            y: self.bounds.y + self.header_height,
168            width: self.bounds.width,
169            height: remaining,
170        }
171    }
172
173    /// Assign the font used to render this widget (FONT-00 §5); resolves to
174    /// `FONT_6X10` when unset.
175    pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
176        self.font.set(font);
177    }
178
179    /// Update the header height and recompute content bounds.
180    pub fn set_header_height(&mut self, h: i32) {
181        self.header_height = h.max(0);
182    }
183
184    // -----------------------------------------------------------------------
185    // Private draw helpers
186    // -----------------------------------------------------------------------
187
188    /// Draw the header bar background, title, and optional icon buttons.
189    fn draw_header(&self, renderer: &mut dyn Renderer) {
190        let header = self.header_bounds();
191        if header.width <= 0 || header.height <= 0 {
192            return;
193        }
194        // Header background.
195        if self.header_color.3 > 0 {
196            renderer.fill_rect(header, self.header_color);
197        }
198        // Compute total button width so the title avoids them.
199        let total_btn_width: i32 = self.buttons.iter().map(|b| b.width).sum();
200        let title_w = (header.width - total_btn_width - TITLE_PAD_X * 2).max(0);
201
202        // Title shaped text.
203        if !self.title.is_empty() && title_w > 0 && self.title_color.3 > 0 {
204            let font = self.font.resolve();
205            let metrics = rlvgl_core::font::FontMetrics::line_metrics(font);
206            let baseline =
207                header.y + metrics.ascent as i32 + (header.height - metrics.line_height as i32) / 2;
208            let shaped = shape_text_ltr(font, &self.title, (header.x + TITLE_PAD_X, baseline), 0);
209            renderer.draw_text_shaped(&shaped, (0, 0), self.title_color);
210        }
211
212        // Header buttons — laid out right-to-left from the header's right edge.
213        let mut btn_right = header.x + header.width;
214        for btn in self.buttons.iter().rev() {
215            let bx = btn_right - btn.width;
216            let btn_rect = Rect {
217                x: bx,
218                y: header.y,
219                width: btn.width,
220                height: header.height,
221            };
222            if self.button_color.3 > 0 {
223                renderer.fill_rect(btn_rect, self.button_color);
224            }
225            if let Some(icon) = &btn.icon
226                && self.button_text_color.3 > 0
227            {
228                let font = self.font.resolve();
229                let metrics = rlvgl_core::font::FontMetrics::line_metrics(font);
230                let baseline = header.y
231                    + metrics.ascent as i32
232                    + (header.height - metrics.line_height as i32) / 2;
233                let shaped = shape_text_ltr(font, icon, (bx + 2, baseline), 0);
234                renderer.draw_text_shaped(&shaped, (0, 0), self.button_text_color);
235            }
236            btn_right = bx;
237        }
238    }
239}
240
241impl Widget for Window {
242    fn bounds(&self) -> Rect {
243        self.bounds
244    }
245
246    fn widget_font_mut(&mut self) -> Option<&mut WidgetFont> {
247        Some(&mut self.font)
248    }
249
250    fn set_bounds(&mut self, bounds: Rect) {
251        self.bounds = bounds;
252        // header_bounds and content_bounds are computed lazily from the fields;
253        // no additional recomputation needed here.
254    }
255
256    fn draw(&self, renderer: &mut dyn Renderer) {
257        if self.bounds.width <= 0 || self.bounds.height <= 0 {
258            return;
259        }
260        // Part::MAIN background.
261        draw_widget_bg(renderer, self.bounds, &self.style);
262        // Content area fill (caller places children inside content_bounds).
263        let content = self.content_bounds();
264        if content.width > 0 && content.height > 0 && self.style.bg_color.3 > 0 {
265            renderer.fill_rect(content, self.style.bg_color);
266        }
267        // Header bar (draws on top of bg to establish visual hierarchy).
268        self.draw_header(renderer);
269    }
270
271    fn handle_event(&mut self, _event: &Event) -> bool {
272        false
273    }
274}
275
276// ---------------------------------------------------------------------------
277// Tests
278// ---------------------------------------------------------------------------
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    fn rect(x: i32, y: i32, w: i32, h: i32) -> Rect {
285        Rect {
286            x,
287            y,
288            width: w,
289            height: h,
290        }
291    }
292
293    struct NullRenderer;
294    impl rlvgl_core::renderer::Renderer for NullRenderer {
295        fn fill_rect(&mut self, _r: Rect, _c: Color) {}
296        fn draw_text(&mut self, _pos: (i32, i32), _t: &str, _c: Color) {}
297    }
298
299    #[test]
300    fn header_and_content_bounds_split_outer_rect() {
301        let w = Window::new(rect(0, 0, 200, 120), 24);
302        assert_eq!(w.header_bounds(), rect(0, 0, 200, 24));
303        assert_eq!(w.content_bounds(), rect(0, 24, 200, 96));
304    }
305
306    #[test]
307    fn set_title_and_retrieve() {
308        let mut w = Window::new(rect(0, 0, 200, 120), 24);
309        w.set_title("Settings");
310        assert_eq!(w.title(), "Settings");
311    }
312
313    #[test]
314    fn set_header_height_adjusts_content() {
315        let mut w = Window::new(rect(0, 0, 200, 120), 24);
316        w.set_header_height(40);
317        assert_eq!(w.header_bounds().height, 40);
318        assert_eq!(w.content_bounds().y, 40);
319        assert_eq!(w.content_bounds().height, 80);
320    }
321
322    #[test]
323    fn add_header_button_returns_sequential_ids() {
324        let mut w = Window::new(rect(0, 0, 200, 120), 24);
325        let a = w.add_header_button(Some("X"), 20);
326        let b = w.add_header_button(None, 20);
327        assert_eq!(a, WindowButtonId(0));
328        assert_eq!(b, WindowButtonId(1));
329    }
330
331    #[test]
332    fn last_button_pressed_drains_slot() {
333        let mut w = Window::new(rect(0, 0, 200, 120), 24);
334        // Manually set last_pressed to simulate activation.
335        w.last_pressed = Some(WindowButtonId(0));
336        assert_eq!(w.last_button_pressed(), Some(WindowButtonId(0)));
337        // Second call should drain.
338        assert_eq!(w.last_button_pressed(), None);
339    }
340
341    #[test]
342    fn set_bounds_updates_outer_rect() {
343        let mut w = Window::new(rect(0, 0, 200, 120), 24);
344        w.set_bounds(rect(10, 10, 300, 200));
345        assert_eq!(w.bounds(), rect(10, 10, 300, 200));
346        assert_eq!(w.header_bounds(), rect(10, 10, 300, 24));
347        assert_eq!(w.content_bounds().y, 34);
348    }
349
350    #[test]
351    fn zero_area_window_draws_without_panic() {
352        let w = Window::new(rect(0, 0, 0, 0), 24);
353        let mut r = NullRenderer;
354        w.draw(&mut r);
355    }
356
357    #[test]
358    fn content_area_does_not_go_negative_when_header_taller_than_bounds() {
359        let w = Window::new(rect(0, 0, 100, 10), 24);
360        let content = w.content_bounds();
361        assert_eq!(content.height, 0); // clamped to 0
362    }
363}