Skip to main content

qframe/widgets/
panel.rs

1//! Panels: raised surfaces that group content without drawing a frame.
2
3use crate::event::Event;
4use crate::geometry::{Rect, Size};
5use crate::text;
6use crate::theme::State;
7use crate::widget::{Axis, Container, EventCx, Flex, MeasureCx, Node, PaintCx, Widget};
8
9use super::cells;
10use super::press::{self, Press};
11
12/// A surface one step above its background, optionally titled, selectable and pressable.
13///
14/// A pressable panel answers the pointer like a button: hovering raises its surface one tone and
15/// shows a soft pillar down its whole left edge, pressing flashes one tone brighter, and focus
16/// reached with the keyboard raises it with a breathing pillar. A selected panel keeps the accent
17/// pillar down the same edge, as on dialogs. Panels without `on_press`, and disabled ones, never react to the pointer.
18///
19/// Style keys: `panel` (`bg`, `padding`, `pillar`), `panel-title` (`fg`, `bold`), states
20/// `hover`, `focus` and `pressed` (pressable panels only) and `selected`.
21pub struct Panel<Msg> {
22    title: Option<String>,
23    variant: Option<String>,
24    selected: bool,
25    disabled: bool,
26    gap: u16,
27    on_press: Option<Msg>,
28    body: Vec<Node<Msg>>,
29}
30
31impl<Msg: 'static> Panel<Msg> {
32    /// An untitled panel. Add its content with [`View::add_with`](crate::widget::View::add_with).
33    #[must_use]
34    pub fn new() -> Self {
35        Self {
36            title: None,
37            variant: None,
38            selected: false,
39            disabled: false,
40            gap: 1,
41            on_press: None,
42            body: vec![Node::new(Flex::new(Axis::Column, Vec::new()), 0)],
43        }
44    }
45
46    /// A small heading drawn in the panel's first row.
47    #[must_use]
48    pub fn title(mut self, title: impl Into<String>) -> Self {
49        self.title = Some(title.into());
50        self
51    }
52
53    /// Theme variant.
54    #[must_use]
55    pub fn variant(mut self, variant: impl Into<String>) -> Self {
56        self.variant = Some(variant.into());
57        self
58    }
59
60    /// Raises the panel to the selected surface with the accent pillar.
61    #[must_use]
62    pub fn selected(mut self, selected: bool) -> Self {
63        self.selected = selected;
64        self
65    }
66
67    /// Rows between children; 1 by default.
68    #[must_use]
69    pub fn gap(mut self, rows: u16) -> Self {
70        self.gap = rows;
71        self
72    }
73
74    /// Makes the whole panel pressable, like a card that opens something. Enter or Space presses
75    /// it while focused; a click presses it on release, and releasing away from the panel cancels.
76    #[must_use]
77    pub fn on_press(mut self, message: Msg) -> Self {
78        self.on_press = Some(message);
79        self
80    }
81
82    /// Keeps a pressable panel from being hovered, focused or pressed; its message is not sent.
83    /// It still shows whether it is selected.
84    #[must_use]
85    pub fn disabled(mut self, disabled: bool) -> Self {
86        self.disabled = disabled;
87        self
88    }
89
90    fn active(&self) -> bool {
91        !self.disabled && self.on_press.is_some()
92    }
93
94    fn title_rows(&self) -> u16 {
95        if self.title.is_some() { 2 } else { 0 }
96    }
97}
98
99impl<Msg: 'static> Default for Panel<Msg> {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105impl<Msg: Clone + 'static> Container<Msg> for Panel<Msg> {
106    fn set_children(&mut self, children: Vec<Node<Msg>>) {
107        let mut column = Node::new(Flex::new(Axis::Column, children), 0);
108        column.layout.gap = self.gap;
109        column.layout.width = crate::widget::Length::Fill(1);
110        self.body = vec![column];
111    }
112}
113
114impl<Msg: Clone + 'static> Widget<Msg> for Panel<Msg> {
115    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
116        let style = cx.env().theme().style("panel", self.variant.as_deref(), &[]);
117        let (vertical, horizontal) = style.pair("padding").unwrap_or((1, 3));
118        let inner = Size::new(
119            available.width.saturating_sub(horizontal.saturating_mul(2)),
120            available.height.saturating_sub(vertical.saturating_mul(2).saturating_add(self.title_rows())),
121        );
122        let content = self.body.first().map_or(Size::default(), |body| cx.measure_child(body, inner));
123        let title_width = self.title.as_deref().map_or(0, text::width);
124        Size::new(
125            content.width.max(title_width).saturating_add(horizontal.saturating_mul(2)),
126            cells::sum([content.height, vertical.saturating_mul(2), self.title_rows()]),
127        )
128        .min(available)
129    }
130
131    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
132        let mut states = if self.active() { cx.pressable_states() } else { Vec::new() };
133        if self.selected {
134            states.push(State::Selected);
135        }
136        let variant = self.variant.as_deref();
137        let style = cx.style("panel", variant, &states);
138        let background = style.text().bg.unwrap_or_else(|| cx.color("surface"));
139        cx.clear(area, background);
140        let padding = style.padding();
141        let inner = area.inset(padding);
142        if let Some(pillar) = style.color("pillar")
143            && (self.selected || padding.left >= 1)
144        {
145            // A panel is a whole surface, so every mark on it (hover, focus, pressed or selected)
146            // runs down its full left edge, like a dialog's pillar.
147            for row in 0..area.height {
148                cx.pillar(area.x, area.y + i32::from(row), pillar);
149            }
150        }
151        if self.active() {
152            cx.register_hit(area);
153        }
154        let mut body = inner;
155        if let Some(title) = &self.title {
156            let title_style = cx.style("panel-title", variant, &states).text();
157            cx.text(inner.x, inner.y, title, title_style, inner.width);
158            body = Rect::new(inner.x, inner.y + 2, inner.width, inner.height.saturating_sub(2));
159        }
160        if let Some(content) = self.body.first() {
161            cx.paint_child(content, body);
162        }
163    }
164
165    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
166        let Some(message) = self.on_press.as_ref().filter(|_| !self.disabled) else {
167            return false;
168        };
169        match press::read(cx, event) {
170            Press::Ignored => false,
171            Press::Used => true,
172            Press::Key | Press::Click(..) => {
173                cx.flash();
174                cx.emit(message.clone());
175                true
176            }
177        }
178    }
179
180    fn focusable(&self) -> bool {
181        self.active()
182    }
183
184    fn children(&self) -> &[Node<Msg>] {
185        &self.body
186    }
187
188    fn children_mut(&mut self) -> &mut [Node<Msg>] {
189        &mut self.body
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::color::Rgb;
197    use crate::event::{MouseButton, MouseKind};
198    use crate::runtime::{App, Command, Harness};
199    use crate::widget::View;
200    use crate::widgets::Text;
201    use std::time::Duration;
202
203    #[derive(Default)]
204    struct Demo {
205        opened: bool,
206        untitled: bool,
207        inset: bool,
208        disabled: bool,
209        plain: bool,
210        /// Pressing does not select, to see the pressed tone on its own.
211        unselected: bool,
212    }
213
214    impl App for Demo {
215        type Msg = ();
216        fn update(&mut self, _: ()) -> Command<()> {
217            self.opened = true;
218            Command::none()
219        }
220        fn view(&self, ui: &mut View<'_, ()>) {
221            let mut panel = Panel::new().selected(self.opened && !self.unselected).disabled(self.disabled);
222            if !self.untitled {
223                panel = panel.title("LIVE");
224            }
225            if self.inset {
226                panel = panel.variant("inset");
227            }
228            if !self.plain {
229                panel = panel.on_press(());
230            }
231            ui.add_with(panel, |ui| {
232                ui.add(Text::new("Content"));
233            })
234            .fill_width();
235        }
236    }
237
238    const THEMES: [&str; 4] = ["monochrome", "iris", "nordic", "amber"];
239
240    fn harness(demo: Demo) -> Harness<Demo> {
241        Harness::new(demo, 20, 6)
242    }
243
244    fn color(h: &Harness<Demo>, token: &str) -> Rgb {
245        h.env().theme().color(token).expect("token")
246    }
247
248    /// `mix($text, $<token>, percent%)` as the theme writes it.
249    fn lifted(h: &Harness<Demo>, token: &str, percent: f32) -> Option<Rgb> {
250        Some(color(h, token).mix(color(h, "text"), percent / 100.0))
251    }
252
253    /// The rows whose first cell shows the pillar.
254    fn pillar_rows(h: &Harness<Demo>) -> Vec<usize> {
255        h.screen().lines().enumerate().filter(|(_, line)| line.starts_with('▌')).map(|(row, _)| row).collect()
256    }
257
258    #[test]
259    fn draws_title_and_content_on_surface() {
260        let h = harness(Demo::default());
261        assert_eq!(h.screen(), "\n   LIVE\n\n   Content\n\n\n");
262        assert_eq!(h.bg(0, 0), h.env().theme().color("surface"));
263        assert_eq!(h.fg(3, 1), h.env().theme().color("muted"));
264    }
265
266    #[test]
267    fn hover_raises_the_surface_a_clear_step_with_a_pillar_down_the_whole_left_edge() {
268        for inset in [false, true] {
269            let mut h = harness(Demo { inset, ..Demo::default() });
270            let rest_token = if inset { "raised" } else { "surface" };
271            for id in THEMES {
272                h.set_theme(id);
273                h.hover(19, 5);
274                h.hover(40, 40);
275                assert_eq!(h.bg(10, 0), Some(color(&h, rest_token)), "{id}: rest");
276                assert!(pillar_rows(&h).is_empty(), "{id}: no pillar at rest");
277                h.hover(10, 3);
278                let hover = h.bg(10, 0).expect("hover tone");
279                assert_eq!(Some(hover), lifted(&h, rest_token, 8.0), "{id}");
280                let ratio = hover.contrast_ratio(color(&h, rest_token));
281                assert!(ratio >= 1.15, "{id} inset={inset}: rest to hover is {ratio:.3}:1");
282                assert_eq!(h.fg(if inset { 2 } else { 3 }, 1), Some(color(&h, "dim")), "{id}: the title wakes");
283                assert_eq!(pillar_rows(&h), vec![0, 1, 2, 3, 4], "{id}: the pillar runs down the whole left edge");
284                assert_eq!(h.fg(0, 1), Some(color(&h, "active").mix(color(&h, "accent"), 0.45)), "{id}");
285            }
286        }
287    }
288
289    #[test]
290    fn an_untitled_panel_shows_the_pillar_down_its_left_edge() {
291        let mut h = harness(Demo { untitled: true, ..Demo::default() });
292        h.hover(10, 1);
293        assert_eq!(h.screen().lines().nth(1), Some("▌  Content"));
294        assert_eq!(pillar_rows(&h), vec![0, 1, 2]);
295    }
296
297    #[test]
298    fn keyboard_focus_raises_like_hover_with_a_breathing_pillar() {
299        let mut h = harness(Demo::default());
300        h.press("tab");
301        assert_eq!(h.bg(10, 0), lifted(&h, "surface", 8.0));
302        assert_eq!(h.fg(3, 1), Some(color(&h, "dim")));
303        assert_eq!(pillar_rows(&h), vec![0, 1, 2, 3, 4]);
304        let start = h.fg(0, 1);
305        h.advance(h.env().theme().motion().pulse_period / 2);
306        assert_ne!(h.fg(0, 1), start, "the pillar breathes");
307    }
308
309    #[test]
310    fn pointer_focus_stays_calm_once_the_pointer_leaves() {
311        let mut h = harness(Demo::default());
312        h.mouse(MouseKind::Down(MouseButton::Left), 10, 3);
313        h.hover(40, 40).advance(Duration::from_millis(200));
314        assert_eq!(h.bg(10, 0), Some(color(&h, "surface")), "a click does not leave a raised panel behind");
315        assert!(pillar_rows(&h).is_empty());
316    }
317
318    #[test]
319    fn pressing_flashes_one_tone_brighter() {
320        let mut h = harness(Demo::default());
321        h.press("tab");
322        let focus = h.bg(10, 0).expect("focus tone");
323        h.press("enter");
324        let pressed = h.bg(10, 0).expect("pressed tone");
325        assert_eq!(Some(pressed), lifted(&h, "active", 16.0), "the press lands on the now selected panel");
326        assert!(pressed.contrast_ratio(focus) >= 1.15, "the flash is a visible step up from focus");
327        assert_eq!(h.fg(3, 1), Some(color(&h, "text")));
328
329        let mut h = harness(Demo::default());
330        h.hover(10, 3);
331        let hover = h.bg(10, 0).expect("hover tone");
332        h.mouse(MouseKind::Down(MouseButton::Left), 10, 3);
333        h.mouse(MouseKind::Up(MouseButton::Left), 10, 3);
334        let pressed = h.bg(10, 0).expect("pressed tone");
335        assert!(pressed.contrast_ratio(hover) >= 1.15, "a click flashes brighter than hover");
336        h.advance(Duration::from_millis(200));
337        assert_eq!(h.bg(10, 0), lifted(&h, "active", 8.0), "selected and still hovered");
338    }
339
340    #[test]
341    fn the_pressed_flash_is_brighter_than_hover_on_an_unselected_panel() {
342        for inset in [false, true] {
343            let mut h = harness(Demo { inset, unselected: true, ..Demo::default() });
344            let rest_token = if inset { "raised" } else { "surface" };
345            for id in THEMES {
346                h.set_theme(id);
347                h.hover(10, 3);
348                let hover = h.bg(10, 0).expect("hover tone");
349                h.click(10, 3);
350                assert_eq!(h.bg(10, 0), lifted(&h, rest_token, 16.0), "{id}");
351                let pressed = h.bg(10, 0).expect("pressed tone");
352                assert!(pressed.contrast_ratio(hover) >= 1.15, "{id} inset={inset}: hover to pressed");
353                assert_eq!(h.fg(0, 1), Some(color(&h, "accent")), "{id}: the pillar takes the accent");
354                assert_eq!(h.fg(if inset { 2 } else { 3 }, 1), Some(color(&h, "text")), "{id}");
355                h.advance(Duration::from_millis(200));
356                assert_eq!(h.bg(10, 0), Some(hover), "{id}: back to hover after the flash");
357            }
358        }
359    }
360
361    #[test]
362    fn pressing_selects_with_a_full_height_pillar() {
363        let mut h = harness(Demo::default());
364        h.click(5, 3);
365        assert!(h.app().opened);
366        h.hover(40, 40).advance(Duration::from_millis(200));
367        assert_eq!(pillar_rows(&h), vec![0, 1, 2, 3, 4]);
368        assert_eq!(h.bg(10, 1), h.env().theme().color("active"));
369    }
370
371    #[test]
372    fn a_disabled_panel_shows_no_hover_and_cannot_be_pressed() {
373        let mut h = harness(Demo { disabled: true, ..Demo::default() });
374        let rest = h.screen();
375        h.hover(10, 3);
376        assert_eq!(h.bg(10, 0), Some(color(&h, "surface")));
377        assert_eq!(h.fg(3, 1), Some(color(&h, "muted")));
378        assert_eq!(h.screen(), rest);
379        h.press("tab").press("enter").click(10, 3);
380        assert!(!h.app().opened);
381        assert!(pillar_rows(&h).is_empty());
382    }
383
384    #[test]
385    fn a_panel_without_on_press_stays_as_it_was() {
386        let mut h = harness(Demo { plain: true, ..Demo::default() });
387        let rest = h.screen();
388        h.hover(10, 3);
389        assert_eq!(h.screen(), rest);
390        assert_eq!(h.bg(10, 0), Some(color(&h, "surface")));
391        assert_eq!(h.fg(3, 1), Some(color(&h, "muted")));
392        h.press("tab").click(10, 3);
393        assert!(!h.app().opened);
394        assert!(pillar_rows(&h).is_empty());
395    }
396
397    #[test]
398    fn presses_on_release_and_moving_away_cancels() {
399        let mut h = harness(Demo::default());
400        h.mouse(MouseKind::Down(MouseButton::Left), 5, 3);
401        assert!(!h.app().opened, "the mouse going down is not a press yet");
402        h.mouse(MouseKind::Up(MouseButton::Left), 5, 3);
403        assert!(h.app().opened, "releasing over the panel presses it");
404
405        let mut h = harness(Demo::default());
406        h.mouse(MouseKind::Down(MouseButton::Left), 5, 3);
407        h.mouse(MouseKind::Drag(MouseButton::Left), 5, 5);
408        h.mouse(MouseKind::Up(MouseButton::Left), 5, 5);
409        assert!(!h.app().opened, "releasing away from the panel cancels");
410    }
411}