Another project
0

Configure Feed

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

feat(ui): toolbar overflow

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

author
Lewis
date (May 21, 2026, 9:19 PM +0300) commit 7da78504 parent 64aafd9b change-id wnxnwlyp
+627 -66
+2
crates/bone-ui/src/gallery.rs
··· 733 733 icon_size: RibbonIconSize::Large, 734 734 min_width: LayoutPx::new(80.0), 735 735 width: LayoutPx::new(140.0), 736 + overflow_open: false, 737 + overflow_label: None, 736 738 }], 737 739 )]; 738 740 let response = show_ribbon(
+41 -11
crates/bone-ui/src/widgets/ribbon.rs
··· 7 7 8 8 use super::paint::{LabelText, WidgetPaint, estimate_label_width_px}; 9 9 use super::tabs::{Tab, Tabs, TabsOrientation, show_tabs}; 10 - use super::toolbar::{Toolbar, ToolbarItem, show_toolbar}; 10 + use super::toolbar::{Toolbar, ToolbarItem, ToolbarOverflowConfig, show_toolbar}; 11 11 12 12 #[derive(Copy, Clone, Debug, PartialEq, Eq)] 13 13 pub enum RibbonIconSize { ··· 33 33 pub icon_size: RibbonIconSize, 34 34 pub min_width: LayoutPx, 35 35 pub width: LayoutPx, 36 + pub overflow_open: bool, 37 + pub overflow_label: Option<StringKey>, 36 38 } 37 39 38 40 #[derive(Clone, Debug, PartialEq)] ··· 110 112 pub activated_tab: Option<WidgetId>, 111 113 pub closed_tab: Option<WidgetId>, 112 114 pub activated_tool: Option<WidgetId>, 115 + pub overflow_toggled: Vec<WidgetId>, 116 + pub popup_consumed_click: bool, 113 117 pub paint: Vec<WidgetPaint>, 118 + pub popover_paint: Vec<WidgetPaint>, 114 119 } 115 120 116 121 #[must_use] ··· 162 167 ); 163 168 paint.extend(tabs_response.paint); 164 169 let mut activated_tool: Option<WidgetId> = None; 170 + let mut overflow_toggled: Vec<WidgetId> = Vec::new(); 171 + let mut popover_paint: Vec<WidgetPaint> = Vec::new(); 172 + let mut popup_consumed_click = false; 165 173 if let Some(active_tab) = tabs.iter().find(|t| t.id == active) { 166 174 let groups_paint = render_groups( 167 175 ctx, ··· 173 181 group_padding, 174 182 }, 175 183 &mut activated_tool, 184 + &mut overflow_toggled, 185 + &mut popover_paint, 186 + &mut popup_consumed_click, 176 187 ); 177 188 paint.extend(groups_paint); 178 189 } ··· 180 191 activated_tab: tabs_response.activated, 181 192 closed_tab: tabs_response.closed, 182 193 activated_tool, 194 + overflow_toggled, 195 + popup_consumed_click, 183 196 paint, 197 + popover_paint, 184 198 } 185 199 } 186 200 ··· 228 242 groups: &[RibbonGroup], 229 243 layout: GroupLayout, 230 244 activated_tool: &mut Option<WidgetId>, 245 + overflow_toggled: &mut Vec<WidgetId>, 246 + popover_paint: &mut Vec<WidgetPaint>, 247 + popup_consumed_click: &mut bool, 231 248 ) -> Vec<WidgetPaint> { 232 249 let GroupLayout { 233 250 body_rect, ··· 248 265 AccessNode::new(Role::Group).with_label(group.label), 249 266 ); 250 267 let toolbar_rect = inner_toolbar_rect(*group_rect, group_label_height, group_padding); 251 - let response = show_toolbar( 252 - ctx, 253 - Toolbar::horizontal( 254 - group.id.child(WidgetKey::new("toolbar")), 255 - toolbar_rect, 256 - group.label, 257 - &group.items, 258 - group.icon_size.item_px(), 259 - LayoutPx::new(4.0), 260 - ), 268 + let toolbar = Toolbar::horizontal( 269 + group.id.child(WidgetKey::new("toolbar")), 270 + toolbar_rect, 271 + group.label, 272 + &group.items, 273 + group.icon_size.item_px(), 274 + LayoutPx::new(4.0), 261 275 ); 276 + let toolbar = match group.overflow_label { 277 + Some(label) => toolbar.with_overflow( 278 + ToolbarOverflowConfig::new(label).with_open(group.overflow_open), 279 + ), 280 + None => toolbar, 281 + }; 282 + let response = show_toolbar(ctx, toolbar); 262 283 paint.extend(response.paint); 284 + popover_paint.extend(response.popover_paint); 285 + *popup_consumed_click |= response.popup_consumed_click; 263 286 if let Some(activated) = response.activated 264 287 && activated_tool.is_none() 265 288 { 266 289 *activated_tool = Some(activated); 290 + } 291 + if response.overflow_toggled { 292 + overflow_toggled.push(group.id); 267 293 } 268 294 paint.push(WidgetPaint::Label { 269 295 rect: group_label_rect(*group_rect, group_label_height), ··· 436 462 icon_size: RibbonIconSize::Large, 437 463 min_width: LayoutPx::new(min), 438 464 width: LayoutPx::new(preferred), 465 + overflow_open: false, 466 + overflow_label: None, 439 467 } 440 468 } 441 469 ··· 568 596 icon_size: RibbonIconSize::Large, 569 597 min_width: LayoutPx::new(80.0), 570 598 width: LayoutPx::new(120.0), 599 + overflow_open: false, 600 + overflow_label: None, 571 601 }], 572 602 ) 573 603 }
+582 -51
crates/bone-ui/src/widgets/toolbar.rs
··· 1 1 use crate::a11y::{AccessNode, Role}; 2 2 use crate::frame::{FrameCtx, InteractDeclaration}; 3 - use crate::hit_test::Sense; 3 + use crate::hit_test::{Sense, ZLayer}; 4 4 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 5 5 use crate::strings::StringKey; 6 6 use crate::theme::Step12; 7 - use crate::widget_id::WidgetId; 7 + use crate::widget_id::{WidgetId, WidgetKey}; 8 8 9 9 use super::keys::{TakeKey, take_key}; 10 - use super::paint::{LabelText, WidgetPaint}; 10 + use super::paint::{GlyphMark, HorizontalAlign, LabelText, WidgetPaint}; 11 11 use super::visuals::push_focus_ring; 12 12 13 13 #[derive(Copy, Clone, Debug, PartialEq)] ··· 67 67 } 68 68 69 69 #[derive(Copy, Clone, Debug, PartialEq)] 70 + pub struct ToolbarOverflowConfig { 71 + pub open: bool, 72 + pub label: StringKey, 73 + } 74 + 75 + impl ToolbarOverflowConfig { 76 + #[must_use] 77 + pub const fn new(label: StringKey) -> Self { 78 + Self { open: false, label } 79 + } 80 + 81 + #[must_use] 82 + pub const fn with_open(self, open: bool) -> Self { 83 + Self { open, ..self } 84 + } 85 + } 86 + 87 + #[derive(Copy, Clone, Debug, PartialEq)] 70 88 pub struct Toolbar<'a> { 71 89 pub id: WidgetId, 72 90 pub rect: LayoutRect, ··· 75 93 pub item_size: LayoutPx, 76 94 pub item_gap: LayoutPx, 77 95 pub orientation: ToolbarOrientation, 96 + pub overflow: Option<ToolbarOverflowConfig>, 78 97 } 79 98 80 99 impl<'a> Toolbar<'a> { ··· 95 114 item_size, 96 115 item_gap, 97 116 orientation: ToolbarOrientation::Horizontal, 117 + overflow: None, 98 118 } 99 119 } 100 120 ··· 115 135 item_size, 116 136 item_gap, 117 137 orientation: ToolbarOrientation::Vertical, 138 + overflow: None, 118 139 } 119 140 } 141 + 142 + #[must_use] 143 + pub const fn with_overflow(mut self, overflow: ToolbarOverflowConfig) -> Self { 144 + self.overflow = Some(overflow); 145 + self 146 + } 120 147 } 121 148 122 149 #[derive(Clone, Debug, PartialEq)] 123 150 pub struct ToolbarResponse { 124 151 pub activated: Option<WidgetId>, 125 152 pub visible_count: usize, 153 + pub overflow_count: usize, 154 + pub overflow_toggled: bool, 155 + pub popup_consumed_click: bool, 126 156 pub paint: Vec<WidgetPaint>, 157 + pub popover_paint: Vec<WidgetPaint>, 127 158 } 128 159 129 160 #[must_use] ··· 136 167 item_size, 137 168 item_gap, 138 169 orientation, 170 + overflow, 139 171 } = toolbar; 140 - let layout = layout_items(rect, items, item_size, item_gap, orientation); 141 - let visible_count = layout.len(); 172 + let plan = layout_with_overflow(rect, items, item_size, item_gap, orientation, overflow); 142 173 ctx.a11y 143 174 .push(id, rect, AccessNode::new(Role::Toolbar).with_label(label)); 144 175 let mut paint = Vec::new(); 145 176 let mut activated: Option<WidgetId> = None; 146 177 items 147 178 .iter() 148 - .take(visible_count) 149 - .zip(layout.iter()) 179 + .take(plan.visible.len()) 180 + .zip(plan.visible.iter()) 150 181 .for_each(|(item, item_rect)| { 151 - let result = draw_item(ctx, *item_rect, item); 182 + let result = draw_item(ctx, *item_rect, item, ItemStyle::Toolbar); 152 183 paint.extend(result.paint); 153 184 if result.activated && activated.is_none() { 154 185 activated = Some(item.id); 155 186 } 156 187 }); 188 + let mut overflow_toggled = false; 189 + let mut popover_paint = Vec::new(); 190 + let mut popup_consumed_click = false; 191 + if let (Some(cfg), Some(chevron_rect)) = (overflow, plan.chevron) { 192 + let chevron_id = overflow_chevron_id(id); 193 + let result = draw_chevron(ctx, chevron_rect, chevron_id, cfg); 194 + paint.extend(result.paint); 195 + overflow_toggled = result.activated; 196 + if cfg.open && plan.hidden_count > 0 { 197 + let hidden_items = &items[items.len() - plan.hidden_count..]; 198 + let popup = render_overflow_popup( 199 + ctx, 200 + chevron_rect, 201 + hidden_items, 202 + item_size, 203 + orientation, 204 + ); 205 + popover_paint.extend(popup.paint); 206 + popup_consumed_click = popup.consumed_click; 207 + if let Some(act) = popup.activated 208 + && activated.is_none() 209 + { 210 + activated = Some(act); 211 + } 212 + } 213 + } 157 214 ToolbarResponse { 158 215 activated, 159 - visible_count, 216 + visible_count: plan.visible.len(), 217 + overflow_count: plan.hidden_count, 218 + overflow_toggled, 219 + popup_consumed_click, 160 220 paint, 221 + popover_paint, 161 222 } 162 223 } 163 224 164 225 struct ItemDraw { 165 226 activated: bool, 227 + consumed_click: bool, 166 228 paint: Vec<WidgetPaint>, 167 229 } 168 230 169 - fn draw_item(ctx: &mut FrameCtx<'_>, rect: LayoutRect, item: &ToolbarItem) -> ItemDraw { 170 - let interaction = ctx.interact( 171 - InteractDeclaration::new(item.id, rect, Sense::INTERACTIVE) 172 - .focusable(true) 173 - .disabled(item.disabled) 174 - .active(item.active) 175 - .a11y( 176 - AccessNode::new(Role::Button) 177 - .with_label(item.label) 178 - .with_disabled(item.disabled) 179 - .with_selected(item.active), 180 - ), 181 - ); 231 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 232 + enum ItemStyle { 233 + Toolbar, 234 + PopupRow, 235 + } 236 + 237 + fn draw_item( 238 + ctx: &mut FrameCtx<'_>, 239 + rect: LayoutRect, 240 + item: &ToolbarItem, 241 + style: ItemStyle, 242 + ) -> ItemDraw { 243 + let decl = InteractDeclaration::new(item.id, rect, Sense::INTERACTIVE) 244 + .focusable(true) 245 + .disabled(item.disabled && style == ItemStyle::Toolbar) 246 + .active(item.active) 247 + .a11y( 248 + AccessNode::new(Role::Button) 249 + .with_label(item.label) 250 + .with_disabled(item.disabled) 251 + .with_selected(item.active), 252 + ); 253 + let decl = match style { 254 + ItemStyle::Toolbar => decl, 255 + ItemStyle::PopupRow => decl.at_z(ZLayer::POPUP), 256 + }; 257 + let interaction = ctx.interact(decl); 182 258 let live_focused = ctx.is_focused(item.id); 183 259 let activated_via_pointer = !item.disabled && interaction.click(); 184 260 let activated_via_key = !item.disabled ··· 193 269 .is_some(); 194 270 let mut paint = vec![WidgetPaint::Surface { 195 271 rect, 196 - fill: item_fill(ctx, item, &interaction), 272 + fill: item_fill(ctx, item, &interaction, style), 197 273 border: None, 198 274 radius: ctx.theme().radius.sm, 199 275 elevation: None, 200 276 }]; 201 - paint.push(WidgetPaint::Label { 202 - rect, 203 - text: LabelText::Key(item.label), 204 - color: if item.disabled { 205 - ctx.theme().colors.text_disabled() 206 - } else { 207 - ctx.theme().colors.text_primary() 208 - }, 209 - role: ctx.theme().typography.label, 210 - }); 277 + let label_color = if item.disabled { 278 + ctx.theme().colors.text_disabled() 279 + } else { 280 + ctx.theme().colors.text_primary() 281 + }; 282 + match style { 283 + ItemStyle::Toolbar => { 284 + paint.push(WidgetPaint::Label { 285 + rect, 286 + text: LabelText::Key(item.label), 287 + color: label_color, 288 + role: ctx.theme().typography.label, 289 + }); 290 + } 291 + ItemStyle::PopupRow => { 292 + paint.push(WidgetPaint::AlignedLabel { 293 + rect: popup_label_rect(rect), 294 + text: LabelText::Key(item.label), 295 + color: label_color, 296 + role: ctx.theme().typography.label, 297 + align: HorizontalAlign::Start, 298 + }); 299 + } 300 + } 211 301 push_focus_ring(ctx, &mut paint, rect, ctx.theme().radius.sm, live_focused); 212 - if let Some(tip_key) = item.tooltip 302 + if style == ItemStyle::Toolbar 303 + && let Some(tip_key) = item.tooltip 213 304 && interaction.hover() 214 305 { 215 306 paint.push(WidgetPaint::Tooltip { ··· 221 312 } 222 313 ItemDraw { 223 314 activated: activated_via_pointer || activated_via_key, 315 + consumed_click: interaction.click() || interaction.pressed(), 224 316 paint, 225 317 } 226 318 } 227 319 320 + fn popup_label_rect(row: LayoutRect) -> LayoutRect { 321 + LayoutRect::new( 322 + LayoutPos::new( 323 + LayoutPx::new(row.origin.x.value() + POPUP_LABEL_INSET_PX), 324 + row.origin.y, 325 + ), 326 + LayoutSize::new( 327 + LayoutPx::saturating_nonneg(row.size.width.value() - 2.0 * POPUP_LABEL_INSET_PX), 328 + row.size.height, 329 + ), 330 + ) 331 + } 332 + 333 + fn draw_chevron( 334 + ctx: &mut FrameCtx<'_>, 335 + rect: LayoutRect, 336 + chevron_id: WidgetId, 337 + cfg: ToolbarOverflowConfig, 338 + ) -> ItemDraw { 339 + let interaction = ctx.interact( 340 + InteractDeclaration::new(chevron_id, rect, Sense::INTERACTIVE) 341 + .focusable(true) 342 + .active(cfg.open) 343 + .a11y( 344 + AccessNode::new(Role::Button) 345 + .with_label(cfg.label) 346 + .with_expanded(cfg.open), 347 + ), 348 + ); 349 + let live_focused = ctx.is_focused(chevron_id); 350 + let activated_via_pointer = interaction.click(); 351 + let activated_via_key = live_focused 352 + && take_key( 353 + ctx.input, 354 + &[ 355 + TakeKey::named(crate::input::NamedKey::Enter), 356 + TakeKey::named(crate::input::NamedKey::Space), 357 + ], 358 + ) 359 + .is_some(); 360 + let fill = if cfg.open || interaction.pressed() { 361 + ctx.theme().colors.neutral.step(Step12::SELECTED_BG) 362 + } else if interaction.hover() { 363 + ctx.theme().colors.neutral.step(Step12::HOVER_BG) 364 + } else { 365 + crate::theme::Color::TRANSPARENT 366 + }; 367 + let mut paint = vec![ 368 + WidgetPaint::Surface { 369 + rect, 370 + fill, 371 + border: None, 372 + radius: ctx.theme().radius.sm, 373 + elevation: None, 374 + }, 375 + WidgetPaint::Mark { 376 + rect, 377 + kind: GlyphMark::Ellipsis, 378 + color: ctx.theme().colors.text_primary(), 379 + }, 380 + ]; 381 + push_focus_ring(ctx, &mut paint, rect, ctx.theme().radius.sm, live_focused); 382 + ItemDraw { 383 + activated: activated_via_pointer || activated_via_key, 384 + consumed_click: interaction.click() || interaction.pressed(), 385 + paint, 386 + } 387 + } 388 + 389 + struct PopupRender { 390 + paint: Vec<WidgetPaint>, 391 + activated: Option<WidgetId>, 392 + consumed_click: bool, 393 + } 394 + 395 + fn render_overflow_popup( 396 + ctx: &mut FrameCtx<'_>, 397 + chevron_rect: LayoutRect, 398 + items: &[ToolbarItem], 399 + item_size: LayoutPx, 400 + orientation: ToolbarOrientation, 401 + ) -> PopupRender { 402 + let primary_pressed_now = !ctx.input.buttons_pressed.is_empty(); 403 + let pointer_now = ctx.input.pointer.map(|p| p.position); 404 + let label_font_px = ctx.theme().typography.label.size.as_px_f32(); 405 + let max_label_w = items 406 + .iter() 407 + .map(|it| { 408 + super::paint::estimate_label_width_px( 409 + ctx.strings.resolve(it.label), 410 + label_font_px, 411 + POPUP_LABEL_PADDING_PX, 412 + ) 413 + }) 414 + .fold(POPUP_MIN_WIDTH_PX, f32::max); 415 + let popup_width = max_label_w.min(POPUP_MAX_WIDTH_PX); 416 + let row_height = item_size.value().max(POPUP_MIN_ROW_HEIGHT_PX); 417 + #[allow( 418 + clippy::cast_precision_loss, 419 + reason = "overflow item count fits in f32 mantissa" 420 + )] 421 + let popup_height = row_height * items.len() as f32 + 2.0 * POPUP_PADDING_PX; 422 + let (origin_x, origin_y) = popup_origin(chevron_rect, popup_width, popup_height, orientation); 423 + let popup_rect = LayoutRect::new( 424 + LayoutPos::new(LayoutPx::new(origin_x), LayoutPx::new(origin_y)), 425 + LayoutSize::new( 426 + LayoutPx::new(popup_width), 427 + LayoutPx::saturating_nonneg(popup_height), 428 + ), 429 + ); 430 + let elevation = ctx.theme().elevation.level2; 431 + let mut paint = vec![WidgetPaint::Surface { 432 + rect: popup_rect, 433 + fill: ctx.theme().colors.surface(elevation.surface), 434 + border: elevation.border, 435 + radius: ctx.theme().radius.sm, 436 + elevation: Some(elevation), 437 + }]; 438 + let mut activated: Option<WidgetId> = None; 439 + let mut consumed_click = false; 440 + items.iter().enumerate().for_each(|(i, item)| { 441 + #[allow( 442 + clippy::cast_precision_loss, 443 + reason = "popup row index fits in f32 mantissa" 444 + )] 445 + let y = popup_rect.origin.y.value() + POPUP_PADDING_PX + i as f32 * row_height; 446 + let row_rect = LayoutRect::new( 447 + LayoutPos::new( 448 + LayoutPx::new(popup_rect.origin.x.value() + POPUP_PADDING_PX), 449 + LayoutPx::new(y), 450 + ), 451 + LayoutSize::new( 452 + LayoutPx::saturating_nonneg(popup_width - 2.0 * POPUP_PADDING_PX), 453 + LayoutPx::new(row_height), 454 + ), 455 + ); 456 + let result = draw_item(ctx, row_rect, item, ItemStyle::PopupRow); 457 + paint.extend(result.paint); 458 + if result.activated && activated.is_none() { 459 + activated = Some(item.id); 460 + } 461 + consumed_click |= result.consumed_click; 462 + }); 463 + let press_inside_popup = 464 + primary_pressed_now && pointer_now.is_some_and(|p| popup_rect.contains(p)); 465 + consumed_click |= press_inside_popup; 466 + PopupRender { 467 + paint, 468 + activated, 469 + consumed_click, 470 + } 471 + } 472 + 473 + fn popup_origin( 474 + chevron: LayoutRect, 475 + popup_w: f32, 476 + popup_h: f32, 477 + orientation: ToolbarOrientation, 478 + ) -> (f32, f32) { 479 + match orientation { 480 + ToolbarOrientation::Horizontal => { 481 + let right = chevron.origin.x.value() + chevron.size.width.value(); 482 + let origin_y = chevron.origin.y.value() + chevron.size.height.value() + POPUP_GAP_PX; 483 + ((right - popup_w).max(0.0), origin_y) 484 + } 485 + ToolbarOrientation::Vertical => { 486 + let bottom = chevron.origin.y.value() + chevron.size.height.value(); 487 + let origin_x = chevron.origin.x.value() + chevron.size.width.value() + POPUP_GAP_PX; 488 + (origin_x, (bottom - popup_h).max(0.0)) 489 + } 490 + } 491 + } 492 + 228 493 fn tooltip_rect_below(anchor: LayoutRect) -> LayoutRect { 229 494 const TIP_WIDTH: f32 = 220.0; 230 495 const TIP_HEIGHT: f32 = 22.0; ··· 242 507 ctx: &FrameCtx<'_>, 243 508 item: &ToolbarItem, 244 509 interaction: &crate::hit_test::Interaction, 510 + style: ItemStyle, 245 511 ) -> crate::theme::Color { 246 512 let neutral = ctx.theme().colors.neutral; 247 - if item.disabled { 513 + let accent = ctx.theme().colors.accent; 514 + let show_hover_when_disabled = style == ItemStyle::PopupRow; 515 + if item.disabled && !show_hover_when_disabled { 248 516 crate::theme::Color::TRANSPARENT 249 517 } else if item.active || interaction.pressed() { 250 - neutral.step(Step12::SELECTED_BG) 518 + match style { 519 + ItemStyle::Toolbar => neutral.step(Step12::SELECTED_BG), 520 + ItemStyle::PopupRow => accent.step(Step12::SELECTED_BG), 521 + } 251 522 } else if interaction.hover() { 252 - neutral.step(Step12::HOVER_BG) 523 + match style { 524 + ItemStyle::Toolbar => neutral.step(Step12::HOVER_BG), 525 + ItemStyle::PopupRow => accent.step(Step12::HOVER_BG), 526 + } 253 527 } else { 254 528 crate::theme::Color::TRANSPARENT 255 529 } ··· 259 533 item.width.unwrap_or(fallback).value() 260 534 } 261 535 262 - fn layout_items( 536 + #[must_use] 537 + pub fn overflow_chevron_id(toolbar_id: WidgetId) -> WidgetId { 538 + toolbar_id.child(WidgetKey::new("overflow.chevron")) 539 + } 540 + 541 + struct LayoutPlan { 542 + visible: Vec<LayoutRect>, 543 + chevron: Option<LayoutRect>, 544 + hidden_count: usize, 545 + } 546 + 547 + fn layout_with_overflow( 263 548 rect: LayoutRect, 264 549 items: &[ToolbarItem], 265 550 item_size: LayoutPx, 266 551 gap: LayoutPx, 267 552 orientation: ToolbarOrientation, 268 - ) -> Vec<LayoutRect> { 553 + overflow: Option<ToolbarOverflowConfig>, 554 + ) -> LayoutPlan { 555 + let chevron_extent = item_size.value(); 269 556 let available = match orientation { 270 557 ToolbarOrientation::Horizontal => rect.size.width.value(), 271 558 ToolbarOrientation::Vertical => rect.size.height.value(), 272 559 }; 560 + let total_needed = total_extent(items, item_size, gap); 561 + let all_fit = total_needed <= available + FIT_EPSILON_PX; 562 + if all_fit { 563 + return LayoutPlan { 564 + visible: lay_out_items(rect, items, item_size, gap, orientation, available), 565 + chevron: None, 566 + hidden_count: 0, 567 + }; 568 + } 569 + if overflow.is_none() { 570 + let visible = lay_out_items(rect, items, item_size, gap, orientation, available); 571 + let hidden_count = items.len().saturating_sub(visible.len()); 572 + return LayoutPlan { 573 + visible, 574 + chevron: None, 575 + hidden_count, 576 + }; 577 + } 578 + let budget_for_items = (available - chevron_extent - gap.value()).max(0.0); 579 + let visible = lay_out_items(rect, items, item_size, gap, orientation, budget_for_items); 580 + let hidden_count = items.len().saturating_sub(visible.len()); 581 + if hidden_count == 0 { 582 + return LayoutPlan { 583 + visible, 584 + chevron: None, 585 + hidden_count: 0, 586 + }; 587 + } 588 + let chevron_offset = visible.last().map_or(0.0, |r| match orientation { 589 + ToolbarOrientation::Horizontal => { 590 + r.origin.x.value() + r.size.width.value() - rect.origin.x.value() + gap.value() 591 + } 592 + ToolbarOrientation::Vertical => { 593 + r.origin.y.value() + r.size.height.value() - rect.origin.y.value() + gap.value() 594 + } 595 + }); 596 + let chevron = single_item_rect(rect, chevron_offset, chevron_extent, orientation); 597 + LayoutPlan { 598 + visible, 599 + chevron: Some(chevron), 600 + hidden_count, 601 + } 602 + } 603 + 604 + fn total_extent(items: &[ToolbarItem], item_size: LayoutPx, gap: LayoutPx) -> f32 { 605 + items 606 + .iter() 607 + .enumerate() 608 + .map(|(i, it)| { 609 + item_extent(it, item_size) + if i == 0 { 0.0 } else { gap.value() } 610 + }) 611 + .sum() 612 + } 613 + 614 + fn lay_out_items( 615 + rect: LayoutRect, 616 + items: &[ToolbarItem], 617 + item_size: LayoutPx, 618 + gap: LayoutPx, 619 + orientation: ToolbarOrientation, 620 + available: f32, 621 + ) -> Vec<LayoutRect> { 273 622 let mut offset = 0.0_f32; 274 623 items 275 624 .iter() ··· 289 638 } 290 639 291 640 const FIT_EPSILON_PX: f32 = 0.5; 641 + const POPUP_PADDING_PX: f32 = 4.0; 642 + const POPUP_GAP_PX: f32 = 4.0; 643 + const POPUP_LABEL_PADDING_PX: f32 = 12.0; 644 + const POPUP_LABEL_INSET_PX: f32 = 12.0; 645 + const POPUP_MIN_WIDTH_PX: f32 = 160.0; 646 + const POPUP_MAX_WIDTH_PX: f32 = 320.0; 647 + const POPUP_MIN_ROW_HEIGHT_PX: f32 = 24.0; 292 648 293 649 fn single_item_rect( 294 650 rect: LayoutRect, ··· 318 674 mod tests { 319 675 use std::sync::Arc; 320 676 321 - use super::{Toolbar, ToolbarItem, show_toolbar}; 677 + use super::{ 678 + Toolbar, ToolbarItem, ToolbarOverflowConfig, overflow_chevron_id, show_toolbar, 679 + }; 322 680 use crate::focus::FocusManager; 323 681 use crate::frame::FrameCtx; 324 682 use crate::hit_test::{HitFrame, HitState, resolve}; ··· 353 711 snap: &mut InputSnapshot, 354 712 prev: &HitState, 355 713 ) -> (super::ToolbarResponse, HitState) { 714 + render_with(items, rect, None, focus, snap, prev) 715 + } 716 + 717 + fn render_with( 718 + items: &[ToolbarItem], 719 + rect: LayoutRect, 720 + overflow: Option<ToolbarOverflowConfig>, 721 + focus: &mut FocusManager, 722 + snap: &mut InputSnapshot, 723 + prev: &HitState, 724 + ) -> (super::ToolbarResponse, HitState) { 356 725 let theme = Arc::new(Theme::light()); 357 726 let table = HotkeyTable::new(); 358 727 let mut hits = HitFrame::new(); ··· 370 739 &mut a11y, 371 740 &mut shaper, 372 741 ); 373 - show_toolbar( 374 - &mut ctx, 375 - Toolbar::horizontal( 376 - toolbar_id(), 377 - rect, 378 - StringKey::new("test.toolbar"), 379 - items, 380 - LayoutPx::new(28.0), 381 - LayoutPx::new(4.0), 382 - ), 383 - ) 742 + let toolbar = Toolbar::horizontal( 743 + toolbar_id(), 744 + rect, 745 + StringKey::new("test.toolbar"), 746 + items, 747 + LayoutPx::new(28.0), 748 + LayoutPx::new(4.0), 749 + ); 750 + let toolbar = match overflow { 751 + Some(cfg) => toolbar.with_overflow(cfg), 752 + None => toolbar, 753 + }; 754 + show_toolbar(&mut ctx, toolbar) 384 755 }; 385 756 let next = resolve(prev, &hits, snap, focus.focused()); 386 757 (response, next) ··· 398 769 let prev = HitState::new(); 399 770 let (response, _) = render(&items, rect, &mut focus, &mut snap, &prev); 400 771 assert_eq!(response.visible_count, 3); 772 + assert_eq!(response.overflow_count, 0); 401 773 } 402 774 403 775 #[test] ··· 412 784 let prev = HitState::new(); 413 785 let (response, _) = render(&items, rect, &mut focus, &mut snap, &prev); 414 786 assert!(response.visible_count < 5); 787 + } 788 + 789 + #[test] 790 + fn overflow_chevron_reports_hidden_count_when_items_dont_fit() { 791 + let items = items(5); 792 + let rect = LayoutRect::new( 793 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 794 + LayoutSize::new(LayoutPx::new(100.0), LayoutPx::new(28.0)), 795 + ); 796 + let cfg = ToolbarOverflowConfig::new(StringKey::new("toolbar.overflow")); 797 + let mut focus = FocusManager::new(); 798 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 799 + let prev = HitState::new(); 800 + let (response, _) = render_with(&items, rect, Some(cfg), &mut focus, &mut snap, &prev); 801 + assert!(response.overflow_count >= 1); 802 + assert_eq!(response.visible_count + response.overflow_count, 5); 803 + } 804 + 805 + #[test] 806 + fn overflow_chevron_absent_when_all_items_fit() { 807 + let items = items(3); 808 + let rect = LayoutRect::new( 809 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 810 + LayoutSize::new(LayoutPx::new(400.0), LayoutPx::new(28.0)), 811 + ); 812 + let cfg = ToolbarOverflowConfig::new(StringKey::new("toolbar.overflow")); 813 + let mut focus = FocusManager::new(); 814 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 815 + let prev = HitState::new(); 816 + let (response, _) = render_with(&items, rect, Some(cfg), &mut focus, &mut snap, &prev); 817 + assert_eq!(response.visible_count, 3); 818 + assert_eq!(response.overflow_count, 0); 819 + assert!(!response.overflow_toggled); 820 + } 821 + 822 + #[test] 823 + fn clicking_chevron_sets_overflow_toggled() { 824 + let items = items(6); 825 + let rect = LayoutRect::new( 826 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 827 + LayoutSize::new(LayoutPx::new(100.0), LayoutPx::new(28.0)), 828 + ); 829 + let cfg = ToolbarOverflowConfig::new(StringKey::new("toolbar.overflow")); 830 + let mut focus = FocusManager::new(); 831 + let mut prev = HitState::new(); 832 + let click_pos = LayoutPos::new(LayoutPx::new(86.0), LayoutPx::new(14.0)); 833 + let mut last: Option<super::ToolbarResponse> = None; 834 + [press(click_pos), release(click_pos), idle(click_pos)] 835 + .into_iter() 836 + .for_each(|mut snap| { 837 + let (response, next) = 838 + render_with(&items, rect, Some(cfg), &mut focus, &mut snap, &prev); 839 + last = Some(response); 840 + prev = next; 841 + }); 842 + let Some(response) = last else { 843 + panic!("response missing") 844 + }; 845 + assert!(response.overflow_toggled); 846 + let _ = overflow_chevron_id(toolbar_id()); 847 + } 848 + 849 + #[test] 850 + fn open_popup_emits_popover_paint_for_hidden_items() { 851 + let items = items(6); 852 + let rect = LayoutRect::new( 853 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 854 + LayoutSize::new(LayoutPx::new(100.0), LayoutPx::new(28.0)), 855 + ); 856 + let cfg = ToolbarOverflowConfig::new(StringKey::new("toolbar.overflow")).with_open(true); 857 + let mut focus = FocusManager::new(); 858 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 859 + let prev = HitState::new(); 860 + let (response, _) = render_with(&items, rect, Some(cfg), &mut focus, &mut snap, &prev); 861 + assert!(!response.popover_paint.is_empty()); 862 + assert!(response.overflow_count >= 1); 863 + } 864 + 865 + #[test] 866 + fn closed_popup_emits_no_popover_paint() { 867 + let items = items(6); 868 + let rect = LayoutRect::new( 869 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 870 + LayoutSize::new(LayoutPx::new(100.0), LayoutPx::new(28.0)), 871 + ); 872 + let cfg = ToolbarOverflowConfig::new(StringKey::new("toolbar.overflow")); 873 + let mut focus = FocusManager::new(); 874 + let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 875 + let prev = HitState::new(); 876 + let (response, _) = render_with(&items, rect, Some(cfg), &mut focus, &mut snap, &prev); 877 + assert!(response.popover_paint.is_empty()); 878 + } 879 + 880 + #[test] 881 + fn clicking_disabled_popup_item_consumes_click_but_does_not_activate() { 882 + let items: Vec<ToolbarItem> = items(6) 883 + .into_iter() 884 + .map(|it| it.disabled(true)) 885 + .collect(); 886 + let rect = LayoutRect::new( 887 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 888 + LayoutSize::new(LayoutPx::new(100.0), LayoutPx::new(28.0)), 889 + ); 890 + let cfg = ToolbarOverflowConfig::new(StringKey::new("toolbar.overflow")).with_open(true); 891 + let mut focus = FocusManager::new(); 892 + let mut prev = HitState::new(); 893 + let mut warm_snap = InputSnapshot::idle(FrameInstant::ZERO); 894 + let (_, warm_state) = 895 + render_with(&items, rect, Some(cfg), &mut focus, &mut warm_snap, &prev); 896 + prev = warm_state; 897 + let click_y = 28.0 + super::POPUP_GAP_PX + super::POPUP_PADDING_PX + 12.0; 898 + let click_pos = LayoutPos::new(LayoutPx::new(60.0), LayoutPx::new(click_y)); 899 + let mut last: Option<super::ToolbarResponse> = None; 900 + [press(click_pos), release(click_pos), idle(click_pos)] 901 + .into_iter() 902 + .for_each(|mut snap| { 903 + let (response, next) = 904 + render_with(&items, rect, Some(cfg), &mut focus, &mut snap, &prev); 905 + last = Some(response); 906 + prev = next; 907 + }); 908 + let Some(response) = last else { 909 + panic!("response missing") 910 + }; 911 + assert!(response.activated.is_none()); 912 + assert!(response.popup_consumed_click); 913 + } 914 + 915 + #[test] 916 + fn clicking_popup_item_activates_it() { 917 + let items = items(6); 918 + let rect = LayoutRect::new( 919 + LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 920 + LayoutSize::new(LayoutPx::new(100.0), LayoutPx::new(28.0)), 921 + ); 922 + let cfg = ToolbarOverflowConfig::new(StringKey::new("toolbar.overflow")).with_open(true); 923 + let mut focus = FocusManager::new(); 924 + let mut prev = HitState::new(); 925 + let mut warm_snap = InputSnapshot::idle(FrameInstant::ZERO); 926 + let (warm, warm_state) = 927 + render_with(&items, rect, Some(cfg), &mut focus, &mut warm_snap, &prev); 928 + prev = warm_state; 929 + let hidden_index = items.len() - warm.overflow_count; 930 + let hidden_item_id = items[hidden_index].id; 931 + let click_y = 28.0 + super::POPUP_GAP_PX + super::POPUP_PADDING_PX + 12.0; 932 + let click_pos = LayoutPos::new(LayoutPx::new(60.0), LayoutPx::new(click_y)); 933 + let mut last: Option<super::ToolbarResponse> = None; 934 + [press(click_pos), release(click_pos), idle(click_pos)] 935 + .into_iter() 936 + .for_each(|mut snap| { 937 + let (response, next) = 938 + render_with(&items, rect, Some(cfg), &mut focus, &mut snap, &prev); 939 + last = Some(response); 940 + prev = next; 941 + }); 942 + let Some(response) = last else { 943 + panic!("response missing") 944 + }; 945 + assert_eq!(response.activated, Some(hidden_item_id)); 415 946 } 416 947 417 948 #[test]
+2 -4
crates/bone-ui/tests/key_ordering.rs
··· 246 246 &mut a11y, 247 247 &mut shaper, 248 248 ); 249 - let widget = 250 - HotkeyCapture::new(field_id(), rect(), FIELD_LABEL, FIELD_LABEL, &mut state); 249 + let widget = HotkeyCapture::new(field_id(), rect(), FIELD_LABEL, FIELD_LABEL, &mut state); 251 250 let response = show_hotkey_capture(&mut ctx, widget); 252 251 let actions = ctx.dispatch_hotkeys(&scopes); 253 252 (actions, response.captured) ··· 303 302 &mut shaper, 304 303 ); 305 304 let actions = ctx.dispatch_hotkeys(&scopes); 306 - let widget = 307 - HotkeyCapture::new(field_id(), rect(), FIELD_LABEL, FIELD_LABEL, &mut state); 305 + let widget = HotkeyCapture::new(field_id(), rect(), FIELD_LABEL, FIELD_LABEL, &mut state); 308 306 let response = show_hotkey_capture(&mut ctx, widget); 309 307 (actions, response.captured) 310 308 };