Another project
1use crate::a11y::{AccessNode, Role};
2use crate::frame::{FrameCtx, InteractDeclaration};
3use crate::hit_test::Sense;
4use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
5use crate::strings::StringKey;
6use crate::theme::{Border, Step12, StrokeWidth, SurfaceLevel};
7use crate::widget_id::{WidgetId, WidgetKey};
8
9use super::keys::{TakeKey, take_key};
10use super::paint::{GlyphMark, HorizontalAlign, LabelText, WidgetPaint};
11use super::visuals::push_focus_ring;
12
13#[derive(Copy, Clone, Debug, PartialEq, Eq)]
14pub enum PanelVariant {
15 Plain,
16 Card,
17}
18
19#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
20pub struct PanelState {
21 pub collapsed: bool,
22}
23
24impl PanelState {
25 #[must_use]
26 pub const fn open() -> Self {
27 Self { collapsed: false }
28 }
29
30 #[must_use]
31 pub const fn collapsed() -> Self {
32 Self { collapsed: true }
33 }
34}
35
36#[derive(Copy, Clone, Debug, PartialEq)]
37pub struct PanelTitlebar {
38 pub label: StringKey,
39 pub height: LayoutPx,
40 pub collapsible: bool,
41}
42
43#[derive(Debug, PartialEq)]
44pub struct Panel<'state> {
45 pub id: WidgetId,
46 pub rect: LayoutRect,
47 pub variant: PanelVariant,
48 pub titlebar: Option<PanelTitlebar>,
49 pub state: &'state mut PanelState,
50}
51
52impl<'state> Panel<'state> {
53 #[must_use]
54 pub fn new(id: WidgetId, rect: LayoutRect, state: &'state mut PanelState) -> Self {
55 Self {
56 id,
57 rect,
58 variant: PanelVariant::Plain,
59 titlebar: None,
60 state,
61 }
62 }
63
64 #[must_use]
65 pub fn variant(self, variant: PanelVariant) -> Self {
66 Self { variant, ..self }
67 }
68
69 #[must_use]
70 pub fn titlebar(self, titlebar: PanelTitlebar) -> Self {
71 Self {
72 titlebar: Some(titlebar),
73 ..self
74 }
75 }
76}
77
78#[derive(Clone, Debug, PartialEq)]
79pub struct PanelResponse {
80 pub body_rect: Option<LayoutRect>,
81 pub paint: Vec<WidgetPaint>,
82}
83
84#[must_use]
85pub fn show_panel(ctx: &mut FrameCtx<'_>, panel: Panel<'_>) -> PanelResponse {
86 let Panel {
87 id,
88 rect,
89 variant,
90 titlebar,
91 state,
92 } = panel;
93 let host = match titlebar {
94 Some(bar) => AccessNode::new(Role::Pane).with_label(bar.label),
95 None => AccessNode::new(Role::Pane),
96 };
97 ctx.a11y.push(id, rect, host);
98 let mut paint = panel_surface(ctx, rect, variant);
99 let body_origin_y = match titlebar {
100 None => rect.origin.y,
101 Some(bar) => {
102 paint.extend(draw_titlebar(ctx, id, rect, bar, state));
103 LayoutPx::new(rect.origin.y.value() + bar.height.value())
104 }
105 };
106 let body_rect = if state.collapsed {
107 None
108 } else {
109 Some(LayoutRect::new(
110 LayoutPos::new(rect.origin.x, body_origin_y),
111 LayoutSize::new(
112 rect.size.width,
113 LayoutPx::saturating_nonneg(
114 rect.size.height.value() - (body_origin_y.value() - rect.origin.y.value()),
115 ),
116 ),
117 ))
118 };
119 PanelResponse { body_rect, paint }
120}
121
122fn panel_surface(ctx: &FrameCtx<'_>, rect: LayoutRect, variant: PanelVariant) -> Vec<WidgetPaint> {
123 let neutral = ctx.theme().colors.neutral;
124 let (fill, border) = match variant {
125 PanelVariant::Plain => (ctx.theme().colors.surface(SurfaceLevel::L0), None),
126 PanelVariant::Card => (
127 ctx.theme().colors.surface(SurfaceLevel::L1),
128 Some(Border {
129 width: StrokeWidth::HAIRLINE,
130 color: neutral.step(Step12::SUBTLE_BORDER),
131 }),
132 ),
133 };
134 vec![WidgetPaint::Surface {
135 rect,
136 fill,
137 border,
138 radius: ctx.theme().radius.sm,
139 elevation: None,
140 }]
141}
142
143fn draw_titlebar(
144 ctx: &mut FrameCtx<'_>,
145 id: WidgetId,
146 panel_rect: LayoutRect,
147 bar: PanelTitlebar,
148 state: &mut PanelState,
149) -> Vec<WidgetPaint> {
150 let title_rect = LayoutRect::new(
151 panel_rect.origin,
152 LayoutSize::new(panel_rect.size.width, bar.height),
153 );
154 let mut paint = vec![WidgetPaint::Surface {
155 rect: title_rect,
156 fill: ctx.theme().colors.neutral.step(Step12::SUBTLE_BG),
157 border: Some(Border {
158 width: StrokeWidth::HAIRLINE,
159 color: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER),
160 }),
161 radius: ctx.theme().radius.sm,
162 elevation: None,
163 }];
164 let toggle_id = id.child(WidgetKey::new("titlebar"));
165 let toggle_rect = title_rect;
166 if bar.collapsible {
167 let interaction = ctx.interact(
168 InteractDeclaration::new(toggle_id, toggle_rect, Sense::INTERACTIVE)
169 .focusable(true)
170 .a11y(
171 AccessNode::new(Role::Button)
172 .with_label(bar.label)
173 .with_expanded(!state.collapsed),
174 ),
175 );
176 let live_focused = ctx.is_focused(toggle_id);
177 if interaction.click() {
178 state.collapsed = !state.collapsed;
179 }
180 if live_focused {
181 let activated = take_key(
182 ctx.input,
183 &[
184 TakeKey::named(crate::input::NamedKey::Enter),
185 TakeKey::named(crate::input::NamedKey::Space),
186 ],
187 );
188 if activated.is_some() {
189 state.collapsed = !state.collapsed;
190 }
191 }
192 let chevron_rect = chevron_rect(title_rect);
193 paint.push(WidgetPaint::Mark {
194 rect: chevron_rect,
195 kind: if state.collapsed {
196 GlyphMark::DisclosureClosed
197 } else {
198 GlyphMark::DisclosureOpen
199 },
200 color: ctx.theme().colors.text_secondary(),
201 });
202 push_focus_ring(
203 ctx,
204 &mut paint,
205 title_rect,
206 ctx.theme().radius.sm,
207 live_focused,
208 );
209 }
210 paint.push(WidgetPaint::AlignedLabel {
211 rect: label_rect(title_rect, bar.collapsible),
212 text: LabelText::Key(bar.label),
213 color: ctx.theme().colors.text_primary(),
214 role: ctx.theme().typography.title,
215 align: HorizontalAlign::Start,
216 });
217 paint
218}
219
220const CHEVRON_PX: f32 = 14.0;
221const CHEVRON_GAP: f32 = 6.0;
222
223fn chevron_rect(title: LayoutRect) -> LayoutRect {
224 let pad = (title.size.height.value() - CHEVRON_PX).max(0.0) / 2.0;
225 LayoutRect::new(
226 LayoutPos::new(
227 LayoutPx::new(title.origin.x.value() + title.size.width.value() - CHEVRON_PX - pad),
228 LayoutPx::new(title.origin.y.value() + pad),
229 ),
230 LayoutSize::new(LayoutPx::new(CHEVRON_PX), LayoutPx::new(CHEVRON_PX)),
231 )
232}
233
234fn label_rect(title: LayoutRect, leave_room_for_chevron: bool) -> LayoutRect {
235 let trail = if leave_room_for_chevron {
236 CHEVRON_PX + CHEVRON_GAP
237 } else {
238 CHEVRON_GAP
239 };
240 LayoutRect::new(
241 LayoutPos::new(
242 LayoutPx::new(title.origin.x.value() + CHEVRON_GAP),
243 title.origin.y,
244 ),
245 LayoutSize::new(
246 LayoutPx::saturating_nonneg(title.size.width.value() - CHEVRON_GAP - trail),
247 title.size.height,
248 ),
249 )
250}
251
252#[cfg(test)]
253mod tests {
254 use std::sync::Arc;
255
256 use super::{Panel, PanelState, PanelTitlebar, PanelVariant, show_panel};
257 use crate::focus::FocusManager;
258 use crate::frame::FrameCtx;
259 use crate::hit_test::{HitFrame, HitState, resolve};
260 use crate::hotkey::HotkeyTable;
261 use crate::input::{
262 FrameInstant, InputSnapshot, PointerButton, PointerButtonMask, PointerSample,
263 };
264 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
265 use crate::strings::{StringKey, StringTable};
266 use crate::theme::Theme;
267 use crate::widget_id::{WidgetId, WidgetKey};
268
269 fn panel_id() -> WidgetId {
270 WidgetId::ROOT.child(WidgetKey::new("panel"))
271 }
272
273 fn panel_rect() -> LayoutRect {
274 LayoutRect::new(
275 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
276 LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(120.0)),
277 )
278 }
279
280 const TITLE: StringKey = StringKey::new("panel.title");
281
282 fn titlebar(collapsible: bool) -> PanelTitlebar {
283 PanelTitlebar {
284 label: TITLE,
285 height: LayoutPx::new(28.0),
286 collapsible,
287 }
288 }
289
290 fn render(
291 state: &mut PanelState,
292 focus: &mut FocusManager,
293 snap: &mut InputSnapshot,
294 prev: &HitState,
295 bar: Option<PanelTitlebar>,
296 ) -> (super::PanelResponse, HitState) {
297 let theme = Arc::new(Theme::light());
298 let table = HotkeyTable::new();
299 let mut hits = HitFrame::new();
300 let response = {
301 let mut shaper = bone_text::Shaper::new();
302 let mut a11y = crate::a11y::AccessTreeBuilder::new();
303 let mut ctx = FrameCtx::new(
304 theme,
305 snap,
306 focus,
307 &table,
308 StringTable::empty(),
309 &mut hits,
310 prev,
311 &mut a11y,
312 &mut shaper,
313 );
314 let mut p = Panel::new(panel_id(), panel_rect(), state).variant(PanelVariant::Card);
315 if let Some(t) = bar {
316 p = p.titlebar(t);
317 }
318 show_panel(&mut ctx, p)
319 };
320 let next = resolve(prev, &hits, snap, focus.focused());
321 (response, next)
322 }
323
324 #[test]
325 fn open_panel_with_titlebar_returns_body_rect_below_bar() {
326 let mut state = PanelState::open();
327 let mut focus = FocusManager::new();
328 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
329 let prev = HitState::new();
330 let (response, _) = render(
331 &mut state,
332 &mut focus,
333 &mut snap,
334 &prev,
335 Some(titlebar(true)),
336 );
337 let Some(body) = response.body_rect else {
338 panic!("expected body rect");
339 };
340 assert!(body.origin.y.value() >= 28.0);
341 assert!(body.size.height.value() <= 120.0 - 28.0 + 0.001);
342 }
343
344 #[test]
345 fn collapsed_panel_omits_body_rect() {
346 let mut state = PanelState::collapsed();
347 let mut focus = FocusManager::new();
348 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
349 let prev = HitState::new();
350 let (response, _) = render(
351 &mut state,
352 &mut focus,
353 &mut snap,
354 &prev,
355 Some(titlebar(true)),
356 );
357 assert!(response.body_rect.is_none());
358 }
359
360 #[test]
361 fn click_titlebar_toggles_collapsed() {
362 let mut state = PanelState::open();
363 let mut focus = FocusManager::new();
364 let mut prev = HitState::new();
365 let title_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(14.0));
366 [press(title_pos), release(title_pos), idle(title_pos)]
367 .into_iter()
368 .for_each(|mut snap| {
369 let (_, next) = render(
370 &mut state,
371 &mut focus,
372 &mut snap,
373 &prev,
374 Some(titlebar(true)),
375 );
376 prev = next;
377 });
378 assert!(state.collapsed);
379 }
380
381 #[test]
382 fn enter_on_focused_titlebar_toggles_collapsed() {
383 use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey};
384
385 let toggle_id = panel_id().child(WidgetKey::new("titlebar"));
386 let mut state = PanelState::open();
387 let mut focus = FocusManager::new();
388 let prev = HitState::new();
389 focus.request_focus(toggle_id);
390 let mut warm = InputSnapshot::idle(FrameInstant::ZERO);
391 let _ = render(
392 &mut state,
393 &mut focus,
394 &mut warm,
395 &prev,
396 Some(titlebar(true)),
397 );
398 assert_eq!(focus.focused(), Some(toggle_id));
399
400 let mut enter = InputSnapshot::idle(FrameInstant::ZERO);
401 enter.keys_pressed.push(KeyEvent::new(
402 KeyCode::Named(NamedKey::Enter),
403 ModifierMask::NONE,
404 ));
405 let _ = render(
406 &mut state,
407 &mut focus,
408 &mut enter,
409 &prev,
410 Some(titlebar(true)),
411 );
412 assert!(state.collapsed, "Enter on focused titlebar must collapse");
413
414 let mut space = InputSnapshot::idle(FrameInstant::ZERO);
415 space.keys_pressed.push(KeyEvent::new(
416 KeyCode::Named(NamedKey::Space),
417 ModifierMask::NONE,
418 ));
419 let _ = render(
420 &mut state,
421 &mut focus,
422 &mut space,
423 &prev,
424 Some(titlebar(true)),
425 );
426 assert!(!state.collapsed, "Space on focused titlebar must expand");
427 }
428
429 #[test]
430 fn no_titlebar_returns_body_equal_to_panel() {
431 let mut state = PanelState::open();
432 let mut focus = FocusManager::new();
433 let mut snap = InputSnapshot::idle(FrameInstant::ZERO);
434 let prev = HitState::new();
435 let (response, _) = render(&mut state, &mut focus, &mut snap, &prev, None);
436 let Some(body) = response.body_rect else {
437 panic!("expected body");
438 };
439 assert_eq!(body, panel_rect());
440 }
441
442 fn press(pos: LayoutPos) -> InputSnapshot {
443 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
444 s.pointer = Some(PointerSample::new(pos));
445 s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
446 s
447 }
448
449 fn release(pos: LayoutPos) -> InputSnapshot {
450 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
451 s.pointer = Some(PointerSample::new(pos));
452 s.buttons_released = PointerButtonMask::just(PointerButton::Primary);
453 s
454 }
455
456 fn idle(pos: LayoutPos) -> InputSnapshot {
457 let mut s = InputSnapshot::idle(FrameInstant::ZERO);
458 s.pointer = Some(PointerSample::new(pos));
459 s
460 }
461}