Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 14 kB View raw
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}