1use 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
12pub 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 #[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 #[must_use]
48 pub fn title(mut self, title: impl Into<String>) -> Self {
49 self.title = Some(title.into());
50 self
51 }
52
53 #[must_use]
55 pub fn variant(mut self, variant: impl Into<String>) -> Self {
56 self.variant = Some(variant.into());
57 self
58 }
59
60 #[must_use]
62 pub fn selected(mut self, selected: bool) -> Self {
63 self.selected = selected;
64 self
65 }
66
67 #[must_use]
69 pub fn gap(mut self, rows: u16) -> Self {
70 self.gap = rows;
71 self
72 }
73
74 #[must_use]
77 pub fn on_press(mut self, message: Msg) -> Self {
78 self.on_press = Some(message);
79 self
80 }
81
82 #[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 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 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 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 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}