Another project
1
fork

Configure Feed

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

feat(ui): ribbon and status bar widgets

Lewis: May this revision serve well! <lu5a@proton.me>

+923
+494
crates/bone-ui/src/widgets/ribbon.rs
··· 1 + use std::collections::BTreeMap; 2 + 3 + use crate::frame::FrameCtx; 4 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 5 + use crate::strings::StringKey; 6 + use crate::theme::{Border, Step12, StrokeWidth}; 7 + use crate::widget_id::WidgetId; 8 + 9 + use super::paint::{LabelText, WidgetPaint}; 10 + use super::tabs::{Tab, Tabs, TabsOrientation, show_tabs}; 11 + use super::toolbar::{Toolbar, ToolbarItem, show_toolbar}; 12 + 13 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 14 + pub enum RibbonIconSize { 15 + Large, 16 + Small, 17 + } 18 + 19 + impl RibbonIconSize { 20 + #[must_use] 21 + pub const fn item_px(self) -> LayoutPx { 22 + match self { 23 + Self::Large => LayoutPx::new(56.0), 24 + Self::Small => LayoutPx::new(28.0), 25 + } 26 + } 27 + } 28 + 29 + #[derive(Clone, Debug, PartialEq)] 30 + pub struct RibbonGroup { 31 + pub id: WidgetId, 32 + pub label: StringKey, 33 + pub items: Vec<ToolbarItem>, 34 + pub icon_size: RibbonIconSize, 35 + pub width: LayoutPx, 36 + } 37 + 38 + #[derive(Clone, Debug, PartialEq)] 39 + pub struct RibbonTab { 40 + pub tab: Tab, 41 + pub groups: Vec<RibbonGroup>, 42 + } 43 + 44 + #[derive(Clone, Debug, Default, PartialEq, Eq)] 45 + pub struct RibbonState { 46 + pub overflow: BTreeMap<WidgetId, bool>, 47 + } 48 + 49 + #[derive(Debug, PartialEq)] 50 + pub struct Ribbon<'a, 'state> { 51 + pub id: WidgetId, 52 + pub rect: LayoutRect, 53 + pub tabs: &'a [RibbonTab], 54 + pub active: WidgetId, 55 + pub state: &'state mut RibbonState, 56 + pub tab_strip_height: LayoutPx, 57 + pub group_label_height: LayoutPx, 58 + pub group_gap: LayoutPx, 59 + pub group_padding: LayoutPx, 60 + } 61 + 62 + impl<'a, 'state> Ribbon<'a, 'state> { 63 + #[must_use] 64 + pub const fn new( 65 + id: WidgetId, 66 + rect: LayoutRect, 67 + tabs: &'a [RibbonTab], 68 + active: WidgetId, 69 + state: &'state mut RibbonState, 70 + ) -> Self { 71 + Self { 72 + id, 73 + rect, 74 + tabs, 75 + active, 76 + state, 77 + tab_strip_height: LayoutPx::new(28.0), 78 + group_label_height: LayoutPx::new(16.0), 79 + group_gap: LayoutPx::new(8.0), 80 + group_padding: LayoutPx::new(8.0), 81 + } 82 + } 83 + } 84 + 85 + #[derive(Clone, Debug, PartialEq)] 86 + pub struct RibbonResponse { 87 + pub activated_tab: Option<WidgetId>, 88 + pub closed_tab: Option<WidgetId>, 89 + pub activated_tool: Option<WidgetId>, 90 + pub paint: Vec<WidgetPaint>, 91 + } 92 + 93 + #[must_use] 94 + pub fn show_ribbon(ctx: &mut FrameCtx<'_>, ribbon: Ribbon<'_, '_>) -> RibbonResponse { 95 + let Ribbon { 96 + id, 97 + rect, 98 + tabs, 99 + active, 100 + state, 101 + tab_strip_height, 102 + group_label_height, 103 + group_gap, 104 + group_padding, 105 + } = ribbon; 106 + let strip_rect = LayoutRect::new( 107 + rect.origin, 108 + LayoutSize::new(rect.size.width, tab_strip_height), 109 + ); 110 + let body_rect = LayoutRect::new( 111 + LayoutPos::new( 112 + rect.origin.x, 113 + LayoutPx::new(rect.origin.y.value() + tab_strip_height.value()), 114 + ), 115 + LayoutSize::new( 116 + rect.size.width, 117 + LayoutPx::saturating_nonneg(rect.size.height.value() - tab_strip_height.value()), 118 + ), 119 + ); 120 + let tab_views: Vec<Tab> = build_tab_strip(tabs, strip_rect); 121 + let mut paint = vec![WidgetPaint::Surface { 122 + rect, 123 + fill: ctx.theme.colors.surface(crate::theme::SurfaceLevel::L1), 124 + border: Some(Border { 125 + width: StrokeWidth::HAIRLINE, 126 + color: ctx.theme.colors.neutral.step(Step12::SUBTLE_BORDER), 127 + }), 128 + radius: ctx.theme.radius.none, 129 + elevation: None, 130 + }]; 131 + let tabs_response = show_tabs( 132 + ctx, 133 + Tabs::new(id, TabsOrientation::Top, tab_views.as_slice(), active), 134 + ); 135 + paint.extend(tabs_response.paint); 136 + let mut activated_tool: Option<WidgetId> = None; 137 + if let Some(active_tab) = tabs.iter().find(|t| t.tab.id == active) { 138 + let groups_paint = render_groups( 139 + ctx, 140 + GroupsArgs { 141 + groups: &active_tab.groups, 142 + body_rect, 143 + group_label_height, 144 + group_gap, 145 + group_padding, 146 + state, 147 + }, 148 + &mut activated_tool, 149 + ); 150 + paint.extend(groups_paint); 151 + } 152 + prune_overflow(state, tabs); 153 + RibbonResponse { 154 + activated_tab: tabs_response.activated, 155 + closed_tab: tabs_response.closed, 156 + activated_tool, 157 + paint, 158 + } 159 + } 160 + 161 + fn prune_overflow(state: &mut RibbonState, tabs: &[RibbonTab]) { 162 + let live: std::collections::BTreeSet<WidgetId> = tabs 163 + .iter() 164 + .flat_map(|t| t.groups.iter().map(|g| g.id)) 165 + .collect(); 166 + state.overflow.retain(|k, _| live.contains(k)); 167 + } 168 + 169 + fn build_tab_strip(tabs: &[RibbonTab], strip_rect: LayoutRect) -> Vec<Tab> { 170 + #[allow( 171 + clippy::cast_precision_loss, 172 + reason = "ribbon tab counts fit in f32 mantissa" 173 + )] 174 + let denom = tabs.len().max(1) as f32; 175 + let stride = strip_rect.size.width.value() / denom; 176 + tabs.iter() 177 + .enumerate() 178 + .map(|(idx, t)| { 179 + #[allow( 180 + clippy::cast_precision_loss, 181 + reason = "ribbon tab index fits in f32 mantissa" 182 + )] 183 + let i = idx as f32; 184 + let rect = LayoutRect::new( 185 + LayoutPos::new( 186 + LayoutPx::new(strip_rect.origin.x.value() + i * stride), 187 + strip_rect.origin.y, 188 + ), 189 + LayoutSize::new(LayoutPx::new(stride), strip_rect.size.height), 190 + ); 191 + Tab { rect, ..t.tab } 192 + }) 193 + .collect() 194 + } 195 + 196 + struct GroupsArgs<'a, 'state> { 197 + groups: &'a [RibbonGroup], 198 + body_rect: LayoutRect, 199 + group_label_height: LayoutPx, 200 + group_gap: LayoutPx, 201 + group_padding: LayoutPx, 202 + state: &'state mut RibbonState, 203 + } 204 + 205 + fn render_groups( 206 + ctx: &mut FrameCtx<'_>, 207 + args: GroupsArgs<'_, '_>, 208 + activated_tool: &mut Option<WidgetId>, 209 + ) -> Vec<WidgetPaint> { 210 + let GroupsArgs { 211 + groups, 212 + body_rect, 213 + group_label_height, 214 + group_gap, 215 + group_padding, 216 + state, 217 + } = args; 218 + let mut paint = Vec::new(); 219 + let layouts = group_rects(body_rect, groups, group_gap); 220 + groups 221 + .iter() 222 + .zip(layouts.iter()) 223 + .for_each(|(group, group_rect)| { 224 + paint.push(WidgetPaint::Surface { 225 + rect: *group_rect, 226 + fill: ctx.theme.colors.surface(crate::theme::SurfaceLevel::L1), 227 + border: Some(Border { 228 + width: StrokeWidth::HAIRLINE, 229 + color: ctx.theme.colors.neutral.step(Step12::SUBTLE_BORDER), 230 + }), 231 + radius: ctx.theme.radius.sm, 232 + elevation: None, 233 + }); 234 + let toolbar_rect = inner_toolbar_rect(*group_rect, group_label_height, group_padding); 235 + let entry = state.overflow.entry(group.id).or_insert(false); 236 + let response = show_toolbar( 237 + ctx, 238 + Toolbar::horizontal( 239 + group.id, 240 + toolbar_rect, 241 + &group.items, 242 + group.icon_size.item_px(), 243 + LayoutPx::new(4.0), 244 + ), 245 + entry, 246 + ); 247 + paint.extend(response.paint); 248 + if let Some(activated) = response.activated 249 + && activated_tool.is_none() 250 + { 251 + *activated_tool = Some(activated); 252 + } 253 + paint.push(WidgetPaint::Label { 254 + rect: group_label_rect(*group_rect, group_label_height), 255 + text: LabelText::Key(group.label), 256 + color: ctx.theme.colors.text_secondary(), 257 + role: ctx.theme.typography.caption, 258 + }); 259 + }); 260 + paint 261 + } 262 + 263 + fn group_rects(body: LayoutRect, groups: &[RibbonGroup], gap: LayoutPx) -> Vec<LayoutRect> { 264 + groups 265 + .iter() 266 + .scan(body.origin.x.value(), |x, group| { 267 + let rect = LayoutRect::new( 268 + LayoutPos::new(LayoutPx::new(*x), body.origin.y), 269 + LayoutSize::new(group.width, body.size.height), 270 + ); 271 + *x += group.width.value() + gap.value(); 272 + Some(rect) 273 + }) 274 + .collect() 275 + } 276 + 277 + fn inner_toolbar_rect(group: LayoutRect, label_height: LayoutPx, padding: LayoutPx) -> LayoutRect { 278 + let avail_height = 279 + (group.size.height.value() - label_height.value() - 2.0 * padding.value()).max(0.0); 280 + LayoutRect::new( 281 + LayoutPos::new( 282 + LayoutPx::new(group.origin.x.value() + padding.value()), 283 + LayoutPx::new(group.origin.y.value() + padding.value()), 284 + ), 285 + LayoutSize::new( 286 + LayoutPx::saturating_nonneg(group.size.width.value() - 2.0 * padding.value()), 287 + LayoutPx::new(avail_height), 288 + ), 289 + ) 290 + } 291 + 292 + fn group_label_rect(group: LayoutRect, label_height: LayoutPx) -> LayoutRect { 293 + LayoutRect::new( 294 + LayoutPos::new( 295 + group.origin.x, 296 + LayoutPx::new( 297 + group.origin.y.value() + group.size.height.value() - label_height.value(), 298 + ), 299 + ), 300 + LayoutSize::new(group.size.width, label_height), 301 + ) 302 + } 303 + 304 + #[cfg(test)] 305 + mod tests { 306 + use std::sync::Arc; 307 + 308 + use super::{Ribbon, RibbonGroup, RibbonIconSize, RibbonState, RibbonTab, show_ribbon}; 309 + use crate::focus::FocusManager; 310 + use crate::frame::FrameCtx; 311 + use crate::hit_test::{HitFrame, HitState, resolve}; 312 + use crate::hotkey::HotkeyTable; 313 + use crate::input::{ 314 + FrameInstant, InputSnapshot, PointerButton, PointerButtonMask, PointerSample, 315 + }; 316 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 317 + use crate::strings::{StringKey, StringTable}; 318 + use crate::theme::Theme; 319 + use crate::widget_id::{WidgetId, WidgetKey}; 320 + use crate::widgets::{Tab, ToolbarItem}; 321 + 322 + fn ribbon_id() -> WidgetId { 323 + WidgetId::ROOT.child(WidgetKey::new("ribbon")) 324 + } 325 + 326 + fn make_ribbon_tab(name: &'static str) -> RibbonTab { 327 + let tab_id = ribbon_id().child(WidgetKey::new(name)); 328 + let tool_id = tab_id.child(WidgetKey::new("tool")); 329 + RibbonTab { 330 + tab: Tab::new( 331 + tab_id, 332 + LayoutRect::new( 333 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 334 + LayoutSize::new(LayoutPx::new(80.0), LayoutPx::new(28.0)), 335 + ), 336 + StringKey::new("ribbon.tab"), 337 + ), 338 + groups: vec![RibbonGroup { 339 + id: tab_id.child(WidgetKey::new("group")), 340 + label: StringKey::new("ribbon.group"), 341 + items: vec![ToolbarItem::new(tool_id, StringKey::new("ribbon.tool"))], 342 + icon_size: RibbonIconSize::Large, 343 + width: LayoutPx::new(120.0), 344 + }], 345 + } 346 + } 347 + 348 + fn render( 349 + tabs: &[RibbonTab], 350 + active: WidgetId, 351 + state: &mut RibbonState, 352 + focus: &mut FocusManager, 353 + snap: &mut InputSnapshot, 354 + prev: &HitState, 355 + ) -> (super::RibbonResponse, HitState) { 356 + let theme = Arc::new(Theme::light()); 357 + let table = HotkeyTable::new(); 358 + let mut hits = HitFrame::new(); 359 + let rect = LayoutRect::new( 360 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 361 + LayoutSize::new(LayoutPx::new(800.0), LayoutPx::new(120.0)), 362 + ); 363 + let response = { 364 + let mut ctx = FrameCtx::new( 365 + theme, 366 + snap, 367 + focus, 368 + &table, 369 + StringTable::empty(), 370 + &mut hits, 371 + prev, 372 + ); 373 + show_ribbon( 374 + &mut ctx, 375 + Ribbon::new(ribbon_id(), rect, tabs, active, state), 376 + ) 377 + }; 378 + let next = resolve(prev, &hits, snap, focus.focused()); 379 + (response, next) 380 + } 381 + 382 + #[test] 383 + fn switching_tabs_emits_activated_tab() { 384 + let tabs = vec![make_ribbon_tab("home"), make_ribbon_tab("sketch")]; 385 + let mut state = RibbonState::default(); 386 + let mut focus = FocusManager::new(); 387 + let mut prev = HitState::new(); 388 + let click_pos = LayoutPos::new(LayoutPx::new(500.0), LayoutPx::new(14.0)); 389 + let mut last: Option<super::RibbonResponse> = None; 390 + [press(click_pos), release(click_pos), idle(click_pos)] 391 + .into_iter() 392 + .for_each(|mut snap| { 393 + let (response, next) = render( 394 + &tabs, 395 + tabs[0].tab.id, 396 + &mut state, 397 + &mut focus, 398 + &mut snap, 399 + &prev, 400 + ); 401 + last = Some(response); 402 + prev = next; 403 + }); 404 + let Some(response) = last else { 405 + panic!("response missing") 406 + }; 407 + assert_eq!(response.activated_tab, Some(tabs[1].tab.id)); 408 + } 409 + 410 + #[test] 411 + fn click_tool_in_active_tab_emits_activated_tool() { 412 + let tabs = vec![make_ribbon_tab("home")]; 413 + let mut state = RibbonState::default(); 414 + let mut focus = FocusManager::new(); 415 + let mut prev = HitState::new(); 416 + let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(60.0)); 417 + let mut last: Option<super::RibbonResponse> = None; 418 + [press(click_pos), release(click_pos), idle(click_pos)] 419 + .into_iter() 420 + .for_each(|mut snap| { 421 + let (response, next) = render( 422 + &tabs, 423 + tabs[0].tab.id, 424 + &mut state, 425 + &mut focus, 426 + &mut snap, 427 + &prev, 428 + ); 429 + last = Some(response); 430 + prev = next; 431 + }); 432 + let Some(response) = last else { 433 + panic!("response missing") 434 + }; 435 + assert_eq!(response.activated_tool, Some(tabs[0].groups[0].items[0].id),); 436 + } 437 + 438 + #[test] 439 + fn closing_a_closable_ribbon_tab_propagates_closed_tab() { 440 + let make_closable = |name: &'static str| -> RibbonTab { 441 + let mut t = make_ribbon_tab(name); 442 + t.tab = t.tab.closable(true); 443 + t 444 + }; 445 + let tabs = vec![make_closable("home"), make_closable("sketch")]; 446 + let mut state = RibbonState::default(); 447 + let mut focus = FocusManager::new(); 448 + let mut prev = HitState::new(); 449 + let strip_w = 800.0; 450 + let stride = strip_w / 2.0; 451 + let close_x = stride + stride - 14.0 - (28.0 - 14.0) / 2.0; 452 + let close_pos = LayoutPos::new(LayoutPx::new(close_x), LayoutPx::new(14.0)); 453 + let mut last: Option<super::RibbonResponse> = None; 454 + [press(close_pos), release(close_pos), idle(close_pos)] 455 + .into_iter() 456 + .for_each(|mut snap| { 457 + let (response, next) = render( 458 + &tabs, 459 + tabs[0].tab.id, 460 + &mut state, 461 + &mut focus, 462 + &mut snap, 463 + &prev, 464 + ); 465 + last = Some(response); 466 + prev = next; 467 + }); 468 + let Some(response) = last else { 469 + panic!("response missing") 470 + }; 471 + assert_eq!(response.closed_tab, Some(tabs[1].tab.id)); 472 + assert!(response.activated_tab.is_none()); 473 + } 474 + 475 + fn press(pos: LayoutPos) -> InputSnapshot { 476 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 477 + s.pointer = Some(PointerSample::new(pos)); 478 + s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 479 + s 480 + } 481 + 482 + fn release(pos: LayoutPos) -> InputSnapshot { 483 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 484 + s.pointer = Some(PointerSample::new(pos)); 485 + s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 486 + s 487 + } 488 + 489 + fn idle(pos: LayoutPos) -> InputSnapshot { 490 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 491 + s.pointer = Some(PointerSample::new(pos)); 492 + s 493 + } 494 + }
+429
crates/bone-ui/src/widgets/status_bar.rs
··· 1 + use crate::frame::{FrameCtx, InteractDeclaration}; 2 + use crate::hit_test::Sense; 3 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 4 + use crate::strings::StringKey; 5 + use crate::theme::{Border, Color, Step12, StrokeWidth}; 6 + use crate::widget_id::WidgetId; 7 + 8 + use super::keys::take_activation; 9 + use super::paint::{LabelText, WidgetPaint}; 10 + use super::visuals::push_focus_ring; 11 + 12 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 13 + pub enum StatusAlign { 14 + Start, 15 + Center, 16 + End, 17 + } 18 + 19 + #[derive(Copy, Clone, Debug, PartialEq)] 20 + pub struct StatusItem { 21 + pub id: WidgetId, 22 + pub label: StringKey, 23 + pub align: StatusAlign, 24 + pub width: LayoutPx, 25 + pub interactive: bool, 26 + pub badge: Option<Color>, 27 + } 28 + 29 + impl StatusItem { 30 + #[must_use] 31 + pub const fn new(id: WidgetId, label: StringKey, align: StatusAlign, width: LayoutPx) -> Self { 32 + Self { 33 + id, 34 + label, 35 + align, 36 + width, 37 + interactive: false, 38 + badge: None, 39 + } 40 + } 41 + 42 + #[must_use] 43 + pub const fn interactive(self, interactive: bool) -> Self { 44 + Self { 45 + interactive, 46 + ..self 47 + } 48 + } 49 + 50 + #[must_use] 51 + pub const fn badge(self, color: Color) -> Self { 52 + Self { 53 + badge: Some(color), 54 + ..self 55 + } 56 + } 57 + } 58 + 59 + #[derive(Copy, Clone, Debug, PartialEq)] 60 + pub struct StatusBar<'a> { 61 + pub rect: LayoutRect, 62 + pub items: &'a [StatusItem], 63 + } 64 + 65 + impl<'a> StatusBar<'a> { 66 + #[must_use] 67 + pub const fn new(rect: LayoutRect, items: &'a [StatusItem]) -> Self { 68 + Self { rect, items } 69 + } 70 + } 71 + 72 + #[derive(Clone, Debug, PartialEq)] 73 + pub struct StatusBarResponse { 74 + pub activated: Option<WidgetId>, 75 + pub paint: Vec<WidgetPaint>, 76 + } 77 + 78 + #[must_use] 79 + pub fn show_status_bar(ctx: &mut FrameCtx<'_>, bar: StatusBar<'_>) -> StatusBarResponse { 80 + let mut paint = vec![WidgetPaint::Surface { 81 + rect: bar.rect, 82 + fill: ctx.theme.colors.neutral.step(Step12::SUBTLE_BG), 83 + border: Some(Border { 84 + width: StrokeWidth::HAIRLINE, 85 + color: ctx.theme.colors.neutral.step(Step12::SUBTLE_BORDER), 86 + }), 87 + radius: ctx.theme.radius.none, 88 + elevation: None, 89 + }]; 90 + let layouts = lay_out_items(bar.rect, bar.items); 91 + let mut activated: Option<WidgetId> = None; 92 + bar.items 93 + .iter() 94 + .zip(layouts.iter()) 95 + .for_each(|(item, item_rect)| { 96 + let item_paint = draw_item(ctx, item, *item_rect, &mut activated); 97 + paint.extend(item_paint); 98 + }); 99 + StatusBarResponse { activated, paint } 100 + } 101 + 102 + fn draw_item( 103 + ctx: &mut FrameCtx<'_>, 104 + item: &StatusItem, 105 + rect: LayoutRect, 106 + activated: &mut Option<WidgetId>, 107 + ) -> Vec<WidgetPaint> { 108 + let mut paint = Vec::new(); 109 + let live_focused = if item.interactive { 110 + let interaction = ctx 111 + .interact(InteractDeclaration::new(item.id, rect, Sense::INTERACTIVE).focusable(true)); 112 + let focused = ctx.is_focused(item.id); 113 + let activated_via_pointer = interaction.click(); 114 + let activated_via_key = focused && take_activation(ctx.input); 115 + if (activated_via_pointer || activated_via_key) && activated.is_none() { 116 + *activated = Some(item.id); 117 + } 118 + if interaction.hover() || interaction.pressed() { 119 + paint.push(WidgetPaint::Surface { 120 + rect, 121 + fill: ctx.theme.colors.neutral.step(if interaction.pressed() { 122 + Step12::SELECTED_BG 123 + } else { 124 + Step12::HOVER_BG 125 + }), 126 + border: None, 127 + radius: ctx.theme.radius.none, 128 + elevation: None, 129 + }); 130 + } 131 + focused 132 + } else { 133 + false 134 + }; 135 + if let Some(badge) = item.badge { 136 + let dot_rect = badge_rect(rect); 137 + paint.push(WidgetPaint::Surface { 138 + rect: dot_rect, 139 + fill: badge, 140 + border: None, 141 + radius: ctx.theme.radius.pill, 142 + elevation: None, 143 + }); 144 + } 145 + paint.push(WidgetPaint::Label { 146 + rect: label_rect(rect, item.badge.is_some()), 147 + text: LabelText::Key(item.label), 148 + color: ctx.theme.colors.text_secondary(), 149 + role: ctx.theme.typography.caption, 150 + }); 151 + push_focus_ring(ctx, &mut paint, rect, ctx.theme.radius.none, live_focused); 152 + paint 153 + } 154 + 155 + const BADGE_PX: f32 = 8.0; 156 + const BADGE_GAP: f32 = 6.0; 157 + 158 + fn badge_rect(item: LayoutRect) -> LayoutRect { 159 + let pad = (item.size.height.value() - BADGE_PX).max(0.0) / 2.0; 160 + LayoutRect::new( 161 + LayoutPos::new( 162 + LayoutPx::new(item.origin.x.value() + pad), 163 + LayoutPx::new(item.origin.y.value() + pad), 164 + ), 165 + LayoutSize::new(LayoutPx::new(BADGE_PX), LayoutPx::new(BADGE_PX)), 166 + ) 167 + } 168 + 169 + fn label_rect(item: LayoutRect, has_badge: bool) -> LayoutRect { 170 + if !has_badge { 171 + return item; 172 + } 173 + let trim = LayoutPx::new(BADGE_PX + BADGE_GAP); 174 + LayoutRect::new( 175 + LayoutPos::new( 176 + LayoutPx::new(item.origin.x.value() + trim.value()), 177 + item.origin.y, 178 + ), 179 + LayoutSize::new( 180 + LayoutPx::saturating_nonneg(item.size.width.value() - trim.value()), 181 + item.size.height, 182 + ), 183 + ) 184 + } 185 + 186 + #[derive(Copy, Clone)] 187 + struct AlignTotals { 188 + start: f32, 189 + center: f32, 190 + end: f32, 191 + } 192 + 193 + fn lay_out_items(bar: LayoutRect, items: &[StatusItem]) -> Vec<LayoutRect> { 194 + let totals = items.iter().fold( 195 + AlignTotals { 196 + start: 0.0, 197 + center: 0.0, 198 + end: 0.0, 199 + }, 200 + |t, item| match item.align { 201 + StatusAlign::Start => AlignTotals { 202 + start: t.start + item.width.value(), 203 + ..t 204 + }, 205 + StatusAlign::Center => AlignTotals { 206 + center: t.center + item.width.value(), 207 + ..t 208 + }, 209 + StatusAlign::End => AlignTotals { 210 + end: t.end + item.width.value(), 211 + ..t 212 + }, 213 + }, 214 + ); 215 + let bar_x = bar.origin.x.value(); 216 + let bar_w = bar.size.width.value(); 217 + let start_end = bar_x + totals.start; 218 + let ideal_center = bar_x + (bar_w - totals.center) / 2.0; 219 + let center_origin = ideal_center.max(start_end); 220 + let center_end = center_origin + totals.center; 221 + let ideal_end = bar_x + bar_w - totals.end; 222 + let end_origin = ideal_end.max(center_end); 223 + items 224 + .iter() 225 + .scan( 226 + (bar_x, center_origin, end_origin), 227 + |(start_cursor, center_cursor, end_cursor), item| { 228 + let origin_x = match item.align { 229 + StatusAlign::Start => { 230 + let r = *start_cursor; 231 + *start_cursor += item.width.value(); 232 + r 233 + } 234 + StatusAlign::Center => { 235 + let r = *center_cursor; 236 + *center_cursor += item.width.value(); 237 + r 238 + } 239 + StatusAlign::End => { 240 + let r = *end_cursor; 241 + *end_cursor += item.width.value(); 242 + r 243 + } 244 + }; 245 + Some(LayoutRect::new( 246 + LayoutPos::new(LayoutPx::new(origin_x), bar.origin.y), 247 + LayoutSize::new(item.width, bar.size.height), 248 + )) 249 + }, 250 + ) 251 + .collect() 252 + } 253 + 254 + #[cfg(test)] 255 + mod tests { 256 + use std::sync::Arc; 257 + 258 + use super::{StatusAlign, StatusBar, StatusItem, show_status_bar}; 259 + use crate::focus::FocusManager; 260 + use crate::frame::FrameCtx; 261 + use crate::hit_test::{HitFrame, HitState, resolve}; 262 + use crate::hotkey::HotkeyTable; 263 + use crate::input::{ 264 + FrameInstant, InputSnapshot, PointerButton, PointerButtonMask, PointerSample, 265 + }; 266 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 267 + use crate::strings::{StringKey, StringTable}; 268 + use crate::theme::Theme; 269 + use crate::widget_id::{WidgetId, WidgetKey}; 270 + 271 + fn item_id(name: &'static str) -> WidgetId { 272 + WidgetId::ROOT.child(WidgetKey::new(name)) 273 + } 274 + 275 + fn items() -> Vec<StatusItem> { 276 + vec![ 277 + StatusItem::new( 278 + item_id("status"), 279 + StringKey::new("status.fully"), 280 + StatusAlign::Start, 281 + LayoutPx::new(140.0), 282 + ), 283 + StatusItem::new( 284 + item_id("zoom"), 285 + StringKey::new("status.zoom"), 286 + StatusAlign::End, 287 + LayoutPx::new(80.0), 288 + ) 289 + .interactive(true), 290 + ] 291 + } 292 + 293 + fn bar_rect() -> LayoutRect { 294 + LayoutRect::new( 295 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::new(700.0)), 296 + LayoutSize::new(LayoutPx::new(800.0), LayoutPx::new(24.0)), 297 + ) 298 + } 299 + 300 + fn render( 301 + items: &[StatusItem], 302 + focus: &mut FocusManager, 303 + snap: &mut InputSnapshot, 304 + prev: &HitState, 305 + ) -> (super::StatusBarResponse, HitState) { 306 + let theme = Arc::new(Theme::light()); 307 + let table = HotkeyTable::new(); 308 + let mut hits = HitFrame::new(); 309 + let response = { 310 + let mut ctx = FrameCtx::new( 311 + theme, 312 + snap, 313 + focus, 314 + &table, 315 + StringTable::empty(), 316 + &mut hits, 317 + prev, 318 + ); 319 + show_status_bar(&mut ctx, StatusBar::new(bar_rect(), items)) 320 + }; 321 + let next = resolve(prev, &hits, snap, focus.focused()); 322 + (response, next) 323 + } 324 + 325 + #[test] 326 + fn renders_one_paint_per_item_plus_surface() { 327 + let items = items(); 328 + let mut focus = FocusManager::new(); 329 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 330 + let prev = HitState::new(); 331 + let (response, _) = render(&items, &mut focus, &mut snap, &prev); 332 + let label_count = response 333 + .paint 334 + .iter() 335 + .filter(|p| matches!(p, super::WidgetPaint::Label { .. })) 336 + .count(); 337 + assert_eq!(label_count, 2); 338 + } 339 + 340 + #[test] 341 + fn click_interactive_item_activates_it() { 342 + let items = items(); 343 + let mut focus = FocusManager::new(); 344 + let mut prev = HitState::new(); 345 + let click_pos = LayoutPos::new(LayoutPx::new(760.0), LayoutPx::new(712.0)); 346 + let mut last: Option<super::StatusBarResponse> = None; 347 + [press(click_pos), release(click_pos), idle(click_pos)] 348 + .into_iter() 349 + .for_each(|mut snap| { 350 + let (response, next) = render(&items, &mut focus, &mut snap, &prev); 351 + last = Some(response); 352 + prev = next; 353 + }); 354 + let Some(response) = last else { 355 + panic!("response missing") 356 + }; 357 + assert_eq!(response.activated, Some(items[1].id)); 358 + } 359 + 360 + #[test] 361 + fn overflow_packs_center_and_end_after_start_without_underflow() { 362 + let crowded = vec![ 363 + StatusItem::new( 364 + item_id("a"), 365 + StringKey::new("status.a"), 366 + StatusAlign::Start, 367 + LayoutPx::new(400.0), 368 + ), 369 + StatusItem::new( 370 + item_id("b"), 371 + StringKey::new("status.b"), 372 + StatusAlign::Center, 373 + LayoutPx::new(400.0), 374 + ), 375 + StatusItem::new( 376 + item_id("c"), 377 + StringKey::new("status.c"), 378 + StatusAlign::End, 379 + LayoutPx::new(400.0), 380 + ), 381 + ]; 382 + let rects = super::lay_out_items(bar_rect(), &crowded); 383 + assert_eq!(rects.len(), 3); 384 + let bar_x = bar_rect().origin.x.value(); 385 + rects.iter().for_each(|r| { 386 + assert!( 387 + r.origin.x.value() >= bar_x, 388 + "item origin must not underflow bar start", 389 + ); 390 + }); 391 + assert!(rects[1].origin.x.value() >= rects[0].origin.x.value() + 400.0); 392 + assert!(rects[2].origin.x.value() >= rects[1].origin.x.value() + 400.0); 393 + } 394 + 395 + #[test] 396 + fn non_interactive_item_does_not_activate() { 397 + let items = items(); 398 + let mut focus = FocusManager::new(); 399 + let mut prev = HitState::new(); 400 + let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(712.0)); 401 + [press(click_pos), release(click_pos), idle(click_pos)] 402 + .into_iter() 403 + .for_each(|mut snap| { 404 + let (response, next) = render(&items, &mut focus, &mut snap, &prev); 405 + assert!(response.activated.is_none()); 406 + prev = next; 407 + }); 408 + } 409 + 410 + fn press(pos: LayoutPos) -> InputSnapshot { 411 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 412 + s.pointer = Some(PointerSample::new(pos)); 413 + s.buttons_pressed = PointerButtonMask::just(PointerButton::Primary); 414 + s 415 + } 416 + 417 + fn release(pos: LayoutPos) -> InputSnapshot { 418 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 419 + s.pointer = Some(PointerSample::new(pos)); 420 + s.buttons_released = PointerButtonMask::just(PointerButton::Primary); 421 + s 422 + } 423 + 424 + fn idle(pos: LayoutPos) -> InputSnapshot { 425 + let mut s = InputSnapshot::idle(FrameInstant::ZERO); 426 + s.pointer = Some(PointerSample::new(pos)); 427 + s 428 + } 429 + }