Another project
0

Configure Feed

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

feat(ui,app): widget polish + selection/settings rework

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

author
Lewis
date (May 16, 2026, 9:18 PM +0300) commit 50d1cb3c parent a1711ac3 change-id mpzsywno
+922 -484
+155 -55
crates/bone-app/src/main.rs
··· 4 4 use std::sync::Arc; 5 5 6 6 use bone_document::{ 7 - DimensionKind, DimensionValue, Document, Sketch, SketchDimension, SketchEdit, SketchEntity, 8 - SketchRelation, SolverError, UndoStack, 7 + DimensionKind, DimensionValue, Document, EditOutcome, Sketch, SketchDimension, SketchEdit, 8 + SketchEntity, SketchRelation, SolverError, UndoStack, 9 9 }; 10 10 use bone_render::{ 11 11 Camera2, ChromeInstance, ChromePipeline, ChromeTextPipeline, PickQuery, PickedItem, 12 12 PixelsPerMm, RenderTargets, SdfGlyphInstance, SketchPreview, SketchRenderer, SketchScene, 13 13 Style, SurfaceContext, ViewportExtent, ViewportPx, 14 14 }; 15 - use bone_types::{BudgetCeiling, DocumentId, Length, Point2, SketchId, Vec2}; 15 + use bone_types::{BudgetCeiling, DocumentId, Length, Point2, SketchId, SketchItemId, Vec2}; 16 16 use bone_ui::a11y::AccessTreeBuilder; 17 17 use bone_ui::focus::FocusManager; 18 18 use bone_ui::frame::FrameCtx; ··· 27 27 use bone_ui::raster::{PngError, encode_png, rasterize}; 28 28 use bone_ui::strings::StringTable; 29 29 use bone_ui::theme::{Theme, ThemeMode}; 30 - use bone_ui::{SdfAtlas, SdfAtlasParams, Shaper}; 30 + use bone_ui::{MaskAtlas, MaskAtlasParams, Shaper}; 31 31 use swash::FontRef; 32 32 use tracing_subscriber::EnvFilter; 33 33 use uom::si::length::millimeter; ··· 44 44 mod dimension_editor; 45 45 mod relation_tools; 46 46 mod selection; 47 + mod settings; 47 48 mod shell; 48 49 mod sketch_mode; 49 50 mod smart_dimension; ··· 107 108 renderer: SketchRenderer, 108 109 chrome_pipeline: ChromePipeline, 109 110 text_pipeline: ChromeTextPipeline, 110 - sdf_atlas: SdfAtlas, 111 + sdf_atlas: MaskAtlas, 111 112 chrome_shaper: Shaper, 112 113 sans_font: FontRef<'static>, 113 114 mono_font: FontRef<'static>, ··· 126 127 viewport_rect: LayoutRect, 127 128 undo: UndoStack, 128 129 selection: Selection, 130 + settings: settings::Settings, 129 131 dim_editor: DimensionEditorState, 130 132 dim_editor_bounds: Option<LayoutRect>, 131 133 pending_exit: bool, ··· 407 409 return None; 408 410 } 409 411 }; 410 - let query = PickQuery::new(ViewportPx::new(qx), ViewportPx::new(qy)); 412 + let query = PickQuery::new(ViewportPx::new(qx), ViewportPx::new(qy)) 413 + .with_aperture(state.settings.pick_aperture); 411 414 match state.surface.picker(index).at(query) { 412 415 Ok(item) => item, 413 416 Err(e) => { ··· 419 422 420 423 fn handle_viewport_click(state: &mut RenderState, cursor: PhysicalPosition<f64>, additive: bool) { 421 424 let picked = pick_at(state, cursor); 422 - let picked_id = match picked { 423 - Some( 424 - PickedItem::Point(id) 425 - | PickedItem::Line(id) 426 - | PickedItem::Arc(id) 427 - | PickedItem::Circle(id), 428 - ) => Some(id), 429 - Some(_) | None => None, 430 - }; 431 - state.selection = std::mem::take(&mut state.selection).picked(picked_id, additive); 425 + let item = picked.map(selection::picked_to_item); 426 + state.selection = std::mem::take(&mut state.selection).picked(item, additive); 432 427 if additive || !state.mode.is_sketch() { 433 428 return; 434 429 } 435 - let Some(entity_id) = picked_id else { 430 + let Some(SketchItemId::Entity(entity_id)) = item else { 436 431 return; 437 432 }; 438 433 let Mode::Sketch { sketch_id, .. } = state.mode else { ··· 456 451 state.mode = core::mem::take(&mut state.mode).start_drag(drag); 457 452 } 458 453 454 + fn drag_resolved(sketch: &Sketch, drag: DragSession, world: Point2) -> Option<Sketch> { 455 + let pins = drag.pins.to_targets(drag.press, world); 456 + sketch 457 + .solve_with_drag_pins(&pins, BudgetCeiling::FRAME_16MS) 458 + .map_err(|e| tracing::trace!(error = %e, "drag solve failed, keeping last-good sketch")) 459 + .ok() 460 + } 461 + 459 462 fn try_drag_to(state: &mut RenderState, world: Point2) { 460 463 let Mode::Sketch { sketch_id, session } = &state.mode else { 461 464 return; ··· 464 467 return; 465 468 }; 466 469 let sketch_id = *sketch_id; 467 - let Some(sketch) = state.document.sketch(sketch_id).cloned() else { 470 + let Some(sketch) = state.document.sketch(sketch_id) else { 468 471 return; 469 472 }; 470 - let pins = drag.pins.to_targets(drag.press, world); 471 - let next = match sketch.solve_with_drag_pins(&pins, BudgetCeiling::FRAME_16MS) { 472 - Ok(solved) => solved, 473 - Err(e) => { 474 - tracing::trace!(error = %e, "drag solve failed; falling back to raw move"); 475 - let Some(baseline) = state.document.sketch(sketch_id).cloned() else { 476 - return; 477 - }; 478 - let folded = pins.iter().try_fold(baseline, |acc, (id, target)| { 479 - acc.apply(SketchEdit::MovePoint { 480 - id: *id, 481 - position: *target, 482 - }) 483 - .map(|(s, _)| s) 484 - }); 485 - match folded { 486 - Ok(s) => s, 487 - Err(_) => return, 488 - } 489 - } 473 + let Some(next) = drag_resolved(sketch, drag, world) else { 474 + return; 490 475 }; 491 476 state.document.replace_sketch(sketch_id, next); 492 477 refresh_active_scene(state); ··· 794 779 }; 795 780 let renderer = SketchRenderer::new(surface.gpu(), surface.color_format()); 796 781 let chrome_pipeline = ChromePipeline::new(surface.gpu(), surface.color_format()); 797 - let sdf_atlas = SdfAtlas::new(SdfAtlasParams::STANDARD); 782 + let sdf_atlas = MaskAtlas::new(MaskAtlasParams::STANDARD); 798 783 let text_pipeline = 799 784 ChromeTextPipeline::new(surface.gpu(), surface.color_format(), sdf_atlas.extent()); 800 785 let chrome_shaper = Shaper::new(); ··· 812 797 let camera = Camera2::new(extent).with_zoom(PixelsPerMm::new(INITIAL_ZOOM_PX_PER_MM)); 813 798 let style = Style::default(); 814 799 let theme = Arc::new(Theme::light()); 815 - let shell = match shell::Shell::new() { 816 - Ok(s) => s, 817 - Err(e) => { 818 - tracing::error!(error = %e, "Shell::new failed"); 819 - event_loop.exit(); 820 - return; 821 - } 822 - }; 800 + let shell = shell::Shell::new(); 823 801 let (document, sketch_id) = initial_document(sketch); 824 802 let plane_sketches = BTreeMap::from([(Plane::Xy, sketch_id)]); 825 803 let strings = strings::make_strings(bone_ui::strings::Locale::EnUs); ··· 853 831 viewport_rect, 854 832 undo: UndoStack::with_capacity(undo_capacity), 855 833 selection: Selection::default(), 834 + settings: settings::Settings::default(), 856 835 dim_editor: DimensionEditorState::default(), 857 836 dim_editor_bounds: None, 858 837 pending_exit: false, ··· 1016 995 self.input.pending_text.push_str(&filtered); 1017 996 } 1018 997 } 1019 - let suppress_camera = dim_flow_active(&state.mode); 998 + let suppress_camera = 999 + dim_flow_active(&state.mode) || state.focus.focused().is_some(); 1020 1000 if let Some(code) = physical_code { 1021 1001 match keyboard_action(code, &self.input, state) { 1022 1002 Some(KeyAction::Exit) => event_loop.exit(), ··· 1037 1017 clippy::cast_precision_loss, 1038 1018 reason = "viewport pixel counts at any realistic display size fit f32 mantissa" 1039 1019 )] 1020 + #[allow( 1021 + clippy::too_many_lines, 1022 + reason = "splitting hides the per-outcome dispatch table" 1023 + )] 1040 1024 fn render_frame(state: &mut RenderState, window: &Window, input_state: &mut InputState) { 1041 1025 let extent = state.surface.extent(); 1042 1026 let layout_size = layout_size_from_extent(extent); ··· 1045 1029 let mut hits = HitFrame::new(); 1046 1030 let mut a11y = AccessTreeBuilder::new(); 1047 1031 let scopes = scopes_for_mode(&state.mode); 1032 + let chrome_cursor_world = input_state 1033 + .cursor_px 1034 + .filter(|c| state.viewport_rect.contains(physical_to_layout_pos(*c))) 1035 + .and_then(|c| cursor_to_world(state.camera, c)); 1048 1036 let (mut frame, hotkey_actions, dim_outcome, conflict_outcome) = run_frame_ui( 1049 1037 state, 1050 1038 theme, ··· 1053 1041 &mut a11y, 1054 1042 &scopes, 1055 1043 layout_size, 1044 + chrome_cursor_world, 1056 1045 ); 1057 1046 state.dim_editor_bounds = apply_popup_overlays( 1058 1047 &mut frame.overlay_paints, ··· 1090 1079 apply_dimension_outcome(state, dim_outcome); 1091 1080 apply_dim_conflict_outcome(state, conflict_outcome); 1092 1081 apply_dimension_request(state, frame.activated_dimension); 1082 + let dimension_edit = match frame.confirm_action { 1083 + Some(shell::ConfirmAction::Cancel) => None, 1084 + _ => frame.dimension_edit, 1085 + }; 1086 + apply_dimension_edit(state, dimension_edit); 1093 1087 apply_undo_actions(state, &hotkey_actions); 1094 1088 apply_menu_action(state, frame.menu_action); 1089 + apply_settings_change(state, frame.settings_change); 1095 1090 apply_relation_action(state, frame.activated_relation); 1096 1091 let cursor_world = input_state 1097 1092 .cursor_px ··· 1250 1245 activated_tool: None, 1251 1246 activated_relation: None, 1252 1247 activated_dimension: None, 1248 + dimension_edit: None, 1253 1249 plane_picked: None, 1254 1250 exit_sketch: false, 1251 + confirm_action: None, 1255 1252 menu_action: None, 1253 + settings_change: None, 1256 1254 } 1257 1255 } 1258 1256 ··· 1298 1296 } 1299 1297 } 1300 1298 1299 + #[allow( 1300 + clippy::too_many_arguments, 1301 + reason = "run_frame_ui threads every per-frame UI subsystem" 1302 + )] 1301 1303 fn run_frame_ui( 1302 1304 state: &mut RenderState, 1303 1305 theme: Arc<Theme>, ··· 1306 1308 a11y: &mut AccessTreeBuilder, 1307 1309 scopes: &HotkeyScopes, 1308 1310 layout_size: LayoutSize, 1311 + cursor_world: Option<Point2>, 1309 1312 ) -> ( 1310 1313 shell::ShellFrame, 1311 1314 Vec<ActionId>, ··· 1327 1330 &mut ctx, 1328 1331 &state.document, 1329 1332 &state.mode, 1330 - state.selection.ids(), 1333 + &state.selection, 1334 + state.settings, 1331 1335 layout_size, 1336 + cursor_world, 1332 1337 ); 1333 1338 let dim_outcome = pending_dim(&state.mode).map(|pending| { 1334 1339 let live_anchor = match state.mode { ··· 1430 1435 return; 1431 1436 }; 1432 1437 state.mode = core::mem::take(&mut state.mode).start_dimension(request); 1433 - state.selection = Selection::default(); 1434 1438 } 1435 1439 1436 1440 fn apply_dimension_outcome(state: &mut RenderState, outcome: Option<DimensionEditorOutcome>) { ··· 1490 1494 return; 1491 1495 } 1492 1496 }; 1493 - let after_add = match sketch.clone().apply(SketchEdit::AddDimension(proto)) { 1494 - Ok((next, _)) => next, 1497 + let (after_add, dim_id) = match sketch.clone().apply(SketchEdit::AddDimension(proto)) { 1498 + Ok((next, EditOutcome::Dimension(id))) => (next, id), 1499 + Ok(_) => { 1500 + tracing::warn!(?proto, "add dimension produced unexpected outcome"); 1501 + return; 1502 + } 1495 1503 Err(e) => { 1496 1504 tracing::warn!(error = %e, ?proto, "add dimension failed"); 1497 1505 return; ··· 1514 1522 }; 1515 1523 state.undo.record(state.document.clone()); 1516 1524 state.document.replace_sketch(sketch_id, solved); 1525 + state.selection = Selection::Dimension(dim_id); 1517 1526 state.mode = core::mem::take(&mut state.mode).cancel_dimension(); 1518 1527 state.dim_editor.close(); 1519 1528 refresh_active_scene(state); ··· 1536 1545 state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); 1537 1546 return; 1538 1547 }; 1539 - let after_add = match sketch.apply(SketchEdit::AddDimension(driven_proto)) { 1540 - Ok((next, _)) => next, 1548 + let (after_add, dim_id) = match sketch.apply(SketchEdit::AddDimension(driven_proto)) { 1549 + Ok((next, EditOutcome::Dimension(id))) => (next, id), 1550 + Ok(_) => { 1551 + tracing::warn!(?driven_proto, "add driven dimension produced unexpected outcome"); 1552 + state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); 1553 + return; 1554 + } 1541 1555 Err(e) => { 1542 1556 tracing::warn!(error = %e, ?driven_proto, "add driven dimension failed"); 1543 1557 state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); ··· 1547 1561 let solved = after_add.clone().solve().unwrap_or(after_add); 1548 1562 state.undo.record(state.document.clone()); 1549 1563 state.document.replace_sketch(sketch_id, solved); 1564 + state.selection = Selection::Dimension(dim_id); 1550 1565 state.mode = core::mem::take(&mut state.mode).cancel_dim_conflict(); 1551 1566 refresh_active_scene(state); 1552 1567 } ··· 1558 1573 .ok() 1559 1574 } 1560 1575 1576 + fn apply_dimension_edit(state: &mut RenderState, edit: Option<shell::DimensionEdit>) { 1577 + let Some(edit) = edit else { return }; 1578 + let Mode::Sketch { sketch_id, .. } = state.mode else { 1579 + return; 1580 + }; 1581 + let Some(sketch) = state.document.sketch(sketch_id).cloned() else { 1582 + return; 1583 + }; 1584 + let after = match sketch.apply(SketchEdit::UpdateDimensionValue { 1585 + id: edit.id, 1586 + value: edit.value, 1587 + }) { 1588 + Ok((next, _)) => next, 1589 + Err(e) => { 1590 + tracing::warn!(error = %e, ?edit, "update dimension value failed"); 1591 + return; 1592 + } 1593 + }; 1594 + let solved = match after.solve() { 1595 + Ok(s) => s, 1596 + Err(e) => { 1597 + tracing::warn!(error = %e, ?edit, "solve after dimension edit failed"); 1598 + return; 1599 + } 1600 + }; 1601 + state.undo.record(state.document.clone()); 1602 + state.document.replace_sketch(sketch_id, solved); 1603 + refresh_active_scene(state); 1604 + } 1605 + 1561 1606 fn apply_relation_action(state: &mut RenderState, relation: Option<SketchRelation>) { 1562 1607 let Some(relation) = relation else { return }; 1563 1608 let Mode::Sketch { sketch_id, .. } = state.mode else { ··· 1593 1638 Some(shell::MenuAction::ZoomFit) => { 1594 1639 state.camera = zoom_fit(state.camera, &state.scene, state.viewport_rect); 1595 1640 } 1596 - Some(shell::MenuAction::Undo | shell::MenuAction::Redo) | None => {} 1641 + Some(shell::MenuAction::OpenSettings) => { 1642 + state.shell.state.settings_dialog_open = true; 1643 + } 1644 + Some(shell::MenuAction::Undo | shell::MenuAction::Redo | shell::MenuAction::ExitSketch) 1645 + | None => {} 1646 + } 1647 + } 1648 + 1649 + fn apply_settings_change(state: &mut RenderState, change: Option<settings::Settings>) { 1650 + if let Some(next) = change { 1651 + state.settings = next; 1597 1652 } 1598 1653 } 1599 1654 ··· 1802 1857 activated_tool: None, 1803 1858 activated_relation: None, 1804 1859 activated_dimension: None, 1860 + dimension_edit: None, 1805 1861 plane_picked: None, 1806 1862 exit_sketch: false, 1863 + confirm_action: None, 1807 1864 menu_action: None, 1865 + settings_change: None, 1808 1866 } 1809 1867 } 1810 1868 ··· 2283 2341 #[test] 2284 2342 fn dim_conflict_pending_returns_none_in_idle() { 2285 2343 assert_eq!(dim_conflict_pending(&Mode::Idle), None); 2344 + } 2345 + 2346 + fn horizontal_line_fixture() -> ( 2347 + Sketch, 2348 + bone_types::SketchEntityId, 2349 + bone_types::SketchEntityId, 2350 + bone_types::SketchEntityId, 2351 + ) { 2352 + let sketch = Sketch::new(Plane::Xy.basis()); 2353 + let (sketch, a) = tools::add_point(sketch, Point2::from_mm(0.0, 0.0)); 2354 + let (sketch, b) = tools::add_point(sketch, Point2::from_mm(10.0, 0.0)); 2355 + let (sketch, line) = tools::add_line(sketch, a, b, false); 2356 + let sketch = tools::add_relation(sketch, SketchRelation::Horizontal(line)); 2357 + let sketch = tools::add_relation(sketch, SketchRelation::Fix(a)); 2358 + (sketch, a, b, line) 2359 + } 2360 + 2361 + fn point_at(sketch: &Sketch, id: bone_types::SketchEntityId) -> Point2 { 2362 + let SketchEntity::Point(p) = sketch.entities()[id] else { 2363 + panic!("expected point entity"); 2364 + }; 2365 + p.at() 2366 + } 2367 + 2368 + #[test] 2369 + fn drag_resolved_translates_endpoint_and_preserves_horizontal() { 2370 + let (sketch, a, b, _) = horizontal_line_fixture(); 2371 + let drag = DragSession { 2372 + entity: b, 2373 + press: Point2::from_mm(10.0, 0.0), 2374 + pins: DragPins::from_array([Some((b, Point2::from_mm(10.0, 0.0))), None, None]), 2375 + }; 2376 + let cursor = Point2::from_mm(13.0, 0.0); 2377 + let Some(next) = drag_resolved(&sketch, drag, cursor) else { 2378 + panic!("solve_with_drag_pins must converge on horizontal line") 2379 + }; 2380 + let (bx, by) = point_at(&next, b).coords_mm(); 2381 + assert!((bx - 13.0).abs() < 1e-6, "b.x slides under cursor: {bx}"); 2382 + assert!(by.abs() < 1e-6, "horizontal preserved: by={by}"); 2383 + let (ax, ay) = point_at(&next, a).coords_mm(); 2384 + assert!(ax.abs() < 1e-9, "fixed a.x stays put: {ax}"); 2385 + assert!(ay.abs() < 1e-9, "fixed a.y stays put: {ay}"); 2286 2386 } 2287 2387 }
+132 -34
crates/bone-app/src/selection.rs
··· 1 - use bone_types::SketchEntityId; 1 + use bone_render::PickedItem; 2 + use bone_types::{SketchDimensionId, SketchEntityId, SketchItemId, SketchRelationId}; 3 + 4 + #[derive(Clone, Debug, PartialEq, Eq)] 5 + pub enum Selection { 6 + Entities(Vec<SketchEntityId>), 7 + Relation(SketchRelationId), 8 + Dimension(SketchDimensionId), 9 + } 2 10 3 - #[derive(Default, Clone, Debug, PartialEq, Eq)] 4 - pub struct Selection(Vec<SketchEntityId>); 11 + impl Default for Selection { 12 + fn default() -> Self { 13 + Self::Entities(Vec::new()) 14 + } 15 + } 5 16 6 17 impl Selection { 7 18 #[must_use] 8 - pub fn ids(&self) -> &[SketchEntityId] { 9 - &self.0 19 + pub fn entity_ids(&self) -> &[SketchEntityId] { 20 + match self { 21 + Self::Entities(v) => v, 22 + Self::Relation(_) | Self::Dimension(_) => &[], 23 + } 10 24 } 11 25 12 26 #[must_use] 13 - pub fn picked(self, id: Option<SketchEntityId>, additive: bool) -> Self { 14 - match (id, additive) { 15 - (Some(id), true) => self.toggled(id), 16 - (Some(id), false) => Self(vec![id]), 27 + pub fn is_empty(&self) -> bool { 28 + matches!(self, Self::Entities(v) if v.is_empty()) 29 + } 30 + 31 + #[must_use] 32 + pub fn picked(self, item: Option<SketchItemId>, additive: bool) -> Self { 33 + match (item, additive) { 17 34 (None, false) => Self::default(), 18 35 (None, true) => self, 36 + (Some(SketchItemId::Entity(id)), is_additive) => match self { 37 + Self::Entities(v) if is_additive => Self::Entities(toggle(v, id)), 38 + _ => Self::Entities(vec![id]), 39 + }, 40 + (Some(SketchItemId::Relation(id)), _) => Self::Relation(id), 41 + (Some(SketchItemId::Dimension(id)), _) => Self::Dimension(id), 19 42 } 20 43 } 44 + } 21 45 22 - fn toggled(mut self, id: SketchEntityId) -> Self { 23 - match self.0.iter().position(|x| *x == id) { 24 - Some(idx) => { 25 - self.0.remove(idx); 26 - } 27 - None => self.0.push(id), 46 + #[must_use] 47 + pub fn picked_to_item(picked: PickedItem) -> SketchItemId { 48 + match picked { 49 + PickedItem::Point(id) 50 + | PickedItem::Line(id) 51 + | PickedItem::Arc(id) 52 + | PickedItem::Circle(id) => SketchItemId::Entity(id), 53 + PickedItem::Relation(id) => SketchItemId::Relation(id), 54 + PickedItem::Dimension(id) => SketchItemId::Dimension(id), 55 + } 56 + } 57 + 58 + fn toggle(mut v: Vec<SketchEntityId>, id: SketchEntityId) -> Vec<SketchEntityId> { 59 + match v.iter().position(|x| *x == id) { 60 + Some(idx) => { 61 + v.remove(idx); 28 62 } 29 - self 63 + None => v.push(id), 30 64 } 65 + v 31 66 } 32 67 33 68 #[cfg(test)] ··· 35 70 use super::*; 36 71 use crate::sketch_mode::Plane; 37 72 use crate::tools::add_point; 38 - use bone_document::Sketch; 39 - use bone_types::Point2; 73 + use bone_document::{ 74 + DimensionKind, EditOutcome, Sketch, SketchDimension, SketchEdit, SketchRelation, 75 + }; 76 + use bone_types::{Length, Point2}; 77 + use uom::si::length::millimeter; 40 78 41 79 fn three_ids() -> (SketchEntityId, SketchEntityId, SketchEntityId) { 42 80 let s = Sketch::new(Plane::Xy.basis()); ··· 46 84 (a, b, c) 47 85 } 48 86 87 + fn rel_id() -> SketchRelationId { 88 + let s = Sketch::new(Plane::Xy.basis()); 89 + let (s, a) = add_point(s, Point2::from_mm(0.0, 0.0)); 90 + let Ok((_, EditOutcome::Relation(id))) = 91 + s.apply(SketchEdit::AddRelation(SketchRelation::Fix(a))) 92 + else { 93 + panic!("expected Relation outcome"); 94 + }; 95 + id 96 + } 97 + 98 + fn dim_id() -> SketchDimensionId { 99 + let s = Sketch::new(Plane::Xy.basis()); 100 + let (s, a) = add_point(s, Point2::from_mm(0.0, 0.0)); 101 + let (s, b) = add_point(s, Point2::from_mm(1.0, 0.0)); 102 + let dim = SketchDimension::Linear { 103 + a, 104 + b, 105 + value: Length::new::<millimeter>(1.0), 106 + kind: DimensionKind::Driving, 107 + }; 108 + let Ok((_, EditOutcome::Dimension(id))) = s.apply(SketchEdit::AddDimension(dim)) else { 109 + panic!("expected Dimension outcome"); 110 + }; 111 + id 112 + } 113 + 49 114 #[test] 50 - fn default_selection_is_empty() { 51 - assert!(Selection::default().ids().is_empty()); 115 + fn default_selection_is_empty_entities() { 116 + assert!(Selection::default().entity_ids().is_empty()); 117 + assert!(matches!(Selection::default(), Selection::Entities(_))); 52 118 } 53 119 54 120 #[test] 55 - fn pick_replace_overwrites_existing() { 121 + fn pick_replace_entity_overwrites_existing() { 56 122 let (a, b, _) = three_ids(); 57 123 let s = Selection::default() 58 - .picked(Some(a), false) 59 - .picked(Some(b), false); 60 - assert_eq!(s.ids(), &[b]); 124 + .picked(Some(SketchItemId::Entity(a)), false) 125 + .picked(Some(SketchItemId::Entity(b)), false); 126 + assert_eq!(s.entity_ids(), &[b]); 61 127 } 62 128 63 129 #[test] 64 130 fn pick_additive_extends_then_toggles_off() { 65 131 let (a, b, _) = three_ids(); 66 132 let s = Selection::default() 67 - .picked(Some(a), true) 68 - .picked(Some(b), true); 69 - assert_eq!(s.ids(), &[a, b]); 70 - let s = s.picked(Some(a), true); 71 - assert_eq!(s.ids(), &[b]); 133 + .picked(Some(SketchItemId::Entity(a)), true) 134 + .picked(Some(SketchItemId::Entity(b)), true); 135 + assert_eq!(s.entity_ids(), &[a, b]); 136 + let s = s.picked(Some(SketchItemId::Entity(a)), true); 137 + assert_eq!(s.entity_ids(), &[b]); 72 138 } 73 139 74 140 #[test] 75 141 fn pick_replace_with_none_clears() { 76 142 let (a, b, _) = three_ids(); 77 143 let s = Selection::default() 78 - .picked(Some(a), true) 79 - .picked(Some(b), true) 144 + .picked(Some(SketchItemId::Entity(a)), true) 145 + .picked(Some(SketchItemId::Entity(b)), true) 80 146 .picked(None, false); 81 - assert!(s.ids().is_empty()); 147 + assert!(s.entity_ids().is_empty()); 82 148 } 83 149 84 150 #[test] 85 151 fn pick_additive_with_none_preserves() { 86 152 let (a, _, _) = three_ids(); 87 153 let s = Selection::default() 88 - .picked(Some(a), true) 154 + .picked(Some(SketchItemId::Entity(a)), true) 89 155 .picked(None, true); 90 - assert_eq!(s.ids(), &[a]); 156 + assert_eq!(s.entity_ids(), &[a]); 157 + } 158 + 159 + #[test] 160 + fn pick_relation_replaces_entities() { 161 + let (a, b, _) = three_ids(); 162 + let r = rel_id(); 163 + let s = Selection::default() 164 + .picked(Some(SketchItemId::Entity(a)), true) 165 + .picked(Some(SketchItemId::Entity(b)), true) 166 + .picked(Some(SketchItemId::Relation(r)), false); 167 + assert!(s.entity_ids().is_empty()); 168 + assert_eq!(s, Selection::Relation(r)); 169 + } 170 + 171 + #[test] 172 + fn pick_entity_after_relation_clears_relation() { 173 + let (a, _, _) = three_ids(); 174 + let r = rel_id(); 175 + let s = Selection::default() 176 + .picked(Some(SketchItemId::Relation(r)), false) 177 + .picked(Some(SketchItemId::Entity(a)), false); 178 + assert_eq!(s.entity_ids(), &[a]); 179 + } 180 + 181 + #[test] 182 + fn pick_dimension_replaces_relation() { 183 + let r = rel_id(); 184 + let d = dim_id(); 185 + let s = Selection::default() 186 + .picked(Some(SketchItemId::Relation(r)), false) 187 + .picked(Some(SketchItemId::Dimension(d)), false); 188 + assert_eq!(s, Selection::Dimension(d)); 91 189 } 92 190 }
+14
crates/bone-app/src/settings.rs
··· 1 + use bone_render::PickAperture; 2 + 3 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 4 + pub struct Settings { 5 + pub pick_aperture: PickAperture, 6 + } 7 + 8 + impl Default for Settings { 9 + fn default() -> Self { 10 + Self { 11 + pick_aperture: PickAperture::DEFAULT, 12 + } 13 + } 14 + }
+135 -8
crates/bone-app/src/strings.rs
··· 3 3 pub const APP_TITLE: StringKey = StringKey::new("app.title"); 4 4 5 5 pub const RIBBON_LABEL: StringKey = StringKey::new("shell.ribbon"); 6 + pub const RIBBON_TAB_FEATURES: StringKey = StringKey::new("shell.ribbon.tab.features"); 6 7 pub const RIBBON_TAB_SKETCH: StringKey = StringKey::new("shell.ribbon.tab.sketch"); 8 + pub const RIBBON_TAB_SURFACES: StringKey = StringKey::new("shell.ribbon.tab.surfaces"); 9 + pub const RIBBON_TAB_EVALUATE: StringKey = StringKey::new("shell.ribbon.tab.evaluate"); 7 10 pub const RIBBON_GROUP_ENTITIES: StringKey = StringKey::new("shell.ribbon.group.entities"); 8 11 pub const RIBBON_GROUP_RELATIONS: StringKey = StringKey::new("shell.ribbon.group.relations"); 9 12 pub const RIBBON_GROUP_DIMENSIONS: StringKey = StringKey::new("shell.ribbon.group.dimensions"); 10 - pub const RIBBON_GROUP_EXIT: StringKey = StringKey::new("shell.ribbon.group.exit"); 11 - pub const TOOL_EXIT_SKETCH: StringKey = StringKey::new("tool.exit_sketch"); 13 + pub const CONFIRM_ACCEPT: StringKey = StringKey::new("confirm.accept"); 14 + pub const CONFIRM_CANCEL: StringKey = StringKey::new("confirm.cancel"); 12 15 13 16 pub const TOOL_POINT: StringKey = StringKey::new("tool.point"); 14 17 pub const TOOL_LINE: StringKey = StringKey::new("tool.line"); ··· 58 61 pub const DIM_CONFLICT_CANCEL: StringKey = StringKey::new("dim.conflict.cancel"); 59 62 60 63 pub const FEATURE_TREE_LABEL: StringKey = StringKey::new("shell.feature_tree"); 64 + pub const FEATURE_HISTORY: StringKey = StringKey::new("feature.history"); 65 + pub const FEATURE_SENSORS: StringKey = StringKey::new("feature.sensors"); 66 + pub const FEATURE_ANNOTATIONS: StringKey = StringKey::new("feature.annotations"); 67 + pub const FEATURE_SOLID_BODIES: StringKey = StringKey::new("feature.solid_bodies"); 68 + pub const FEATURE_MATERIAL: StringKey = StringKey::new("feature.material"); 61 69 pub const FEATURE_ORIGIN: StringKey = StringKey::new("feature.origin"); 62 70 pub const FEATURE_PLANE_XY: StringKey = StringKey::new("feature.plane.xy"); 63 71 pub const FEATURE_PLANE_YZ: StringKey = StringKey::new("feature.plane.yz"); ··· 65 73 pub const FEATURE_SKETCH_DEFAULT: StringKey = StringKey::new("feature.sketch.default"); 66 74 67 75 pub const PROPERTY_PANE_LABEL: StringKey = StringKey::new("shell.property_pane"); 76 + pub const LEFT_PANE_LABEL: StringKey = StringKey::new("shell.left_pane"); 77 + pub const LEFT_PANE_TAB_CONFIGURATION: StringKey = 78 + StringKey::new("shell.left_pane.tab.configuration"); 79 + pub const LEFT_PANE_TAB_DIMENSION_EXPERT: StringKey = 80 + StringKey::new("shell.left_pane.tab.dimension_expert"); 81 + pub const LEFT_PANE_TAB_DISPLAY: StringKey = StringKey::new("shell.left_pane.tab.display"); 82 + 83 + pub const DOC_TABS_LABEL: StringKey = StringKey::new("shell.doc_tabs"); 84 + pub const DOC_TAB_MODEL: StringKey = StringKey::new("shell.doc_tabs.model"); 68 85 69 86 pub const STATUS_BAR_LABEL: StringKey = StringKey::new("shell.status_bar"); 70 87 pub const STATUS_READY: StringKey = StringKey::new("status.ready"); 71 88 pub const STATUS_SKETCH_ACTIVE: StringKey = StringKey::new("status.sketch_active"); 89 + pub const STATUS_UNITS_MM: StringKey = StringKey::new("status.units.mm"); 72 90 73 91 pub const MENU_BAR_LABEL: StringKey = StringKey::new("shell.menu_bar"); 74 92 pub const MENU_FILE: StringKey = StringKey::new("menu.file"); ··· 76 94 pub const MENU_VIEW: StringKey = StringKey::new("menu.view"); 77 95 pub const MENU_INSERT: StringKey = StringKey::new("menu.insert"); 78 96 pub const MENU_TOOLS: StringKey = StringKey::new("menu.tools"); 97 + pub const MENU_SKETCH: StringKey = StringKey::new("menu.sketch"); 98 + pub const MENU_SKETCH_EXIT: StringKey = StringKey::new("menu.sketch.exit"); 79 99 pub const MENU_WINDOW: StringKey = StringKey::new("menu.window"); 80 100 pub const MENU_HELP: StringKey = StringKey::new("menu.help"); 81 101 pub const MENU_FILE_NEW: StringKey = StringKey::new("menu.file.new"); ··· 86 106 pub const MENU_EDIT_REDO: StringKey = StringKey::new("menu.edit.redo"); 87 107 pub const MENU_VIEW_ZOOM_FIT: StringKey = StringKey::new("menu.view.zoom_fit"); 88 108 pub const MENU_PLACEHOLDER_COMING_SOON: StringKey = StringKey::new("menu.placeholder.coming_soon"); 109 + pub const MENU_TOOLS_OPTIONS: StringKey = StringKey::new("menu.tools.options"); 110 + pub const SETTINGS_DIALOG_TITLE: StringKey = StringKey::new("settings.dialog.title"); 111 + pub const SETTINGS_PICK_APERTURE_LABEL: StringKey = 112 + StringKey::new("settings.pick_aperture.label"); 113 + pub const SETTINGS_PICK_APERTURE_HINT: StringKey = StringKey::new("settings.pick_aperture.hint"); 114 + pub const SETTINGS_RESET: StringKey = StringKey::new("settings.reset"); 115 + pub const SETTINGS_CLOSE: StringKey = StringKey::new("settings.close"); 89 116 pub const SHORTCUT_QUIT: StringKey = StringKey::new("shortcut.quit"); 90 117 pub const SHORTCUT_UNDO: StringKey = StringKey::new("shortcut.undo"); 91 118 pub const SHORTCUT_REDO: StringKey = StringKey::new("shortcut.redo"); ··· 108 135 pub const PROPERTY_KIND_CIRCLE: StringKey = StringKey::new("property.kind.circle"); 109 136 pub const PROPERTY_VALUE_YES: StringKey = StringKey::new("property.value.yes"); 110 137 pub const PROPERTY_VALUE_NO: StringKey = StringKey::new("property.value.no"); 138 + pub const PROPERTY_ROW_FIRST: StringKey = StringKey::new("property.row.first"); 139 + pub const PROPERTY_ROW_SECOND: StringKey = StringKey::new("property.row.second"); 140 + pub const PROPERTY_ROW_TARGET: StringKey = StringKey::new("property.row.target"); 141 + pub const PROPERTY_ROW_POINT: StringKey = StringKey::new("property.row.point"); 142 + pub const PROPERTY_ROW_LINE: StringKey = StringKey::new("property.row.line"); 143 + pub const PROPERTY_ROW_DIM_KIND: StringKey = StringKey::new("property.row.dim.kind"); 144 + pub const PROPERTY_ROW_DIM_DRIVES: StringKey = StringKey::new("property.row.dim.drives"); 145 + pub const PROPERTY_ROW_DIM_LENGTH: StringKey = StringKey::new("property.row.dim.length"); 146 + pub const PROPERTY_ROW_DIM_DIAMETER: StringKey = StringKey::new("property.row.dim.diameter"); 147 + pub const PROPERTY_ROW_DIM_ANGLE: StringKey = StringKey::new("property.row.dim.angle"); 148 + pub const PROPERTY_KIND_DIM_LINEAR: StringKey = StringKey::new("property.kind.dim.linear"); 149 + pub const PROPERTY_KIND_DIM_RADIUS: StringKey = StringKey::new("property.kind.dim.radius"); 150 + pub const PROPERTY_KIND_DIM_DIAMETER: StringKey = StringKey::new("property.kind.dim.diameter"); 151 + pub const PROPERTY_KIND_DIM_ANGULAR: StringKey = StringKey::new("property.kind.dim.angular"); 152 + pub const PROPERTY_VALUE_DRIVING: StringKey = StringKey::new("property.value.driving"); 153 + pub const PROPERTY_VALUE_DRIVEN: StringKey = StringKey::new("property.value.driven"); 111 154 112 155 #[must_use] 113 156 pub fn make_strings(locale: Locale) -> StringTable { ··· 125 168 const EN_US: &[(StringKey, &str)] = &[ 126 169 (APP_TITLE, "Bone"), 127 170 (RIBBON_LABEL, "Ribbon"), 171 + (RIBBON_TAB_FEATURES, "Features"), 128 172 (RIBBON_TAB_SKETCH, "Sketch"), 173 + (RIBBON_TAB_SURFACES, "Surfaces"), 174 + (RIBBON_TAB_EVALUATE, "Evaluate"), 129 175 (RIBBON_GROUP_ENTITIES, "Entities"), 130 176 (RIBBON_GROUP_RELATIONS, "Relations"), 131 177 (RIBBON_GROUP_DIMENSIONS, "Dimensions"), 132 - (RIBBON_GROUP_EXIT, "Exit"), 133 - (TOOL_EXIT_SKETCH, "Exit Sketch"), 178 + (CONFIRM_ACCEPT, "Accept"), 179 + (CONFIRM_CANCEL, "Cancel"), 134 180 (TOOL_POINT, "Point"), 135 181 (TOOL_LINE, "Line"), 136 182 (TOOL_CENTERPOINT_ARC, "Centerpoint Arc"), ··· 184 230 (DIM_CONFLICT_MAKE_DRIVEN, "Make driven"), 185 231 (DIM_CONFLICT_CANCEL, "Cancel"), 186 232 (FEATURE_TREE_LABEL, "Feature Tree"), 233 + (FEATURE_HISTORY, "History"), 234 + (FEATURE_SENSORS, "Sensors"), 235 + (FEATURE_ANNOTATIONS, "Annotations"), 236 + (FEATURE_SOLID_BODIES, "Solid Bodies"), 237 + (FEATURE_MATERIAL, "Material <not specified>"), 187 238 (FEATURE_ORIGIN, "Origin"), 188 239 (FEATURE_PLANE_XY, "Front Plane"), 189 240 (FEATURE_PLANE_YZ, "Right Plane"), 190 241 (FEATURE_PLANE_ZX, "Top Plane"), 191 242 (FEATURE_SKETCH_DEFAULT, "Sketch"), 192 - (PROPERTY_PANE_LABEL, "Property Pane"), 243 + (PROPERTY_PANE_LABEL, "Property Manager"), 244 + (LEFT_PANE_LABEL, "Left Pane"), 245 + (LEFT_PANE_TAB_CONFIGURATION, "Configuration Manager"), 246 + (LEFT_PANE_TAB_DIMENSION_EXPERT, "DimXpert Manager"), 247 + (LEFT_PANE_TAB_DISPLAY, "Display Manager"), 248 + (DOC_TABS_LABEL, "Document Tabs"), 249 + (DOC_TAB_MODEL, "Model"), 193 250 (STATUS_BAR_LABEL, "Status Bar"), 194 251 (STATUS_READY, "Ready"), 195 252 (STATUS_SKETCH_ACTIVE, "Editing Sketch"), 253 + (STATUS_UNITS_MM, "MMGS"), 196 254 (MENU_BAR_LABEL, "Menu Bar"), 197 255 (MENU_FILE, "File"), 198 256 (MENU_EDIT, "Edit"), 199 257 (MENU_VIEW, "View"), 200 258 (MENU_INSERT, "Insert"), 201 259 (MENU_TOOLS, "Tools"), 260 + (MENU_SKETCH, "Sketch"), 261 + (MENU_SKETCH_EXIT, "Exit Sketch"), 202 262 (MENU_WINDOW, "Window"), 203 263 (MENU_HELP, "Help"), 204 264 (MENU_FILE_NEW, "New"), ··· 209 269 (MENU_EDIT_REDO, "Redo"), 210 270 (MENU_VIEW_ZOOM_FIT, "Zoom to Fit"), 211 271 (MENU_PLACEHOLDER_COMING_SOON, "Coming Soon"), 272 + (MENU_TOOLS_OPTIONS, "Options..."), 273 + (SETTINGS_DIALOG_TITLE, "Selection options"), 274 + (SETTINGS_PICK_APERTURE_LABEL, "Pick aperture"), 275 + ( 276 + SETTINGS_PICK_APERTURE_HINT, 277 + "How many pixels of slack the picker allows when clicking thin lines.", 278 + ), 279 + (SETTINGS_RESET, "Reset"), 280 + (SETTINGS_CLOSE, "Close"), 212 281 (SHORTCUT_QUIT, "Ctrl+Q"), 213 282 (SHORTCUT_UNDO, "Ctrl+Z"), 214 283 (SHORTCUT_REDO, "Ctrl+Y"), ··· 230 299 (PROPERTY_KIND_CIRCLE, "Circle"), 231 300 (PROPERTY_VALUE_YES, "Yes"), 232 301 (PROPERTY_VALUE_NO, "No"), 302 + (PROPERTY_ROW_FIRST, "First"), 303 + (PROPERTY_ROW_SECOND, "Second"), 304 + (PROPERTY_ROW_TARGET, "Target"), 305 + (PROPERTY_ROW_POINT, "Point"), 306 + (PROPERTY_ROW_LINE, "Line"), 307 + (PROPERTY_ROW_DIM_KIND, "Type"), 308 + (PROPERTY_ROW_DIM_DRIVES, "Drives"), 309 + (PROPERTY_ROW_DIM_LENGTH, "Length"), 310 + (PROPERTY_ROW_DIM_DIAMETER, "Diameter"), 311 + (PROPERTY_ROW_DIM_ANGLE, "Angle"), 312 + (PROPERTY_KIND_DIM_LINEAR, "Linear"), 313 + (PROPERTY_KIND_DIM_RADIUS, "Radius"), 314 + (PROPERTY_KIND_DIM_DIAMETER, "Diameter"), 315 + (PROPERTY_KIND_DIM_ANGULAR, "Angular"), 316 + (PROPERTY_VALUE_DRIVING, "Driving"), 317 + (PROPERTY_VALUE_DRIVEN, "Driven"), 233 318 ]; 234 319 235 320 const AR_XB: &[(StringKey, &str)] = &[ 236 321 (APP_TITLE, "[!! Bône !!]"), 237 322 (RIBBON_LABEL, "[!! ʁibbon !!]"), 323 + (RIBBON_TAB_FEATURES, "[!! Featûres !!]"), 238 324 (RIBBON_TAB_SKETCH, "[!! Skêtch !!]"), 325 + (RIBBON_TAB_SURFACES, "[!! Sûrfaces !!]"), 326 + (RIBBON_TAB_EVALUATE, "[!! Évaluate !!]"), 239 327 (RIBBON_GROUP_ENTITIES, "[!! Entîtîes !!]"), 240 328 (RIBBON_GROUP_RELATIONS, "[!! Relâtions !!]"), 241 329 (RIBBON_GROUP_DIMENSIONS, "[!! Dîmensions !!]"), 242 - (RIBBON_GROUP_EXIT, "[!! Êxit !!]"), 243 - (TOOL_EXIT_SKETCH, "[!! Êxit Skêtch !!]"), 330 + (CONFIRM_ACCEPT, "[!! Accêpt !!]"), 331 + (CONFIRM_CANCEL, "[!! Cancêl !!]"), 244 332 (TOOL_POINT, "[!! Pôint !!]"), 245 333 (TOOL_LINE, "[!! Lîne !!]"), 246 334 (TOOL_CENTERPOINT_ARC, "[!! Cêntrepoint Arc !!]"), ··· 300 388 (DIM_CONFLICT_MAKE_DRIVEN, "[!! Mâke drîven !!]"), 301 389 (DIM_CONFLICT_CANCEL, "[!! Cancêl !!]"), 302 390 (FEATURE_TREE_LABEL, "[!! Featûre Tree !!]"), 391 + (FEATURE_HISTORY, "[!! Hîstory !!]"), 392 + (FEATURE_SENSORS, "[!! Sênsors !!]"), 393 + (FEATURE_ANNOTATIONS, "[!! Annôtations !!]"), 394 + (FEATURE_SOLID_BODIES, "[!! Sôlid Bôdies !!]"), 395 + (FEATURE_MATERIAL, "[!! Matérial <nôt specîfied> !!]"), 303 396 (FEATURE_ORIGIN, "[!! Orîgin !!]"), 304 397 (FEATURE_PLANE_XY, "[!! Front Plàne !!]"), 305 398 (FEATURE_PLANE_YZ, "[!! Rîght Plàne !!]"), 306 399 (FEATURE_PLANE_ZX, "[!! Tôp Plàne !!]"), 307 400 (FEATURE_SKETCH_DEFAULT, "[!! Skêtch !!]"), 308 - (PROPERTY_PANE_LABEL, "[!! Propérty Pâne !!]"), 401 + (PROPERTY_PANE_LABEL, "[!! Propérty Mânager !!]"), 402 + (LEFT_PANE_LABEL, "[!! Léft Pâne !!]"), 403 + (LEFT_PANE_TAB_CONFIGURATION, "[!! Cônfig Mânager !!]"), 404 + (LEFT_PANE_TAB_DIMENSION_EXPERT, "[!! DimXpêrt Mânager !!]"), 405 + (LEFT_PANE_TAB_DISPLAY, "[!! Display Mânager !!]"), 406 + (DOC_TABS_LABEL, "[!! Dôc Tâbs !!]"), 407 + (DOC_TAB_MODEL, "[!! Môdel !!]"), 309 408 (STATUS_BAR_LABEL, "[!! Statûs Bar !!]"), 310 409 (STATUS_READY, "[!! Réady !!]"), 311 410 (STATUS_SKETCH_ACTIVE, "[!! Edîting Skêtch !!]"), 411 + (STATUS_UNITS_MM, "[!! MMGS !!]"), 312 412 (MENU_BAR_LABEL, "[!! Mênu Bâr !!]"), 313 413 (MENU_FILE, "[!! Fîle !!]"), 314 414 (MENU_EDIT, "[!! Édit !!]"), 315 415 (MENU_VIEW, "[!! Vîew !!]"), 316 416 (MENU_INSERT, "[!! Insêrt !!]"), 317 417 (MENU_TOOLS, "[!! Tôols !!]"), 418 + (MENU_SKETCH, "[!! Skêtch !!]"), 419 + (MENU_SKETCH_EXIT, "[!! Êxit Skêtch !!]"), 318 420 (MENU_WINDOW, "[!! Wîndow !!]"), 319 421 (MENU_HELP, "[!! Hêlp !!]"), 320 422 (MENU_FILE_NEW, "[!! Néw !!]"), ··· 325 427 (MENU_EDIT_REDO, "[!! Redô !!]"), 326 428 (MENU_VIEW_ZOOM_FIT, "[!! Zôom to Fît !!]"), 327 429 (MENU_PLACEHOLDER_COMING_SOON, "[!! Côming Sôon !!]"), 430 + (MENU_TOOLS_OPTIONS, "[!! Ôptions... !!]"), 431 + (SETTINGS_DIALOG_TITLE, "[!! Sêlection ôptions !!]"), 432 + (SETTINGS_PICK_APERTURE_LABEL, "[!! Pîck âperture !!]"), 433 + ( 434 + SETTINGS_PICK_APERTURE_HINT, 435 + "[!! Hôw mâny pîxels ôf slâck thê pîcker âllows whên clîcking thîn lînes. !!]", 436 + ), 437 + (SETTINGS_RESET, "[!! Resêt !!]"), 438 + (SETTINGS_CLOSE, "[!! Clôse !!]"), 328 439 (SHORTCUT_QUIT, "Ctrl+Q"), 329 440 (SHORTCUT_UNDO, "Ctrl+Z"), 330 441 (SHORTCUT_REDO, "Ctrl+Y"), ··· 346 457 (PROPERTY_KIND_CIRCLE, "[!! Cîrcle !!]"), 347 458 (PROPERTY_VALUE_YES, "[!! Yés !!]"), 348 459 (PROPERTY_VALUE_NO, "[!! Nô !!]"), 460 + (PROPERTY_ROW_FIRST, "[!! Fîrst !!]"), 461 + (PROPERTY_ROW_SECOND, "[!! Sêcond !!]"), 462 + (PROPERTY_ROW_TARGET, "[!! Târget !!]"), 463 + (PROPERTY_ROW_POINT, "[!! Pôint !!]"), 464 + (PROPERTY_ROW_LINE, "[!! Lîne !!]"), 465 + (PROPERTY_ROW_DIM_KIND, "[!! Týpe !!]"), 466 + (PROPERTY_ROW_DIM_DRIVES, "[!! Drîves !!]"), 467 + (PROPERTY_ROW_DIM_LENGTH, "[!! Léngth !!]"), 468 + (PROPERTY_ROW_DIM_DIAMETER, "[!! Dîameter !!]"), 469 + (PROPERTY_ROW_DIM_ANGLE, "[!! Ângle !!]"), 470 + (PROPERTY_KIND_DIM_LINEAR, "[!! Lînear !!]"), 471 + (PROPERTY_KIND_DIM_RADIUS, "[!! Râdius !!]"), 472 + (PROPERTY_KIND_DIM_DIAMETER, "[!! Dîameter !!]"), 473 + (PROPERTY_KIND_DIM_ANGULAR, "[!! Ângûlar !!]"), 474 + (PROPERTY_VALUE_DRIVING, "[!! Drîving !!]"), 475 + (PROPERTY_VALUE_DRIVEN, "[!! Drîven !!]"), 349 476 ];
+125 -30
crates/bone-ui/src/widgets/menu.rs
··· 8 8 use crate::widget_id::{WidgetId, WidgetKey}; 9 9 10 10 use super::keys::{TakeKey, take_key}; 11 - use super::paint::{GlyphMark, LabelText, WidgetPaint}; 11 + use bone_text::{ShapeRequest, ShapedLine}; 12 + 13 + use super::paint::{GlyphMark, HorizontalAlign, LabelText, WidgetPaint}; 12 14 use super::visuals::push_focus_ring; 13 15 14 16 #[derive(Clone, Debug, PartialEq)] ··· 135 137 rect, 136 138 fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L1), 137 139 border: Some(Border { 138 - width: StrokeWidth::HAIRLINE, 140 + width: StrokeWidth::px(1.5), 139 141 color: ctx.theme().colors.neutral.step(Step12::BORDER), 140 142 }), 141 143 radius: ctx.theme().radius.sm, ··· 307 309 let activated = (!*disabled && interaction.click()).then_some(*id); 308 310 let mut paint = 309 311 item_surface(ctx, args.rect, args.is_highlighted || interaction.hover()); 310 - paint.push(WidgetPaint::Label { 312 + paint.push(WidgetPaint::AlignedLabel { 311 313 rect: label_only_rect(args.rect, args.metrics), 312 314 text: LabelText::Key(*label), 313 315 color: if *disabled { ··· 316 318 ctx.theme().colors.text_primary() 317 319 }, 318 320 role: ctx.theme().typography.body, 321 + align: HorizontalAlign::Start, 319 322 }); 320 323 if let Some(sc) = shortcut { 321 - paint.push(WidgetPaint::Label { 324 + paint.push(WidgetPaint::AlignedLabel { 322 325 rect: shortcut_rect(args.rect, args.metrics), 323 326 text: LabelText::Key(*sc), 324 327 color: ctx.theme().colors.text_secondary(), 325 328 role: ctx.theme().typography.caption, 329 + align: HorizontalAlign::End, 326 330 }); 327 331 } 328 332 ItemDrawResult { ··· 351 355 args.rect, 352 356 args.is_highlighted || interaction.hover() || args.is_open_submenu, 353 357 ); 354 - paint.push(WidgetPaint::Label { 358 + paint.push(WidgetPaint::AlignedLabel { 355 359 rect: label_only_rect(args.rect, args.metrics), 356 360 text: LabelText::Key(*label), 357 361 color: ctx.theme().colors.text_primary(), 358 362 role: ctx.theme().typography.body, 363 + align: HorizontalAlign::Start, 359 364 }); 360 365 paint.push(WidgetPaint::Mark { 361 366 rect: arrow_rect(args.rect, args.metrics), ··· 631 636 pub label: StringKey, 632 637 pub entries: &'a [MenuBarEntry], 633 638 pub state: &'state mut MenuBarState, 634 - pub item_width: LayoutPx, 639 + pub min_item_width: LayoutPx, 635 640 pub item_padding: LayoutPx, 641 + pub trailing_label: Option<LabelText>, 636 642 } 637 643 638 644 impl<'a, 'state> MenuBar<'a, 'state> { ··· 650 656 label, 651 657 entries, 652 658 state, 653 - item_width: LayoutPx::new(56.0), 659 + min_item_width: LayoutPx::new(36.0), 654 660 item_padding: LayoutPx::new(10.0), 661 + trailing_label: None, 655 662 } 656 663 } 664 + 665 + #[must_use] 666 + pub fn with_trailing_label(mut self, label: LabelText) -> Self { 667 + self.trailing_label = Some(label); 668 + self 669 + } 657 670 } 658 671 659 672 #[derive(Clone, Debug, PartialEq)] 660 673 pub struct MenuBarResponse { 661 674 pub activated: Option<WidgetId>, 662 675 pub paint: Vec<WidgetPaint>, 676 + pub popover_paint: Vec<WidgetPaint>, 663 677 } 664 678 665 679 #[must_use] ··· 670 684 label, 671 685 entries, 672 686 state, 673 - item_width, 687 + min_item_width, 674 688 item_padding, 689 + trailing_label, 675 690 } = bar; 676 691 ctx.a11y 677 692 .push(id, rect, AccessNode::new(Role::MenuBar).with_label(label)); ··· 685 700 radius: ctx.theme().radius.none, 686 701 elevation: None, 687 702 }]; 688 - let entry_layouts = entry_rects(rect, entries, item_width); 703 + let role = ctx.theme().typography.label; 704 + let request = ShapeRequest { 705 + face: role.face, 706 + size_px: role.size.as_px_f32(), 707 + weight: role.weight, 708 + line_height_px: 0.0, 709 + letter_spacing_px: 0.0, 710 + max_width: None, 711 + }; 712 + let widths: Vec<LayoutPx> = entries 713 + .iter() 714 + .map(|e| { 715 + let resolved = ctx.strings.resolve(e.label); 716 + let advance = ctx 717 + .shaper 718 + .shape(resolved, request) 719 + .lines 720 + .first() 721 + .map_or(0.0, ShapedLine::visible_advance_px); 722 + let total = advance + 2.0 * item_padding.value(); 723 + LayoutPx::new(total.max(min_item_width.value())) 724 + }) 725 + .collect(); 726 + let entry_layouts = entry_rects(rect, &widths); 689 727 entries 690 728 .iter() 691 729 .zip(entry_layouts.iter()) ··· 698 736 state, 699 737 )); 700 738 }); 701 - let activated = open_menu_bar_dropdown(ctx, entries, &entry_layouts, state, &mut paint); 702 - MenuBarResponse { activated, paint } 739 + if let Some(label_text) = trailing_label { 740 + paint.push(trailing_label_paint( 741 + ctx, 742 + label_text, 743 + rect, 744 + request, 745 + item_padding, 746 + entry_layouts.as_slice(), 747 + )); 748 + } 749 + let mut popover_paint = Vec::new(); 750 + let activated = open_menu_bar_dropdown( 751 + ctx, 752 + entries, 753 + &entry_layouts, 754 + state, 755 + &mut popover_paint, 756 + ); 757 + MenuBarResponse { 758 + activated, 759 + paint, 760 + popover_paint, 761 + } 703 762 } 704 763 705 764 fn draw_menu_bar_entry( ··· 753 812 radius: ctx.theme().radius.sm, 754 813 elevation: None, 755 814 }, 756 - WidgetPaint::Label { 815 + WidgetPaint::AlignedLabel { 757 816 rect: LayoutRect::new( 758 817 LayoutPos::new( 759 818 LayoutPx::new(entry_rect.origin.x.value() + item_padding.value()), ··· 769 828 text: LabelText::Key(entry.label), 770 829 color: ctx.theme().colors.text_primary(), 771 830 role: ctx.theme().typography.label, 831 + align: HorizontalAlign::Start, 772 832 }, 773 833 ]; 774 834 push_focus_ring( ··· 815 875 response.activated 816 876 } 817 877 818 - fn entry_rects(bar: LayoutRect, entries: &[MenuBarEntry], item_width: LayoutPx) -> Vec<LayoutRect> { 819 - entries 878 + fn trailing_label_paint( 879 + ctx: &mut FrameCtx<'_>, 880 + label_text: LabelText, 881 + bar_rect: LayoutRect, 882 + request: ShapeRequest, 883 + item_padding: LayoutPx, 884 + entry_layouts: &[LayoutRect], 885 + ) -> WidgetPaint { 886 + let resolved = label_text.resolve(ctx.strings); 887 + let advance = ctx 888 + .shaper 889 + .shape(resolved, request) 890 + .lines 891 + .first() 892 + .map_or(0.0, ShapedLine::visible_advance_px); 893 + let bar_max_x = bar_rect.origin.x.value() + bar_rect.size.width.value(); 894 + let trailing_width = (advance + 2.0 * item_padding.value()) 895 + .min(bar_rect.size.width.value()) 896 + .max(0.0); 897 + let trailing_x = bar_max_x - trailing_width; 898 + let entries_end = entry_layouts 899 + .last() 900 + .map_or(bar_rect.origin.x.value(), |r| { 901 + r.origin.x.value() + r.size.width.value() 902 + }); 903 + let anchored_x = trailing_x.max(entries_end); 904 + let trailing_rect = LayoutRect::new( 905 + LayoutPos::new(LayoutPx::saturating(anchored_x), bar_rect.origin.y), 906 + LayoutSize::new( 907 + LayoutPx::saturating_nonneg(bar_max_x - anchored_x), 908 + bar_rect.size.height, 909 + ), 910 + ); 911 + WidgetPaint::AlignedLabel { 912 + rect: trailing_rect, 913 + text: label_text, 914 + color: ctx.theme().colors.text_primary(), 915 + role: ctx.theme().typography.label, 916 + align: HorizontalAlign::Center, 917 + } 918 + } 919 + 920 + fn entry_rects(bar: LayoutRect, widths: &[LayoutPx]) -> Vec<LayoutRect> { 921 + widths 820 922 .iter() 821 - .enumerate() 822 - .map(|(idx, _)| { 823 - #[allow( 824 - clippy::cast_precision_loss, 825 - reason = "menu bar entries fit in f32 mantissa" 826 - )] 827 - let i = idx as f32; 828 - LayoutRect::new( 829 - LayoutPos::new( 830 - LayoutPx::new(bar.origin.x.value() + i * item_width.value()), 831 - bar.origin.y, 832 - ), 833 - LayoutSize::new(item_width, bar.size.height), 834 - ) 923 + .scan(bar.origin.x.value(), |x, w| { 924 + let rect = LayoutRect::new( 925 + LayoutPos::new(LayoutPx::new(*x), bar.origin.y), 926 + LayoutSize::new(*w, bar.size.height), 927 + ); 928 + *x += w.value(); 929 + Some(rect) 835 930 }) 836 931 .collect() 837 932 } ··· 1075 1170 let table = HotkeyTable::new(); 1076 1171 let mut focus = FocusManager::new(); 1077 1172 let mut prev = HitState::new(); 1078 - let click_pos = LayoutPos::new(LayoutPx::new(80.0), LayoutPx::new(10.0)); 1173 + let click_pos = LayoutPos::new(LayoutPx::new(200.0), LayoutPx::new(10.0)); 1079 1174 let bar_rect = LayoutRect::new( 1080 1175 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 1081 - LayoutSize::new(LayoutPx::new(400.0), LayoutPx::new(24.0)), 1176 + LayoutSize::new(LayoutPx::new(600.0), LayoutPx::new(24.0)), 1082 1177 ); 1083 1178 [press(click_pos), release(click_pos), idle(click_pos)] 1084 1179 .into_iter()
+5 -2
crates/bone-ui/src/widgets/mod.rs
··· 49 49 MenuResponse, MenuState, show_context_menu, show_menu, show_menu_bar, 50 50 }; 51 51 pub use numeric_input::{NumericFloatParseError, NumericInput, NumericInputResponse}; 52 - pub use paint::{ButtonPaintKind, GlyphMark, LabelText, PaintPrim, WidgetPaint, lower_paint}; 52 + pub use paint::{ 53 + ButtonPaintKind, GlyphMark, HorizontalAlign, LabelText, PaintPrim, WidgetPaint, 54 + estimate_label_width_px, lower_paint, 55 + }; 53 56 pub use panel::{Panel, PanelResponse, PanelState, PanelTitlebar, PanelVariant, show_panel}; 54 57 pub use parsed_input::{ParsedInput, ParsedInputResponse, ParsedValue, show_parsed_input}; 55 58 pub use property_grid::{ ··· 61 64 RadioGroup, RadioGroupResponse, RadioOption, RadioOrientation, show_radio_group, 62 65 }; 63 66 pub use ribbon::{ 64 - Ribbon, RibbonGroup, RibbonIconSize, RibbonResponse, RibbonState, RibbonTab, show_ribbon, 67 + Ribbon, RibbonGroup, RibbonIconSize, RibbonResponse, RibbonTab, show_ribbon, 65 68 }; 66 69 pub use slider::{ 67 70 Slider, SliderCoarseStep, SliderRange, SliderRangeError, SliderResponse, SliderScalar,
+45 -1
crates/bone-ui/src/widgets/paint.rs
··· 32 32 SortAscending, 33 33 SortDescending, 34 34 Ellipsis, 35 + TreeFeature, 36 + TreePlane, 37 + TreeSketch, 38 + TabTree, 39 + TabProperties, 40 + TabConfiguration, 41 + TabDimensionExpert, 42 + TabDisplay, 35 43 } 36 44 37 45 impl GlyphMark { ··· 47 55 Self::Close => "\u{00D7}", 48 56 Self::Spinner => "\u{25D0}", 49 57 Self::Ellipsis => "\u{2026}", 58 + Self::TreeFeature => "\u{25CB}", 59 + Self::TreePlane => "\u{25C7}", 60 + Self::TreeSketch => "\u{25A1}", 61 + Self::TabTree => "\u{25A6}", 62 + Self::TabProperties => "\u{25A4}", 63 + Self::TabConfiguration => "\u{25A5}", 64 + Self::TabDimensionExpert => "\u{25C8}", 65 + Self::TabDisplay => "\u{25D1}", 50 66 } 51 67 } 52 68 } ··· 67 83 } 68 84 } 69 85 86 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize)] 87 + pub enum HorizontalAlign { 88 + #[default] 89 + Center, 90 + Start, 91 + End, 92 + } 93 + 70 94 #[derive(Clone, Debug, PartialEq, Serialize)] 71 95 pub enum WidgetPaint { 72 96 Surface { ··· 82 106 color: Color, 83 107 role: TypographyRole, 84 108 }, 109 + AlignedLabel { 110 + rect: LayoutRect, 111 + text: LabelText, 112 + color: Color, 113 + role: TypographyRole, 114 + align: HorizontalAlign, 115 + }, 85 116 Mark { 86 117 rect: LayoutRect, 87 118 kind: GlyphMark, ··· 147 178 border: *border, 148 179 radius: *radius, 149 180 }, 150 - WidgetPaint::Label { rect, color, .. } => PaintPrim::solid( 181 + WidgetPaint::Label { rect, color, .. } 182 + | WidgetPaint::AlignedLabel { rect, color, .. } => PaintPrim::solid( 151 183 label_placeholder_bar(*rect), 152 184 color.with_alpha(LABEL_PLACEHOLDER_ALPHA * color.alpha()), 153 185 ), ··· 198 230 LayoutPx::saturating_nonneg(height), 199 231 ), 200 232 ) 233 + } 234 + 235 + const LABEL_AVG_ADVANCE_RATIO: f32 = 0.6; 236 + 237 + #[must_use] 238 + pub fn estimate_label_width_px(text: &str, font_size_px: f32, horizontal_padding_px: f32) -> f32 { 239 + #[allow( 240 + clippy::cast_precision_loss, 241 + reason = "string lengths fit in f32 mantissa for any realistic label" 242 + )] 243 + let chars = text.chars().count() as f32; 244 + chars * font_size_px * LABEL_AVG_ADVANCE_RATIO + 2.0 * horizontal_padding_px 201 245 } 202 246 203 247 fn centered_square(rect: LayoutRect, factor: f32) -> LayoutRect {
+101 -97
crates/bone-ui/src/widgets/ribbon.rs
··· 1 - use std::collections::BTreeMap; 2 - 3 1 use crate::a11y::{AccessNode, Role}; 4 2 use crate::frame::FrameCtx; 5 3 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; ··· 7 5 use crate::theme::{Border, Step12, StrokeWidth}; 8 6 use crate::widget_id::{WidgetId, WidgetKey}; 9 7 10 - use super::paint::{LabelText, WidgetPaint}; 8 + use super::paint::{LabelText, WidgetPaint, estimate_label_width_px}; 11 9 use super::tabs::{Tab, Tabs, TabsOrientation, show_tabs}; 12 10 use super::toolbar::{Toolbar, ToolbarItem, show_toolbar}; 13 11 ··· 71 69 } 72 70 } 73 71 74 - #[derive(Clone, Debug, Default, PartialEq, Eq)] 75 - pub struct RibbonState { 76 - pub overflow: BTreeMap<WidgetId, bool>, 77 - } 78 - 79 - #[derive(Debug, PartialEq)] 80 - pub struct Ribbon<'a, 'state> { 72 + #[derive(Copy, Clone, Debug, PartialEq)] 73 + pub struct Ribbon<'a> { 81 74 pub id: WidgetId, 82 75 pub rect: LayoutRect, 83 76 pub label: StringKey, 84 77 pub tabs: &'a [RibbonTab], 85 78 pub active: WidgetId, 86 - pub state: &'state mut RibbonState, 87 79 pub tab_strip_height: LayoutPx, 88 80 pub group_label_height: LayoutPx, 89 81 pub group_gap: LayoutPx, 90 82 pub group_padding: LayoutPx, 91 83 } 92 84 93 - impl<'a, 'state> Ribbon<'a, 'state> { 85 + impl<'a> Ribbon<'a> { 94 86 #[must_use] 95 87 pub const fn new( 96 88 id: WidgetId, ··· 98 90 label: StringKey, 99 91 tabs: &'a [RibbonTab], 100 92 active: WidgetId, 101 - state: &'state mut RibbonState, 102 93 ) -> Self { 103 94 Self { 104 95 id, ··· 106 97 label, 107 98 tabs, 108 99 active, 109 - state, 110 100 tab_strip_height: LayoutPx::new(28.0), 111 101 group_label_height: LayoutPx::new(16.0), 112 102 group_gap: LayoutPx::new(8.0), ··· 124 114 } 125 115 126 116 #[must_use] 127 - pub fn show_ribbon(ctx: &mut FrameCtx<'_>, ribbon: Ribbon<'_, '_>) -> RibbonResponse { 117 + pub fn show_ribbon(ctx: &mut FrameCtx<'_>, ribbon: Ribbon<'_>) -> RibbonResponse { 128 118 let Ribbon { 129 119 id, 130 120 rect, 131 121 label, 132 122 tabs, 133 123 active, 134 - state, 135 124 tab_strip_height, 136 125 group_label_height, 137 126 group_gap, 138 127 group_padding, 139 128 } = ribbon; 140 - let strip_rect = LayoutRect::new( 129 + let body_height = LayoutPx::saturating_nonneg(rect.size.height.value() - tab_strip_height.value()); 130 + let body_rect = LayoutRect::new( 141 131 rect.origin, 142 - LayoutSize::new(rect.size.width, tab_strip_height), 132 + LayoutSize::new(rect.size.width, body_height), 143 133 ); 144 - let body_rect = LayoutRect::new( 134 + let strip_rect = LayoutRect::new( 145 135 LayoutPos::new( 146 136 rect.origin.x, 147 - LayoutPx::new(rect.origin.y.value() + tab_strip_height.value()), 137 + LayoutPx::new(rect.origin.y.value() + body_height.value()), 148 138 ), 149 - LayoutSize::new( 150 - rect.size.width, 151 - LayoutPx::saturating_nonneg(rect.size.height.value() - tab_strip_height.value()), 152 - ), 139 + LayoutSize::new(rect.size.width, tab_strip_height), 153 140 ); 154 - let tab_views: Vec<Tab> = build_tab_strip(tabs, strip_rect); 141 + let label_font_px = ctx.theme().typography.label.size.as_px_f32(); 142 + let tab_views: Vec<Tab> = build_tab_strip(ctx, tabs, strip_rect, label_font_px); 155 143 ctx.a11y 156 144 .push(id, rect, AccessNode::new(Role::TabPanel).with_label(label)); 157 145 let mut paint = vec![WidgetPaint::Surface { ··· 168 156 ctx, 169 157 Tabs::new( 170 158 id.child(WidgetKey::new("tabs")), 171 - TabsOrientation::Top, 159 + TabsOrientation::Bottom, 172 160 label, 173 161 tab_views.as_slice(), 174 162 active, ··· 179 167 if let Some(active_tab) = tabs.iter().find(|t| t.id == active) { 180 168 let groups_paint = render_groups( 181 169 ctx, 182 - GroupsArgs { 183 - groups: &active_tab.groups, 170 + &active_tab.groups, 171 + GroupLayout { 184 172 body_rect, 185 173 group_label_height, 186 174 group_gap, 187 175 group_padding, 188 - state, 189 176 }, 190 177 &mut activated_tool, 191 178 ); 192 179 paint.extend(groups_paint); 193 180 } 194 - prune_overflow(state, tabs); 195 181 RibbonResponse { 196 182 activated_tab: tabs_response.activated, 197 183 closed_tab: tabs_response.closed, ··· 200 186 } 201 187 } 202 188 203 - fn prune_overflow(state: &mut RibbonState, tabs: &[RibbonTab]) { 204 - let live: std::collections::BTreeSet<WidgetId> = tabs 205 - .iter() 206 - .flat_map(|t| t.groups.iter().map(|g| g.id)) 207 - .collect(); 208 - state.overflow.retain(|k, _| live.contains(k)); 209 - } 189 + const RIBBON_TAB_PADDING_PX: f32 = 14.0; 210 190 211 - fn build_tab_strip(tabs: &[RibbonTab], strip_rect: LayoutRect) -> Vec<Tab> { 212 - #[allow( 213 - clippy::cast_precision_loss, 214 - reason = "ribbon tab counts fit in f32 mantissa" 215 - )] 216 - let denom = tabs.len().max(1) as f32; 217 - let stride = strip_rect.size.width.value() / denom; 191 + fn build_tab_strip( 192 + ctx: &FrameCtx<'_>, 193 + tabs: &[RibbonTab], 194 + strip_rect: LayoutRect, 195 + label_font_px: f32, 196 + ) -> Vec<Tab> { 197 + let strip_max_x = strip_rect.origin.x.value() + strip_rect.size.width.value(); 218 198 tabs.iter() 219 - .enumerate() 220 - .map(|(idx, t)| { 221 - #[allow( 222 - clippy::cast_precision_loss, 223 - reason = "ribbon tab index fits in f32 mantissa" 224 - )] 225 - let i = idx as f32; 199 + .scan(strip_rect.origin.x.value(), |x, t| { 200 + if *x >= strip_max_x { 201 + return None; 202 + } 203 + let resolved = ctx.strings.resolve(t.label); 204 + let desired = estimate_label_width_px(resolved, label_font_px, RIBBON_TAB_PADDING_PX); 205 + let width = desired.min(strip_max_x - *x).max(0.0); 226 206 let rect = LayoutRect::new( 227 - LayoutPos::new( 228 - LayoutPx::new(strip_rect.origin.x.value() + i * stride), 229 - strip_rect.origin.y, 230 - ), 231 - LayoutSize::new(LayoutPx::new(stride), strip_rect.size.height), 207 + LayoutPos::new(LayoutPx::new(*x), strip_rect.origin.y), 208 + LayoutSize::new(LayoutPx::new(width), strip_rect.size.height), 232 209 ); 233 - Tab::new(t.id, rect, t.label) 234 - .closable(t.closable) 235 - .disabled(t.disabled) 210 + *x += width; 211 + Some( 212 + Tab::new(t.id, rect, t.label) 213 + .closable(t.closable) 214 + .disabled(t.disabled), 215 + ) 236 216 }) 237 217 .collect() 238 218 } 239 219 240 - struct GroupsArgs<'a, 'state> { 241 - groups: &'a [RibbonGroup], 220 + #[derive(Copy, Clone)] 221 + struct GroupLayout { 242 222 body_rect: LayoutRect, 243 223 group_label_height: LayoutPx, 244 224 group_gap: LayoutPx, 245 225 group_padding: LayoutPx, 246 - state: &'state mut RibbonState, 247 226 } 248 227 249 228 fn render_groups( 250 229 ctx: &mut FrameCtx<'_>, 251 - args: GroupsArgs<'_, '_>, 230 + groups: &[RibbonGroup], 231 + layout: GroupLayout, 252 232 activated_tool: &mut Option<WidgetId>, 253 233 ) -> Vec<WidgetPaint> { 254 - let GroupsArgs { 255 - groups, 234 + let GroupLayout { 256 235 body_rect, 257 236 group_label_height, 258 237 group_gap, 259 238 group_padding, 260 - state, 261 - } = args; 239 + } = layout; 262 240 let mut paint = Vec::new(); 263 241 let layouts = group_rects(body_rect, groups, group_gap); 242 + paint.extend(group_dividers(&layouts, body_rect, group_gap, ctx)); 264 243 groups 265 244 .iter() 266 245 .zip(layouts.iter()) ··· 270 249 *group_rect, 271 250 AccessNode::new(Role::Group).with_label(group.label), 272 251 ); 273 - paint.push(WidgetPaint::Surface { 274 - rect: *group_rect, 275 - fill: ctx.theme().colors.surface(crate::theme::SurfaceLevel::L1), 276 - border: Some(Border { 277 - width: StrokeWidth::HAIRLINE, 278 - color: ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER), 279 - }), 280 - radius: ctx.theme().radius.sm, 281 - elevation: None, 282 - }); 283 252 let toolbar_rect = inner_toolbar_rect(*group_rect, group_label_height, group_padding); 284 - let entry = state.overflow.entry(group.id).or_insert(false); 285 253 let response = show_toolbar( 286 254 ctx, 287 255 Toolbar::horizontal( ··· 292 260 group.icon_size.item_px(), 293 261 LayoutPx::new(4.0), 294 262 ), 295 - entry, 296 263 ); 297 264 paint.extend(response.paint); 298 265 if let Some(activated) = response.activated ··· 310 277 paint 311 278 } 312 279 280 + fn group_dividers( 281 + layouts: &[LayoutRect], 282 + body: LayoutRect, 283 + gap: LayoutPx, 284 + ctx: &FrameCtx<'_>, 285 + ) -> Vec<WidgetPaint> { 286 + let thickness = StrokeWidth::HAIRLINE.value_px(); 287 + let color = ctx.theme().colors.neutral.step(Step12::SUBTLE_BORDER); 288 + let inset_y = body.size.height.value() * 0.15; 289 + layouts 290 + .iter() 291 + .take(layouts.len().saturating_sub(1)) 292 + .map(|rect| { 293 + let x = rect.origin.x.value() + rect.size.width.value() + gap.value() * 0.5 294 + - thickness * 0.5; 295 + LayoutRect::new( 296 + LayoutPos::new( 297 + LayoutPx::new(x), 298 + LayoutPx::new(body.origin.y.value() + inset_y), 299 + ), 300 + LayoutSize::new( 301 + LayoutPx::new(thickness), 302 + LayoutPx::saturating_nonneg(body.size.height.value() - 2.0 * inset_y), 303 + ), 304 + ) 305 + }) 306 + .map(|rect| WidgetPaint::Surface { 307 + rect, 308 + fill: color, 309 + border: None, 310 + radius: ctx.theme().radius.none, 311 + elevation: None, 312 + }) 313 + .collect() 314 + } 315 + 313 316 fn group_rects(body: LayoutRect, groups: &[RibbonGroup], gap: LayoutPx) -> Vec<LayoutRect> { 314 317 let n = groups.len(); 315 318 if n == 0 { ··· 396 399 mod tests { 397 400 use std::sync::Arc; 398 401 399 - use super::{Ribbon, RibbonGroup, RibbonIconSize, RibbonState, RibbonTab, show_ribbon}; 402 + use super::{Ribbon, RibbonGroup, RibbonIconSize, RibbonTab, show_ribbon}; 400 403 use crate::focus::FocusManager; 401 404 use crate::frame::FrameCtx; 402 405 use crate::hit_test::{HitFrame, HitState, resolve}; ··· 574 577 fn render( 575 578 tabs: &[RibbonTab], 576 579 active: WidgetId, 577 - state: &mut RibbonState, 578 580 focus: &mut FocusManager, 579 581 snap: &mut InputSnapshot, 580 582 prev: &HitState, ··· 608 610 StringKey::new("test.ribbon"), 609 611 tabs, 610 612 active, 611 - state, 612 613 ), 613 614 ) 614 615 }; ··· 619 620 #[test] 620 621 fn switching_tabs_emits_activated_tab() { 621 622 let tabs = vec![make_ribbon_tab("home"), make_ribbon_tab("sketch")]; 622 - let mut state = RibbonState::default(); 623 623 let mut focus = FocusManager::new(); 624 624 let mut prev = HitState::new(); 625 - let click_pos = LayoutPos::new(LayoutPx::new(500.0), LayoutPx::new(14.0)); 625 + let click_pos = LayoutPos::new(LayoutPx::new(150.0), LayoutPx::new(105.0)); 626 626 let mut last: Option<super::RibbonResponse> = None; 627 627 [press(click_pos), release(click_pos), idle(click_pos)] 628 628 .into_iter() 629 629 .for_each(|mut snap| { 630 630 let (response, next) = 631 - render(&tabs, tabs[0].id, &mut state, &mut focus, &mut snap, &prev); 631 + render(&tabs, tabs[0].id, &mut focus, &mut snap, &prev); 632 632 last = Some(response); 633 633 prev = next; 634 634 }); ··· 641 641 #[test] 642 642 fn click_tool_in_active_tab_emits_activated_tool() { 643 643 let tabs = vec![make_ribbon_tab("home")]; 644 - let mut state = RibbonState::default(); 645 644 let mut focus = FocusManager::new(); 646 645 let mut prev = HitState::new(); 647 646 let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(60.0)); ··· 650 649 .into_iter() 651 650 .for_each(|mut snap| { 652 651 let (response, next) = 653 - render(&tabs, tabs[0].id, &mut state, &mut focus, &mut snap, &prev); 652 + render(&tabs, tabs[0].id, &mut focus, &mut snap, &prev); 654 653 last = Some(response); 655 654 prev = next; 656 655 }); ··· 665 664 let make_closable = 666 665 |name: &'static str| -> RibbonTab { make_ribbon_tab(name).closable(true) }; 667 666 let tabs = vec![make_closable("home"), make_closable("sketch")]; 668 - let mut state = RibbonState::default(); 669 667 let mut focus = FocusManager::new(); 670 668 let mut prev = HitState::new(); 671 - let strip_w = 800.0; 672 - let stride = strip_w / 2.0; 673 - let close_x = stride + stride - 14.0 - (28.0 - 14.0) / 2.0; 674 - let close_pos = LayoutPos::new(LayoutPx::new(close_x), LayoutPx::new(14.0)); 669 + let label_font_px = 12.0_f32; 670 + let tab_width = super::estimate_label_width_px( 671 + "ribbon.tab", 672 + label_font_px, 673 + super::RIBBON_TAB_PADDING_PX, 674 + ); 675 + let close_pad = (28.0 - 14.0) / 2.0; 676 + let close_x = tab_width + tab_width - 14.0 - close_pad; 677 + let strip_top = 120.0 - 28.0; 678 + let close_y = strip_top + 28.0 / 2.0; 679 + let close_pos = LayoutPos::new(LayoutPx::new(close_x), LayoutPx::new(close_y)); 675 680 let mut last: Option<super::RibbonResponse> = None; 676 681 [press(close_pos), release(close_pos), idle(close_pos)] 677 682 .into_iter() 678 683 .for_each(|mut snap| { 679 684 let (response, next) = 680 - render(&tabs, tabs[0].id, &mut state, &mut focus, &mut snap, &prev); 685 + render(&tabs, tabs[0].id, &mut focus, &mut snap, &prev); 681 686 last = Some(response); 682 687 prev = next; 683 688 }); ··· 693 698 use crate::input::{KeyCode, KeyEvent, ModifierMask, NamedKey}; 694 699 695 700 let tabs = vec![make_ribbon_tab("home"), make_ribbon_tab("sketch")]; 696 - let mut state = RibbonState::default(); 697 701 let mut focus = FocusManager::new(); 698 702 let prev = HitState::new(); 699 703 focus.request_focus(tabs[0].id); 700 704 let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 701 - let _ = render(&tabs, tabs[0].id, &mut state, &mut focus, &mut warm, &prev); 705 + let _ = render(&tabs, tabs[0].id, &mut focus, &mut warm, &prev); 702 706 assert_eq!(focus.focused(), Some(tabs[0].id)); 703 707 704 708 let mut arrow = InputSnapshot::idle(FrameInstant::ZERO); ··· 706 710 KeyCode::Named(NamedKey::ArrowRight), 707 711 ModifierMask::NONE, 708 712 )); 709 - let _ = render(&tabs, tabs[0].id, &mut state, &mut focus, &mut arrow, &prev); 713 + let _ = render(&tabs, tabs[0].id, &mut focus, &mut arrow, &prev); 710 714 assert_eq!(focus.focused(), Some(tabs[1].id)); 711 715 712 716 let mut enter = InputSnapshot::idle(FrameInstant::ZERO); ··· 714 718 KeyCode::Named(NamedKey::Enter), 715 719 ModifierMask::NONE, 716 720 )); 717 - let (response, _) = render(&tabs, tabs[0].id, &mut state, &mut focus, &mut enter, &prev); 721 + let (response, _) = render(&tabs, tabs[0].id, &mut focus, &mut enter, &prev); 718 722 assert_eq!(response.activated_tab, Some(tabs[1].id)); 719 723 } 720 724
+14 -9
crates/bone-ui/src/widgets/status_bar.rs
··· 17 17 End, 18 18 } 19 19 20 - #[derive(Copy, Clone, Debug, PartialEq)] 20 + #[derive(Clone, Debug, PartialEq)] 21 21 pub struct StatusItem { 22 22 pub id: WidgetId, 23 - pub label: StringKey, 23 + pub label: LabelText, 24 24 pub align: StatusAlign, 25 25 pub width: LayoutPx, 26 26 pub interactive: bool, ··· 29 29 30 30 impl StatusItem { 31 31 #[must_use] 32 - pub const fn new(id: WidgetId, label: StringKey, align: StatusAlign, width: LayoutPx) -> Self { 32 + pub fn new(id: WidgetId, label: StringKey, align: StatusAlign, width: LayoutPx) -> Self { 33 + Self::with_text(id, LabelText::Key(label), align, width) 34 + } 35 + 36 + #[must_use] 37 + pub fn with_text(id: WidgetId, label: LabelText, align: StatusAlign, width: LayoutPx) -> Self { 33 38 Self { 34 39 id, 35 40 label, ··· 41 46 } 42 47 43 48 #[must_use] 44 - pub const fn interactive(self, interactive: bool) -> Self { 49 + pub fn interactive(self, interactive: bool) -> Self { 45 50 Self { 46 51 interactive, 47 52 ..self ··· 49 54 } 50 55 51 56 #[must_use] 52 - pub const fn badge(self, color: Color) -> Self { 57 + pub fn badge(self, color: Color) -> Self { 53 58 Self { 54 59 badge: Some(color), 55 60 ..self ··· 128 133 let interaction = ctx.interact( 129 134 InteractDeclaration::new(item.id, rect, Sense::INTERACTIVE) 130 135 .focusable(true) 131 - .a11y(AccessNode::new(Role::Button).with_label(item.label)), 136 + .a11y(AccessNode::new(Role::Button).with_label_text(item.label.clone())), 132 137 ); 133 138 let focused = ctx.is_focused(item.id); 134 139 let activated_via_pointer = interaction.click(); ··· 165 170 } 166 171 paint.push(WidgetPaint::Label { 167 172 rect: label_rect(rect, item.badge.is_some()), 168 - text: LabelText::Key(item.label), 169 - color: ctx.theme().colors.text_secondary(), 170 - role: ctx.theme().typography.caption, 173 + text: item.label.clone(), 174 + color: ctx.theme().colors.text_primary(), 175 + role: ctx.theme().typography.label, 171 176 }); 172 177 push_focus_ring(ctx, &mut paint, rect, ctx.theme().radius.none, live_focused); 173 178 paint
+82 -23
crates/bone-ui/src/widgets/tabs.rs
··· 4 4 use crate::input::{KeyCode, NamedKey}; 5 5 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 6 6 use crate::strings::StringKey; 7 - use crate::theme::{Border, Color, Step12, StrokeWidth}; 7 + use crate::theme::{Color, Step12}; 8 8 use crate::widget_id::{WidgetId, WidgetKey}; 9 9 10 10 use super::keys::{TakeKey, take_key}; ··· 14 14 #[derive(Copy, Clone, Debug, PartialEq, Eq)] 15 15 pub enum TabsOrientation { 16 16 Top, 17 + Bottom, 17 18 Side, 18 19 } 19 20 ··· 24 25 pub label: StringKey, 25 26 pub disabled: bool, 26 27 pub closable: bool, 28 + pub glyph: Option<GlyphMark>, 27 29 } 28 30 29 31 impl Tab { ··· 35 37 label, 36 38 disabled: false, 37 39 closable: false, 40 + glyph: None, 38 41 } 39 42 } 40 43 ··· 46 49 #[must_use] 47 50 pub const fn closable(self, closable: bool) -> Self { 48 51 Self { closable, ..self } 52 + } 53 + 54 + #[must_use] 55 + pub const fn with_glyph(self, glyph: GlyphMark) -> Self { 56 + Self { 57 + glyph: Some(glyph), 58 + ..self 59 + } 49 60 } 50 61 } 51 62 ··· 115 126 let mut paint = Vec::new(); 116 127 let folded = items 117 128 .iter() 118 - .map(|tab| draw_tab(ctx, tabs_id, tab, tab.id == active && active_present)) 129 + .map(|tab| draw_tab(ctx, tabs_id, tab, tab.id == active && active_present, orientation)) 119 130 .fold( 120 131 (None::<WidgetId>, None::<WidgetId>), 121 132 |(activated, closed), per_tab| { ··· 148 159 paint: Vec<WidgetPaint>, 149 160 } 150 161 151 - fn draw_tab(ctx: &mut FrameCtx<'_>, tabs_id: WidgetId, tab: &Tab, is_active: bool) -> PerTab { 162 + fn draw_tab( 163 + ctx: &mut FrameCtx<'_>, 164 + tabs_id: WidgetId, 165 + tab: &Tab, 166 + is_active: bool, 167 + orientation: TabsOrientation, 168 + ) -> PerTab { 152 169 let interactive = !tab.disabled; 153 170 let interaction = ctx.interact( 154 171 InteractDeclaration::new(tab.id, tab.rect, Sense::INTERACTIVE) ··· 167 184 } 168 185 let live_focused = ctx.is_focused(tab.id); 169 186 let mut paint = Vec::new(); 170 - paint.extend(tab_surface_paint(ctx, tab.rect, is_active, interaction)); 171 - paint.push(WidgetPaint::Label { 172 - rect: label_rect(tab.rect, tab.closable), 173 - text: LabelText::Key(tab.label), 174 - color: tab_label_color(ctx, is_active, tab.disabled), 175 - role: ctx.theme().typography.label, 176 - }); 187 + paint.extend(tab_surface_paint(ctx, tab.rect, is_active, interaction, orientation)); 188 + let label_color = tab_label_color(ctx, is_active, tab.disabled); 189 + if let Some(glyph) = tab.glyph { 190 + paint.push(WidgetPaint::Mark { 191 + rect: label_rect(tab.rect, tab.closable), 192 + kind: glyph, 193 + color: label_color, 194 + }); 195 + } else { 196 + paint.push(WidgetPaint::Label { 197 + rect: label_rect(tab.rect, tab.closable), 198 + text: LabelText::Key(tab.label), 199 + color: label_color, 200 + role: ctx.theme().typography.label, 201 + }); 202 + } 177 203 let mut closed = None; 178 204 let close_id = tabs_id.child_indexed(WidgetKey::new("close"), tab_close_index(tab.id)); 179 205 if tab.closable { ··· 257 283 rect: LayoutRect, 258 284 active: bool, 259 285 interaction: Interaction, 286 + orientation: TabsOrientation, 260 287 ) -> Vec<WidgetPaint> { 261 288 let neutral = ctx.theme().colors.neutral; 262 289 let accent = ctx.theme().colors.accent; 263 290 let fill = if interaction.disabled() { 264 - neutral.step(Step12::SUBTLE_BG) 265 - } else if active { 266 - neutral.step(Step12::APP_BG) 291 + Color::TRANSPARENT 267 292 } else if interaction.pressed() { 268 293 neutral.step(Step12::SELECTED_BG) 269 294 } else if interaction.hover() { 270 295 neutral.step(Step12::HOVER_BG) 271 296 } else { 272 - neutral.step(Step12::SUBTLE_BG) 297 + Color::TRANSPARENT 273 298 }; 274 - let border = active.then_some(Border { 275 - width: StrokeWidth::HAIRLINE, 276 - color: accent.step(Step12::SOLID), 277 - }); 278 - vec![WidgetPaint::Surface { 299 + let mut paints = vec![WidgetPaint::Surface { 279 300 rect, 280 301 fill, 281 - border, 282 - radius: ctx.theme().radius.sm, 302 + border: None, 303 + radius: ctx.theme().radius.none, 283 304 elevation: None, 284 - }] 305 + }]; 306 + if active { 307 + paints.push(WidgetPaint::Surface { 308 + rect: tab_underline_rect(rect, orientation), 309 + fill: accent.step(Step12::SOLID), 310 + border: None, 311 + radius: ctx.theme().radius.none, 312 + elevation: None, 313 + }); 314 + } 315 + paints 316 + } 317 + 318 + const TAB_UNDERLINE_THICKNESS_PX: f32 = 2.0; 319 + 320 + fn tab_underline_rect(rect: LayoutRect, orientation: TabsOrientation) -> LayoutRect { 321 + let thickness = LayoutPx::new(TAB_UNDERLINE_THICKNESS_PX); 322 + match orientation { 323 + TabsOrientation::Top => LayoutRect::new( 324 + LayoutPos::new( 325 + rect.origin.x, 326 + LayoutPx::new(rect.origin.y.value() + rect.size.height.value() - thickness.value()), 327 + ), 328 + LayoutSize::new(rect.size.width, thickness), 329 + ), 330 + TabsOrientation::Bottom => LayoutRect::new( 331 + rect.origin, 332 + LayoutSize::new(rect.size.width, thickness), 333 + ), 334 + TabsOrientation::Side => LayoutRect::new( 335 + LayoutPos::new( 336 + LayoutPx::new(rect.origin.x.value() + rect.size.width.value() - thickness.value()), 337 + rect.origin.y, 338 + ), 339 + LayoutSize::new(thickness, rect.size.height), 340 + ), 341 + } 285 342 } 286 343 287 344 fn tab_label_color(ctx: &FrameCtx<'_>, active: bool, disabled: bool) -> Color { ··· 300 357 orientation: TabsOrientation, 301 358 ) -> Option<WidgetId> { 302 359 let (prev, next) = match orientation { 303 - TabsOrientation::Top => (NamedKey::ArrowLeft, NamedKey::ArrowRight), 360 + TabsOrientation::Top | TabsOrientation::Bottom => { 361 + (NamedKey::ArrowLeft, NamedKey::ArrowRight) 362 + } 304 363 TabsOrientation::Side => (NamedKey::ArrowUp, NamedKey::ArrowDown), 305 364 }; 306 365 let event = take_key(
+16 -210
crates/bone-ui/src/widgets/toolbar.rs
··· 3 3 use crate::hit_test::Sense; 4 4 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 5 5 use crate::strings::StringKey; 6 - use crate::theme::{Border, Step12, StrokeWidth}; 7 - use crate::widget_id::{WidgetId, WidgetKey}; 6 + use crate::theme::Step12; 7 + use crate::widget_id::WidgetId; 8 8 9 - use super::keys::{TakeKey, take_activation, take_key}; 10 - use super::paint::{GlyphMark, LabelText, WidgetPaint}; 9 + use super::keys::{TakeKey, take_key}; 10 + use super::paint::{LabelText, WidgetPaint}; 11 11 use super::visuals::push_focus_ring; 12 12 13 13 #[derive(Copy, Clone, Debug, PartialEq)] ··· 122 122 #[derive(Clone, Debug, PartialEq)] 123 123 pub struct ToolbarResponse { 124 124 pub activated: Option<WidgetId>, 125 - pub overflow_open: bool, 126 125 pub visible_count: usize, 127 - pub overflow: Vec<WidgetId>, 128 126 pub paint: Vec<WidgetPaint>, 129 127 } 130 128 131 129 #[must_use] 132 - pub fn show_toolbar( 133 - ctx: &mut FrameCtx<'_>, 134 - toolbar: Toolbar<'_>, 135 - overflow_open: &mut bool, 136 - ) -> ToolbarResponse { 130 + pub fn show_toolbar(ctx: &mut FrameCtx<'_>, toolbar: Toolbar<'_>) -> ToolbarResponse { 137 131 let Toolbar { 138 132 id, 139 133 rect, ··· 145 139 } = toolbar; 146 140 let layout = layout_items(rect, items, item_size, item_gap, orientation); 147 141 let visible_count = layout.len(); 148 - let needs_overflow = visible_count < items.len(); 149 142 ctx.a11y 150 143 .push(id, rect, AccessNode::new(Role::Toolbar).with_label(label)); 151 144 let mut paint = Vec::new(); ··· 161 154 activated = Some(item.id); 162 155 } 163 156 }); 164 - let overflow_ids: Vec<WidgetId> = items.iter().skip(visible_count).map(|i| i.id).collect(); 165 - if needs_overflow { 166 - let overflow_rect = overflow_rect(rect, &layout, item_size, item_gap, orientation); 167 - let overflow_id = id.child(WidgetKey::new("overflow")); 168 - let interaction = ctx.interact( 169 - InteractDeclaration::new(overflow_id, overflow_rect, Sense::INTERACTIVE) 170 - .focusable(true) 171 - .active(*overflow_open) 172 - .a11y( 173 - AccessNode::new(Role::Button) 174 - .with_label(StringKey::new("toolbar.overflow")) 175 - .with_expanded(*overflow_open), 176 - ), 177 - ); 178 - let live_focused = ctx.is_focused(overflow_id); 179 - if interaction.click() { 180 - *overflow_open = !*overflow_open; 181 - } 182 - if live_focused && take_activation(ctx.input) { 183 - *overflow_open = !*overflow_open; 184 - } 185 - paint.push(WidgetPaint::Surface { 186 - rect: overflow_rect, 187 - fill: if *overflow_open { 188 - ctx.theme().colors.neutral.step(Step12::SELECTED_BG) 189 - } else if interaction.hover() { 190 - ctx.theme().colors.neutral.step(Step12::HOVER_BG) 191 - } else { 192 - ctx.theme().colors.neutral.step(Step12::SUBTLE_BG) 193 - }, 194 - border: Some(Border { 195 - width: StrokeWidth::HAIRLINE, 196 - color: ctx.theme().colors.neutral.step(Step12::BORDER), 197 - }), 198 - radius: ctx.theme().radius.sm, 199 - elevation: None, 200 - }); 201 - paint.push(WidgetPaint::Mark { 202 - rect: overflow_rect, 203 - kind: GlyphMark::Ellipsis, 204 - color: ctx.theme().colors.text_secondary(), 205 - }); 206 - push_focus_ring( 207 - ctx, 208 - &mut paint, 209 - overflow_rect, 210 - ctx.theme().radius.sm, 211 - live_focused, 212 - ); 213 - } else { 214 - *overflow_open = false; 215 - } 216 157 ToolbarResponse { 217 158 activated, 218 - overflow_open: *overflow_open, 219 159 visible_count, 220 - overflow: overflow_ids, 221 160 paint, 222 161 } 223 162 } ··· 267 206 } else { 268 207 ctx.theme().colors.text_primary() 269 208 }, 270 - role: ctx.theme().typography.caption, 209 + role: ctx.theme().typography.label, 271 210 }); 272 211 push_focus_ring(ctx, &mut paint, rect, ctx.theme().radius.sm, live_focused); 273 212 if let Some(tip_key) = item.tooltip ··· 331 270 ToolbarOrientation::Horizontal => rect.size.width.value(), 332 271 ToolbarOrientation::Vertical => rect.size.height.value(), 333 272 }; 334 - let total_extent: f32 = items 335 - .iter() 336 - .enumerate() 337 - .map(|(i, it)| item_extent(it, item_size) + if i == 0 { 0.0 } else { gap.value() }) 338 - .sum(); 339 - let overflows = total_extent > available + FIT_EPSILON_PX; 340 - let cap = if overflows { 341 - (available - item_size.value() - gap.value()).max(0.0) 342 - } else { 343 - available 344 - }; 345 273 let mut offset = 0.0_f32; 346 274 items 347 275 .iter() ··· 350 278 let extent = item_extent(item, item_size); 351 279 let lead = if i == 0 { 0.0 } else { gap.value() }; 352 280 let next = offset + lead + extent; 353 - if overflows && next > cap + FIT_EPSILON_PX { 281 + if next > available + FIT_EPSILON_PX { 354 282 return None; 355 283 } 356 284 let single = single_item_rect(rect, offset + lead, extent, orientation); ··· 386 314 } 387 315 } 388 316 389 - fn overflow_rect( 390 - rect: LayoutRect, 391 - laid_out: &[LayoutRect], 392 - item_size: LayoutPx, 393 - gap: LayoutPx, 394 - orientation: ToolbarOrientation, 395 - ) -> LayoutRect { 396 - let lead_offset = laid_out.last().map_or(0.0, |last| match orientation { 397 - ToolbarOrientation::Horizontal => { 398 - (last.origin.x.value() - rect.origin.x.value()) + last.size.width.value() + gap.value() 399 - } 400 - ToolbarOrientation::Vertical => { 401 - (last.origin.y.value() - rect.origin.y.value()) + last.size.height.value() + gap.value() 402 - } 403 - }); 404 - single_item_rect(rect, lead_offset, item_size.value(), orientation) 405 - } 406 - 407 317 #[cfg(test)] 408 318 mod tests { 409 319 use std::sync::Arc; ··· 439 349 fn render( 440 350 items: &[ToolbarItem], 441 351 rect: LayoutRect, 442 - overflow_open: &mut bool, 443 352 focus: &mut FocusManager, 444 353 snap: &mut InputSnapshot, 445 354 prev: &HitState, ··· 471 380 LayoutPx::new(28.0), 472 381 LayoutPx::new(4.0), 473 382 ), 474 - overflow_open, 475 383 ) 476 384 }; 477 385 let next = resolve(prev, &hits, snap, focus.focused()); ··· 479 387 } 480 388 481 389 #[test] 482 - fn fits_all_items_no_overflow() { 390 + fn fits_all_items() { 483 391 let items = items(3); 484 392 let rect = LayoutRect::new( 485 393 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 486 394 LayoutSize::new(LayoutPx::new(100.0), LayoutPx::new(28.0)), 487 395 ); 488 - let mut overflow_open = false; 489 396 let mut focus = FocusManager::new(); 490 397 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 491 398 let prev = HitState::new(); 492 - let (response, _) = render( 493 - &items, 494 - rect, 495 - &mut overflow_open, 496 - &mut focus, 497 - &mut snap, 498 - &prev, 499 - ); 399 + let (response, _) = render(&items, rect, &mut focus, &mut snap, &prev); 500 400 assert_eq!(response.visible_count, 3); 501 - assert!(response.overflow.is_empty()); 502 401 } 503 402 504 403 #[test] 505 - fn truncates_to_make_room_for_overflow_button() { 404 + fn drops_items_that_dont_fit() { 506 405 let items = items(5); 507 406 let rect = LayoutRect::new( 508 407 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 509 408 LayoutSize::new(LayoutPx::new(100.0), LayoutPx::new(28.0)), 510 409 ); 511 - let mut overflow_open = false; 512 410 let mut focus = FocusManager::new(); 513 411 let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 514 412 let prev = HitState::new(); 515 - let (response, _) = render( 516 - &items, 517 - rect, 518 - &mut overflow_open, 519 - &mut focus, 520 - &mut snap, 521 - &prev, 522 - ); 413 + let (response, _) = render(&items, rect, &mut focus, &mut snap, &prev); 523 414 assert!(response.visible_count < 5); 524 - assert!(!response.overflow.is_empty()); 525 415 } 526 416 527 417 #[test] ··· 531 421 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 532 422 LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(28.0)), 533 423 ); 534 - let mut overflow_open = false; 535 424 let mut focus = FocusManager::new(); 536 425 let mut prev = HitState::new(); 537 426 let click_pos = LayoutPos::new(LayoutPx::new(40.0), LayoutPx::new(14.0)); ··· 539 428 [press(click_pos), release(click_pos), idle(click_pos)] 540 429 .into_iter() 541 430 .for_each(|mut snap| { 542 - let (response, next) = render( 543 - &items, 544 - rect, 545 - &mut overflow_open, 546 - &mut focus, 547 - &mut snap, 548 - &prev, 549 - ); 431 + let (response, next) = render(&items, rect, &mut focus, &mut snap, &prev); 550 432 last = Some(response); 551 433 prev = next; 552 434 }); ··· 556 438 assert_eq!(response.activated, Some(items[1].id)); 557 439 } 558 440 559 - #[test] 560 - fn click_overflow_button_toggles_state() { 561 - let items = items(8); 562 - let rect = LayoutRect::new( 563 - LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 564 - LayoutSize::new(LayoutPx::new(80.0), LayoutPx::new(28.0)), 565 - ); 566 - let mut overflow_open = false; 567 - let mut focus = FocusManager::new(); 568 - let mut prev = HitState::new(); 569 - let initial_visible = { 570 - let mut snap = InputSnapshot::idle(FrameInstant::ZERO); 571 - let (response, _) = render( 572 - &items, 573 - rect, 574 - &mut overflow_open, 575 - &mut focus, 576 - &mut snap, 577 - &prev, 578 - ); 579 - response.visible_count 580 - }; 581 - #[allow(clippy::cast_precision_loss, reason = "small index in test")] 582 - let initial_visible_f32 = initial_visible as f32; 583 - let overflow_x = initial_visible_f32 * (28.0 + 4.0) + 14.0; 584 - let overflow_pos = LayoutPos::new(LayoutPx::new(overflow_x), LayoutPx::new(14.0)); 585 - [ 586 - press(overflow_pos), 587 - release(overflow_pos), 588 - idle(overflow_pos), 589 - ] 590 - .into_iter() 591 - .for_each(|mut snap| { 592 - let (_, next) = render( 593 - &items, 594 - rect, 595 - &mut overflow_open, 596 - &mut focus, 597 - &mut snap, 598 - &prev, 599 - ); 600 - prev = next; 601 - }); 602 - assert!(overflow_open); 603 - } 604 - 605 441 fn press(pos: LayoutPos) -> InputSnapshot { 606 442 let mut s = InputSnapshot::idle(FrameInstant::ZERO); 607 443 s.pointer = Some(PointerSample::new(pos)); ··· 631 467 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 632 468 LayoutSize::new(LayoutPx::new(200.0), LayoutPx::new(28.0)), 633 469 ); 634 - let mut overflow_open = false; 635 470 let mut focus = FocusManager::new(); 636 471 let prev = HitState::new(); 637 472 let mut snap_focus = InputSnapshot::idle(FrameInstant::ZERO); 638 473 focus.request_focus(items[1].id); 639 - let _ = render( 640 - &items, 641 - rect, 642 - &mut overflow_open, 643 - &mut focus, 644 - &mut snap_focus, 645 - &prev, 646 - ); 474 + let _ = render(&items, rect, &mut focus, &mut snap_focus, &prev); 647 475 assert_eq!(focus.focused(), Some(items[1].id)); 648 476 649 477 let mut snap_space = InputSnapshot::idle(FrameInstant::ZERO); ··· 651 479 KeyCode::Named(NamedKey::Space), 652 480 ModifierMask::NONE, 653 481 )); 654 - let (response, _) = render( 655 - &items, 656 - rect, 657 - &mut overflow_open, 658 - &mut focus, 659 - &mut snap_space, 660 - &prev, 661 - ); 482 + let (response, _) = render(&items, rect, &mut focus, &mut snap_space, &prev); 662 483 assert_eq!(response.activated, Some(items[1].id)); 663 484 } 664 485 ··· 671 492 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), 672 493 LayoutSize::new(LayoutPx::new(160.0), LayoutPx::new(28.0)), 673 494 ); 674 - let mut overflow_open = false; 675 495 let mut focus = FocusManager::new(); 676 496 let prev = HitState::new(); 677 497 let mut warm = InputSnapshot::idle(FrameInstant::ZERO); 678 498 focus.request_focus(items[0].id); 679 - let _ = render( 680 - &items, 681 - rect, 682 - &mut overflow_open, 683 - &mut focus, 684 - &mut warm, 685 - &prev, 686 - ); 499 + let _ = render(&items, rect, &mut focus, &mut warm, &prev); 687 500 688 501 let mut enter = InputSnapshot::idle(FrameInstant::ZERO); 689 502 enter.keys_pressed.push(KeyEvent::new( 690 503 KeyCode::Named(NamedKey::Enter), 691 504 ModifierMask::NONE, 692 505 )); 693 - let (response, _) = render( 694 - &items, 695 - rect, 696 - &mut overflow_open, 697 - &mut focus, 698 - &mut enter, 699 - &prev, 700 - ); 506 + let (response, _) = render(&items, rect, &mut focus, &mut enter, &prev); 701 507 assert_eq!(response.activated, Some(items[0].id)); 702 508 } 703 509 }
+98 -15
crates/bone-ui/src/widgets/tree_view.rs
··· 10 10 use crate::widget_id::{WidgetId, WidgetKey}; 11 11 12 12 use super::keys::{TakeKey, take_key}; 13 - use super::paint::{GlyphMark, LabelText, WidgetPaint}; 13 + use super::paint::{GlyphMark, HorizontalAlign, LabelText, WidgetPaint}; 14 14 use super::text_input::{AlwaysValid, MemoryClipboard, TextInput, TextInputState, show_text_input}; 15 15 use super::visuals::push_focus_ring; 16 16 ··· 19 19 pub id: WidgetId, 20 20 pub label: LabelText, 21 21 pub children: Vec<TreeNode>, 22 + pub disabled: bool, 23 + pub glyph: Option<GlyphMark>, 22 24 } 23 25 24 26 impl TreeNode { ··· 48 50 id, 49 51 label, 50 52 children, 53 + disabled: false, 54 + glyph: None, 51 55 } 52 56 } 53 57 54 58 #[must_use] 59 + pub fn disabled(mut self, disabled: bool) -> Self { 60 + self.disabled = disabled; 61 + self 62 + } 63 + 64 + #[must_use] 65 + pub fn with_glyph(mut self, glyph: GlyphMark) -> Self { 66 + self.glyph = Some(glyph); 67 + self 68 + } 69 + 70 + #[must_use] 55 71 pub fn has_children(&self) -> bool { 56 72 !self.children.is_empty() 57 73 } ··· 237 253 label: LabelText, 238 254 depth: usize, 239 255 has_children: bool, 256 + disabled: bool, 257 + glyph: Option<GlyphMark>, 240 258 } 241 259 242 260 fn flatten(roots: &[TreeNode], expanded: &BTreeSet<WidgetId>, depth: usize) -> Vec<VisibleRow> { ··· 248 266 label: node.label.clone(), 249 267 depth, 250 268 has_children: node.has_children(), 269 + disabled: node.disabled, 270 + glyph: node.glyph, 251 271 }; 252 272 let children = if expanded.contains(&node.id) { 253 273 flatten(&node.children, expanded, depth + 1) ··· 346 366 disclosure_icon_rect, 347 367 disclosure_hit_rect, 348 368 state, 369 + row.disabled, 349 370 )); 350 371 } 351 372 if state.renaming == Some(row.id) { ··· 353 374 ctx, row.id, &row.label, label_rect, state, 354 375 )); 355 376 } else { 356 - paint.push(label_paint(ctx, row, label_rect)); 377 + paint.extend(label_with_glyph_paint(ctx, row, label_rect)); 357 378 } 358 379 push_focus_ring( 359 380 ctx, ··· 396 417 double_activated, 397 418 drop_committed, 398 419 } = outcomes; 420 + if row.disabled { 421 + return; 422 + } 399 423 if interaction.click() { 400 424 update_selection(state, row.id, ctx.input.modifiers, mode); 401 425 if activated.is_none() { ··· 437 461 icon_rect: LayoutRect, 438 462 hit_rect: LayoutRect, 439 463 state: &mut TreeViewState, 464 + disabled: bool, 440 465 ) -> Vec<WidgetPaint> { 441 466 let disclosure_id = row.id.child(WidgetKey::new("disclosure")); 467 + let sense = if disabled { 468 + Sense::HOVER 469 + } else { 470 + Sense::INTERACTIVE 471 + }; 442 472 let disclosure_interaction = ctx.interact( 443 - InteractDeclaration::new(disclosure_id, hit_rect, Sense::INTERACTIVE).a11y( 444 - AccessNode::new(Role::DisclosureTriangle) 445 - .with_label_text(row.label.clone()) 446 - .with_expanded(state.expanded.contains(&row.id)), 447 - ), 473 + InteractDeclaration::new(disclosure_id, hit_rect, sense) 474 + .disabled(disabled) 475 + .a11y( 476 + AccessNode::new(Role::DisclosureTriangle) 477 + .with_label_text(row.label.clone()) 478 + .with_expanded(state.expanded.contains(&row.id)) 479 + .with_disabled(disabled), 480 + ), 448 481 ); 449 - if disclosure_interaction.click() { 482 + if !disabled && disclosure_interaction.click() { 450 483 toggle_expanded(state, row.id); 451 484 } 452 485 vec![WidgetPaint::Mark { ··· 460 493 }] 461 494 } 462 495 463 - fn label_paint(ctx: &FrameCtx<'_>, row: &VisibleRow, label_rect: LayoutRect) -> WidgetPaint { 464 - WidgetPaint::Label { 465 - rect: label_rect, 466 - text: row.label.clone(), 467 - color: ctx.theme().colors.text_primary(), 468 - role: ctx.theme().typography.body, 469 - } 496 + const TREE_GLYPH_COLUMN_PX: f32 = 18.0; 497 + 498 + fn label_with_glyph_paint( 499 + ctx: &FrameCtx<'_>, 500 + row: &VisibleRow, 501 + label_rect: LayoutRect, 502 + ) -> Vec<WidgetPaint> { 503 + let color = if row.disabled { 504 + ctx.theme().colors.text_disabled() 505 + } else { 506 + ctx.theme().colors.text_primary() 507 + }; 508 + let secondary = if row.disabled { 509 + ctx.theme().colors.text_disabled() 510 + } else { 511 + ctx.theme().colors.text_secondary() 512 + }; 513 + let glyph_paint = row.glyph.map(|kind| WidgetPaint::Mark { 514 + rect: glyph_slot_rect(label_rect), 515 + kind, 516 + color: secondary, 517 + }); 518 + let text_rect = if row.glyph.is_some() { 519 + text_after_glyph_rect(label_rect) 520 + } else { 521 + label_rect 522 + }; 523 + glyph_paint 524 + .into_iter() 525 + .chain(core::iter::once(WidgetPaint::AlignedLabel { 526 + rect: text_rect, 527 + text: row.label.clone(), 528 + color, 529 + role: ctx.theme().typography.label, 530 + align: HorizontalAlign::Start, 531 + })) 532 + .collect() 533 + } 534 + 535 + fn glyph_slot_rect(label_rect: LayoutRect) -> LayoutRect { 536 + LayoutRect::new( 537 + label_rect.origin, 538 + LayoutSize::new(LayoutPx::new(TREE_GLYPH_COLUMN_PX), label_rect.size.height), 539 + ) 540 + } 541 + 542 + fn text_after_glyph_rect(label_rect: LayoutRect) -> LayoutRect { 543 + LayoutRect::new( 544 + LayoutPos::new( 545 + LayoutPx::new(label_rect.origin.x.value() + TREE_GLYPH_COLUMN_PX), 546 + label_rect.origin.y, 547 + ), 548 + LayoutSize::new( 549 + LayoutPx::saturating_nonneg(label_rect.size.width.value() - TREE_GLYPH_COLUMN_PX), 550 + label_rect.size.height, 551 + ), 552 + ) 470 553 } 471 554 472 555 const RENAME_FALLBACK_PLACEHOLDER: StringKey = StringKey::new("tree.rename.placeholder");
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.