Another project
0

Configure Feed

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

feat(ui): widget gallery, app can --gallery emit

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

author
Lewis
date (May 7, 2026, 9:02 AM +0300) commit 2af84552 parent b30fcf85 change-id tskrqywm
+1455 -600
+1
crates/bone-app/Cargo.toml
··· 9 9 bone-types = { workspace = true } 10 10 bone-document = { workspace = true } 11 11 bone-render = { workspace = true } 12 + bone-ui = { workspace = true } 12 13 13 14 pollster = { workspace = true } 14 15 thiserror = { workspace = true }
+84
crates/bone-app/src/main.rs
··· 1 + use std::path::{Path, PathBuf}; 1 2 use std::sync::Arc; 2 3 3 4 use bone_document::{EditOutcome, Sketch, SketchEdit, SketchEntity}; ··· 8 9 use bone_types::{ 9 10 Length, Point2, Point3, SketchEntityId, SketchPlaneBasis, Tolerance, UnitVec3, Vec2, 10 11 }; 12 + use bone_ui::gallery::{GALLERY_CANVAS, GalleryState, render}; 13 + use bone_ui::raster::{PngError, encode_png, rasterize}; 14 + use bone_ui::theme::{Theme, ThemeMode}; 11 15 use tracing_subscriber::EnvFilter; 12 16 use uom::si::length::millimeter; 13 17 use winit::{ ··· 23 27 enum AppError { 24 28 #[error("event loop: {0}")] 25 29 EventLoop(#[from] winit::error::EventLoopError), 30 + #[error("missing argument for {flag}")] 31 + MissingArg { flag: &'static str }, 32 + #[error("unknown argument: {0}")] 33 + UnknownArg(String), 34 + #[error("encode png ({mode}): {source}")] 35 + GalleryEncode { 36 + mode: ThemeMode, 37 + #[source] 38 + source: PngError, 39 + }, 40 + #[error("write {path}: {source}")] 41 + GalleryWrite { 42 + path: PathBuf, 43 + #[source] 44 + source: std::io::Error, 45 + }, 46 + #[error("create directory {path}: {source}")] 47 + GalleryDir { 48 + path: PathBuf, 49 + #[source] 50 + source: std::io::Error, 51 + }, 26 52 } 27 53 28 54 const DEFAULT_LOG_FILTER: &str = "bone_app=info,bone_render=info,bone_document=info,bone_kernel=info,bone_types=info,wgpu_core=warn,wgpu_hal=warn"; ··· 391 417 } 392 418 } 393 419 420 + enum Mode { 421 + Window, 422 + Gallery(PathBuf), 423 + } 424 + 425 + fn parse_mode(args: impl IntoIterator<Item = String>) -> Result<Mode, AppError> { 426 + let collected: Vec<String> = args.into_iter().skip(1).collect(); 427 + parse_args(&collected, Mode::Window) 428 + } 429 + 430 + fn parse_args(args: &[String], acc: Mode) -> Result<Mode, AppError> { 431 + match args { 432 + [] => Ok(acc), 433 + [first, second, rest @ ..] if first == "--gallery" => { 434 + parse_args(rest, Mode::Gallery(PathBuf::from(second))) 435 + } 436 + [only] if only == "--gallery" => Err(AppError::MissingArg { 437 + flag: "--gallery <out-dir>", 438 + }), 439 + [unknown, ..] => Err(AppError::UnknownArg(unknown.clone())), 440 + } 441 + } 442 + 443 + fn emit_gallery(out_dir: &Path) -> Result<(), AppError> { 444 + std::fs::create_dir_all(out_dir).map_err(|e| AppError::GalleryDir { 445 + path: out_dir.to_path_buf(), 446 + source: e, 447 + })?; 448 + [ 449 + (ThemeMode::Light, Theme::light()), 450 + (ThemeMode::Dark, Theme::dark()), 451 + ] 452 + .into_iter() 453 + .try_for_each(|(mode, theme)| -> Result<(), AppError> { 454 + let mut state = GalleryState::new(); 455 + let paint = render(Arc::new(theme.clone()), &mut state); 456 + let rgba = rasterize(&theme, &paint, GALLERY_CANVAS); 457 + let png = encode_png(&rgba, GALLERY_CANVAS).map_err(|source| AppError::GalleryEncode { 458 + mode, 459 + source, 460 + })?; 461 + let target = out_dir.join(format!("gallery_{mode}.png")); 462 + std::fs::write(&target, &png).map_err(|source| AppError::GalleryWrite { 463 + path: target.clone(), 464 + source, 465 + })?; 466 + tracing::info!(path = %target.display(), "gallery png written"); 467 + Ok(()) 468 + }) 469 + } 470 + 394 471 fn main() -> Result<(), AppError> { 395 472 tracing_subscriber::fmt() 396 473 .with_env_filter( ··· 398 475 .unwrap_or_else(|_| EnvFilter::new(DEFAULT_LOG_FILTER)), 399 476 ) 400 477 .init(); 478 + match parse_mode(std::env::args())? { 479 + Mode::Gallery(dir) => emit_gallery(&dir), 480 + Mode::Window => run_window(), 481 + } 482 + } 483 + 484 + fn run_window() -> Result<(), AppError> { 401 485 let event_loop = EventLoop::new()?; 402 486 event_loop.set_control_flow(ControlFlow::Wait); 403 487 let mut app = App {
+1020
crates/bone-ui/src/gallery.rs
··· 1 + use core::time::Duration; 2 + use std::sync::Arc; 3 + 4 + use uom::si::angle::degree; 5 + use uom::si::f64::{Angle, Length}; 6 + use uom::si::length::millimeter; 7 + 8 + use crate::a11y::AccessTreeBuilder; 9 + use crate::focus::FocusManager; 10 + use crate::frame::FrameCtx; 11 + use crate::hit_test::{HitFrame, HitState, Interaction, InteractionState}; 12 + use crate::hotkey::HotkeyTable; 13 + use crate::input::{FrameInstant, InputSnapshot}; 14 + use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 15 + use crate::raster::{CanvasPx, CanvasSize}; 16 + use crate::strings::{StringKey, StringTable}; 17 + use crate::theme::Theme; 18 + use crate::widget_id::{WidgetId, WidgetKey}; 19 + use crate::widgets::{ 20 + AlwaysValid, AngleEditor, BoolEditor, Button, ButtonState, ButtonVariant, Checkbox, CheckboxState, 21 + ConfirmationDialog, ContextMenu, Dialog, DialogButton, Dropdown, DropdownItem, DropdownState, 22 + FilePickerDialog, FilePickerEntry, FilePickerMode, FilePickerState, HotkeyCapture, 23 + HotkeyCaptureState, LengthEditor, ListItem, ListView, ListViewState, MemoryClipboard, Menu, 24 + MenuBar, MenuBarEntry, MenuBarState, MenuItem, MenuState, Modal, NumericInput, Panel, 25 + PanelState, PanelTitlebar, PropertyGrid, PropertyOption, PropertyRow, RadioGroup, RadioOption, 26 + Ribbon, RibbonGroup, RibbonIconSize, RibbonState, RibbonTab, SelectionEditor, Slider, 27 + SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, Tab, Table, TableColumn, TableRow, 28 + TableState, Tabs, TabsOrientation, TextEditor, TextInput, TextInputState, Toast, ToastKind, 29 + ToastState, ToggleButton, Toolbar, ToolbarItem, Tooltip, TooltipPlacement, TooltipState, 30 + TreeNode, TreeView, TreeViewState, WidgetPaint, show_button, show_checkbox, show_confirmation, 31 + show_context_menu, show_dialog, show_dropdown, show_file_picker, show_hotkey_capture, 32 + show_list_view, show_menu, show_menu_bar, show_modal, show_panel, show_parsed_input, 33 + show_property_grid, show_radio_group, show_ribbon, show_slider, show_status_bar, show_table, 34 + show_tabs, show_text_input, show_toast, show_toggle_button, show_toolbar, show_tooltip, 35 + show_tree_view, 36 + }; 37 + 38 + pub const GALLERY_LABEL: StringKey = StringKey::new("gallery.label"); 39 + pub const GALLERY_FRAME_NOW: FrameInstant = FrameInstant::from_duration(Duration::from_secs(1)); 40 + pub const GALLERY_CANVAS: CanvasSize = CanvasSize::new(CanvasPx::new(1400), CanvasPx::new(2400)); 41 + const TOOLTIP_ANCHOR_KEY: &str = "button"; 42 + 43 + pub type StoryPath = &'static [&'static str]; 44 + 45 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 46 + pub struct Story { 47 + pub key: &'static str, 48 + pub parent: Option<&'static str>, 49 + pub kind: StoryKind, 50 + pub extras: &'static [StoryPath], 51 + } 52 + 53 + impl Story { 54 + const fn root(key: &'static str, kind: StoryKind) -> Self { 55 + Self { 56 + key, 57 + parent: None, 58 + kind, 59 + extras: &[], 60 + } 61 + } 62 + 63 + const fn root_with( 64 + key: &'static str, 65 + kind: StoryKind, 66 + extras: &'static [StoryPath], 67 + ) -> Self { 68 + Self { 69 + key, 70 + parent: None, 71 + kind, 72 + extras, 73 + } 74 + } 75 + 76 + pub fn ids(self) -> impl Iterator<Item = WidgetId> { 77 + core::iter::once(story_id(self)).chain(self.extras.iter().copied().map(path_id)) 78 + } 79 + } 80 + 81 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 82 + pub enum StoryKind { 83 + InputPrimitive, 84 + Composite, 85 + Overlay, 86 + } 87 + 88 + pub const STORIES: &[Story] = &[ 89 + Story::root(TOOLTIP_ANCHOR_KEY, StoryKind::InputPrimitive), 90 + Story::root("button_secondary", StoryKind::InputPrimitive), 91 + Story::root("button_destructive", StoryKind::InputPrimitive), 92 + Story::root("button_ghost", StoryKind::InputPrimitive), 93 + Story::root("button_icon", StoryKind::InputPrimitive), 94 + Story::root("button_disabled", StoryKind::InputPrimitive), 95 + Story::root("button_loading", StoryKind::InputPrimitive), 96 + Story::root("check", StoryKind::InputPrimitive), 97 + Story::root("check_unchecked", StoryKind::InputPrimitive), 98 + Story::root("check_indeterminate", StoryKind::InputPrimitive), 99 + Story::root("toggle", StoryKind::InputPrimitive), 100 + Story::root("toggle_off", StoryKind::InputPrimitive), 101 + Story::root_with("radio_a", StoryKind::InputPrimitive, &[&["radio_b"]]), 102 + Story::root("slider", StoryKind::InputPrimitive), 103 + Story::root_with( 104 + "tabs", 105 + StoryKind::InputPrimitive, 106 + &[&["tab_a"], &["tab_b"]], 107 + ), 108 + Story::root_with("status_bar", StoryKind::Composite, &[&["status_item"]]), 109 + Story::root_with("panel", StoryKind::Composite, &[&["panel", "titlebar"]]), 110 + Story::root_with( 111 + "panel_collapsed", 112 + StoryKind::Composite, 113 + &[&["panel_collapsed", "titlebar"]], 114 + ), 115 + Story::root("dropdown", StoryKind::InputPrimitive), 116 + Story::root("text_input", StoryKind::InputPrimitive), 117 + Story::root("numeric_input", StoryKind::InputPrimitive), 118 + Story::root("hotkey_capture", StoryKind::InputPrimitive), 119 + Story::root_with( 120 + "list_view", 121 + StoryKind::Composite, 122 + &[&["list_a"], &["list_b"]], 123 + ), 124 + Story::root_with( 125 + "table", 126 + StoryKind::Composite, 127 + &[ 128 + &["col_a"], 129 + &["col_b"], 130 + &["row_a"], 131 + &["row_b"], 132 + &["col_a", "resize"], 133 + &["col_b", "resize"], 134 + ], 135 + ), 136 + Story::root_with( 137 + "tree_view", 138 + StoryKind::Composite, 139 + &[ 140 + &["tree_root"], 141 + &["tree_child"], 142 + &["tree_root", "disclosure"], 143 + ], 144 + ), 145 + Story::root_with( 146 + "property_grid", 147 + StoryKind::Composite, 148 + &[ 149 + &["prop_text"], 150 + &["prop_bool"], 151 + &["prop_select"], 152 + &["prop_length"], 153 + &["prop_angle"], 154 + ], 155 + ), 156 + Story::root_with( 157 + "toolbar", 158 + StoryKind::Composite, 159 + &[&["toolbar_a"], &["toolbar_b"]], 160 + ), 161 + Story::root_with( 162 + "ribbon", 163 + StoryKind::Composite, 164 + &[ 165 + &["ribbon_tab"], 166 + &["ribbon_tool_a"], 167 + &["ribbon_tool_b"], 168 + &["ribbon_group"], 169 + &["ribbon", "tabs"], 170 + &["ribbon_group", "toolbar"], 171 + ], 172 + ), 173 + Story::root_with( 174 + "menu", 175 + StoryKind::Composite, 176 + &[&["menu_action"], &["menu_submenu"]], 177 + ), 178 + Story::root_with("context_menu", StoryKind::Composite, &[&["context_action"]]), 179 + Story::root_with("menu_bar", StoryKind::Composite, &[&["menubar_entry"]]), 180 + Story::root_with("toast", StoryKind::Overlay, &[&["toast", "close"]]), 181 + Story::root_with( 182 + "toast_success", 183 + StoryKind::Overlay, 184 + &[&["toast_success", "close"]], 185 + ), 186 + Story::root_with( 187 + "toast_warning", 188 + StoryKind::Overlay, 189 + &[&["toast_warning", "close"]], 190 + ), 191 + Story::root_with( 192 + "toast_danger", 193 + StoryKind::Overlay, 194 + &[&["toast_danger", "close"]], 195 + ), 196 + Story::root("modal", StoryKind::Overlay), 197 + Story::root_with( 198 + "dialog", 199 + StoryKind::Overlay, 200 + &[&["dialog_ok"], &["dialog_cancel"]], 201 + ), 202 + Story::root_with( 203 + "confirmation", 204 + StoryKind::Overlay, 205 + &[&["confirmation", "confirm"], &["confirmation", "cancel"]], 206 + ), 207 + Story::root_with( 208 + "file_picker", 209 + StoryKind::Overlay, 210 + &[ 211 + &["picker_entry"], 212 + &["file_picker", "confirm"], 213 + &["file_picker", "cancel"], 214 + &["file_picker", "list"], 215 + &["file_picker", "filename"], 216 + ], 217 + ), 218 + Story::root_with( 219 + "file_picker_open", 220 + StoryKind::Overlay, 221 + &[ 222 + &["picker_entry_open"], 223 + &["file_picker_open", "confirm"], 224 + &["file_picker_open", "cancel"], 225 + &["file_picker_open", "list"], 226 + ], 227 + ), 228 + Story { 229 + key: "tooltip", 230 + parent: Some(TOOLTIP_ANCHOR_KEY), 231 + kind: StoryKind::Overlay, 232 + extras: &[], 233 + }, 234 + ]; 235 + 236 + #[must_use] 237 + pub fn path_id(path: &[&'static str]) -> WidgetId { 238 + path.iter() 239 + .fold(WidgetId::ROOT, |acc, seg| acc.child(WidgetKey::new(seg))) 240 + } 241 + 242 + #[must_use] 243 + pub fn story_id(story: Story) -> WidgetId { 244 + let base = match story.parent { 245 + Some(parent) => WidgetId::ROOT.child(WidgetKey::new(parent)), 246 + None => WidgetId::ROOT, 247 + }; 248 + base.child(WidgetKey::new(story.key)) 249 + } 250 + 251 + #[must_use] 252 + pub fn declared_ids() -> std::collections::BTreeSet<WidgetId> { 253 + STORIES.iter().copied().flat_map(Story::ids).collect() 254 + } 255 + 256 + #[must_use] 257 + pub fn id(name: &'static str) -> WidgetId { 258 + path_id(&[name]) 259 + } 260 + 261 + fn rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect { 262 + LayoutRect::new( 263 + LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), 264 + LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)), 265 + ) 266 + } 267 + 268 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 269 + enum Choice { 270 + A, 271 + B, 272 + } 273 + 274 + pub struct GalleryState { 275 + pub panel: PanelState, 276 + pub panel_collapsed: PanelState, 277 + pub dropdown: DropdownState, 278 + pub text_input: TextInputState, 279 + pub numeric_input: TextInputState, 280 + pub clipboard: MemoryClipboard, 281 + pub list: ListViewState, 282 + pub table: TableState, 283 + pub tree: TreeViewState, 284 + pub toolbar_overflow: bool, 285 + pub ribbon: RibbonState, 286 + pub menu: MenuState, 287 + pub context_menu: MenuState, 288 + pub menu_bar: MenuBarState, 289 + pub tooltip: TooltipState, 290 + pub toast_info: ToastState, 291 + pub toast_success: ToastState, 292 + pub toast_warning: ToastState, 293 + pub toast_danger: ToastState, 294 + pub file_picker: FilePickerState, 295 + pub file_picker_open: FilePickerState, 296 + pub property_text: TextEditor, 297 + pub property_bool: BoolEditor, 298 + pub property_select: SelectionEditor, 299 + pub property_length: LengthEditor, 300 + pub property_angle: AngleEditor, 301 + pub hotkey: HotkeyCaptureState, 302 + } 303 + 304 + impl Default for GalleryState { 305 + fn default() -> Self { 306 + Self::new() 307 + } 308 + } 309 + 310 + impl GalleryState { 311 + #[must_use] 312 + pub fn new() -> Self { 313 + let mut tree = TreeViewState::default(); 314 + tree.expanded.insert(id("tree_root")); 315 + Self { 316 + panel: PanelState::open(), 317 + panel_collapsed: PanelState::collapsed(), 318 + dropdown: DropdownState::closed(), 319 + text_input: TextInputState::from_text("Hello"), 320 + numeric_input: TextInputState::from_text("42"), 321 + clipboard: MemoryClipboard::default(), 322 + list: ListViewState::default(), 323 + table: TableState::default(), 324 + tree, 325 + toolbar_overflow: false, 326 + ribbon: RibbonState::default(), 327 + menu: MenuState::default(), 328 + context_menu: MenuState::default(), 329 + menu_bar: MenuBarState::default(), 330 + tooltip: TooltipState { 331 + hover_began: Some(FrameInstant::ZERO), 332 + ..TooltipState::default() 333 + }, 334 + toast_info: ToastState::fresh(), 335 + toast_success: ToastState::fresh(), 336 + toast_warning: ToastState::fresh(), 337 + toast_danger: ToastState::fresh(), 338 + file_picker: FilePickerState::default(), 339 + file_picker_open: FilePickerState::default(), 340 + property_text: TextEditor::new("name"), 341 + property_bool: BoolEditor::new(true), 342 + property_select: SelectionEditor::new( 343 + vec![ 344 + PropertyOption { 345 + label: StringKey::new("gallery.option_a"), 346 + }, 347 + PropertyOption { 348 + label: StringKey::new("gallery.option_b"), 349 + }, 350 + ], 351 + Some(0), 352 + ), 353 + property_length: LengthEditor::new(Length::new::<millimeter>(10.0)), 354 + property_angle: AngleEditor::new(Angle::new::<degree>(45.0)), 355 + hotkey: HotkeyCaptureState::default(), 356 + } 357 + } 358 + } 359 + 360 + #[must_use] 361 + pub fn render(theme: Arc<Theme>, state: &mut GalleryState) -> Vec<WidgetPaint> { 362 + let mut focus = FocusManager::new(); 363 + let mut a11y = AccessTreeBuilder::new(); 364 + render_with(theme, state, &mut focus, &mut a11y, StringTable::empty()) 365 + } 366 + 367 + #[must_use] 368 + pub fn render_with( 369 + theme: Arc<Theme>, 370 + state: &mut GalleryState, 371 + focus: &mut FocusManager, 372 + a11y: &mut AccessTreeBuilder, 373 + strings: &StringTable, 374 + ) -> Vec<WidgetPaint> { 375 + let table = HotkeyTable::new(); 376 + let mut hits = HitFrame::new(); 377 + let prev = prev_hit_state(); 378 + let mut input = InputSnapshot::idle(GALLERY_FRAME_NOW); 379 + let mut paint = Vec::new(); 380 + 381 + let mut ctx = FrameCtx::new( 382 + theme, &mut input, focus, &table, strings, &mut hits, &prev, a11y, 383 + ); 384 + render_basics(&mut ctx, state, &mut paint); 385 + render_inputs(&mut ctx, state, &mut paint); 386 + render_collections(&mut ctx, state, &mut paint); 387 + render_chrome(&mut ctx, state, &mut paint); 388 + render_state_variants(&mut ctx, state, &mut paint); 389 + render_overlays(&mut ctx, state, &mut paint); 390 + paint 391 + } 392 + 393 + fn prev_hit_state() -> HitState { 394 + let mut state = HitState::new(); 395 + let hover = Interaction { 396 + state: InteractionState::NONE.with(InteractionState::HOVER, true), 397 + ..Interaction::idle() 398 + }; 399 + let anchor = id(TOOLTIP_ANCHOR_KEY); 400 + state.interactions.insert(anchor, hover); 401 + state.hovered = Some(anchor); 402 + state 403 + } 404 + 405 + #[allow( 406 + clippy::too_many_lines, 407 + reason = "gallery renders many widgets in one frame" 408 + )] 409 + fn render_basics(ctx: &mut FrameCtx<'_>, state: &mut GalleryState, paint: &mut Vec<WidgetPaint>) { 410 + let response = show_button( 411 + ctx, 412 + Button::new( 413 + id("button"), 414 + rect(0.0, 0.0, 80.0, 24.0), 415 + GALLERY_LABEL, 416 + ButtonVariant::Primary, 417 + ), 418 + ); 419 + paint.extend(response.paint); 420 + let response = show_checkbox( 421 + ctx, 422 + Checkbox::new( 423 + id("check"), 424 + rect(0.0, 28.0, 80.0, 20.0), 425 + GALLERY_LABEL, 426 + CheckboxState::Checked, 427 + ), 428 + ); 429 + paint.extend(response.paint); 430 + let response = show_toggle_button( 431 + ctx, 432 + ToggleButton::new(id("toggle"), rect(0.0, 52.0, 80.0, 20.0), GALLERY_LABEL, true), 433 + ); 434 + paint.extend(response.paint); 435 + let response = show_radio_group( 436 + ctx, 437 + RadioGroup::new( 438 + vec![ 439 + RadioOption { 440 + id: id("radio_a"), 441 + rect: rect(0.0, 76.0, 80.0, 20.0), 442 + label: GALLERY_LABEL, 443 + value: Choice::A, 444 + }, 445 + RadioOption { 446 + id: id("radio_b"), 447 + rect: rect(0.0, 100.0, 80.0, 20.0), 448 + label: GALLERY_LABEL, 449 + value: Choice::B, 450 + }, 451 + ], 452 + Choice::A, 453 + ), 454 + ); 455 + paint.extend(response.paint); 456 + let Ok(slider_range) = SliderRange::try_new(0.0_f64, 10.0) else { 457 + unreachable!("slider range"); 458 + }; 459 + let Ok(slider_step) = SliderStep::try_new(1.0_f64) else { 460 + unreachable!("slider step"); 461 + }; 462 + let response = show_slider( 463 + ctx, 464 + Slider::new( 465 + id("slider"), 466 + rect(0.0, 124.0, 200.0, 18.0), 467 + GALLERY_LABEL, 468 + 5.0_f64, 469 + slider_range, 470 + slider_step, 471 + ), 472 + ); 473 + paint.extend(response.paint); 474 + let tab_items = [ 475 + Tab::new(id("tab_a"), rect(0.0, 144.0, 80.0, 24.0), GALLERY_LABEL), 476 + Tab::new(id("tab_b"), rect(80.0, 144.0, 80.0, 24.0), GALLERY_LABEL), 477 + ]; 478 + let response = show_tabs( 479 + ctx, 480 + Tabs::new( 481 + id("tabs"), 482 + TabsOrientation::Top, 483 + GALLERY_LABEL, 484 + &tab_items, 485 + id("tab_a"), 486 + ), 487 + ); 488 + paint.extend(response.paint); 489 + let status_items = [ 490 + StatusItem::new( 491 + id("status_item"), 492 + GALLERY_LABEL, 493 + StatusAlign::Start, 494 + LayoutPx::new(80.0), 495 + ) 496 + .interactive(true), 497 + ]; 498 + let response = show_status_bar( 499 + ctx, 500 + StatusBar::new( 501 + id("status_bar"), 502 + rect(0.0, 168.0, 200.0, 22.0), 503 + GALLERY_LABEL, 504 + &status_items, 505 + ), 506 + ); 507 + paint.extend(response.paint); 508 + let response = show_panel( 509 + ctx, 510 + Panel::new(id("panel"), rect(0.0, 192.0, 200.0, 100.0), &mut state.panel).titlebar( 511 + PanelTitlebar { 512 + label: GALLERY_LABEL, 513 + height: LayoutPx::new(22.0), 514 + collapsible: true, 515 + }, 516 + ), 517 + ); 518 + paint.extend(response.paint); 519 + let response = show_dropdown( 520 + ctx, 521 + Dropdown::new( 522 + id("dropdown"), 523 + rect(0.0, 296.0, 160.0, 24.0), 524 + LayoutPx::new(20.0), 525 + vec![ 526 + DropdownItem { 527 + value: Choice::A, 528 + label: GALLERY_LABEL, 529 + }, 530 + DropdownItem { 531 + value: Choice::B, 532 + label: GALLERY_LABEL, 533 + }, 534 + ], 535 + None, 536 + GALLERY_LABEL, 537 + &mut state.dropdown, 538 + ), 539 + ); 540 + paint.extend(response.paint); 541 + } 542 + 543 + fn render_inputs(ctx: &mut FrameCtx<'_>, state: &mut GalleryState, paint: &mut Vec<WidgetPaint>) { 544 + let response = show_text_input( 545 + ctx, 546 + TextInput { 547 + id: id("text_input"), 548 + rect: rect(0.0, 324.0, 200.0, 24.0), 549 + placeholder: GALLERY_LABEL, 550 + state: &mut state.text_input, 551 + disabled: false, 552 + validator: AlwaysValid, 553 + }, 554 + &mut state.clipboard, 555 + ); 556 + paint.extend(response.paint); 557 + let response = show_parsed_input::<i32, _>( 558 + ctx, 559 + NumericInput::<i32>::new( 560 + id("numeric_input"), 561 + rect(0.0, 352.0, 200.0, 24.0), 562 + GALLERY_LABEL, 563 + &mut state.numeric_input, 564 + ), 565 + &mut state.clipboard, 566 + ); 567 + paint.extend(response.paint); 568 + let response = show_hotkey_capture( 569 + ctx, 570 + HotkeyCapture::new( 571 + id("hotkey_capture"), 572 + rect(0.0, 380.0, 200.0, 24.0), 573 + GALLERY_LABEL, 574 + &mut state.hotkey, 575 + ), 576 + ); 577 + paint.extend(response.paint); 578 + } 579 + 580 + #[allow( 581 + clippy::too_many_lines, 582 + reason = "gallery renders many widgets in one frame" 583 + )] 584 + fn render_collections( 585 + ctx: &mut FrameCtx<'_>, 586 + state: &mut GalleryState, 587 + paint: &mut Vec<WidgetPaint>, 588 + ) { 589 + let list_items = [ 590 + ListItem { 591 + id: id("list_a"), 592 + label: GALLERY_LABEL, 593 + }, 594 + ListItem { 595 + id: id("list_b"), 596 + label: GALLERY_LABEL, 597 + }, 598 + ]; 599 + let response = show_list_view( 600 + ctx, 601 + ListView::new( 602 + id("list_view"), 603 + rect(220.0, 0.0, 180.0, 60.0), 604 + GALLERY_LABEL, 605 + &list_items, 606 + &mut state.list, 607 + ), 608 + ); 609 + paint.extend(response.paint); 610 + let columns = [ 611 + TableColumn::new(id("col_a"), GALLERY_LABEL, LayoutPx::new(80.0)), 612 + TableColumn::new(id("col_b"), GALLERY_LABEL, LayoutPx::new(80.0)), 613 + ]; 614 + let rows = [ 615 + TableRow { 616 + id: id("row_a"), 617 + cells: [GALLERY_LABEL, GALLERY_LABEL], 618 + }, 619 + TableRow { 620 + id: id("row_b"), 621 + cells: [GALLERY_LABEL, GALLERY_LABEL], 622 + }, 623 + ]; 624 + let response = show_table( 625 + ctx, 626 + Table::new( 627 + id("table"), 628 + rect(220.0, 64.0, 180.0, 80.0), 629 + GALLERY_LABEL, 630 + &columns, 631 + &rows, 632 + &mut state.table, 633 + ), 634 + ); 635 + paint.extend(response.paint); 636 + let tree_roots = [TreeNode::parent( 637 + id("tree_root"), 638 + GALLERY_LABEL, 639 + vec![TreeNode::leaf(id("tree_child"), GALLERY_LABEL)], 640 + )]; 641 + let response = show_tree_view( 642 + ctx, 643 + TreeView::new( 644 + id("tree_view"), 645 + rect(220.0, 152.0, 180.0, 60.0), 646 + GALLERY_LABEL, 647 + &tree_roots, 648 + &mut state.tree, 649 + ), 650 + ); 651 + paint.extend(response.paint); 652 + let mut rows: [PropertyRow<'_>; 5] = [ 653 + PropertyRow { 654 + id: id("prop_text"), 655 + label: StringKey::new("gallery.prop.text"), 656 + editor: &mut state.property_text, 657 + read_only: false, 658 + }, 659 + PropertyRow { 660 + id: id("prop_bool"), 661 + label: StringKey::new("gallery.prop.bool"), 662 + editor: &mut state.property_bool, 663 + read_only: false, 664 + }, 665 + PropertyRow { 666 + id: id("prop_select"), 667 + label: StringKey::new("gallery.prop.select"), 668 + editor: &mut state.property_select, 669 + read_only: false, 670 + }, 671 + PropertyRow { 672 + id: id("prop_length"), 673 + label: StringKey::new("gallery.prop.length"), 674 + editor: &mut state.property_length, 675 + read_only: false, 676 + }, 677 + PropertyRow { 678 + id: id("prop_angle"), 679 + label: StringKey::new("gallery.prop.angle"), 680 + editor: &mut state.property_angle, 681 + read_only: false, 682 + }, 683 + ]; 684 + let response = show_property_grid( 685 + ctx, 686 + PropertyGrid::new( 687 + id("property_grid"), 688 + rect(220.0, 220.0, 240.0, 160.0), 689 + GALLERY_LABEL, 690 + &mut rows, 691 + ), 692 + &mut state.clipboard, 693 + ); 694 + paint.extend(response.paint); 695 + } 696 + 697 + #[allow( 698 + clippy::too_many_lines, 699 + reason = "gallery renders many widgets in one frame" 700 + )] 701 + fn render_chrome(ctx: &mut FrameCtx<'_>, state: &mut GalleryState, paint: &mut Vec<WidgetPaint>) { 702 + let toolbar_items = [ 703 + ToolbarItem::new(id("toolbar_a"), GALLERY_LABEL), 704 + ToolbarItem::new(id("toolbar_b"), GALLERY_LABEL), 705 + ]; 706 + let response = show_toolbar( 707 + ctx, 708 + Toolbar::horizontal( 709 + id("toolbar"), 710 + rect(480.0, 0.0, 160.0, 32.0), 711 + GALLERY_LABEL, 712 + &toolbar_items, 713 + LayoutPx::new(28.0), 714 + LayoutPx::new(4.0), 715 + ), 716 + &mut state.toolbar_overflow, 717 + ); 718 + paint.extend(response.paint); 719 + let ribbon_toolbar_items = [ 720 + ToolbarItem::new(id("ribbon_tool_a"), GALLERY_LABEL), 721 + ToolbarItem::new(id("ribbon_tool_b"), GALLERY_LABEL), 722 + ]; 723 + let ribbon_tabs = [RibbonTab { 724 + tab: Tab::new(id("ribbon_tab"), rect(0.0, 0.0, 80.0, 24.0), GALLERY_LABEL), 725 + groups: vec![RibbonGroup { 726 + id: id("ribbon_group"), 727 + label: GALLERY_LABEL, 728 + items: ribbon_toolbar_items.to_vec(), 729 + icon_size: RibbonIconSize::Large, 730 + width: LayoutPx::new(140.0), 731 + }], 732 + }]; 733 + let response = show_ribbon( 734 + ctx, 735 + Ribbon::new( 736 + id("ribbon"), 737 + rect(480.0, 36.0, 200.0, 96.0), 738 + GALLERY_LABEL, 739 + &ribbon_tabs, 740 + id("ribbon_tab"), 741 + &mut state.ribbon, 742 + ), 743 + ); 744 + paint.extend(response.paint); 745 + let menu_items = vec![ 746 + MenuItem::Action { 747 + id: id("menu_action"), 748 + label: GALLERY_LABEL, 749 + shortcut: None, 750 + disabled: false, 751 + }, 752 + MenuItem::Separator, 753 + MenuItem::Submenu { 754 + id: id("menu_submenu"), 755 + label: GALLERY_LABEL, 756 + items: vec![MenuItem::Action { 757 + id: id("menu_subaction"), 758 + label: GALLERY_LABEL, 759 + shortcut: None, 760 + disabled: false, 761 + }], 762 + }, 763 + ]; 764 + let response = show_menu( 765 + ctx, 766 + Menu::new( 767 + id("menu"), 768 + LayoutPos::new(LayoutPx::new(480.0), LayoutPx::new(140.0)), 769 + GALLERY_LABEL, 770 + &menu_items, 771 + &mut state.menu, 772 + ), 773 + ); 774 + paint.extend(response.paint); 775 + let context_items = vec![MenuItem::Action { 776 + id: id("context_action"), 777 + label: GALLERY_LABEL, 778 + shortcut: None, 779 + disabled: false, 780 + }]; 781 + let response = show_context_menu( 782 + ctx, 783 + ContextMenu::at_cursor( 784 + id("context_menu"), 785 + LayoutPos::new(LayoutPx::new(480.0), LayoutPx::new(220.0)), 786 + GALLERY_LABEL, 787 + &context_items, 788 + &mut state.context_menu, 789 + ), 790 + ); 791 + paint.extend(response.paint); 792 + let menu_bar_entries = [MenuBarEntry { 793 + id: id("menubar_entry"), 794 + label: GALLERY_LABEL, 795 + items: vec![MenuItem::Action { 796 + id: id("menubar_action"), 797 + label: GALLERY_LABEL, 798 + shortcut: None, 799 + disabled: false, 800 + }], 801 + }]; 802 + let response = show_menu_bar( 803 + ctx, 804 + MenuBar::new( 805 + id("menu_bar"), 806 + rect(480.0, 280.0, 200.0, 24.0), 807 + GALLERY_LABEL, 808 + &menu_bar_entries, 809 + &mut state.menu_bar, 810 + ), 811 + ); 812 + paint.extend(response.paint); 813 + } 814 + 815 + const BUTTON_VARIANTS: [(&str, ButtonVariant, ButtonState); 6] = [ 816 + ("button_secondary", ButtonVariant::Secondary, ButtonState::Idle), 817 + ("button_destructive", ButtonVariant::Destructive, ButtonState::Idle), 818 + ("button_ghost", ButtonVariant::Ghost, ButtonState::Idle), 819 + ("button_icon", ButtonVariant::IconOnly, ButtonState::Idle), 820 + ("button_disabled", ButtonVariant::Primary, ButtonState::Disabled), 821 + ("button_loading", ButtonVariant::Primary, ButtonState::Loading), 822 + ]; 823 + 824 + const CHECKBOX_VARIANTS: [(&str, CheckboxState); 2] = [ 825 + ("check_unchecked", CheckboxState::Unchecked), 826 + ("check_indeterminate", CheckboxState::Indeterminate), 827 + ]; 828 + 829 + const TOAST_VARIANTS: [(&str, ToastKind, f32); 3] = [ 830 + ("toast_success", ToastKind::Success, 620.0), 831 + ("toast_warning", ToastKind::Warning, 672.0), 832 + ("toast_danger", ToastKind::Danger, 724.0), 833 + ]; 834 + 835 + #[allow( 836 + clippy::cast_precision_loss, 837 + reason = "i bounded by small const arrays, far below f32 precision boundary" 838 + )] 839 + fn variant_x(i: usize) -> f32 { 840 + (i as f32) * 84.0 841 + } 842 + 843 + fn render_state_variants( 844 + ctx: &mut FrameCtx<'_>, 845 + state: &mut GalleryState, 846 + paint: &mut Vec<WidgetPaint>, 847 + ) { 848 + BUTTON_VARIANTS 849 + .iter() 850 + .enumerate() 851 + .for_each(|(i, (key, variant, button_state))| { 852 + let response = show_button( 853 + ctx, 854 + Button::new( 855 + id(key), 856 + rect(variant_x(i), 412.0, 80.0, 24.0), 857 + GALLERY_LABEL, 858 + *variant, 859 + ) 860 + .with_state(*button_state), 861 + ); 862 + paint.extend(response.paint); 863 + }); 864 + CHECKBOX_VARIANTS 865 + .iter() 866 + .enumerate() 867 + .for_each(|(i, (key, cb_state))| { 868 + let response = show_checkbox( 869 + ctx, 870 + Checkbox::new( 871 + id(key), 872 + rect(variant_x(i), 440.0, 80.0, 20.0), 873 + GALLERY_LABEL, 874 + *cb_state, 875 + ), 876 + ); 877 + paint.extend(response.paint); 878 + }); 879 + let response = show_toggle_button( 880 + ctx, 881 + ToggleButton::new(id("toggle_off"), rect(0.0, 468.0, 80.0, 20.0), GALLERY_LABEL, false), 882 + ); 883 + paint.extend(response.paint); 884 + let response = show_panel( 885 + ctx, 886 + Panel::new( 887 + id("panel_collapsed"), 888 + rect(0.0, 492.0, 200.0, 100.0), 889 + &mut state.panel_collapsed, 890 + ) 891 + .titlebar(PanelTitlebar { 892 + label: GALLERY_LABEL, 893 + height: LayoutPx::new(22.0), 894 + collapsible: true, 895 + }), 896 + ); 897 + paint.extend(response.paint); 898 + let toast_states = [ 899 + &mut state.toast_success, 900 + &mut state.toast_warning, 901 + &mut state.toast_danger, 902 + ]; 903 + TOAST_VARIANTS 904 + .iter() 905 + .zip(toast_states) 906 + .for_each(|((key, kind, y), toast_state)| { 907 + let response = show_toast( 908 + ctx, 909 + Toast::new( 910 + id(key), 911 + rect(20.0, *y, 360.0, 48.0), 912 + *kind, 913 + GALLERY_LABEL, 914 + toast_state, 915 + ), 916 + ); 917 + paint.extend(response.paint); 918 + }); 919 + } 920 + 921 + fn render_overlays(ctx: &mut FrameCtx<'_>, state: &mut GalleryState, paint: &mut Vec<WidgetPaint>) { 922 + let tooltip_paint = show_tooltip( 923 + ctx, 924 + Tooltip::new( 925 + id(TOOLTIP_ANCHOR_KEY), 926 + rect(0.0, 0.0, 80.0, 24.0), 927 + GALLERY_LABEL, 928 + TooltipPlacement::Below, 929 + LayoutSize::new(LayoutPx::new(80.0), LayoutPx::new(20.0)), 930 + ), 931 + &mut state.tooltip, 932 + ); 933 + paint.extend(tooltip_paint); 934 + let response = show_toast( 935 + ctx, 936 + Toast::new( 937 + id("toast"), 938 + rect(20.0, 560.0, 360.0, 48.0), 939 + ToastKind::Info, 940 + GALLERY_LABEL, 941 + &mut state.toast_info, 942 + ), 943 + ); 944 + paint.extend(response.paint); 945 + let (modal_response, ()) = show_modal( 946 + ctx, 947 + Modal::new( 948 + id("modal"), 949 + rect(700.0, 0.0, 600.0, 400.0), 950 + LayoutSize::new(LayoutPx::new(300.0), LayoutPx::new(200.0)), 951 + GALLERY_LABEL, 952 + ), 953 + |_ctx, _body, _paint| {}, 954 + ); 955 + paint.extend(modal_response.paint); 956 + let dialog_buttons = [ 957 + DialogButton::primary(id("dialog_ok"), GALLERY_LABEL), 958 + DialogButton::secondary(id("dialog_cancel"), GALLERY_LABEL), 959 + ]; 960 + let (dialog_response, ()) = show_dialog( 961 + ctx, 962 + Dialog::new( 963 + id("dialog"), 964 + rect(700.0, 410.0, 600.0, 400.0), 965 + LayoutSize::new(LayoutPx::new(360.0), LayoutPx::new(200.0)), 966 + GALLERY_LABEL, 967 + &dialog_buttons, 968 + ), 969 + |_ctx, _body, _paint| {}, 970 + ); 971 + paint.extend(dialog_response.paint); 972 + let response = show_confirmation( 973 + ctx, 974 + ConfirmationDialog { 975 + id: id("confirmation"), 976 + viewport: rect(700.0, 820.0, 600.0, 400.0), 977 + size: LayoutSize::new(LayoutPx::new(360.0), LayoutPx::new(180.0)), 978 + title: GALLERY_LABEL, 979 + message: GALLERY_LABEL, 980 + confirm_label: GALLERY_LABEL, 981 + cancel_label: GALLERY_LABEL, 982 + destructive: false, 983 + }, 984 + ); 985 + paint.extend(response.paint); 986 + let picker_entries = [FilePickerEntry { 987 + id: id("picker_entry"), 988 + label: GALLERY_LABEL, 989 + }]; 990 + let response = show_file_picker( 991 + ctx, 992 + FilePickerDialog::new( 993 + id("file_picker"), 994 + rect(700.0, 1240.0, 600.0, 500.0), 995 + FilePickerMode::Save, 996 + GALLERY_LABEL, 997 + &picker_entries, 998 + GALLERY_LABEL, 999 + &mut state.file_picker, 1000 + ), 1001 + ); 1002 + paint.extend(response.paint); 1003 + let open_entries = [FilePickerEntry { 1004 + id: id("picker_entry_open"), 1005 + label: GALLERY_LABEL, 1006 + }]; 1007 + let response = show_file_picker( 1008 + ctx, 1009 + FilePickerDialog::new( 1010 + id("file_picker_open"), 1011 + rect(700.0, 1820.0, 600.0, 500.0), 1012 + FilePickerMode::Open, 1013 + GALLERY_LABEL, 1014 + &open_entries, 1015 + GALLERY_LABEL, 1016 + &mut state.file_picker_open, 1017 + ), 1018 + ); 1019 + paint.extend(response.paint); 1020 + }
+3 -1
crates/bone-ui/src/lib.rs
··· 1 1 pub mod a11y; 2 2 pub mod focus; 3 3 pub mod frame; 4 + pub mod gallery; 4 5 pub mod hit_test; 5 6 pub mod hotkey; 6 7 pub mod input; 7 8 pub mod layout; 9 + pub mod raster; 8 10 pub mod strings; 9 11 pub mod text; 10 12 pub mod theme; ··· 25 27 }; 26 28 pub use input::{ 27 29 ClickCount, DoubleClickWindow, DragThreshold, FrameInstant, InputSnapshot, KeyChar, KeyCode, 28 - KeyEvent, ModifierMask, NamedKey, PointerButton, PointerButtonMask, PointerSample, 30 + KeyEvent, ModifierMask, NamedKey, PointerButton, PointerButtonMask, PointerSample, Script, 29 31 }; 30 32 pub use strings::{Locale, PluralCategory, PluralEntry, StringKey, StringTable}; 31 33 pub use text::{
+17 -599
crates/bone-ui/tests/a11y_smoke.rs
··· 1 - use core::time::Duration; 2 1 use std::collections::BTreeSet; 3 - use std::sync::Arc; 4 2 5 3 use accesskit::NodeId; 6 - use uom::si::angle::degree; 7 - use uom::si::f64::{Angle, Length}; 8 - use uom::si::length::millimeter; 9 - 10 4 use bone_ui::a11y::{root_node_id, widget_node_id}; 11 - use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 12 - use bone_ui::widgets::{ 13 - AlwaysValid, AngleEditor, BoolEditor, Button, ButtonVariant, Checkbox, CheckboxState, 14 - ConfirmationDialog, ContextMenu, Dialog, DialogButton, Dropdown, DropdownItem, DropdownState, 15 - FilePickerDialog, FilePickerEntry, FilePickerMode, FilePickerState, HotkeyCapture, 16 - HotkeyCaptureState, LengthEditor, ListItem, ListView, ListViewState, MemoryClipboard, Menu, 17 - MenuBar, MenuBarEntry, MenuBarState, MenuItem, MenuState, Modal, NumericInput, Panel, 18 - PanelState, PanelTitlebar, PropertyGrid, PropertyOption, PropertyRow, RadioGroup, RadioOption, 19 - Ribbon, RibbonGroup, RibbonIconSize, RibbonState, RibbonTab, SelectionEditor, Slider, 20 - SliderRange, SliderStep, StatusAlign, StatusBar, StatusItem, Tab, Table, TableColumn, TableRow, 21 - TableState, Tabs, TabsOrientation, TextEditor, TextInput, TextInputState, Toast, ToastKind, 22 - ToastState, ToggleButton, Toolbar, ToolbarItem, Tooltip, TooltipPlacement, TooltipState, 23 - TreeNode, TreeView, TreeViewState, show_button, show_checkbox, show_confirmation, 24 - show_context_menu, show_dialog, show_dropdown, show_file_picker, show_hotkey_capture, 25 - show_list_view, show_menu, show_menu_bar, show_modal, show_panel, show_parsed_input, 26 - show_property_grid, show_radio_group, show_ribbon, show_slider, show_status_bar, show_table, 27 - show_tabs, show_text_input, show_toast, show_toggle_button, show_toolbar, show_tooltip, 28 - show_tree_view, 29 - }; 30 - use bone_ui::{ 31 - AccessTreeBuilder, FocusManager, FrameCtx, FrameInstant, HitFrame, HitState, HotkeyTable, 32 - InputSnapshot, StringKey, StringTable, Theme, WidgetId, WidgetKey, 33 - }; 34 - 35 - const LABEL: StringKey = StringKey::new("smoke.label"); 36 - 37 - fn id(name: &'static str) -> WidgetId { 38 - WidgetId::ROOT.child(WidgetKey::new(name)) 39 - } 40 - 41 - fn rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect { 42 - LayoutRect::new( 43 - LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)), 44 - LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)), 45 - ) 46 - } 47 - 48 - #[derive(Copy, Clone, Debug, PartialEq, Eq)] 49 - enum Choice { 50 - A, 51 - B, 52 - } 53 - 54 - const FRAME_NOW: FrameInstant = FrameInstant::from_duration(Duration::from_secs(1)); 55 - 56 - struct GalleryState { 57 - panel: PanelState, 58 - dropdown: DropdownState, 59 - text_input: TextInputState, 60 - numeric_input: TextInputState, 61 - clipboard: MemoryClipboard, 62 - list: ListViewState, 63 - table: TableState, 64 - tree: TreeViewState, 65 - toolbar_overflow: bool, 66 - ribbon: RibbonState, 67 - menu: MenuState, 68 - context_menu: MenuState, 69 - menu_bar: MenuBarState, 70 - tooltip: TooltipState, 71 - toast: ToastState, 72 - file_picker: FilePickerState, 73 - property_text: TextEditor, 74 - property_bool: BoolEditor, 75 - property_select: SelectionEditor, 76 - property_length: LengthEditor, 77 - property_angle: AngleEditor, 78 - hotkey: HotkeyCaptureState, 79 - } 80 - 81 - impl GalleryState { 82 - fn new() -> Self { 83 - Self { 84 - panel: PanelState::open(), 85 - dropdown: DropdownState::closed(), 86 - text_input: TextInputState::from_text("Hello"), 87 - numeric_input: TextInputState::from_text("42"), 88 - clipboard: MemoryClipboard::default(), 89 - list: ListViewState::default(), 90 - table: TableState::default(), 91 - tree: TreeViewState::default(), 92 - toolbar_overflow: false, 93 - ribbon: RibbonState::default(), 94 - menu: MenuState::default(), 95 - context_menu: MenuState::default(), 96 - menu_bar: MenuBarState::default(), 97 - tooltip: TooltipState { 98 - hover_began: Some(FrameInstant::ZERO), 99 - ..TooltipState::default() 100 - }, 101 - toast: ToastState::fresh(), 102 - file_picker: FilePickerState::default(), 103 - property_text: TextEditor::new("name"), 104 - property_bool: BoolEditor::new(true), 105 - property_select: SelectionEditor::new( 106 - vec![ 107 - PropertyOption { 108 - label: StringKey::new("smoke.option_a"), 109 - }, 110 - PropertyOption { 111 - label: StringKey::new("smoke.option_b"), 112 - }, 113 - ], 114 - Some(0), 115 - ), 116 - property_length: LengthEditor::new(Length::new::<millimeter>(10.0)), 117 - property_angle: AngleEditor::new(Angle::new::<degree>(45.0)), 118 - hotkey: HotkeyCaptureState::default(), 119 - } 120 - } 121 - } 122 - 123 - fn render_gallery(focus: &mut FocusManager, a11y: &mut AccessTreeBuilder, state: &mut GalleryState) { 124 - let theme = Arc::new(Theme::light()); 125 - let table = HotkeyTable::new(); 126 - let mut hits = HitFrame::new(); 127 - let prev = HitState::new(); 128 - let mut input = InputSnapshot::idle(FRAME_NOW); 129 - 130 - let mut ctx = FrameCtx::new( 131 - theme, 132 - &mut input, 133 - focus, 134 - &table, 135 - StringTable::empty(), 136 - &mut hits, 137 - &prev, 138 - a11y, 139 - ); 140 - 141 - render_basics(&mut ctx, state); 142 - render_inputs(&mut ctx, state); 143 - render_collections(&mut ctx, state); 144 - render_chrome(&mut ctx, state); 145 - render_overlays(&mut ctx, state); 146 - } 147 - 148 - #[allow( 149 - clippy::too_many_lines, 150 - reason = "gallery test renders many widgets in one frame" 151 - )] 152 - fn render_basics(ctx: &mut FrameCtx<'_>, state: &mut GalleryState) { 153 - let _ = show_button( 154 - ctx, 155 - Button::new(id("button"), rect(0.0, 0.0, 80.0, 24.0), LABEL, ButtonVariant::Primary), 156 - ); 157 - let _ = show_checkbox( 158 - ctx, 159 - Checkbox::new( 160 - id("check"), 161 - rect(0.0, 28.0, 80.0, 20.0), 162 - LABEL, 163 - CheckboxState::Checked, 164 - ), 165 - ); 166 - let _ = show_toggle_button( 167 - ctx, 168 - ToggleButton::new(id("toggle"), rect(0.0, 52.0, 80.0, 20.0), LABEL, true), 169 - ); 170 - let _ = show_radio_group( 171 - ctx, 172 - RadioGroup::new( 173 - vec![ 174 - RadioOption { 175 - id: id("radio_a"), 176 - rect: rect(0.0, 76.0, 80.0, 20.0), 177 - label: LABEL, 178 - value: Choice::A, 179 - }, 180 - RadioOption { 181 - id: id("radio_b"), 182 - rect: rect(0.0, 100.0, 80.0, 20.0), 183 - label: LABEL, 184 - value: Choice::B, 185 - }, 186 - ], 187 - Choice::A, 188 - ), 189 - ); 190 - let Ok(slider_range) = SliderRange::try_new(0.0_f64, 10.0) else { 191 - panic!("slider range") 192 - }; 193 - let Ok(slider_step) = SliderStep::try_new(1.0_f64) else { 194 - panic!("slider step") 195 - }; 196 - let _ = show_slider( 197 - ctx, 198 - Slider::new( 199 - id("slider"), 200 - rect(0.0, 124.0, 200.0, 18.0), 201 - LABEL, 202 - 5.0_f64, 203 - slider_range, 204 - slider_step, 205 - ), 206 - ); 207 - let tab_items = [ 208 - Tab::new(id("tab_a"), rect(0.0, 144.0, 80.0, 24.0), LABEL), 209 - Tab::new(id("tab_b"), rect(80.0, 144.0, 80.0, 24.0), LABEL), 210 - ]; 211 - let _ = show_tabs( 212 - ctx, 213 - Tabs::new( 214 - id("tabs"), 215 - TabsOrientation::Top, 216 - LABEL, 217 - &tab_items, 218 - id("tab_a"), 219 - ), 220 - ); 221 - let status_items = [ 222 - StatusItem::new(id("status_item"), LABEL, StatusAlign::Start, LayoutPx::new(80.0)) 223 - .interactive(true), 224 - ]; 225 - let _ = show_status_bar( 226 - ctx, 227 - StatusBar::new( 228 - id("status_bar"), 229 - rect(0.0, 168.0, 200.0, 22.0), 230 - LABEL, 231 - &status_items, 232 - ), 233 - ); 234 - let _ = show_panel( 235 - ctx, 236 - Panel::new(id("panel"), rect(0.0, 192.0, 200.0, 100.0), &mut state.panel).titlebar( 237 - PanelTitlebar { 238 - label: LABEL, 239 - height: LayoutPx::new(22.0), 240 - collapsible: true, 241 - }, 242 - ), 243 - ); 244 - let _ = show_dropdown( 245 - ctx, 246 - Dropdown::new( 247 - id("dropdown"), 248 - rect(0.0, 296.0, 160.0, 24.0), 249 - LayoutPx::new(20.0), 250 - vec![ 251 - DropdownItem { 252 - value: Choice::A, 253 - label: LABEL, 254 - }, 255 - DropdownItem { 256 - value: Choice::B, 257 - label: LABEL, 258 - }, 259 - ], 260 - None, 261 - LABEL, 262 - &mut state.dropdown, 263 - ), 264 - ); 265 - } 266 - 267 - fn render_inputs(ctx: &mut FrameCtx<'_>, state: &mut GalleryState) { 268 - let _ = show_text_input( 269 - ctx, 270 - TextInput { 271 - id: id("text_input"), 272 - rect: rect(0.0, 324.0, 200.0, 24.0), 273 - placeholder: LABEL, 274 - state: &mut state.text_input, 275 - disabled: false, 276 - validator: AlwaysValid, 277 - }, 278 - &mut state.clipboard, 279 - ); 280 - let _ = show_parsed_input::<i32, _>( 281 - ctx, 282 - NumericInput::<i32>::new( 283 - id("numeric_input"), 284 - rect(0.0, 352.0, 200.0, 24.0), 285 - LABEL, 286 - &mut state.numeric_input, 287 - ), 288 - &mut state.clipboard, 289 - ); 290 - let _ = show_hotkey_capture( 291 - ctx, 292 - HotkeyCapture::new( 293 - id("hotkey_capture"), 294 - rect(0.0, 380.0, 200.0, 24.0), 295 - LABEL, 296 - &mut state.hotkey, 297 - ), 298 - ); 299 - } 300 - 301 - #[allow( 302 - clippy::too_many_lines, 303 - reason = "gallery test renders many widgets in one frame" 304 - )] 305 - fn render_collections(ctx: &mut FrameCtx<'_>, state: &mut GalleryState) { 306 - let list_items = [ 307 - ListItem { 308 - id: id("list_a"), 309 - label: LABEL, 310 - }, 311 - ListItem { 312 - id: id("list_b"), 313 - label: LABEL, 314 - }, 315 - ]; 316 - let _ = show_list_view( 317 - ctx, 318 - ListView::new( 319 - id("list_view"), 320 - rect(220.0, 0.0, 180.0, 60.0), 321 - LABEL, 322 - &list_items, 323 - &mut state.list, 324 - ), 325 - ); 326 - let columns = [ 327 - TableColumn::new(id("col_a"), LABEL, LayoutPx::new(80.0)), 328 - TableColumn::new(id("col_b"), LABEL, LayoutPx::new(80.0)), 329 - ]; 330 - let rows = [ 331 - TableRow { 332 - id: id("row_a"), 333 - cells: [LABEL, LABEL], 334 - }, 335 - TableRow { 336 - id: id("row_b"), 337 - cells: [LABEL, LABEL], 338 - }, 339 - ]; 340 - let _ = show_table( 341 - ctx, 342 - Table::new( 343 - id("table"), 344 - rect(220.0, 64.0, 180.0, 80.0), 345 - LABEL, 346 - &columns, 347 - &rows, 348 - &mut state.table, 349 - ), 350 - ); 351 - let tree_roots = [TreeNode::parent( 352 - id("tree_root"), 353 - LABEL, 354 - vec![TreeNode::leaf(id("tree_child"), LABEL)], 355 - )]; 356 - state.tree.expanded.insert(id("tree_root")); 357 - let _ = show_tree_view( 358 - ctx, 359 - TreeView::new( 360 - id("tree_view"), 361 - rect(220.0, 152.0, 180.0, 60.0), 362 - LABEL, 363 - &tree_roots, 364 - &mut state.tree, 365 - ), 366 - ); 367 - let mut rows: [PropertyRow<'_>; 5] = [ 368 - PropertyRow { 369 - id: id("prop_text"), 370 - label: StringKey::new("smoke.prop.text"), 371 - editor: &mut state.property_text, 372 - read_only: false, 373 - }, 374 - PropertyRow { 375 - id: id("prop_bool"), 376 - label: StringKey::new("smoke.prop.bool"), 377 - editor: &mut state.property_bool, 378 - read_only: false, 379 - }, 380 - PropertyRow { 381 - id: id("prop_select"), 382 - label: StringKey::new("smoke.prop.select"), 383 - editor: &mut state.property_select, 384 - read_only: false, 385 - }, 386 - PropertyRow { 387 - id: id("prop_length"), 388 - label: StringKey::new("smoke.prop.length"), 389 - editor: &mut state.property_length, 390 - read_only: false, 391 - }, 392 - PropertyRow { 393 - id: id("prop_angle"), 394 - label: StringKey::new("smoke.prop.angle"), 395 - editor: &mut state.property_angle, 396 - read_only: false, 397 - }, 398 - ]; 399 - let _ = show_property_grid( 400 - ctx, 401 - PropertyGrid::new( 402 - id("property_grid"), 403 - rect(220.0, 220.0, 240.0, 160.0), 404 - LABEL, 405 - &mut rows, 406 - ), 407 - &mut state.clipboard, 408 - ); 409 - } 410 - 411 - #[allow( 412 - clippy::too_many_lines, 413 - reason = "gallery test renders many widgets in one frame" 414 - )] 415 - fn render_chrome(ctx: &mut FrameCtx<'_>, state: &mut GalleryState) { 416 - let toolbar_items = [ 417 - ToolbarItem::new(id("toolbar_a"), LABEL), 418 - ToolbarItem::new(id("toolbar_b"), LABEL), 419 - ]; 420 - let _ = show_toolbar( 421 - ctx, 422 - Toolbar::horizontal( 423 - id("toolbar"), 424 - rect(480.0, 0.0, 160.0, 32.0), 425 - LABEL, 426 - &toolbar_items, 427 - LayoutPx::new(28.0), 428 - LayoutPx::new(4.0), 429 - ), 430 - &mut state.toolbar_overflow, 431 - ); 432 - let ribbon_toolbar_items = [ 433 - ToolbarItem::new(id("ribbon_tool_a"), LABEL), 434 - ToolbarItem::new(id("ribbon_tool_b"), LABEL), 435 - ]; 436 - let ribbon_tabs = [RibbonTab { 437 - tab: Tab::new(id("ribbon_tab"), rect(0.0, 0.0, 80.0, 24.0), LABEL), 438 - groups: vec![RibbonGroup { 439 - id: id("ribbon_group"), 440 - label: LABEL, 441 - items: ribbon_toolbar_items.to_vec(), 442 - icon_size: RibbonIconSize::Large, 443 - width: LayoutPx::new(140.0), 444 - }], 445 - }]; 446 - let _ = show_ribbon( 447 - ctx, 448 - Ribbon::new( 449 - id("ribbon"), 450 - rect(480.0, 36.0, 200.0, 96.0), 451 - LABEL, 452 - &ribbon_tabs, 453 - id("ribbon_tab"), 454 - &mut state.ribbon, 455 - ), 456 - ); 457 - let menu_items = vec![ 458 - MenuItem::Action { 459 - id: id("menu_action"), 460 - label: LABEL, 461 - shortcut: None, 462 - disabled: false, 463 - }, 464 - MenuItem::Separator, 465 - MenuItem::Submenu { 466 - id: id("menu_submenu"), 467 - label: LABEL, 468 - items: vec![MenuItem::Action { 469 - id: id("menu_subaction"), 470 - label: LABEL, 471 - shortcut: None, 472 - disabled: false, 473 - }], 474 - }, 475 - ]; 476 - let _ = show_menu( 477 - ctx, 478 - Menu::new( 479 - id("menu"), 480 - LayoutPos::new(LayoutPx::new(480.0), LayoutPx::new(140.0)), 481 - LABEL, 482 - &menu_items, 483 - &mut state.menu, 484 - ), 485 - ); 486 - let context_items = vec![MenuItem::Action { 487 - id: id("context_action"), 488 - label: LABEL, 489 - shortcut: None, 490 - disabled: false, 491 - }]; 492 - let _ = show_context_menu( 493 - ctx, 494 - ContextMenu::at_cursor( 495 - id("context_menu"), 496 - LayoutPos::new(LayoutPx::new(480.0), LayoutPx::new(220.0)), 497 - LABEL, 498 - &context_items, 499 - &mut state.context_menu, 500 - ), 501 - ); 502 - let menu_bar_entries = [MenuBarEntry { 503 - id: id("menubar_entry"), 504 - label: LABEL, 505 - items: vec![MenuItem::Action { 506 - id: id("menubar_action"), 507 - label: LABEL, 508 - shortcut: None, 509 - disabled: false, 510 - }], 511 - }]; 512 - let _ = show_menu_bar( 513 - ctx, 514 - MenuBar::new( 515 - id("menu_bar"), 516 - rect(480.0, 280.0, 200.0, 24.0), 517 - LABEL, 518 - &menu_bar_entries, 519 - &mut state.menu_bar, 520 - ), 521 - ); 522 - } 523 - 524 - fn render_overlays(ctx: &mut FrameCtx<'_>, state: &mut GalleryState) { 525 - let _ = show_tooltip( 526 - ctx, 527 - Tooltip::new( 528 - id("button"), 529 - rect(0.0, 0.0, 80.0, 24.0), 530 - LABEL, 531 - TooltipPlacement::Below, 532 - LayoutSize::new(LayoutPx::new(120.0), LayoutPx::new(20.0)), 533 - ), 534 - &mut state.tooltip, 535 - ); 536 - let _ = show_toast( 537 - ctx, 538 - Toast::new( 539 - id("toast"), 540 - rect(20.0, 560.0, 360.0, 48.0), 541 - ToastKind::Info, 542 - LABEL, 543 - &mut state.toast, 544 - ), 545 - ); 546 - let (_, ()) = show_modal( 547 - ctx, 548 - Modal::new( 549 - id("modal"), 550 - rect(700.0, 0.0, 600.0, 400.0), 551 - LayoutSize::new(LayoutPx::new(300.0), LayoutPx::new(200.0)), 552 - LABEL, 553 - ), 554 - |_ctx, _body, _paint| {}, 555 - ); 556 - let dialog_buttons = [ 557 - DialogButton::primary(id("dialog_ok"), LABEL), 558 - DialogButton::secondary(id("dialog_cancel"), LABEL), 559 - ]; 560 - let (_, ()) = show_dialog( 561 - ctx, 562 - Dialog::new( 563 - id("dialog"), 564 - rect(700.0, 410.0, 600.0, 400.0), 565 - LayoutSize::new(LayoutPx::new(360.0), LayoutPx::new(200.0)), 566 - LABEL, 567 - &dialog_buttons, 568 - ), 569 - |_ctx, _body, _paint| {}, 570 - ); 571 - let _ = show_confirmation( 572 - ctx, 573 - ConfirmationDialog { 574 - id: id("confirmation"), 575 - viewport: rect(700.0, 820.0, 600.0, 400.0), 576 - size: LayoutSize::new(LayoutPx::new(360.0), LayoutPx::new(180.0)), 577 - title: LABEL, 578 - message: LABEL, 579 - confirm_label: LABEL, 580 - cancel_label: LABEL, 581 - destructive: false, 582 - }, 583 - ); 584 - let picker_entries = [FilePickerEntry { 585 - id: id("picker_entry"), 586 - label: LABEL, 587 - }]; 588 - let _ = show_file_picker( 589 - ctx, 590 - FilePickerDialog::new( 591 - id("file_picker"), 592 - rect(700.0, 1240.0, 600.0, 500.0), 593 - FilePickerMode::Save, 594 - LABEL, 595 - &picker_entries, 596 - LABEL, 597 - &mut state.file_picker, 598 - ), 599 - ); 600 - } 5 + use bone_ui::gallery::{GALLERY_LABEL, GalleryState, render_with}; 6 + use bone_ui::{AccessTreeBuilder, FocusManager, StringTable, Theme, WidgetId}; 601 7 602 8 fn collect_reachable(update: &accesskit::TreeUpdate) -> BTreeSet<NodeId> { 603 9 let nodes_by_id: std::collections::BTreeMap<NodeId, &accesskit::Node> = ··· 623 29 let mut focus = FocusManager::new(); 624 30 let mut a11y = AccessTreeBuilder::new(); 625 31 let mut state = GalleryState::new(); 626 - render_gallery(&mut focus, &mut a11y, &mut state); 32 + let _ = render_with( 33 + std::sync::Arc::new(Theme::light()), 34 + &mut state, 35 + &mut focus, 36 + &mut a11y, 37 + StringTable::empty(), 38 + ); 627 39 628 40 let strings = StringTable::empty(); 629 41 let update = a11y.build(strings, focus.focused()); ··· 652 64 let mut focus = FocusManager::new(); 653 65 let mut a11y = AccessTreeBuilder::new(); 654 66 let mut state = GalleryState::new(); 655 - render_gallery(&mut focus, &mut a11y, &mut state); 67 + let _ = render_with( 68 + std::sync::Arc::new(Theme::light()), 69 + &mut state, 70 + &mut focus, 71 + &mut a11y, 72 + StringTable::empty(), 73 + ); 656 74 657 - let strings = StringTable::from_entries([(LABEL, "Smoke".to_owned())]); 75 + let strings = StringTable::from_entries([(GALLERY_LABEL, "Gallery".to_owned())]); 658 76 let update = a11y.build(&strings, focus.focused()); 659 77 let nodes_by_id: std::collections::BTreeMap<NodeId, &accesskit::Node> = 660 78 update.nodes.iter().map(|(id, node)| (*id, node)).collect();
+330
crates/bone-ui/tests/gallery_snapshot.rs
··· 1 + use std::collections::{BTreeMap, BTreeSet}; 2 + use std::fs; 3 + use std::path::{Path, PathBuf}; 4 + use std::sync::Arc; 5 + 6 + use bone_ui::a11y::widget_node_id; 7 + use bone_ui::gallery::{ 8 + GALLERY_CANVAS, GalleryState, STORIES, declared_ids, path_id, render_with, story_id, 9 + }; 10 + use bone_ui::raster::{decode_png, encode_png, rasterize}; 11 + use bone_ui::theme::ThemeMode; 12 + use bone_ui::widgets::WidgetPaint; 13 + use bone_ui::{AccessTreeBuilder, FocusManager, StringTable, Theme, WidgetId}; 14 + 15 + const SNAPSHOT_CHANNEL_TOLERANCE: u8 = 1; 16 + const SNAPSHOT_DRIFT_LIMIT: f64 = 0.0; 17 + 18 + fn render_once() -> (FocusManager, AccessTreeBuilder) { 19 + let mut state = GalleryState::new(); 20 + let mut focus = FocusManager::new(); 21 + let mut a11y = AccessTreeBuilder::new(); 22 + let _ = render_with( 23 + Arc::new(Theme::light()), 24 + &mut state, 25 + &mut focus, 26 + &mut a11y, 27 + StringTable::empty(), 28 + ); 29 + (focus, a11y) 30 + } 31 + 32 + fn name_lookup() -> BTreeMap<WidgetId, String> { 33 + let primary = STORIES.iter().copied().map(|story| { 34 + let path: Vec<&str> = story.parent.into_iter().chain([story.key]).collect(); 35 + (story_id(story), path.join("/")) 36 + }); 37 + let extras = STORIES.iter().copied().flat_map(|story| { 38 + story 39 + .extras 40 + .iter() 41 + .map(|path| (path_id(path), path.join("/"))) 42 + }); 43 + primary.chain(extras).collect() 44 + } 45 + 46 + fn label(id: WidgetId, lookup: &BTreeMap<WidgetId, String>) -> String { 47 + match lookup.get(&id) { 48 + Some(name) => name.clone(), 49 + None => format!("{id:?}"), 50 + } 51 + } 52 + 53 + fn snapshot_path(theme: ThemeMode) -> PathBuf { 54 + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 55 + path.push("tests"); 56 + path.push("snapshots"); 57 + path.push(format!("gallery_{theme}.png")); 58 + path 59 + } 60 + 61 + fn render_theme(theme: &Theme) -> Vec<u8> { 62 + let mut state = GalleryState::new(); 63 + let mut focus = FocusManager::new(); 64 + let mut a11y = AccessTreeBuilder::new(); 65 + let paint = render_with( 66 + Arc::new(theme.clone()), 67 + &mut state, 68 + &mut focus, 69 + &mut a11y, 70 + StringTable::empty(), 71 + ); 72 + rasterize(theme, &paint, GALLERY_CANVAS) 73 + } 74 + 75 + struct Drift { 76 + max_channel_diff: u8, 77 + drifting_pixels: usize, 78 + total_pixels: usize, 79 + } 80 + 81 + impl Drift { 82 + fn measure(fresh: &[u8], pinned: &[u8]) -> Self { 83 + assert_eq!( 84 + fresh.len(), 85 + pinned.len(), 86 + "rgba buffer length mismatch: fresh={}, pinned={}", 87 + fresh.len(), 88 + pinned.len(), 89 + ); 90 + let total = fresh.len() / 4; 91 + let (max_channel_diff, drifting_pixels) = fresh 92 + .chunks_exact(4) 93 + .zip(pinned.chunks_exact(4)) 94 + .fold((0_u8, 0_usize), |(mx, count), (a, b)| { 95 + let pixel_max = a 96 + .iter() 97 + .zip(b.iter()) 98 + .map(|(x, y)| x.abs_diff(*y)) 99 + .max() 100 + .unwrap_or(0); 101 + let counted = if pixel_max > SNAPSHOT_CHANNEL_TOLERANCE { 102 + count + 1 103 + } else { 104 + count 105 + }; 106 + (mx.max(pixel_max), counted) 107 + }); 108 + Self { 109 + max_channel_diff, 110 + drifting_pixels, 111 + total_pixels: total, 112 + } 113 + } 114 + 115 + fn drift_ratio(&self) -> f64 { 116 + #[allow( 117 + clippy::cast_precision_loss, 118 + reason = "pixel counts bounded by canvas dimensions, far below f64 mantissa" 119 + )] 120 + let ratio = self.drifting_pixels as f64 / self.total_pixels.max(1) as f64; 121 + ratio 122 + } 123 + 124 + fn within_tolerance(&self) -> bool { 125 + self.drift_ratio() <= SNAPSHOT_DRIFT_LIMIT 126 + } 127 + } 128 + 129 + fn bless(path: &Path, fresh: &[u8], theme_mode: ThemeMode) { 130 + let Ok(png) = encode_png(fresh, GALLERY_CANVAS) else { 131 + panic!("encode_png({theme_mode}) failed"); 132 + }; 133 + if let Some(parent) = path.parent() 134 + && let Err(e) = fs::create_dir_all(parent) 135 + { 136 + panic!("create snapshot dir {}: {e}", parent.display()); 137 + } 138 + let Ok(()) = fs::write(path, &png) else { 139 + panic!("failed to write pinned PNG at {}", path.display()); 140 + }; 141 + } 142 + 143 + fn bless_or_compare(theme_mode: ThemeMode, theme: &Theme) { 144 + let fresh = render_theme(theme); 145 + let path = snapshot_path(theme_mode); 146 + 147 + if std::env::var_os("BONE_GALLERY_BLESS").is_some() { 148 + bless(&path, &fresh, theme_mode); 149 + return; 150 + } 151 + 152 + let Ok(pinned_bytes) = fs::read(&path) else { 153 + panic!( 154 + "pinned PNG missing at {}. Run with BONE_GALLERY_BLESS=1 to bless.", 155 + path.display(), 156 + ); 157 + }; 158 + let Ok(pinned_rgba) = decode_png(&pinned_bytes, GALLERY_CANVAS) else { 159 + panic!("decode pinned PNG at {} failed", path.display()); 160 + }; 161 + let drift = Drift::measure(&fresh, &pinned_rgba); 162 + assert!( 163 + drift.within_tolerance(), 164 + "{theme_mode} gallery PNG drift: max channel diff = {} (per-pixel tolerance {}), \ 165 + {} of {} pixels exceed tolerance ({:.4}%, limit {:.4}%). \ 166 + Run with BONE_GALLERY_BLESS=1 to refresh {}", 167 + drift.max_channel_diff, 168 + SNAPSHOT_CHANNEL_TOLERANCE, 169 + drift.drifting_pixels, 170 + drift.total_pixels, 171 + 100.0 * drift.drift_ratio(), 172 + 100.0 * SNAPSHOT_DRIFT_LIMIT, 173 + path.display(), 174 + ); 175 + } 176 + 177 + #[test] 178 + fn gallery_light_matches_pinned_png() { 179 + bless_or_compare(ThemeMode::Light, &Theme::light()); 180 + } 181 + 182 + #[test] 183 + fn gallery_dark_matches_pinned_png() { 184 + bless_or_compare(ThemeMode::Dark, &Theme::dark()); 185 + } 186 + 187 + #[test] 188 + fn every_declared_id_has_a_focusable_or_a11y_entry() { 189 + let (focus, a11y) = render_once(); 190 + let focusables: BTreeSet<WidgetId> = focus.focusable_ids().collect(); 191 + let a11y_ids: BTreeSet<WidgetId> = a11y.ids().collect(); 192 + let lookup = name_lookup(); 193 + let missing: Vec<String> = declared_ids() 194 + .into_iter() 195 + .filter(|id| !focusables.contains(id) && !a11y_ids.contains(id)) 196 + .map(|id| label(id, &lookup)) 197 + .collect(); 198 + assert!( 199 + missing.is_empty(), 200 + "ids declared in STORIES but absent from focus and a11y trees: {missing:?}. \ 201 + Adding a component or state requires a gallery entry that renders it.", 202 + ); 203 + } 204 + 205 + #[test] 206 + fn every_rendered_id_is_declared_in_stories() { 207 + let (focus, a11y) = render_once(); 208 + let mut rendered: BTreeSet<WidgetId> = focus.focusable_ids().collect(); 209 + rendered.extend(a11y.ids()); 210 + let declared = declared_ids(); 211 + let lookup = name_lookup(); 212 + let extras: Vec<String> = rendered 213 + .difference(&declared) 214 + .copied() 215 + .map(|id| label(id, &lookup)) 216 + .collect(); 217 + assert!( 218 + extras.is_empty(), 219 + "gallery rendered ids absent from STORIES: {extras:?}. \ 220 + Adding a widget or sub-id requires a STORIES entry or extras key.", 221 + ); 222 + } 223 + 224 + #[test] 225 + fn gallery_emits_at_least_one_tooltip_paint() { 226 + let mut state = GalleryState::new(); 227 + let mut focus = FocusManager::new(); 228 + let mut a11y = AccessTreeBuilder::new(); 229 + let paint = render_with( 230 + Arc::new(Theme::light()), 231 + &mut state, 232 + &mut focus, 233 + &mut a11y, 234 + StringTable::empty(), 235 + ); 236 + let tooltip_paints = paint 237 + .iter() 238 + .filter(|p| matches!(p, WidgetPaint::Tooltip { .. })) 239 + .count(); 240 + assert!( 241 + tooltip_paints > 0, 242 + "gallery must paint a tooltip so the snapshot covers tooltip styling; \ 243 + if this fails, the seeded hover or tooltip delay rules drifted", 244 + ); 245 + } 246 + 247 + #[test] 248 + fn paint_rects_stay_inside_gallery_canvas() { 249 + let mut state = GalleryState::new(); 250 + let mut focus = FocusManager::new(); 251 + let mut a11y = AccessTreeBuilder::new(); 252 + let paint = render_with( 253 + Arc::new(Theme::light()), 254 + &mut state, 255 + &mut focus, 256 + &mut a11y, 257 + StringTable::empty(), 258 + ); 259 + #[allow( 260 + clippy::cast_precision_loss, 261 + reason = "canvas dimensions far below f32 precision boundary" 262 + )] 263 + let max_x = GALLERY_CANVAS.width.value() as f32; 264 + #[allow( 265 + clippy::cast_precision_loss, 266 + reason = "canvas dimensions far below f32 precision boundary" 267 + )] 268 + let max_y = GALLERY_CANVAS.height.value() as f32; 269 + let lookup = name_lookup(); 270 + let oob: Vec<String> = paint 271 + .iter() 272 + .filter_map(|p| { 273 + let (rect, anchor) = match p { 274 + WidgetPaint::Surface { rect, .. } 275 + | WidgetPaint::Label { rect, .. } 276 + | WidgetPaint::Mark { rect, .. } 277 + | WidgetPaint::FocusRing { rect, .. } 278 + | WidgetPaint::SelectionHighlight { rect, .. } 279 + | WidgetPaint::Caret { rect, .. } => (*rect, None), 280 + WidgetPaint::Tooltip { rect, anchor, .. } => (*rect, Some(*anchor)), 281 + }; 282 + let outside = rect.min_x().value() < 0.0 283 + || rect.min_y().value() < 0.0 284 + || rect.max_x().value() > max_x 285 + || rect.max_y().value() > max_y; 286 + outside.then(|| match anchor { 287 + Some(id) => format!("{} (anchor {})", paint_kind(p), label(id, &lookup)), 288 + None => paint_kind(p).to_string(), 289 + }) 290 + }) 291 + .collect(); 292 + assert!( 293 + oob.is_empty(), 294 + "paint rects exceed canvas {max_x}x{max_y}: {oob:?}", 295 + ); 296 + } 297 + 298 + fn paint_kind(p: &WidgetPaint) -> &'static str { 299 + match p { 300 + WidgetPaint::Surface { .. } => "Surface", 301 + WidgetPaint::Label { .. } => "Label", 302 + WidgetPaint::Mark { .. } => "Mark", 303 + WidgetPaint::FocusRing { .. } => "FocusRing", 304 + WidgetPaint::SelectionHighlight { .. } => "SelectionHighlight", 305 + WidgetPaint::Caret { .. } => "Caret", 306 + WidgetPaint::Tooltip { .. } => "Tooltip", 307 + } 308 + } 309 + 310 + #[test] 311 + fn every_story_primary_id_has_layout_bounds_in_a11y_tree() { 312 + let (focus, a11y) = render_once(); 313 + let strings = StringTable::empty(); 314 + let update = a11y.build(strings, focus.focused()); 315 + let nodes: BTreeMap<accesskit::NodeId, &accesskit::Node> = 316 + update.nodes.iter().map(|(id, node)| (*id, node)).collect(); 317 + let missing: Vec<&'static str> = STORIES 318 + .iter() 319 + .copied() 320 + .filter(|story| { 321 + let node_id = widget_node_id(story_id(*story)); 322 + !nodes.contains_key(&node_id) 323 + }) 324 + .map(|s| s.key) 325 + .collect(); 326 + assert!( 327 + missing.is_empty(), 328 + "stories absent from accesskit tree: {missing:?}", 329 + ); 330 + }
crates/bone-ui/tests/snapshots/gallery_dark.png

This is a binary file and will not be displayed.

crates/bone-ui/tests/snapshots/gallery_light.png

This is a binary file and will not be displayed.