From c2e3bdfe1c6a5f435e1a8d4fba1214bed164fa2d Mon Sep 17 00:00:00 2001 From: Nathan Dane Date: Tue, 20 Oct 2026 09:35:39 +0200 Subject: [PATCH 01/16] Floating shell frame: workspace and ribbon cards over a full-bleed viewport (step 2 of #2016, rebased onto #2176) --- crates/ui-egui/src/browser.rs | 7 +- crates/ui-egui/src/canvas.rs | 5 +- crates/ui-egui/src/dialogs.rs | 4 +- crates/ui-egui/src/dialogs_sheet_tests.rs | 1157 +++++++++-------- crates/ui-egui/src/extrude_edit_tests.rs | 2 + crates/ui-egui/src/frame.rs | 148 ++- .../src/infinite_line_display_tests.rs | 13 +- crates/ui-egui/src/lib.rs | 65 +- crates/ui-egui/src/navigation.rs | 3 +- crates/ui-egui/src/navigation_cube.rs | 2 +- crates/ui-egui/src/pattern_arrow_tests.rs | 5 + crates/ui-egui/src/revolve_edit_tests.rs | 3 + crates/ui-egui/src/shell.rs | 125 ++ crates/ui-egui/src/shell_tests.rs | 240 ++++ crates/ui-egui/src/sketch_corner_ui_tests.rs | 3 + .../ui-egui/src/sketch_guidance_ui_tests.rs | 2 + crates/ui-egui/src/sketch_palette.rs | 370 +++--- crates/ui-egui/src/sketch_text_handles.rs | 6 +- crates/ui-egui/src/theme.rs | 5 + crates/ui-egui/src/toolbar.rs | 111 +- crates/ui-egui/src/viewport.rs | 51 +- crates/ui-egui/src/viewport_layout.rs | 19 +- crates/ui-egui/src/viewport_layout_tests.rs | 359 ++--- .../s37_construction_geometries_toggle.json | 1 + .../sketch_pattern_circular_center.json | 3 +- crates/ui-egui/tests/ui_layout.rs | 34 +- docs/control-protocol.md | 2 +- 27 files changed, 1685 insertions(+), 1060 deletions(-) create mode 100644 crates/ui-egui/src/shell.rs create mode 100644 crates/ui-egui/src/shell_tests.rs diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 7a429b3851..50b54e0536 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -730,8 +730,8 @@ fn browser_panel(app: &mut SolveApp, ui: &mut egui::Ui, timeline_above: bool) { } /// The tree on a translucent card over the workspace, instead of a column that takes width from -/// it (Preferences: "Float it over the workspace"). Drawn before the central view, in a layer -/// above it, so the viewport keeps the whole window and the card sits on top of it. +/// it (Preferences: "Float it over the workspace"). The card occupies the content below the +/// ribbon in either shell layout, leaving the viewport projection unchanged. pub fn floating(app: &mut SolveApp, ui: &mut egui::Ui, viewport: egui::Rect) { let t = Tokens::get(); app.tree.order.clear(); @@ -760,6 +760,7 @@ pub fn floating(app: &mut SolveApp, ui: &mut egui::Ui, viewport: egui::Rect) { }); }, ); + app.shell.browser = Some(card.response.rect); crate::scenario::publish_rect("browser:float", card.response.rect); finish_browser(app, ui, acts); } @@ -2277,7 +2278,7 @@ pub fn dims_overlay(app: &SolveApp, ctx: &egui::Context) { /// Where the browser's panels open: the viewport's top left corner. fn panel_pos(app: &SolveApp) -> Pos2 { - app.viewport.rect.map(|r| r.left_top() + vec2(16.0, 16.0)).unwrap_or(pos2(270.0, 140.0)) + app.viewport.rect.map(|r| crate::shell::content_rect(app, r).left_top() + vec2(16.0, 16.0)).unwrap_or(pos2(270.0, 140.0)) } /// The Capture Position question stands for saving. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a6944e88be..6a96c74aed 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1178,7 +1178,10 @@ fn draw_revolve( } } if to_mode { - crate::frame::area(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + // This badge is read-only. Its placement follows the model and can overlap the + // command dialog, whose buttons must keep receiving clicks. + let area = crate::frame::area(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).interactable(false); + area.show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| { let text = if extent.curved_to { "To surface".into() diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 529cc1fa13..5a06ffd634 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2420,8 +2420,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { // Physical materials: the library's, then the design's own (material.create). let material_names = app.session.doc.material_names(); let t = Tokens::get(); - let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect()); - let top = (vp.top() + crate::viewport::AXIS_VIEW_CLEARANCE).min((vp.bottom() - 360.0).max(vp.top())); + let vp = crate::shell::content_rect(app, app.viewport.rect.unwrap_or_else(|| ctx.content_rect())); // The Sketch Palette owns the right edge for as long as a sketch is being edited. A dialog // started there docks at the left instead, so neither panel hides the other. let side = if crate::sketch_palette::editing(app) && crate::sketch_palette::fits_beside_a_dialog(app, ctx) { @@ -2429,6 +2428,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } else { crate::frame::Side::Right }; + let top = crate::shell::panel_top(app, vp, vp.bottom(), side); // Dragged by its title band, the dialog keeps that offset from its docked place while it // is open (#1335), kept inside the viewport. let moved = crate::frame::dock_offset(ctx, title(&d.kind)); diff --git a/crates/ui-egui/src/dialogs_sheet_tests.rs b/crates/ui-egui/src/dialogs_sheet_tests.rs index 01d6f58755..92646293a4 100644 --- a/crates/ui-egui/src/dialogs_sheet_tests.rs +++ b/crates/ui-egui/src/dialogs_sheet_tests.rs @@ -6,12 +6,14 @@ use crate::Services; use crate::dialogs::{apply_commands, for_feature as edit_dialog}; /// A 100 x 60 base flange on XY (2.5 mm steel). -fn plate() -> SolveApp { +fn plate(mode: crate::shell::LayoutMode) -> SolveApp { let mut s = Session::default(); s.execute("sketch.create", &json!({"plane": "XY", "name": "Base"})).unwrap(); s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [100, 60]})).unwrap(); s.execute("sketch.finish", &json!({})).unwrap(); - SolveApp::new(s, Services::default()) + let mut app = SolveApp::new(s, Services::default()); + app.ui.layout = mode; + app } fn run_all(app: &mut SolveApp, c: Vec<(String, Value)>) { @@ -24,350 +26,377 @@ fn run_all(app: &mut SolveApp, c: Vec<(String, Value)>) { /// dialog's height, angle and bend position; Edit Feature brings the edges back. #[test] fn flange_follows_the_picks() { - let mut app = plate(); - let sk = app.session.doc.features[0].id; - app.session.selection = vec![Sel::Profile { sketch: sk, index: 0 }]; - app.start("sheet.flange"); - let d = app.dialog.clone().unwrap(); - let c = apply_commands(&app, &d).unwrap(); - assert_eq!(c[0].1["type"], "base"); - assert_eq!(c[0].1["profiles"], json!([0])); - run_all(&mut app, c); - assert_eq!(app.session.model.state().sheets.len(), 1); - // Four edge flanges at once, 20 high, outside. - let edges: Vec = [[50.0, 0.0, 2.5], [50.0, 60.0, 2.5], [0.0, 30.0, 2.5], [100.0, 30.0, 2.5]] - .iter() - .filter_map(|p| edge_sel(&app.session, Vec3::new(p[0], p[1], p[2]))) - .collect(); - assert_eq!(edges.len(), 4); - app.session.selection = edges; - app.start("sheet.flange"); - let mut d = app.dialog.clone().unwrap(); - if let Kind::Sheet(Sm::Flange { height, position, .. }) = &mut d.kind { - *height = "20 mm".into(); - *position = 1; + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let mut app = plate(mode); + let sk = app.session.doc.features[0].id; + app.session.selection = vec![Sel::Profile { sketch: sk, index: 0 }]; + app.start("sheet.flange"); + let d = app.dialog.clone().unwrap(); + let c = apply_commands(&app, &d).unwrap(); + assert_eq!(c[0].1["type"], "base"); + assert_eq!(c[0].1["profiles"], json!([0])); + run_all(&mut app, c); + assert_eq!(app.session.model.state().sheets.len(), 1); + // Four edge flanges at once, 20 high, outside. + let edges: Vec = [[50.0, 0.0, 2.5], [50.0, 60.0, 2.5], [0.0, 30.0, 2.5], [100.0, 30.0, 2.5]] + .iter() + .filter_map(|p| edge_sel(&app.session, Vec3::new(p[0], p[1], p[2]))) + .collect(); + assert_eq!(edges.len(), 4); + app.session.selection = edges; + app.start("sheet.flange"); + let mut d = app.dialog.clone().unwrap(); + if let Kind::Sheet(Sm::Flange { height, position, .. }) = &mut d.kind { + *height = "20 mm".into(); + *position = 1; + } + assert_eq!(d.primary().map(|p| p.0), Some("Height"), "the arrow drives the height"); + assert!(arrow(&app, &d).is_some_and(|n| n.dist(Vec3::Z) < 1e-9), "edge flanges rise out of the sheet"); + let c = apply_commands(&app, &d).unwrap(); + assert_eq!(c[0].1["type"], "edge"); + assert_eq!(c[0].1["edges"].as_array().map(Vec::len), Some(4)); + assert_eq!(c[0].1["position"], "outside"); + run_all(&mut app, c); + let f = app.session.doc.features.last().unwrap().clone(); + let e = edit_dialog(&app, f.id, None).unwrap(); + assert_eq!(e.inputs[0].items.len(), 4); + assert!(matches!(&e.kind, Kind::Sheet(Sm::Flange { ty: 1, position: 1, .. }))); + // Rebuilt in place. + let c = apply_commands(&app, &e).unwrap(); + assert_eq!(c[0].0, "timeline.redefine"); + assert_eq!(c[0].1["params"]["height"], "20 mm"); + // Flat pattern: four bends; Unfold previews. + let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap(); + assert_eq!(flat["bends"].as_array().map(Vec::len), Some(4), "{flat}"); + app.dialog = None; + app.start("sheet.unfold"); + assert_eq!(apply_commands(&app, app.dialog.as_ref().unwrap()).unwrap()[0].0, "sheet.unfold"); } - assert_eq!(d.primary().map(|p| p.0), Some("Height"), "the arrow drives the height"); - assert!(arrow(&app, &d).is_some_and(|n| n.dist(Vec3::Z) < 1e-9), "edge flanges rise out of the sheet"); - let c = apply_commands(&app, &d).unwrap(); - assert_eq!(c[0].1["type"], "edge"); - assert_eq!(c[0].1["edges"].as_array().map(Vec::len), Some(4)); - assert_eq!(c[0].1["position"], "outside"); - run_all(&mut app, c); - let f = app.session.doc.features.last().unwrap().clone(); - let e = edit_dialog(&app, f.id, None).unwrap(); - assert_eq!(e.inputs[0].items.len(), 4); - assert!(matches!(&e.kind, Kind::Sheet(Sm::Flange { ty: 1, position: 1, .. }))); - // Rebuilt in place. - let c = apply_commands(&app, &e).unwrap(); - assert_eq!(c[0].0, "timeline.redefine"); - assert_eq!(c[0].1["params"]["height"], "20 mm"); - // Flat pattern: four bends; Unfold previews. - let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap(); - assert_eq!(flat["bends"].as_array().map(Vec::len), Some(4), "{flat}"); - app.dialog = None; - app.start("sheet.unfold"); - assert_eq!(apply_commands(&app, app.dialog.as_ref().unwrap()).unwrap()[0].0, "sheet.unfold"); } /// Hem takes edges and an optional gap; rules make and pick a rule with its expressions. #[test] fn hem_and_rules() { - let mut app = plate(); - app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); - app.session.selection = edge_sel(&app.session, Vec3::new(50.0, 0.0, 2.5)).into_iter().collect(); - app.start("sheet.hem"); - let d = app.dialog.clone().unwrap(); - let c = apply_commands(&app, &d).unwrap(); - assert_eq!(c[0].1["length"], "5 mm"); - assert!(c[0].1.get("gap").is_none(), "the rule's gap"); - run_all(&mut app, c); - app.dialog = None; - app.start("sheet.manage_rules"); - let mut d = app.dialog.clone().unwrap(); - if let Kind::Sheet(Sm::Rules { pick, name, values, loaded, .. }) = &mut d.kind { - *pick = 1; - *loaded = Some(1); - *name = "Alu".into(); - *values = vec![ - "1.5 mm".into(), - "0.4".into(), - "Thickness".into(), - "Thickness".into(), - "Thickness".into(), - "Thickness".into(), - "Thickness".into(), - "Thickness".into(), - ]; + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let mut app = plate(mode); + app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); + app.session.selection = edge_sel(&app.session, Vec3::new(50.0, 0.0, 2.5)).into_iter().collect(); + app.start("sheet.hem"); + let d = app.dialog.clone().unwrap(); + let c = apply_commands(&app, &d).unwrap(); + assert_eq!(c[0].1["length"], "5 mm"); + assert!(c[0].1.get("gap").is_none(), "the rule's gap"); + run_all(&mut app, c); + app.dialog = None; + app.start("sheet.manage_rules"); + let mut d = app.dialog.clone().unwrap(); + if let Kind::Sheet(Sm::Rules { pick, name, values, loaded, .. }) = &mut d.kind { + *pick = 1; + *loaded = Some(1); + *name = "Alu".into(); + *values = vec![ + "1.5 mm".into(), + "0.4".into(), + "Thickness".into(), + "Thickness".into(), + "Thickness".into(), + "Thickness".into(), + "Thickness".into(), + "Thickness".into(), + ]; + } + let c = apply_commands(&app, &d).unwrap(); + assert_eq!(c[0].1["name"], "Alu"); + assert_eq!(c[0].1["thickness"], "1.5 mm"); + run_all(&mut app, c); + assert!(app.session.doc.sheet.rules.iter().any(|r| r.name == "Alu")); } - let c = apply_commands(&app, &d).unwrap(); - assert_eq!(c[0].1["name"], "Alu"); - assert_eq!(c[0].1["thickness"], "1.5 mm"); - run_all(&mut app, c); - assert!(app.session.doc.sheet.rules.iter().any(|r| r.name == "Alu")); } #[test] fn flat_exports_use_the_save_picker() { - use std::sync::{Arc, Mutex}; - let mut app = plate(); - app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); - let picks = Arc::new(Mutex::new(Vec::new())); - let captured = picks.clone(); - app.services.pick_save = Some(Box::new(move |name, extensions| { - captured.lock().unwrap().push((name.to_string(), extensions.iter().map(|x| x.to_string()).collect::>())); - Box::pin(std::future::ready(None)) - })); - assert_eq!(export_flat(&mut app, "Base", "csv"), "Choose an export file…"); - crate::file_picker::tick(&mut app, &egui::Context::default()); - assert_eq!(export_flat(&mut app, "Base", "dxf"), "Choose an export file…"); - crate::file_picker::tick(&mut app, &egui::Context::default()); - let picks = picks.lock().unwrap(); - assert!(picks[0].0.ends_with("-Base.csv")); - assert_eq!(picks[0].1, vec!["csv"]); - assert!(picks[1].0.ends_with("-Base.dxf")); - assert_eq!(picks[1].1, vec!["dxf"]); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + use std::sync::{Arc, Mutex}; + let mut app = plate(mode); + app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); + let picks = Arc::new(Mutex::new(Vec::new())); + let captured = picks.clone(); + app.services.pick_save = Some(Box::new(move |name, extensions| { + captured.lock().unwrap().push((name.to_string(), extensions.iter().map(|x| x.to_string()).collect::>())); + Box::pin(std::future::ready(None)) + })); + assert_eq!(export_flat(&mut app, "Base", "csv"), "Choose an export file…"); + crate::file_picker::tick(&mut app, &egui::Context::default()); + assert_eq!(export_flat(&mut app, "Base", "dxf"), "Choose an export file…"); + crate::file_picker::tick(&mut app, &egui::Context::default()); + let picks = picks.lock().unwrap(); + assert!(picks[0].0.ends_with("-Base.csv")); + assert_eq!(picks[0].1, vec!["csv"]); + assert!(picks[1].0.ends_with("-Base.dxf")); + assert_eq!(picks[1].1, vec!["dxf"]); + } } #[test] fn shallow_pocket_overlay_labels_depth_without_drawing_a_through_void() { - let mut app = plate(); - app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); - app.run("sketch.create", json!({"plane": "XY", "name": "Pocket"})).unwrap(); - app.run("sketch.rectangle.two_point", json!({"p0": [20, 20], "p1": [30, 30]})).unwrap(); - app.run("sketch.finish", json!({})).unwrap(); - app.run("solid.extrude", json!({"sketch": "Pocket", "distance": 1, "operation": "cut"})).unwrap(); - let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap(); - assert_eq!(flat["cutouts"], json!([])); - assert_eq!(flat["pockets"][0]["depth_mm"], 1.0); - app.start("sheet.flat_pattern"); - let mut dialog = app.dialog.clone().unwrap(); - let Kind::Sheet(Sm::FlatPattern { data, .. }) = &mut dialog.kind else { panic!("flat dialog") }; - *data = Some(Ok(flat)); - let ctx = egui::Context::default(); - let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("flat-pocket-test"))); - assert!(overlay(&mut app, &painter, Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)), &dialog)); - }); - output.textures_delta.clear(); - let texts: Vec<&str> = output - .shapes - .iter() - .filter_map(|s| match &s.shape { - egui::Shape::Text(text) => Some(text.galley.text()), - _ => None, - }) - .collect(); - assert!(texts.contains(&"Bottom pocket: 1.00 mm deep")); - assert!(texts.contains(&"Pocket boundary (material remains)")); - let paths: Vec<_> = output - .shapes - .iter() - .filter_map(|s| match &s.shape { - egui::Shape::Path(path) => Some(path), - _ => None, - }) - .collect(); - assert!(!paths.is_empty(), "the retained sheet outline is drawn"); - assert!(paths.iter().all(|p| p.fill == Color32::TRANSPARENT), "pockets must not be rendered as filled voids"); - let lines = output.shapes.iter().filter(|s| matches!(s.shape, egui::Shape::LineSegment { .. })).count(); - assert!(lines > 6, "pocket contours and legend use dashed lines, not through-cut strokes"); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let mut app = plate(mode); + app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); + app.run("sketch.create", json!({"plane": "XY", "name": "Pocket"})).unwrap(); + app.run("sketch.rectangle.two_point", json!({"p0": [20, 20], "p1": [30, 30]})).unwrap(); + app.run("sketch.finish", json!({})).unwrap(); + app.run("solid.extrude", json!({"sketch": "Pocket", "distance": 1, "operation": "cut"})).unwrap(); + let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap(); + assert_eq!(flat["cutouts"], json!([])); + assert_eq!(flat["pockets"][0]["depth_mm"], 1.0); + app.start("sheet.flat_pattern"); + let mut dialog = app.dialog.clone().unwrap(); + let Kind::Sheet(Sm::FlatPattern { data, .. }) = &mut dialog.kind else { panic!("flat dialog") }; + *data = Some(Ok(flat)); + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("flat-pocket-test"))); + assert!(overlay(&mut app, &painter, Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)), &dialog)); + }); + output.textures_delta.clear(); + let texts: Vec<&str> = output + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(text) => Some(text.galley.text()), + _ => None, + }) + .collect(); + assert!(texts.contains(&"Bottom pocket: 1.00 mm deep")); + assert!(texts.contains(&"Pocket boundary (material remains)")); + let paths: Vec<_> = output + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Path(path) => Some(path), + _ => None, + }) + .collect(); + assert!(!paths.is_empty(), "the retained sheet outline is drawn"); + assert!(paths.iter().all(|p| p.fill == Color32::TRANSPARENT), "pockets must not be rendered as filled voids"); + let lines = output.shapes.iter().filter(|s| matches!(s.shape, egui::Shape::LineSegment { .. })).count(); + assert!(lines > 6, "pocket contours and legend use dashed lines, not through-cut strokes"); + } } #[test] fn flat_pattern_islands_fit_before_the_actual_command_panel() { - let mut app = plate(); - app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); - app.run("sketch.create", json!({"plane": "XY", "name": "Retain"})).unwrap(); - for x in [20, 60] { - app.run("sketch.rectangle.two_point", json!({"p0": [x, 20], "p1": [x + 10, 30]})).unwrap(); - } - app.run("sketch.finish", json!({})).unwrap(); - app.run("solid.extrude", json!({"sketch": "Retain", "profiles": "all", "through_all": true, "direction": "symmetric", "operation": "intersect"})) + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let mut app = plate(mode); + app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); + app.run("sketch.create", json!({"plane": "XY", "name": "Retain"})).unwrap(); + for x in [20, 60] { + app.run("sketch.rectangle.two_point", json!({"p0": [x, 20], "p1": [x + 10, 30]})).unwrap(); + } + app.run("sketch.finish", json!({})).unwrap(); + app.run( + "solid.extrude", + json!({"sketch": "Retain", "profiles": "all", "through_all": true, "direction": "symmetric", "operation": "intersect"}), + ) .unwrap(); - let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap(); - assert_eq!(flat["outline"].as_array().unwrap().len(), 2); - app.start("sheet.flat_pattern"); - let mut dialog = app.dialog.clone().unwrap(); - let Kind::Sheet(Sm::FlatPattern { data, .. }) = &mut dialog.kind else { panic!("flat dialog") }; - *data = Some(Ok(flat)); - let ctx = egui::Context::default(); - let id = egui::Id::new(("sc_dialog", "FLAT PATTERN")); - let mut output = egui::FullOutput::default(); - for _ in 0..3 { - output = ctx.run_ui(egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), ..Default::default() }, |ui| { - crate::frame::docked("FLAT PATTERN", id, pos2(1400.0, 294.0), 540.0, crate::frame::Width::Normal).min_width(360.0).show(ui.ctx(), |ui| { - ui.label("Flat size: 50 × 10 × 2.5 mm"); - }); - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Background, egui::Id::new("island-fit"))); - assert!(overlay( - &mut app, - &painter, - Rect::from_min_max(pos2(250.0, crate::toolbar::APP_BAR_HEIGHT + crate::toolbar::RIBBON_HEIGHT), pos2(1400.0, 832.0)), - &dialog - )); - }); - output.textures_delta.clear(); + let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap(); + assert_eq!(flat["outline"].as_array().unwrap().len(), 2); + app.start("sheet.flat_pattern"); + let mut dialog = app.dialog.clone().unwrap(); + let Kind::Sheet(Sm::FlatPattern { data, .. }) = &mut dialog.kind else { panic!("flat dialog") }; + *data = Some(Ok(flat)); + let ctx = egui::Context::default(); + let id = egui::Id::new(("sc_dialog", "FLAT PATTERN")); + let mut output = egui::FullOutput::default(); + for _ in 0..3 { + output = + ctx.run_ui(egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), ..Default::default() }, |ui| { + crate::frame::docked("FLAT PATTERN", id, pos2(1400.0, 294.0), 540.0, crate::frame::Width::Normal).min_width(360.0).show( + ui.ctx(), + |ui| { + ui.label("Flat size: 50 × 10 × 2.5 mm"); + }, + ); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Background, egui::Id::new("island-fit"))); + assert!(overlay( + &mut app, + &painter, + Rect::from_min_max(pos2(250.0, crate::toolbar::APP_BAR_HEIGHT + crate::toolbar::RIBBON_HEIGHT), pos2(1400.0, 832.0)), + &dialog + )); + }); + output.textures_delta.clear(); + } + let panel = ctx.memory(|m| m.area_rect(id)).unwrap(); + let outlines: Vec<_> = output + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Path(p) if p.closed && p.fill == Color32::TRANSPARENT => Some(p), + _ => None, + }) + .collect(); + assert_eq!(outlines.len(), 2, "both retained islands are drawn"); + assert!( + outlines.iter().flat_map(|p| &p.points).all(|p| p.x <= panel.left() - 19.0), + "both complete islands must remain exposed before the command panel: {panel:?}" + ); } - let panel = ctx.memory(|m| m.area_rect(id)).unwrap(); - let outlines: Vec<_> = output - .shapes - .iter() - .filter_map(|s| match &s.shape { - egui::Shape::Path(p) if p.closed && p.fill == Color32::TRANSPARENT => Some(p), - _ => None, - }) - .collect(); - assert_eq!(outlines.len(), 2, "both retained islands are drawn"); - assert!( - outlines.iter().flat_map(|p| &p.points).all(|p| p.x <= panel.left() - 19.0), - "both complete islands must remain exposed before the command panel: {panel:?}" - ); } #[test] fn coincident_top_and_bottom_pocket_depths_share_a_readable_annotation() { - let mut app = plate(); - app.run("sheet.flange", json!({"sketch":"Base"})).unwrap(); - app.run("sketch.create", json!({"plane":"XY","name":"Retain"})).unwrap(); - app.run("sketch.rectangle.two_point", json!({"p0":[20,20],"p1":[30,30]})).unwrap(); - app.run("sketch.finish", json!({})).unwrap(); - app.run("solid.extrude", json!({"sketch":"Retain","distance":1,"start_offset":0.5,"operation":"intersect"})).unwrap(); - let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap(); - assert_eq!(flat["cutouts"], json!([])); - assert_eq!(flat["pockets"].as_array().unwrap().len(), 2); - let doc = app.session.doc.to_json(); - app.start("sheet.flat_pattern"); - let mut dialog = app.dialog.clone().unwrap(); - let Kind::Sheet(Sm::FlatPattern { data, .. }) = &mut dialog.kind else { panic!("flat dialog") }; - *data = Some(Ok(flat)); - let ctx = egui::Context::default(); - let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("coincident-pocket-test"))); - assert!(overlay(&mut app, &painter, Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)), &dialog)); - }); - output.textures_delta.clear(); - let labels: Vec<_> = output - .shapes - .iter() - .filter_map(|shape| match &shape.shape { - egui::Shape::Text(text) if text.galley.text().contains("pocket:") => Some(text.galley.text()), - _ => None, - }) - .collect(); - assert_eq!(labels.len(), 1, "coincident depths must not overwrite one another"); - assert!(labels[0].contains("Top pocket: 1.00 mm deep")); - assert!(labels[0].contains("Bottom pocket: 0.50 mm deep")); - assert!(labels[0].contains('\n')); - assert_eq!(app.session.doc.to_json(), doc); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let mut app = plate(mode); + app.run("sheet.flange", json!({"sketch":"Base"})).unwrap(); + app.run("sketch.create", json!({"plane":"XY","name":"Retain"})).unwrap(); + app.run("sketch.rectangle.two_point", json!({"p0":[20,20],"p1":[30,30]})).unwrap(); + app.run("sketch.finish", json!({})).unwrap(); + app.run("solid.extrude", json!({"sketch":"Retain","distance":1,"start_offset":0.5,"operation":"intersect"})).unwrap(); + let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap(); + assert_eq!(flat["cutouts"], json!([])); + assert_eq!(flat["pockets"].as_array().unwrap().len(), 2); + let doc = app.session.doc.to_json(); + app.start("sheet.flat_pattern"); + let mut dialog = app.dialog.clone().unwrap(); + let Kind::Sheet(Sm::FlatPattern { data, .. }) = &mut dialog.kind else { panic!("flat dialog") }; + *data = Some(Ok(flat)); + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("coincident-pocket-test"))); + assert!(overlay(&mut app, &painter, Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)), &dialog)); + }); + output.textures_delta.clear(); + let labels: Vec<_> = output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Text(text) if text.galley.text().contains("pocket:") => Some(text.galley.text()), + _ => None, + }) + .collect(); + assert_eq!(labels.len(), 1, "coincident depths must not overwrite one another"); + assert!(labels[0].contains("Top pocket: 1.00 mm deep")); + assert!(labels[0].contains("Bottom pocket: 0.50 mm deep")); + assert!(labels[0].contains('\n')); + assert_eq!(app.session.doc.to_json(), doc); + } } #[test] fn pocket_export_button_uses_the_picker_and_preserves_depth_layers() { - let mut h = crate::scenario::Harness::new(); - for (command, params) in [ - ("sketch.create", json!({"plane":"XY","name":"Base"})), - ("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[100,60]})), - ("sketch.finish", json!({})), - ("sheet.flange", json!({"sketch":"Base"})), - ("sketch.create", json!({"plane":"XY","name":"Pocket"})), - ("sketch.rectangle.two_point", json!({"p0":[20,20],"p1":[30,30]})), - ("sketch.finish", json!({})), - ("solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"})), - ] { - h.app.run(command, params).unwrap(); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let mut h = crate::scenario::Harness::new(); + h.call("ui.set", json!({"layout":mode})); + for (command, params) in [ + ("sketch.create", json!({"plane":"XY","name":"Base"})), + ("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[100,60]})), + ("sketch.finish", json!({})), + ("sheet.flange", json!({"sketch":"Base"})), + ("sketch.create", json!({"plane":"XY","name":"Pocket"})), + ("sketch.rectangle.two_point", json!({"p0":[20,20],"p1":[30,30]})), + ("sketch.finish", json!({})), + ("solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"})), + ] { + h.app.run(command, params).unwrap(); + } + let path = h.dir.join("picked-pocket.dxf"); + let chosen = path.to_string_lossy().into_owned(); + h.app.services.pick_save = Some(Box::new(move |name, extensions| { + assert!(name.ends_with("-Body1.dxf")); + assert_eq!(extensions, &["dxf"]); + Box::pin(std::future::ready(Some(chosen.clone()))) + })); + h.app.start("sheet.flat_pattern"); + h.settle(); + let undo = h.app.session.undo.len(); + h.step(&json!({"widget":"Export DXF…"})).unwrap(); + let exported = std::fs::read_to_string(&path).expect("actual Export DXF button writes the picker destination"); + assert!(exported.contains("POCKET_BOTTOM_Z0.000000_TO_1.000000_MM")); + assert!(exported.contains("OUTLINE")); + assert_eq!(h.app.session.undo.len(), undo, "export does not alter the part"); + assert!(h.app.dialog.is_some(), "export keeps the flat viewer open"); } - let path = h.dir.join("picked-pocket.dxf"); - let chosen = path.to_string_lossy().into_owned(); - h.app.services.pick_save = Some(Box::new(move |name, extensions| { - assert!(name.ends_with("-Body1.dxf")); - assert_eq!(extensions, &["dxf"]); - Box::pin(std::future::ready(Some(chosen.clone()))) - })); - h.app.start("sheet.flat_pattern"); - h.settle(); - let undo = h.app.session.undo.len(); - h.step(&json!({"widget":"Export DXF…"})).unwrap(); - let exported = std::fs::read_to_string(&path).expect("actual Export DXF button writes the picker destination"); - assert!(exported.contains("POCKET_BOTTOM_Z0.000000_TO_1.000000_MM")); - assert!(exported.contains("OUTLINE")); - assert_eq!(h.app.session.undo.len(), undo, "export does not alter the part"); - assert!(h.app.dialog.is_some(), "export keeps the flat viewer open"); } #[test] #[cfg(not(target_arch = "wasm32"))] fn bend_table_picker_writes_the_current_body() { - let mut app = plate(); - app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); - app.run("sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20})).unwrap(); - let body = app.session.model.state().sheets[0].body.clone(); - let path = std::env::temp_dir().join(format!("solidcraft-bend-table-{}.csv", std::process::id())); - let output = path.to_string_lossy().into_owned(); - let chosen = output.clone(); - app.services.pick_save = Some(Box::new(move |_, extensions| { - assert_eq!(extensions, &["csv"]); - Box::pin(std::future::ready(Some(chosen.clone()))) - })); - let undo = app.session.undo.len(); - assert_eq!(export_flat(&mut app, &body, "csv"), "Choose an export file…"); - crate::file_picker::tick(&mut app, &egui::Context::default()); - assert_eq!(app.status.as_ref().map(|s| &s.0), Some(&format!("Exported {output}"))); - let contents = std::fs::read_to_string(&path).unwrap(); - std::fs::remove_file(&path).unwrap(); - assert_eq!(contents.lines().count(), 2); - assert!(contents.contains(",90.000000000,up,2.500000000,"), "{contents}"); - assert_eq!(app.session.undo.len(), undo); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let mut app = plate(mode); + app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); + app.run("sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20})).unwrap(); + let body = app.session.model.state().sheets[0].body.clone(); + let path = std::env::temp_dir().join(format!("solidcraft-bend-table-{}.csv", std::process::id())); + let output = path.to_string_lossy().into_owned(); + let chosen = output.clone(); + app.services.pick_save = Some(Box::new(move |_, extensions| { + assert_eq!(extensions, &["csv"]); + Box::pin(std::future::ready(Some(chosen.clone()))) + })); + let undo = app.session.undo.len(); + assert_eq!(export_flat(&mut app, &body, "csv"), "Choose an export file…"); + crate::file_picker::tick(&mut app, &egui::Context::default()); + assert_eq!(app.status.as_ref().map(|s| &s.0), Some(&format!("Exported {output}"))); + let contents = std::fs::read_to_string(&path).unwrap(); + std::fs::remove_file(&path).unwrap(); + assert_eq!(contents.lines().count(), 2); + assert!(contents.contains(",90.000000000,up,2.500000000,"), "{contents}"); + assert_eq!(app.session.undo.len(), undo); + } } /// The rule manager's shape selector: picked in the dialog, the shape reaches the rule and the part /// made with it, and the dialog shows it the next time. #[test] fn rules_dialog_picks_the_corner_relief_shape() { - let mut h = crate::scenario::Harness::new(); - h.app - .run( - "sheet.manage_rules", - json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "gap": "0.5 mm", "active": true}), - ) - .unwrap(); - h.app.run("sketch.create", json!({"plane": "XY", "name": "Base"})).unwrap(); - h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [120, 90]})).unwrap(); - h.app.run("sketch.finish", json!({})).unwrap(); - h.app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); - h.app.run("sheet.flange", json!({"edges": [[60, 0, 2], [120, 45, 2], [60, 90, 2], [0, 45, 2]], "height": 20, "angle": "72 deg"})).unwrap(); - // The rounds in the base of the tray, and the shape the open dialog shows. - let rounds = |app: &SolveApp| { - let state = app.session.model.state(); - state.sheets.last().map(|s| s.base.outer.segs.iter().filter(|g| matches!(g, solidcraft_engine::geom::Seg2::Arc { .. })).count()) - }; - let shown = |app: &SolveApp| match app.dialog.as_ref().map(|d| &d.kind) { - Some(Kind::Sheet(Sm::Rules { relief, .. })) => Some(*relief), - _ => None, - }; - assert_eq!(rounds(&h.app), Some(0)); - h.step(&json!({"start": "sheet.manage_rules"})).unwrap(); - h.frames(3); - assert_eq!(shown(&h.app), Some(0), "a rule starts with the notch"); - h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape"}})).unwrap(); - h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape:Round"}})).unwrap(); - assert_eq!(shown(&h.app), Some(1)); - h.step(&json!({"widget": "OK"})).unwrap(); - assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog); - assert_eq!(h.app.session.doc.sheet_rule(None).corner_relief_shape, "round"); - assert_eq!(rounds(&h.app), Some(4), "one round at each corner of the tray"); - h.step(&json!({"start": "sheet.manage_rules"})).unwrap(); - h.frames(3); - assert_eq!(shown(&h.app), Some(1), "the dialog shows the rule's shape"); - // The tear: no relief in the base, both ends of all four bends torn. - h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape"}})).unwrap(); - h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape:Tear"}})).unwrap(); - assert_eq!(shown(&h.app), Some(3)); - h.step(&json!({"widget": "OK"})).unwrap(); - assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog); - assert_eq!(h.app.session.doc.sheet_rule(None).corner_relief_shape, "tear"); - let torn = h.app.session.model.state().sheets.last().map(|s| s.flanges.iter().filter(|f| f.tear == (true, true)).count()); - assert_eq!((rounds(&h.app), torn), (Some(0), Some(4))); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let mut h = crate::scenario::Harness::new(); + h.call("ui.set", json!({"layout":mode})); + h.app + .run( + "sheet.manage_rules", + json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "gap": "0.5 mm", "active": true}), + ) + .unwrap(); + h.app.run("sketch.create", json!({"plane": "XY", "name": "Base"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [120, 90]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); + h.app.run("sheet.flange", json!({"edges": [[60, 0, 2], [120, 45, 2], [60, 90, 2], [0, 45, 2]], "height": 20, "angle": "72 deg"})).unwrap(); + // The rounds in the base of the tray, and the shape the open dialog shows. + let rounds = |app: &SolveApp| { + let state = app.session.model.state(); + state.sheets.last().map(|s| s.base.outer.segs.iter().filter(|g| matches!(g, solidcraft_engine::geom::Seg2::Arc { .. })).count()) + }; + let shown = |app: &SolveApp| match app.dialog.as_ref().map(|d| &d.kind) { + Some(Kind::Sheet(Sm::Rules { relief, .. })) => Some(*relief), + _ => None, + }; + assert_eq!(rounds(&h.app), Some(0)); + h.step(&json!({"start": "sheet.manage_rules"})).unwrap(); + h.frames(3); + assert_eq!(shown(&h.app), Some(0), "a rule starts with the notch"); + h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape"}})).unwrap(); + h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape:Round"}})).unwrap(); + assert_eq!(shown(&h.app), Some(1)); + h.step(&json!({"widget": "OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog); + assert_eq!(h.app.session.doc.sheet_rule(None).corner_relief_shape, "round"); + assert_eq!(rounds(&h.app), Some(4), "one round at each corner of the tray"); + h.step(&json!({"start": "sheet.manage_rules"})).unwrap(); + h.frames(3); + assert_eq!(shown(&h.app), Some(1), "the dialog shows the rule's shape"); + // The tear: no relief in the base, both ends of all four bends torn. + h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape"}})).unwrap(); + h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape:Tear"}})).unwrap(); + assert_eq!(shown(&h.app), Some(3)); + h.step(&json!({"widget": "OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog); + assert_eq!(h.app.session.doc.sheet_rule(None).corner_relief_shape, "tear"); + let torn = h.app.session.model.state().sheets.last().map(|s| s.flanges.iter().filter(|f| f.tear == (true, true)).count()); + assert_eq!((rounds(&h.app), torn), (Some(0), Some(4))); + } } /// The rule manager's rectangular relief, on a base whose corners are not square: the shape and @@ -433,80 +462,86 @@ fn the_rule_manager_relieves_a_corner_that_is_not_square() { /// a shape and a size reach the feature, Edit Feature shows them again, and Rule takes them back. #[test] fn flange_dialog_picks_a_corner_relief_of_its_own() { - let mut h = crate::scenario::Harness::new(); - h.app - .run( - "sheet.manage_rules", - json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "gap": "0.5 mm", "active": true}), - ) - .unwrap(); - h.app.run("sketch.create", json!({"plane": "XY", "name": "Base"})).unwrap(); - h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [120, 90]})).unwrap(); - h.app.run("sketch.finish", json!({})).unwrap(); - h.app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); - let edges: Vec = [[60.0, 0.0, 2.0], [0.0, 45.0, 2.0]].iter().filter_map(|p| edge_sel(&h.app.session, Vec3::new(p[0], p[1], p[2]))).collect(); - assert_eq!(edges.len(), 2); - h.app.session.selection = edges; - h.app.start("sheet.flange"); - h.frames(3); - let shown = |app: &SolveApp| match app.dialog.as_ref().map(|d| &d.kind) { - Some(Kind::Sheet(Sm::Flange { relief, relief_size, .. })) => Some((*relief, relief_size.clone())), - _ => None, - }; - let relief = |app: &SolveApp| match &app.session.doc.features.last().map(|f| &f.kind) { - Some(FeatureKind::SheetFlange { corner_relief_shape, corner_relief, .. }) => Some((corner_relief_shape.clone(), corner_relief.clone())), - _ => None, - }; - // Untouched, the dialog asks for nothing: the rule's relief. - assert_eq!(shown(&h.app), Some((0, String::new()))); - let d = h.app.dialog.clone().unwrap(); - let c = apply_commands(&h.app, &d).unwrap(); - assert!(c[0].1.get("corner_relief_shape").is_none() && c[0].1.get("corner_relief").is_none(), "{}", c[0].1); - // Round from the selector, 6 mm in the size row. - h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape"}})).unwrap(); - h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape:Round"}})).unwrap(); - assert_eq!(shown(&h.app), Some((2, String::new()))); - if let Some(Kind::Sheet(Sm::Flange { relief_size, .. })) = h.app.dialog.as_mut().map(|d| &mut d.kind) { - *relief_size = "6 mm".into(); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let mut h = crate::scenario::Harness::new(); + h.call("ui.set", json!({"layout":mode})); + h.app + .run( + "sheet.manage_rules", + json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "gap": "0.5 mm", "active": true}), + ) + .unwrap(); + h.app.run("sketch.create", json!({"plane": "XY", "name": "Base"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [120, 90]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); + let edges: Vec = + [[60.0, 0.0, 2.0], [0.0, 45.0, 2.0]].iter().filter_map(|p| edge_sel(&h.app.session, Vec3::new(p[0], p[1], p[2]))).collect(); + assert_eq!(edges.len(), 2); + h.app.session.selection = edges; + h.app.start("sheet.flange"); + h.frames(3); + let shown = |app: &SolveApp| match app.dialog.as_ref().map(|d| &d.kind) { + Some(Kind::Sheet(Sm::Flange { relief, relief_size, .. })) => Some((*relief, relief_size.clone())), + _ => None, + }; + let relief = |app: &SolveApp| match &app.session.doc.features.last().map(|f| &f.kind) { + Some(FeatureKind::SheetFlange { corner_relief_shape, corner_relief, .. }) => Some((corner_relief_shape.clone(), corner_relief.clone())), + _ => None, + }; + // Untouched, the dialog asks for nothing: the rule's relief. + assert_eq!(shown(&h.app), Some((0, String::new()))); + let d = h.app.dialog.clone().unwrap(); + let c = apply_commands(&h.app, &d).unwrap(); + assert!(c[0].1.get("corner_relief_shape").is_none() && c[0].1.get("corner_relief").is_none(), "{}", c[0].1); + // Round from the selector, 6 mm in the size row. + h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape"}})).unwrap(); + h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape:Round"}})).unwrap(); + assert_eq!(shown(&h.app), Some((2, String::new()))); + if let Some(Kind::Sheet(Sm::Flange { relief_size, .. })) = h.app.dialog.as_mut().map(|d| &mut d.kind) { + *relief_size = "6 mm".into(); + } + let d = h.app.dialog.clone().unwrap(); + let c = apply_commands(&h.app, &d).unwrap(); + assert_eq!((&c[0].1["corner_relief_shape"], &c[0].1["corner_relief"]), (&json!("round"), &json!("6 mm"))); + h.step(&json!({"widget": "OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog); + assert_eq!(relief(&h.app), Some((Some("round".into()), Some("6 mm".into())))); + assert!(h.app.session.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{:?}", h.app.session.model.results); + let arcs = |app: &SolveApp| { + app.session + .model + .state() + .sheets + .last() + .map(|s| s.base.outer.segs.iter().filter(|g| matches!(g, solidcraft_engine::geom::Seg2::Arc { .. })).count()) + }; + assert_eq!(arcs(&h.app), Some(1), "the round is cut at the one corner the two flanges make"); + // Edit Feature shows both again; Rule and an empty size take the feature back to the rule's relief. + let id = h.app.session.doc.features.last().map(|f| f.id).unwrap(); + let mut e = edit_dialog(&h.app, id, None).unwrap(); + assert!(matches!(&e.kind, Kind::Sheet(Sm::Flange { relief: 2, relief_size, .. }) if relief_size == "6 mm")); + let c = apply_commands(&h.app, &e).unwrap(); + assert_eq!((&c[0].1["params"]["corner_relief_shape"], &c[0].1["params"]["corner_relief"]), (&json!("round"), &json!("6 mm"))); + if let Kind::Sheet(Sm::Flange { relief, relief_size, .. }) = &mut e.kind { + *relief = 0; + relief_size.clear(); + } + let commands = apply_commands(&h.app, &e).unwrap(); + run_all(&mut h.app, commands); + assert_eq!(relief(&h.app), Some((None, None))); + assert_eq!(arcs(&h.app), Some(0)); } - let d = h.app.dialog.clone().unwrap(); - let c = apply_commands(&h.app, &d).unwrap(); - assert_eq!((&c[0].1["corner_relief_shape"], &c[0].1["corner_relief"]), (&json!("round"), &json!("6 mm"))); - h.step(&json!({"widget": "OK"})).unwrap(); - assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog); - assert_eq!(relief(&h.app), Some((Some("round".into()), Some("6 mm".into())))); - assert!(h.app.session.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{:?}", h.app.session.model.results); - let arcs = |app: &SolveApp| { - app.session - .model - .state() - .sheets - .last() - .map(|s| s.base.outer.segs.iter().filter(|g| matches!(g, solidcraft_engine::geom::Seg2::Arc { .. })).count()) - }; - assert_eq!(arcs(&h.app), Some(1), "the round is cut at the one corner the two flanges make"); - // Edit Feature shows both again; Rule and an empty size take the feature back to the rule's relief. - let id = h.app.session.doc.features.last().map(|f| f.id).unwrap(); - let mut e = edit_dialog(&h.app, id, None).unwrap(); - assert!(matches!(&e.kind, Kind::Sheet(Sm::Flange { relief: 2, relief_size, .. }) if relief_size == "6 mm")); - let c = apply_commands(&h.app, &e).unwrap(); - assert_eq!((&c[0].1["params"]["corner_relief_shape"], &c[0].1["params"]["corner_relief"]), (&json!("round"), &json!("6 mm"))); - if let Kind::Sheet(Sm::Flange { relief, relief_size, .. }) = &mut e.kind { - *relief = 0; - relief_size.clear(); - } - let commands = apply_commands(&h.app, &e).unwrap(); - run_all(&mut h.app, commands); - assert_eq!(relief(&h.app), Some((None, None))); - assert_eq!(arcs(&h.app), Some(0)); } #[test] fn pocket_depth_labels_follow_inches_without_changing_flat_data() { - let mut app = plate(); - app.run("document.units", json!({"units":"in"})).unwrap(); - assert_eq!(super::pocket_label(&app, &json!({"side":"top","depth_mm":25.4*0.1234})).as_deref(), Some("Top pocket: 0.1234 in deep")); - assert!(super::pocket_label(&app, &json!({"side":"top","depth_mm":-1})).is_none()); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let mut app = plate(mode); + app.run("document.units", json!({"units":"in"})).unwrap(); + assert_eq!(super::pocket_label(&app, &json!({"side":"top","depth_mm":25.4*0.1234})).as_deref(), Some("Top pocket: 0.1234 in deep")); + assert!(super::pocket_label(&app, &json!({"side":"top","depth_mm":-1})).is_none()); + } } /// Edges with an angle and/or a height of their own: the texts of the dialog's rows per edge @@ -514,61 +549,63 @@ fn pocket_depth_labels_follow_inches_without_changing_flat_data() { /// brings them back and rebuilds the same flanges. #[test] fn edge_flanges_can_have_an_angle_and_a_height_of_their_own() { - let mut app = plate(); - app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); - let edges: Vec = [[50.0, 0.0, 2.5], [50.0, 60.0, 2.5], [0.0, 30.0, 2.5], [100.0, 30.0, 2.5]] - .iter() - .filter_map(|p| edge_sel(&app.session, Vec3::new(p[0], p[1], p[2]))) - .collect(); - assert_eq!(edges.len(), 4); - let point = |i: usize| if let Sel::Edge { point, .. } = &edges[i] { *point } else { Vec3::ZERO }; - app.session.selection = edges.clone(); - app.start("sheet.flange"); - let mut d = app.dialog.clone().unwrap(); - if let Kind::Sheet(Sm::Flange { height, angle, own, .. }) = &mut d.kind { - *height = "20 mm".into(); - *angle = "75 deg".into(); - // The second edge: its own angle; the fourth: both; the third: texts left blank. - own.push((point(1), "100 deg".into(), String::new())); - own.push((point(3), "90 deg".into(), "15 mm".into())); - own.push((point(2), " ".into(), String::new())); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let mut app = plate(mode); + app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); + let edges: Vec = [[50.0, 0.0, 2.5], [50.0, 60.0, 2.5], [0.0, 30.0, 2.5], [100.0, 30.0, 2.5]] + .iter() + .filter_map(|p| edge_sel(&app.session, Vec3::new(p[0], p[1], p[2]))) + .collect(); + assert_eq!(edges.len(), 4); + let point = |i: usize| if let Sel::Edge { point, .. } = &edges[i] { *point } else { Vec3::ZERO }; + app.session.selection = edges.clone(); + app.start("sheet.flange"); + let mut d = app.dialog.clone().unwrap(); + if let Kind::Sheet(Sm::Flange { height, angle, own, .. }) = &mut d.kind { + *height = "20 mm".into(); + *angle = "75 deg".into(); + // The second edge: its own angle; the fourth: both; the third: texts left blank. + own.push((point(1), "100 deg".into(), String::new())); + own.push((point(3), "90 deg".into(), "15 mm".into())); + own.push((point(2), " ".into(), String::new())); + } + let c = apply_commands(&app, &d).unwrap(); + assert_eq!( + c[0].1["edges"], + json!([pt(point(0)), {"point": pt(point(1)), "angle": "100 deg"}, pt(point(2)), {"point": pt(point(3)), "angle": "90 deg", "height": "15 mm"}]), + "no text or a blank one: a plain point" + ); + run_all(&mut app, c); + let bends = app.session.execute("sheet.flat_pattern", &json!({})).unwrap()["bends"].clone(); + let angles: Vec = bends.as_array().unwrap().iter().map(|b| b["angle_deg"].as_f64().unwrap().round()).collect(); + assert_eq!(angles, [75.0, 100.0, 75.0, 90.0], "{bends}"); + assert!(app.session.model.results.iter().all(|r| r.error.is_none()), "{:?}", app.session.model.results); + // Edit Feature: the edges and their own values come back, and OK rebuilds the same part. + let f = app.session.doc.features.last().unwrap().clone(); + let mut e = edit_dialog(&app, f.id, None).unwrap(); + assert_eq!(e.inputs[0].items.len(), 4); + let Kind::Sheet(Sm::Flange { own, .. }) = &e.kind else { panic!("not a flange dialog") }; + assert_eq!(own.len(), 2, "{own:?}"); + assert!(own.iter().any(|o| o.0.dist(point(1)) < 1e-9 && o.1 == "100 deg" && o.2.is_empty()), "{own:?}"); + assert!(own.iter().any(|o| o.0.dist(point(3)) < 1e-9 && o.1 == "90 deg" && o.2 == "15 mm"), "{own:?}"); + let volume = |app: &mut SolveApp| app.session.execute("inspect.measure", &json!({})).unwrap()["total"]["volume_mm3"].as_f64().unwrap(); + let before = volume(&mut app); + let c = apply_commands(&app, &e).unwrap(); + assert_eq!(c[0].0, "timeline.redefine"); + assert_eq!(c[0].1["params"]["edges"][1], json!({"point": pt(point(1)), "angle": "100 deg"})); + assert_eq!(c[0].1["params"]["edges"][3]["height"], "15 mm"); + run_all(&mut app, c); + let after = volume(&mut app); + assert!((after - before).abs() < 1e-6 * before, "{after} vs {before}"); + // Taking edges out of the selection leaves each value with its edge. + e.inputs[0].items.remove(2); + e.inputs[0].items.remove(0); + let c = apply_commands(&app, &e).unwrap(); + assert_eq!( + c[0].1["params"]["edges"], + json!([{"point": pt(point(1)), "angle": "100 deg"}, {"point": pt(point(3)), "angle": "90 deg", "height": "15 mm"}]) + ); } - let c = apply_commands(&app, &d).unwrap(); - assert_eq!( - c[0].1["edges"], - json!([pt(point(0)), {"point": pt(point(1)), "angle": "100 deg"}, pt(point(2)), {"point": pt(point(3)), "angle": "90 deg", "height": "15 mm"}]), - "no text or a blank one: a plain point" - ); - run_all(&mut app, c); - let bends = app.session.execute("sheet.flat_pattern", &json!({})).unwrap()["bends"].clone(); - let angles: Vec = bends.as_array().unwrap().iter().map(|b| b["angle_deg"].as_f64().unwrap().round()).collect(); - assert_eq!(angles, [75.0, 100.0, 75.0, 90.0], "{bends}"); - assert!(app.session.model.results.iter().all(|r| r.error.is_none()), "{:?}", app.session.model.results); - // Edit Feature: the edges and their own values come back, and OK rebuilds the same part. - let f = app.session.doc.features.last().unwrap().clone(); - let mut e = edit_dialog(&app, f.id, None).unwrap(); - assert_eq!(e.inputs[0].items.len(), 4); - let Kind::Sheet(Sm::Flange { own, .. }) = &e.kind else { panic!("not a flange dialog") }; - assert_eq!(own.len(), 2, "{own:?}"); - assert!(own.iter().any(|o| o.0.dist(point(1)) < 1e-9 && o.1 == "100 deg" && o.2.is_empty()), "{own:?}"); - assert!(own.iter().any(|o| o.0.dist(point(3)) < 1e-9 && o.1 == "90 deg" && o.2 == "15 mm"), "{own:?}"); - let volume = |app: &mut SolveApp| app.session.execute("inspect.measure", &json!({})).unwrap()["total"]["volume_mm3"].as_f64().unwrap(); - let before = volume(&mut app); - let c = apply_commands(&app, &e).unwrap(); - assert_eq!(c[0].0, "timeline.redefine"); - assert_eq!(c[0].1["params"]["edges"][1], json!({"point": pt(point(1)), "angle": "100 deg"})); - assert_eq!(c[0].1["params"]["edges"][3]["height"], "15 mm"); - run_all(&mut app, c); - let after = volume(&mut app); - assert!((after - before).abs() < 1e-6 * before, "{after} vs {before}"); - // Taking edges out of the selection leaves each value with its edge. - e.inputs[0].items.remove(2); - e.inputs[0].items.remove(0); - let c = apply_commands(&app, &e).unwrap(); - assert_eq!( - c[0].1["params"]["edges"], - json!([{"point": pt(point(1)), "angle": "100 deg"}, {"point": pt(point(3)), "angle": "90 deg", "height": "15 mm"}]) - ); } /// Convert takes a fixed face and, for a solid or shelled body, its bend edges; Edit Feature @@ -611,8 +648,8 @@ fn convert_takes_the_fixed_face_and_the_bend_edges() { // ---- flat pattern navigation ---- -fn flat_app(preset: &str) -> SolveApp { - let mut app = plate(); +fn flat_app(preset: &str, mode: crate::shell::LayoutMode) -> SolveApp { + let mut app = plate(mode); app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap(); app.start("sheet.flat_pattern"); @@ -652,82 +689,85 @@ fn frame(app: &mut SolveApp, ctx: &egui::Context, time: &mut f64, mods: egui::Mo /// cursor. The flat view never orbits. #[test] fn flat_pattern_pans_and_zooms_like_the_design_viewport() { - use egui::{Event, Modifiers, PointerButton}; - let none = Modifiers::NONE; - let chords = [none, Modifiers::SHIFT, Modifiers::CTRL, Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT]; - for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) { - for (reverse, to_cursor) in [(false, true), (true, true), (false, false)] { - let (flat_ctx, design_ctx) = (egui::Context::default(), egui::Context::default()); - let mut flat = flat_app(preset); - let mut design = flat_app(preset); - design.dialog = None; - for app in [&mut flat, &mut design] { - app.preferences.zoom_reverse = reverse; - app.preferences.zoom_to_cursor = to_cursor; - } - let (mut ft, mut dt) = (0.0, 0.0); - frame(&mut flat, &flat_ctx, &mut ft, none, vec![]); - frame(&mut flat, &flat_ctx, &mut ft, none, vec![]); - assert!(flat.flat_view.fitted); - // The Design viewport starts from the flat view's camera: top-down, orthographic. - design.cam = flat.flat_view.cam; - design.ui.perspective = false; - let design_before = flat.cam; - let (mut panned, mut zoomed) = (0, 0); - let at = pos2(300.0, 250.0); - for button in [PointerButton::Primary, PointerButton::Middle, PointerButton::Secondary] { - for mods in chords { - let mode = crate::prefs::nav_mode(&flat, button.into_nav(), mods); - if !matches!(mode, Some(crate::viewport::NavMode::Pan | crate::viewport::NavMode::Zoom)) { - continue; - } - let before = flat.flat_view.cam; - let mut both = |events: Vec, mods: Modifiers, flat: &mut SolveApp, design: &mut SolveApp| { - frame(flat, &flat_ctx, &mut ft, mods, events.clone()); - frame(design, &design_ctx, &mut dt, mods, events); - }; - both(vec![Event::PointerMoved(at)], mods, &mut flat, &mut design); - both(vec![Event::PointerButton { pos: at, button, pressed: true, modifiers: mods }], mods, &mut flat, &mut design); - let mut p = at; - for _ in 0..5 { - p += vec2(14.0, -9.0); - both(vec![Event::PointerMoved(p)], mods, &mut flat, &mut design); - } - both(vec![Event::PointerButton { pos: p, button, pressed: false, modifiers: mods }], mods, &mut flat, &mut design); - for _ in 0..10 { - both(vec![], none, &mut flat, &mut design); - } - assert!(same_view(&flat.flat_view.cam, &design.cam), "{preset}: {button:?} drag with {mods:?} ({mode:?})"); - assert_ne!(flat.flat_view.cam, before, "{preset}: {button:?} drag with {mods:?} moves the flat view"); - if mode == Some(crate::viewport::NavMode::Pan) { - panned += 1 - } else { - zoomed += 1 - } - flat.flat_view.cam = before; - design.cam = before; + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + use egui::{Event, Modifiers, PointerButton}; + let none = Modifiers::NONE; + let chords = [none, Modifiers::SHIFT, Modifiers::CTRL, Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT]; + for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) { + for (reverse, to_cursor) in [(false, true), (true, true), (false, false)] { + let (flat_ctx, design_ctx) = (egui::Context::default(), egui::Context::default()); + let mut flat = flat_app(preset, mode); + let mut design = flat_app(preset, mode); + design.dialog = None; + for app in [&mut flat, &mut design] { + app.preferences.zoom_reverse = reverse; + app.preferences.zoom_to_cursor = to_cursor; } - } - assert!(panned + zoomed >= 1, "{preset}: the preset has a pan or zoom drag"); - // The wheel zooms (to the cursor when asked), identically. - let before = flat.flat_view.cam; - let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, 3.0), modifiers: none, phase: egui::TouchPhase::Move }; - for (app, ctx, t) in [(&mut flat, &flat_ctx, &mut ft), (&mut design, &design_ctx, &mut dt)] { - frame(app, ctx, t, none, vec![Event::PointerMoved(at)]); - frame(app, ctx, t, none, vec![wheel.clone()]); - for _ in 0..30 { - frame(app, ctx, t, none, vec![]); + let (mut ft, mut dt) = (0.0, 0.0); + frame(&mut flat, &flat_ctx, &mut ft, none, vec![]); + frame(&mut flat, &flat_ctx, &mut ft, none, vec![]); + assert!(flat.flat_view.fitted); + // The Design viewport starts from the flat view's camera: top-down, orthographic. + design.cam = flat.flat_view.cam; + design.ui.perspective = false; + let design_before = flat.cam; + let (mut panned, mut zoomed) = (0, 0); + let at = pos2(300.0, 250.0); + for button in [PointerButton::Primary, PointerButton::Middle, PointerButton::Secondary] { + for mods in chords { + let mode = crate::prefs::nav_mode(&flat, button.into_nav(), mods); + if !matches!(mode, Some(crate::viewport::NavMode::Pan | crate::viewport::NavMode::Zoom)) { + continue; + } + let before = flat.flat_view.cam; + let mut both = |events: Vec, mods: Modifiers, flat: &mut SolveApp, design: &mut SolveApp| { + frame(flat, &flat_ctx, &mut ft, mods, events.clone()); + frame(design, &design_ctx, &mut dt, mods, events); + }; + both(vec![Event::PointerMoved(at)], mods, &mut flat, &mut design); + both(vec![Event::PointerButton { pos: at, button, pressed: true, modifiers: mods }], mods, &mut flat, &mut design); + let mut p = at; + for _ in 0..5 { + p += vec2(14.0, -9.0); + both(vec![Event::PointerMoved(p)], mods, &mut flat, &mut design); + } + both(vec![Event::PointerButton { pos: p, button, pressed: false, modifiers: mods }], mods, &mut flat, &mut design); + for _ in 0..10 { + both(vec![], none, &mut flat, &mut design); + } + assert!(same_view(&flat.flat_view.cam, &design.cam), "{preset}: {button:?} drag with {mods:?} ({mode:?})"); + assert_ne!(flat.flat_view.cam, before, "{preset}: {button:?} drag with {mods:?} moves the flat view"); + if mode == Some(crate::viewport::NavMode::Pan) { + panned += 1 + } else { + zoomed += 1 + } + flat.flat_view.cam = before; + design.cam = before; + } } + assert!(panned + zoomed >= 1, "{preset}: the preset has a pan or zoom drag"); + // The wheel zooms (to the cursor when asked), identically. + let before = flat.flat_view.cam; + let wheel = + Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, 3.0), modifiers: none, phase: egui::TouchPhase::Move }; + for (app, ctx, t) in [(&mut flat, &flat_ctx, &mut ft), (&mut design, &design_ctx, &mut dt)] { + frame(app, ctx, t, none, vec![Event::PointerMoved(at)]); + frame(app, ctx, t, none, vec![wheel.clone()]); + for _ in 0..30 { + frame(app, ctx, t, none, vec![]); + } + } + assert_ne!(flat.flat_view.cam.distance, before.distance, "{preset}: the wheel zooms the flat view"); + assert!(same_view(&flat.flat_view.cam, &design.cam), "{preset} reverse {reverse} cursor {to_cursor}: wheel zoom"); + assert_eq!(flat.flat_view.cam.distance < before.distance, !reverse, "{preset}: reverse zoom flips the wheel"); + if to_cursor { + assert!(flat.flat_view.cam.target.dist(before.target) > 1e-6, "{preset}: zoom to the cursor moves the target"); + } + assert_eq!(flat.flat_view.cam.pitch, before.pitch); + assert_eq!(flat.flat_view.cam.yaw, before.yaw); + assert_eq!(flat.cam, design_before, "{preset}: the Design camera is untouched"); } - assert_ne!(flat.flat_view.cam.distance, before.distance, "{preset}: the wheel zooms the flat view"); - assert!(same_view(&flat.flat_view.cam, &design.cam), "{preset} reverse {reverse} cursor {to_cursor}: wheel zoom"); - assert_eq!(flat.flat_view.cam.distance < before.distance, !reverse, "{preset}: reverse zoom flips the wheel"); - if to_cursor { - assert!(flat.flat_view.cam.target.dist(before.target) > 1e-6, "{preset}: zoom to the cursor moves the target"); - } - assert_eq!(flat.flat_view.cam.pitch, before.pitch); - assert_eq!(flat.flat_view.cam.yaw, before.yaw); - assert_eq!(flat.cam, design_before, "{preset}: the Design camera is untouched"); } } } @@ -749,66 +789,73 @@ impl IntoNav for egui::PointerButton { /// camera and selection alone. #[test] fn design_camera_is_unchanged_while_the_flat_pattern_has_focus() { - use egui::{Event, Modifiers, PointerButton}; - let none = Modifiers::NONE; - for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) { - let ctx = egui::Context::default(); - let mut app = flat_app(preset); - let mut t = 0.0; - frame(&mut app, &ctx, &mut t, none, vec![]); - let cam = app.cam; - let at = pos2(300.0, 250.0); - for button in [PointerButton::Primary, PointerButton::Middle, PointerButton::Secondary] { - for mods in [none, Modifiers::SHIFT, Modifiers::CTRL, Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT] { - frame(&mut app, &ctx, &mut t, mods, vec![Event::PointerMoved(at)]); - frame(&mut app, &ctx, &mut t, mods, vec![Event::PointerButton { pos: at, button, pressed: true, modifiers: mods }]); - for i in 1..6 { - frame(&mut app, &ctx, &mut t, mods, vec![Event::PointerMoved(at + vec2(14.0, -9.0) * i as f32)]); - } - frame(&mut app, &ctx, &mut t, mods, vec![Event::PointerButton { pos: at, button, pressed: false, modifiers: mods }]); - } - } - let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, 3.0), modifiers: none, phase: egui::TouchPhase::Move }; - frame(&mut app, &ctx, &mut t, none, vec![wheel]); - frame(&mut app, &ctx, &mut t, none, vec![Event::Zoom(1.3)]); - for _ in 0..30 { + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + use egui::{Event, Modifiers, PointerButton}; + let none = Modifiers::NONE; + for preset in crate::prefs::NAV_PRESETS.iter().map(|p| p.id) { + let ctx = egui::Context::default(); + let mut app = flat_app(preset, mode); + let mut t = 0.0; frame(&mut app, &ctx, &mut t, none, vec![]); + let cam = app.cam; + let at = pos2(300.0, 250.0); + for button in [PointerButton::Primary, PointerButton::Middle, PointerButton::Secondary] { + for mods in [none, Modifiers::SHIFT, Modifiers::CTRL, Modifiers::ALT, Modifiers::SHIFT | Modifiers::ALT] { + frame(&mut app, &ctx, &mut t, mods, vec![Event::PointerMoved(at)]); + frame(&mut app, &ctx, &mut t, mods, vec![Event::PointerButton { pos: at, button, pressed: true, modifiers: mods }]); + for i in 1..6 { + frame(&mut app, &ctx, &mut t, mods, vec![Event::PointerMoved(at + vec2(14.0, -9.0) * i as f32)]); + } + frame(&mut app, &ctx, &mut t, mods, vec![Event::PointerButton { pos: at, button, pressed: false, modifiers: mods }]); + } + } + let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, 3.0), modifiers: none, phase: egui::TouchPhase::Move }; + frame(&mut app, &ctx, &mut t, none, vec![wheel]); + frame(&mut app, &ctx, &mut t, none, vec![Event::Zoom(1.3)]); + for _ in 0..30 { + frame(&mut app, &ctx, &mut t, none, vec![]); + } + assert_eq!(app.cam, cam, "{preset}: the Design camera stays put under the flat pattern"); + assert!(app.session.selection.is_empty(), "{preset}: nothing underneath is selected"); + assert_eq!(app.flat_view.cam.pitch, app.flat_view.cam.pitch.clamp(1.5, 2.0), "{preset}: still looking straight down"); } - assert_eq!(app.cam, cam, "{preset}: the Design camera stays put under the flat pattern"); - assert!(app.session.selection.is_empty(), "{preset}: nothing underneath is selected"); - assert_eq!(app.flat_view.cam.pitch, app.flat_view.cam.pitch.clamp(1.5, 2.0), "{preset}: still looking straight down"); } } /// Fit and Home frame the flat pattern (not the model), and leave the Design camera alone. #[test] fn fit_and_home_frame_the_flat_pattern() { - let ctx = egui::Context::default(); - let mut app = flat_app("fusion"); - let mut t = 0.0; - let none = egui::Modifiers::NONE; - frame(&mut app, &ctx, &mut t, none, vec![]); - let fitted = app.flat_view.cam; - // The 100 x 60 pattern sits inside the view with room for the command panel. - let scale = 600.0 / (2.0 * fitted.half_height()); - assert!(100.0 * scale < 800.0 && 60.0 * scale < 600.0 && 100.0 * scale > 200.0, "fits: {scale}"); - let design = app.cam; - for view in ["fit", "home"] { - app.flat_view.cam.pan(120.0, -80.0, 600.0); - app.flat_view.cam.zoom_at(0.3, None); - assert_ne!(app.flat_view.cam, fitted); - app.animate_view(view); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let ctx = egui::Context::default(); + let mut app = flat_app("fusion", mode); + let mut t = 0.0; + let none = egui::Modifiers::NONE; frame(&mut app, &ctx, &mut t, none, vec![]); - assert!(app.flat_view.cam.target.dist(fitted.target) < 1e-9 && (app.flat_view.cam.distance - fitted.distance).abs() < 1e-9, "{view} refits"); - assert_eq!(app.cam, design, "{view} leaves the Design camera alone"); + let fitted = app.flat_view.cam; + // The 100 x 60 pattern sits inside the view with room for the command panel. + let scale = 600.0 / (2.0 * fitted.half_height()); + assert!(100.0 * scale < 800.0 && 60.0 * scale < 600.0 && 100.0 * scale > 200.0, "fits: {scale}"); + let design = app.cam; + for view in ["fit", "home"] { + app.flat_view.cam.pan(120.0, -80.0, 600.0); + app.flat_view.cam.zoom_at(0.3, None); + assert_ne!(app.flat_view.cam, fitted); + app.animate_view(view); + frame(&mut app, &ctx, &mut t, none, vec![]); + assert!( + app.flat_view.cam.target.dist(fitted.target) < 1e-9 && (app.flat_view.cam.distance - fitted.distance).abs() < 1e-9, + "{view} refits" + ); + assert_eq!(app.cam, design, "{view} leaves the Design camera alone"); + } + app.flat_view.cam.pan(50.0, 50.0, 600.0); + app.fit_view(); + frame(&mut app, &ctx, &mut t, none, vec![]); + assert!(app.flat_view.cam.target.dist(fitted.target) < 1e-9, "fit_view refits"); + // Closing the dialog returns the viewport to the Design camera, and reopening refits. + app.flat_view.cam.pan(50.0, 50.0, 600.0); + app.dialog = None; + frame(&mut app, &ctx, &mut t, none, vec![]); + assert!(!app.flat_view.fitted); } - app.flat_view.cam.pan(50.0, 50.0, 600.0); - app.fit_view(); - frame(&mut app, &ctx, &mut t, none, vec![]); - assert!(app.flat_view.cam.target.dist(fitted.target) < 1e-9, "fit_view refits"); - // Closing the dialog returns the viewport to the Design camera, and reopening refits. - app.flat_view.cam.pan(50.0, 50.0, 600.0); - app.dialog = None; - frame(&mut app, &ctx, &mut t, none, vec![]); - assert!(!app.flat_view.fitted); } diff --git a/crates/ui-egui/src/extrude_edit_tests.rs b/crates/ui-egui/src/extrude_edit_tests.rs index 05600cfe60..5a2dab69ae 100644 --- a/crates/ui-egui/src/extrude_edit_tests.rs +++ b/crates/ui-egui/src/extrude_edit_tests.rs @@ -304,6 +304,8 @@ fn symmetric_total_distance_preview_handle_drag_apply_and_reopen_agree() { let mut h = make(json!({"direction":"symmetric","distance":"100 mm","convention":convention})); h.call("ui.view", json!({"view":"fit"})); h.frames(4); + // Leave room above the fitted solid to grow the arrow without entering the ribbon. + h.app.cam.distance *= 1.5; open(&mut h); let expected_tip = h.at(&json!({"world":[5,5,cap]})).unwrap(); let handle = h.at(&json!({"handle":"arrow"})).unwrap(); diff --git a/crates/ui-egui/src/frame.rs b/crates/ui-egui/src/frame.rs index e9ee18701e..d80da2b255 100644 --- a/crates/ui-egui/src/frame.rs +++ b/crates/ui-egui/src/frame.rs @@ -243,84 +243,90 @@ pub fn right_aligned(ui: &mut Ui, total: f32, width: f32, add: impl FnOnce(&m mod tests { #[test] fn feature_forms_use_available_height_and_keep_actions_visible() { - use crate::scenario::Harness; - use serde_json::json; - for (width, height, scale) in [(1400.0, 900.0, 1.0), (1400.0, 950.0, 2.0)] { - let mut h = Harness::new(); - h.app.preferences.ui_scale = scale; - h.call("ui.resize", json!({"width":width / scale,"height":height / scale})); - h.app.run("sketch.create", json!({"plane":"XY","name":"Base"})).unwrap(); - h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]})).unwrap(); - h.app.run("sketch.finish", json!({})).unwrap(); - h.step(&json!({"start":"solid.extrude"})).unwrap(); - h.frames(10); - let dialog = h.app.viewport.dialog_rect.unwrap(); - let viewport = h.app.viewport.rect.unwrap(); - assert!(dialog.height() > 230.0, "scale={scale}: {dialog:?}"); - assert!(viewport.expand(1.0).contains_rect(dialog), "scale={scale}: {dialog:?} outside {viewport:?}"); - assert!(h.widget("Distance").is_some(), "distance must be visible without scrolling"); - if scale == 1.0 { - assert!(h.widget("Operation").is_some(), "operation must be visible without scrolling"); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + use crate::scenario::Harness; + use serde_json::json; + for (width, height, scale) in [(1400.0, 900.0, 1.0), (1400.0, 950.0, 2.0)] { + let mut h = Harness::new(); + h.call("ui.set", json!({"layout":mode})); + h.app.preferences.ui_scale = scale; + h.call("ui.resize", json!({"width":width / scale,"height":height / scale})); + h.app.run("sketch.create", json!({"plane":"XY","name":"Base"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.step(&json!({"start":"solid.extrude"})).unwrap(); + h.frames(10); + let dialog = h.app.viewport.dialog_rect.unwrap(); + let viewport = h.app.viewport.rect.unwrap(); + assert!(dialog.height() > 230.0, "scale={scale}: {dialog:?}"); + assert!(viewport.expand(1.0).contains_rect(dialog), "scale={scale}: {dialog:?} outside {viewport:?}"); + assert!(h.widget("Distance").is_some(), "distance must be visible without scrolling"); + if scale == 1.0 { + assert!(h.widget("Operation").is_some(), "operation must be visible without scrolling"); + } + assert!(dialog.contains(crate::scenario::handle_at("dialog:OK").unwrap())); + assert!(h.widget("Cancel").is_some(), "cancel must be visible without scrolling"); + h.step(&json!({"widget":"Cancel"})).unwrap(); + assert!(h.app.dialog.is_none()); + h.step(&json!({"start":"solid.shell"})).unwrap(); + h.frames(8); + let short = h.app.viewport.dialog_rect.unwrap(); + assert!(short.height() < dialog.height(), "short forms should shrink: {short:?}, {dialog:?}"); + assert!(h.widget("Cancel").is_some()); + h.step(&json!({"widget":"Cancel"})).unwrap(); + h.step(&json!({"start":"solid.hole"})).unwrap(); + if let Some(crate::dialogs::Dialog { kind: crate::dialogs::Kind::Hole { kind, opts, .. }, .. }) = h.app.dialog.as_mut() { + *kind = 1; + opts.tapped = true; + } + h.frames(8); + let long = h.app.viewport.dialog_rect.unwrap(); + assert!(viewport.expand(1.0).contains_rect(long), "long form clipped: {long:?}"); + assert!(h.widget("Cancel").is_some(), "long-form actions must stay visible"); + assert!(long.contains(crate::scenario::handle_at("dialog:OK").unwrap())); + h.step(&json!({"widget":"Cancel"})).unwrap(); + assert!(h.app.dialog.is_none()); } - assert!(dialog.contains(crate::scenario::handle_at("dialog:OK").unwrap())); - assert!(h.widget("Cancel").is_some(), "cancel must be visible without scrolling"); - h.step(&json!({"widget":"Cancel"})).unwrap(); - assert!(h.app.dialog.is_none()); - h.step(&json!({"start":"solid.shell"})).unwrap(); - h.frames(8); - let short = h.app.viewport.dialog_rect.unwrap(); - assert!(short.height() < dialog.height(), "short forms should shrink: {short:?}, {dialog:?}"); - assert!(h.widget("Cancel").is_some()); - h.step(&json!({"widget":"Cancel"})).unwrap(); - h.step(&json!({"start":"solid.hole"})).unwrap(); - if let Some(crate::dialogs::Dialog { kind: crate::dialogs::Kind::Hole { kind, opts, .. }, .. }) = h.app.dialog.as_mut() { - *kind = 1; - opts.tapped = true; - } - h.frames(8); - let long = h.app.viewport.dialog_rect.unwrap(); - assert!(viewport.expand(1.0).contains_rect(long), "long form clipped: {long:?}"); - assert!(h.widget("Cancel").is_some(), "long-form actions must stay visible"); - assert!(long.contains(crate::scenario::handle_at("dialog:OK").unwrap())); - h.step(&json!({"widget":"Cancel"})).unwrap(); - assert!(h.app.dialog.is_none()); } } /// #1335: a dialog keeps its size while its Select chip is hovered, and its title band /// drags it; it opens docked again after it closes. #[test] fn a_docked_dialog_keeps_its_size_under_the_pointer_and_moves_by_its_title() { - use crate::scenario::Harness; - use serde_json::json; - let mut h = Harness::new(); - h.call("ui.resize", json!({"width":1400.0,"height":900.0})); - h.step(&json!({"start":"sketch.create"})).unwrap(); - h.frames(6); - let rest = h.app.viewport.dialog_rect.unwrap(); - let chip = crate::scenario::handle_at("dialog:Plane").unwrap(); - h.call("ui.move", json!({"x": chip.x, "y": chip.y})); - h.frames(6); - let hovered = h.app.viewport.dialog_rect.unwrap(); - assert!((hovered.size() - rest.size()).length() < 0.01, "hovering resized the dialog: {rest:?} -> {hovered:?}"); - // Drag the published title band, independent of similarly labelled toolbar controls. - let title = crate::scenario::handle_at("dialog:title").unwrap(); - h.call("ui.drag", json!({"x0": title.x, "y0": title.y, "x1": title.x - 300.0, "y1": title.y + 120.0, "steps": 8})); - h.frames(4); - let moved = h.app.viewport.dialog_rect.unwrap(); - assert!((moved.min - rest.min - egui::vec2(-300.0, 120.0)).length() < 2.0, "{rest:?} -> {moved:?}"); - assert!(h.app.dialog.is_some(), "dragging the title keeps the dialog open"); - // Dragged far off, it stays on screen. - let title = crate::scenario::handle_at("dialog:title").unwrap(); - h.call("ui.drag", json!({"x0": title.x, "y0": title.y, "x1": title.x + 3000.0, "y1": title.y + 3000.0, "steps": 8})); - h.frames(4); - let viewport = h.app.viewport.rect.unwrap(); - assert!(viewport.contains(h.app.viewport.dialog_rect.unwrap().min)); - h.step(&json!({"key":"Escape"})).unwrap(); - h.frames(2); - assert!(h.app.dialog.is_none()); - h.step(&json!({"start":"sketch.create"})).unwrap(); - h.frames(6); - assert!((h.app.viewport.dialog_rect.unwrap().min - rest.min).length() < 0.01, "a new dialog opens docked"); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + use crate::scenario::Harness; + use serde_json::json; + let mut h = Harness::new(); + h.call("ui.set", json!({"layout":mode})); + h.call("ui.resize", json!({"width":1400.0,"height":900.0})); + h.step(&json!({"start":"sketch.create"})).unwrap(); + h.frames(6); + let rest = h.app.viewport.dialog_rect.unwrap(); + let chip = crate::scenario::handle_at("dialog:Plane").unwrap(); + h.call("ui.move", json!({"x": chip.x, "y": chip.y})); + h.frames(6); + let hovered = h.app.viewport.dialog_rect.unwrap(); + assert!((hovered.size() - rest.size()).length() < 0.01, "hovering resized the dialog: {rest:?} -> {hovered:?}"); + // Drag the published title band, independent of similarly labelled toolbar controls. + let title = crate::scenario::handle_at("dialog:title").unwrap(); + h.call("ui.drag", json!({"x0": title.x, "y0": title.y, "x1": title.x - 300.0, "y1": title.y + 120.0, "steps": 8})); + h.frames(4); + let moved = h.app.viewport.dialog_rect.unwrap(); + assert!((moved.min - rest.min - egui::vec2(-300.0, 120.0)).length() < 2.0, "{rest:?} -> {moved:?}"); + assert!(h.app.dialog.is_some(), "dragging the title keeps the dialog open"); + // Dragged far off, it stays on screen. + let title = crate::scenario::handle_at("dialog:title").unwrap(); + h.call("ui.drag", json!({"x0": title.x, "y0": title.y, "x1": title.x + 3000.0, "y1": title.y + 3000.0, "steps": 8})); + h.frames(4); + let viewport = h.app.viewport.rect.unwrap(); + assert!(viewport.contains(h.app.viewport.dialog_rect.unwrap().min)); + h.step(&json!({"key":"Escape"})).unwrap(); + h.frames(2); + assert!(h.app.dialog.is_none()); + h.step(&json!({"start":"sketch.create"})).unwrap(); + h.frames(6); + assert!((h.app.viewport.dialog_rect.unwrap().min - rest.min).length() < 0.01, "a new dialog opens docked"); + } } /// Windows are made in this file only, so none can skip the width limits. diff --git a/crates/ui-egui/src/infinite_line_display_tests.rs b/crates/ui-egui/src/infinite_line_display_tests.rs index bbb3bcc548..fc0cb52e36 100644 --- a/crates/ui-egui/src/infinite_line_display_tests.rs +++ b/crates/ui-egui/src/infinite_line_display_tests.rs @@ -27,10 +27,15 @@ fn it_is_drawn_from_edge_to_edge_of_the_view() { let ss = st.sketch(h.app.session.active_sketch.unwrap()).unwrap(); let i = ss.sketch.curve_index("inf").unwrap(); let parts = super::curve_parts(&ss.sketch, i, &ss.plane, &proj, 1.0); - let xs: Vec = parts.iter().flatten().filter_map(|p| proj.to_screen(*p)).map(|p| p.x).collect(); - let (lo, hi) = xs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), x| (lo.min(*x), hi.max(*x))); - // Its two points are a few pixels apart; drawn, it spans the view. - assert!(lo <= rect.left() + 2.0 && hi >= rect.right() - 2.0, "drawn from {lo} to {hi} in {rect:?}"); + let screen: Vec<_> = parts.iter().flatten().filter_map(|p| proj.to_screen(*p)).collect(); + let (first, last) = (screen.first().unwrap(), screen.last().unwrap()); + // Its two defining points are a few pixels apart; drawn, it spans the view. At the + // default isometric angle a wide viewport clips the line at the top and bottom instead + // of the left and right, so either pair of edges is valid. + for point in [first, last] { + assert!(rect.expand(2.0).contains(*point) && !rect.shrink(2.0).contains(*point), "endpoint {point:?} must reach an edge of {rect:?}"); + } + assert!(first.distance(*last) >= rect.width().min(rect.height()) - 4.0, "line must span the view: {screen:?} in {rect:?}"); } #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4f6ea11075..57d861d527 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -158,6 +158,7 @@ pub mod save_job; pub mod scenario; pub mod select_other; pub mod selection; +pub mod shell; #[cfg(test)] mod shell_face_sketch_ui_tests; pub mod shortcut_box; @@ -228,6 +229,8 @@ pub use control::{ControlRequest, ControlResponse}; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { + /// Automation-only shell mode; never persisted in preferences. + pub layout: shell::LayoutMode, /// Ribbon tab; contextual SKETCH and SHEET METAL join the seven design tabs. pub tab: String, /// Last contextual body/sketch, only used to select on entry. @@ -265,6 +268,7 @@ pub struct UiState { impl Default for UiState { fn default() -> Self { UiState { + layout: Default::default(), tab: "MODEL".into(), ribbon_context: None, show_browser: true, @@ -324,6 +328,7 @@ pub struct Services { pub struct SolveApp { pub session: Session, pub ui: UiState, + pub(crate) shell: shell::State, pub cam: Camera, /// A view change in progress (axis view, Home, Fit); any mouse navigation cancels it. pub cam_anim: Option, @@ -486,6 +491,7 @@ impl SolveApp { SolveApp { session, ui: UiState::default(), + shell: Default::default(), cam: Camera::default(), cam_anim: None, now: 0.0, @@ -1487,16 +1493,42 @@ impl SolveApp { return; } } - toolbar::toolbar(self, ui); - theme::TabAccent::publish(self, ui.ctx()); + let floating = self.ui.layout == shell::LayoutMode::Floating; let show_timeline = self.ui.show_timeline && !render_workspace::active(self); let left_timeline = show_timeline && self.preferences.timeline_left; + if !floating { + toolbar::toolbar(self, ui); + } + theme::TabAccent::publish(self, ui.ctx()); if show_timeline && !left_timeline { timeline::timeline(self, ui); } palette::palette_bar(self, ui); - // A floating browser keeps the workspace the whole width: it is drawn over the view below. + // A floating browser keeps the workspace the whole width and covers viewport input. let browser_float = self.ui.show_browser && self.preferences.browser_float; + if !browser_float { + self.shell.browser = None; + } + let full = ui.available_rect_before_wrap(); + if floating { + let cards = shell::Cards::new(full, self.ui.layout); + self.shell.cards = vec![cards.workspace, cards.ribbon]; + // Draw the scene first, then let the unchanged panel layouts cover it. The + // viewport keeps its full projection even underneath the browser and cards. + preview::update(self, ui.ctx()); + let blocked = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| shell::covered(self, p)); + let mut viewport_ui = ui.new_child(egui::UiBuilder::new().max_rect(full).id_salt("floating_viewport")); + if blocked { + // Disabled only to shield the scene from input under a card or menu; it must + // not fade, or the whole viewport dims whenever the pointer is over the shell. + viewport_ui.visuals_mut().disabled_alpha = 1.0; + viewport_ui.disable(); + } + self.show_workspace(&mut viewport_ui); + shell::paint_top_corners(ui.painter(), full, theme::Tokens::get().app_bar); + toolbar::toolbar(self, ui); + theme::TabAccent::publish(self, ui.ctx()); + } if self.ui.show_browser && !browser_float { if left_timeline { browser::browser_below_timeline(self, ui); @@ -1510,17 +1542,26 @@ impl SolveApp { render_workspace::panel(self, ui); } agent_chat::show(self, ui); - preview::update(self, ui.ctx()); if browser_float { - // The room the central view is about to take, before it takes it. + // The remaining content starts below the ribbon in either shell layout. let viewport = ui.available_rect_before_wrap(); browser::floating(self, ui, viewport); } egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { - if render_workspace::active(self) { - render_workspace::show(self, ui); + if floating { + let content = ui.max_rect(); + self.shell.content = Some(content); + self.shell.panels = [ + egui::Rect::from_min_max(egui::pos2(full.left(), content.top()), egui::pos2(content.left(), full.bottom())), + egui::Rect::from_min_max(egui::pos2(content.right(), content.top()), full.max), + ] + .into_iter() + .filter(|r| r.is_positive()) + .collect(); + viewport::refresh_layout(self, ui.ctx().pixels_per_point()); } else { - viewport::show(self, ui); + preview::update(self, ui.ctx()); + self.show_workspace(ui); } }); dialogs::show(self, ui.ctx()); @@ -1542,6 +1583,14 @@ impl SolveApp { toolbar_custom::window(self, ui.ctx()); } + fn show_workspace(&mut self, ui: &mut egui::Ui) { + if render_workspace::active(self) { + render_workspace::show(self, ui); + } else { + viewport::show(self, ui); + } + } + fn drain_control(&mut self, ctx: &egui::Context) { /// Requests run on this thread, so a flood would starve rendering. The rest stay /// queued for the next frame. diff --git a/crates/ui-egui/src/navigation.rs b/crates/ui-egui/src/navigation.rs index 657d5d423e..982bf9fdbf 100644 --- a/crates/ui-egui/src/navigation.rs +++ b/crates/ui-egui/src/navigation.rs @@ -272,6 +272,7 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e i.pointer.delta(), ) }); + let hover = hover.filter(|p| !crate::shell::covered(app, *p)); // The right button belongs to the marking menu while it is open. let secondary_down = secondary_down && app.viewport.context_menu.is_none(); // The axis view handles its own clicks; the model underneath must not see them. @@ -280,7 +281,7 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e // hit test also counts the model as under the pointer a few points past its own edge and // inside a window's or a list's edge (#678), so the pointer must be in the viewport too, // with no window or menu above it. - let inside = resp.contains_pointer() && ui.rect_contains_pointer(rect) && hover.is_some_and(|p| !axes.contains(p)); + let inside = ui.is_enabled() && resp.contains_pointer() && ui.rect_contains_pointer(rect) && hover.is_some_and(|p| !axes.contains(p)); app.viewport.mouse = hover.filter(|_| inside); // Sketch snap suppression is set once in `viewport::show` from preferences. middle_double_click(app, ui, inside); diff --git a/crates/ui-egui/src/navigation_cube.rs b/crates/ui-egui/src/navigation_cube.rs index fa7f4ff382..f3525f9e86 100644 --- a/crates/ui-egui/src/navigation_cube.rs +++ b/crates/ui-egui/src/navigation_cube.rs @@ -155,7 +155,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let t = Tokens::get().navigation_cube(active); crate::scenario::publish_count("navigation_cube:active", f64::from(u8::from(active))); let faces = facets(&app.cam, c, HALF * f64::from(scale)); - let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect)); + let hover = ui.input(|i| i.pointer.hover_pos()).filter(|p| ui.is_enabled() && ui.rect_contains_pointer(rect) && !crate::shell::covered(app, *p)); let hovered = if orbiting { None } else { hover.and_then(|p| hit(&faces, p)) }; let painter = ui.painter_at(outer); paint_cube(&painter, &faces, hovered, t, true, scale); diff --git a/crates/ui-egui/src/pattern_arrow_tests.rs b/crates/ui-egui/src/pattern_arrow_tests.rs index 6aad4a42de..68d7a11ee2 100644 --- a/crates/ui-egui/src/pattern_arrow_tests.rs +++ b/crates/ui-egui/src/pattern_arrow_tests.rs @@ -29,6 +29,9 @@ fn a_sketch_pattern_drags_its_second_spacing_square_to_the_first() { {"click": {"sketch": [5, 0]}} ])) .unwrap(); + // Keep both arrows in the exposed viewport beside the left dialog. The full-bleed + // projection otherwise puts Spacing 2 under the dialog's top edge. + h.call("ui.camera", json!({"target":[-30,0,0]})); h.settle(); let (Some(a1), Some(a2)) = (handle_at("arrow"), handle_at("arrow2")) else { panic!("both arrows: {:?} {:?}", handle_at("arrow"), handle_at("arrow2")); @@ -36,6 +39,8 @@ fn a_sketch_pattern_drags_its_second_spacing_square_to_the_first() { // Looking down on XY, the first direction (sketch x) points right and the second (square to // it) up the screen: the arrows share their base, so each lies along its own axis. assert!(a1.x > a2.x + 10.0 && (a2.y - a1.y).abs() > 10.0, "arrow {a1:?}, arrow2 {a2:?}"); + assert!(!crate::shell::covered(&h.app, a1)); + assert!(!crate::shell::covered(&h.app, a2)); let (spacing, spacing2) = spacings(&h); // Dragging the second arrow further out sets Spacing 2 only. h.call("ui.drag", json!({"x0": a2.x, "y0": a2.y, "x1": a2.x, "y1": a2.y - 60.0, "steps": 6})); diff --git a/crates/ui-egui/src/revolve_edit_tests.rs b/crates/ui-egui/src/revolve_edit_tests.rs index fa56996047..7a82ba9969 100644 --- a/crates/ui-egui/src/revolve_edit_tests.rs +++ b/crates/ui-egui/src/revolve_edit_tests.rs @@ -128,7 +128,10 @@ fn second_active_occurrence_noop_preserves_definition_axis_target_and_instances( assert!(placed.mesh().triangles.len() > 10); assert_eq!(placed.mesh().bounds(), before.iter().find(|(_, bounds)| bounds.center().x > 50.0).unwrap().1); assert!(h.widget("↖ Custom Axis").is_some()); + // In the floating shell the read-only Sweep badge overlaps OK at this placement. + // It must not intercept the dialog's click or leave the timeline rolled back. h.step(&json!({"widget":"OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "OK must apply even underneath the Sweep badge"); assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original); let after: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect(); assert_eq!(after, before); diff --git a/crates/ui-egui/src/shell.rs b/crates/ui-egui/src/shell.rs new file mode 100644 index 0000000000..3f4cd186af --- /dev/null +++ b/crates/ui-egui/src/shell.rs @@ -0,0 +1,125 @@ +//! Transient shell geometry shared by automation, drawing and viewport occlusion. +use egui::{Rect, pos2, vec2}; +use serde::{Deserialize, Serialize}; + +use crate::{SolveApp, theme::Tokens, toolbar}; + +/// Shell frame selected by automation for this session. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LayoutMode { + #[default] + Floating, + Docked, +} + +/// The gap from the viewport edge to the floating cards and between the cards. +pub(crate) const SHELL_GAP: f32 = 7.0; + +/// The viewport's top corners are rounded concentrically with the cards: the card margin plus the +/// card radius. +pub(crate) fn corner_radius() -> f32 { + SHELL_GAP + f32::from(Tokens::get().radius_lg) +} + +/// Fill the two concave corners where the application bar flows into the viewport's top edge, so +/// the viewport reads as a rounded surface under the bar. +pub(crate) fn paint_top_corners(painter: &egui::Painter, viewport: Rect, color: egui::Color32) { + let r = corner_radius().min(viewport.width() / 2.0).min(viewport.height() / 2.0); + if !(r.is_finite() && r > 0.5) { + return; + } + const STEPS: usize = 32; + for left in [true, false] { + let (corner, centre, dir) = if left { + (viewport.left_top(), pos2(viewport.left() + r, viewport.top() + r), -1.0) + } else { + (viewport.right_top(), pos2(viewport.right() - r, viewport.top() + r), 1.0) + }; + let mut mesh = egui::Mesh::default(); + mesh.colored_vertex(corner, color); + for i in 0..=STEPS { + // From the top tangent point round to the side tangent point. + let a = std::f32::consts::FRAC_PI_2 * i as f32 / STEPS as f32; + mesh.colored_vertex(pos2(centre.x + dir * r * a.sin(), centre.y - r * a.cos()), color); + } + for i in 0..STEPS as u32 { + mesh.add_triangle(0, i + 1, i + 2); + } + painter.add(egui::Shape::mesh(mesh)); + } +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Cards { + pub workspace: Rect, + pub ribbon: Rect, + pub bottom: f32, +} + +impl Cards { + pub fn new(available: Rect, mode: LayoutMode) -> Self { + let margin = if mode == LayoutMode::Floating { SHELL_GAP } else { 0.0 }; + let gap = if mode == LayoutMode::Floating { SHELL_GAP } else { 1.0 }; + let left = available.left() + margin; + let top = available.top() + margin; + let right = (available.right() - margin).max(left); + let workspace = Rect::from_min_size( + pos2(left, top), + vec2(toolbar::WORKSPACE_WIDTH.min((right - left).max(0.0)), toolbar::RIBBON_HEIGHT - if mode == LayoutMode::Docked { 1.0 } else { 0.0 }), + ); + let ribbon = Rect::from_min_max(pos2((workspace.right() + gap).min(right), top), pos2(right, top + toolbar::RIBBON_HEIGHT)); + Self { workspace, ribbon, bottom: ribbon.bottom() + margin } + } +} + +/// Shell panels measured after drawing the full-bleed viewport. No design or preferences data. +#[derive(Default)] +pub(crate) struct State { + pub cards: Vec, + pub panels: Vec, + pub content: Option, + pub browser: Option, +} + +/// One source of occlusion for navigation, picking, overlays and fit. +pub(crate) fn covered_rects(app: &SolveApp) -> Vec { + let mut rects = Vec::new(); + if app.ui.layout == LayoutMode::Floating { + rects.extend(app.shell.cards.iter().copied()); + rects.extend(app.shell.panels.iter().copied()); + } + rects.extend(app.shell.browser.filter(|_| app.ui.show_browser && app.preferences.browser_float)); + rects.extend(app.viewport.palette_rect.filter(|_| app.session.active_sketch.is_some())); + rects.extend(app.viewport.dialog_rect.filter(|_| app.dialog.is_some())); + rects.retain(|r| r.is_finite() && r.is_positive()); + rects +} + +pub(crate) fn covered(app: &SolveApp, p: egui::Pos2) -> bool { + covered_rects(app).iter().any(|r| r.contains(p)) +} + +/// The former central panel remains the anchor for controls and docked panels. +pub(crate) fn content_rect(app: &SolveApp, viewport: Rect) -> Rect { + if app.ui.layout == LayoutMode::Floating { app.shell.content.unwrap_or(viewport).intersect(viewport) } else { viewport } +} + +pub(crate) fn panel_top(app: &SolveApp, viewport: Rect, bottom: f32, side: crate::frame::Side) -> f32 { + let content = content_rect(app, viewport); + if app.ui.layout == LayoutMode::Floating && side == crate::frame::Side::Right { + // Preserve measured compass clearance when there is room; reclaim it on short + // views with the same 360 px panel budget as Docked. + app.viewport + .layout + .as_ref() + .map_or(content.top(), |l| if l.axis_view.is_positive() { l.axis_view.bottom().min(bottom) } else { content.top() }) + .min((bottom - 360.0).max(content.top())) + } else { + (viewport.top() + crate::viewport::AXIS_VIEW_CLEARANCE).min((bottom - 360.0).max(viewport.top())) + } +} + +#[cfg(test)] +#[path = "shell_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/shell_tests.rs b/crates/ui-egui/src/shell_tests.rs new file mode 100644 index 0000000000..870878128c --- /dev/null +++ b/crates/ui-egui/src/shell_tests.rs @@ -0,0 +1,240 @@ +use super::*; +use crate::scenario::{Harness, rect_of}; +use serde_json::json; + +#[test] +fn modes_are_transient_and_available_to_control_and_scenarios() { + let mut h = Harness::new(); + assert_eq!(h.app.ui.layout, LayoutMode::Floating); + let prefs = serde_json::to_value(&h.app.preferences).unwrap(); + let doc = h.app.session.doc.to_json(); + h.step(&json!({"call":"ui.set", "params":{"layout":"docked"}})).unwrap(); + assert_eq!(h.app.ui.layout, LayoutMode::Docked); + h.step(&json!({"call":"ui.set", "params":{"layout":"invalid"},"fail":true})).unwrap(); + assert_eq!(h.app.ui.layout, LayoutMode::Docked); + h.call("ui.set", json!({"layout":"floating"})); + assert_eq!(h.app.ui.layout, LayoutMode::Floating); + assert_eq!(h.app.session.doc.to_json(), doc); + assert_eq!(serde_json::to_value(&h.app.preferences).unwrap(), prefs); +} + +#[test] +fn both_modes_keep_their_geometry_and_fit_clear_of_covered_rects() { + for mode in [LayoutMode::Docked, LayoutMode::Floating] { + let mut h = Harness::new(); + h.call("ui.resize", json!({"width":1400,"height":900})); + h.call("ui.set", json!({"layout":mode})); + h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap(); + h.settle(); + let viewport = h.app.viewport.rect.unwrap(); + let workspace = rect_of("ribbon:workspace").unwrap(); + let toolbar = rect_of("toolbar").unwrap(); + if mode == LayoutMode::Floating { + let ribbon = rect_of("ribbon:card").unwrap(); + assert_eq!(viewport.left(), 0.0); + assert_eq!(viewport.top(), toolbar::APP_BAR_HEIGHT); + assert_eq!(workspace.left(), SHELL_GAP); + assert_eq!(workspace.top(), viewport.top() + SHELL_GAP); + assert_eq!(ribbon.left() - workspace.right(), SHELL_GAP); + assert_eq!(ribbon.right(), viewport.right() - SHELL_GAP); + assert!(!covered(&h.app, pos2(4.0, workspace.center().y))); + assert!(!covered(&h.app, pos2(workspace.right() + 2.0, workspace.center().y))); + assert!(covered(&h.app, workspace.center())); + assert!(covered(&h.app, ribbon.center())); + } else { + assert_eq!(toolbar.top(), toolbar::APP_BAR_HEIGHT); + assert_eq!(viewport.top(), toolbar.bottom()); + assert_eq!(toolbar.height(), toolbar::RIBBON_HEIGHT); + assert_eq!(workspace.left(), 0.0); + assert!(covered_rects(&h.app).is_empty()); + } + h.app.fit_view(); + h.settle(); + let layout = h.app.viewport.layout.as_ref().unwrap(); + for covered in covered_rects(&h.app) { + assert!(!layout.drawable.intersect(covered).is_positive(), "{layout:?} {covered:?}"); + } + let projection = crate::viewport::projection(&h.app, viewport); + for point in [solidcraft_engine::geom::Vec3::ZERO, solidcraft_engine::geom::Vec3::new(40.0, 30.0, 20.0)] { + assert!(layout.drawable.contains(projection.to_screen(point).unwrap())); + } + h.app.start("solid.extrude"); + h.settle(); + if mode == LayoutMode::Floating { + assert!(h.app.viewport.dialog_rect.unwrap().top() >= h.app.viewport.layout.as_ref().unwrap().axis_view.bottom()); + } + } +} + +#[test] +fn cards_block_model_hover_wheel_and_drag_but_the_gap_remains_viewport() { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap(); + h.settle(); + let ribbon = rect_of("ribbon:card").unwrap(); + // Blank space at the right, away from the search and command buttons. + let at = pos2(ribbon.right() - 12.0, ribbon.bottom() - 28.0); + let camera = h.app.cam; + let doc = h.app.session.doc.to_json(); + h.call("ui.move", json!({"x":at.x,"y":at.y})); + assert!(h.app.viewport.hover.is_none()); + assert!(h.app.viewport.mouse.is_none()); + h.call("ui.scroll", json!({"x":at.x,"y":at.y,"delta":120})); + h.call("ui.drag", json!({"x0":at.x,"y0":at.y,"x1":at.x-30.0,"y1":at.y+15.0,"button":"middle","steps":6})); + assert_eq!(h.app.cam, camera); + assert!(h.app.viewport.boxsel.is_none()); + assert_eq!(h.app.session.doc.to_json(), doc); + let gap = pos2(4.0, ribbon.center().y); + h.call("ui.move", json!({"x":gap.x,"y":gap.y})); + assert_eq!(h.app.viewport.mouse, Some(gap)); +} + +#[test] +fn floating_sketch_palette_and_compass_share_measured_clearance() { + for style in crate::prefs::NavigatorStyle::ALL { + for (width, height, scale) in [(1400.0, 900.0, 1.0), (700.0, 720.0, 2.0)] { + let mut h = Harness::new(); + h.app.preferences.navigator_style = style; + h.call("ui.resize", json!({"width":width,"height":height})); + h.call("ui.prefs", json!({"ui_scale":scale})); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[0,0],[20,0]]})).unwrap(); + h.settle(); + h.app.fit_view(); + h.settle(); + let layout = h.app.viewport.layout.as_ref().unwrap(); + let palette = h.app.viewport.palette_rect.unwrap(); + assert!(palette.top() >= layout.axis_view.bottom(), "{style:?} {palette:?} {layout:?}"); + assert!(layout.viewport.expand(1.0).contains_rect(palette), "{palette:?} {layout:?}"); + assert!(palette.contains(crate::scenario::handle_at("sketch_palette:finish").unwrap())); + let proj = crate::viewport::projection(&h.app, layout.viewport); + for point in [solidcraft_engine::geom::Vec3::ZERO, solidcraft_engine::geom::Vec3::new(20.0, 0.0, 0.0)] { + assert!(layout.drawable.contains(proj.to_screen(point).unwrap()), "{layout:?}"); + } + if width > 1000.0 { + let content = layout.content; + h.app.start("solid.extrude"); + h.settle(); + let dialog = h.app.viewport.dialog_rect.unwrap(); + let expected_top = (content.top() + crate::viewport::AXIS_VIEW_CLEARANCE).min((content.bottom() - 360.0).max(content.top())); + assert!((dialog.left() - content.left()).abs() < 1.0); + assert!((dialog.top() - expected_top).abs() < 1.0, "left dialogs retain their existing top: {dialog:?}"); + } + } + } +} + +#[test] +fn short_floating_palette_stays_in_viewport() { + for style in crate::prefs::NavigatorStyle::ALL { + for height in [475, 385, 300] { + let mut h = Harness::new(); + h.app.preferences.navigator_style = style; + h.call("ui.resize", json!({"width":700,"height":height})); + h.call("ui.prefs", json!({"ui_scale":2.0})); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + // Main's open/untied-end feedback also consumes footer space. + for points in [[[0, 0], [10, 0]], [[10, 0], [10, 10]], [[10, 10], [0, 10]], [[0, 10], [0, 0]], [[20, 0], [30, 5]]] { + h.app.run("sketch.line", json!({"points":points,"infer":false,"connect":false})).unwrap(); + } + // Include the extra Feature Options section in the tiny-height case. + if height == 300 { + h.app.start("sketch.rectangle.two_point"); + } + h.settle(); + let viewport = h.app.viewport.rect.unwrap(); + let palette = h.app.viewport.palette_rect.unwrap(); + assert!( + viewport.contains_rect(palette), + "{style:?}, height={height}: {palette:?} extends beyond {viewport:?}, right={:.9}, bottom={:.9}", + palette.right(), + palette.bottom() + ); + let compass = h.app.viewport.layout.as_ref().unwrap().axis_view; + if compass.is_positive() { + assert!(palette.top() < compass.bottom(), "short panels reclaim compass space"); + } + let finish = crate::scenario::handle_at("sketch_palette:finish").unwrap(); + if !palette.contains(finish) { + // Once reclaiming is insufficient, the whole palette must scroll to Finish. + h.call("ui.scroll", json!({"x":palette.center().x,"y":palette.center().y,"delta":-1200})); + h.frames(20); + } + let finish = crate::scenario::handle_at("sketch_palette:finish").unwrap(); + assert!(palette.contains(finish), "Finish Sketch must be reachable: {style:?}, height={height}, {finish:?} in {palette:?}"); + h.call("ui.click", json!({"x":finish.x,"y":finish.y})); + assert!(h.app.session.active_sketch.is_none(), "Finish Sketch must work after scrolling"); + } + } +} + +#[test] +fn selection_drag_released_over_a_card_leaves_no_rubber_band() { + let mut h = Harness::new(); + h.call("ui.resize", json!({"width":1400,"height":900})); + h.settle(); + let ribbon = rect_of("ribbon:card").unwrap(); + let viewport = h.app.viewport.rect.unwrap(); + h.call( + "ui.drag", + json!({"x0":viewport.right()-20.0,"y0":viewport.bottom()-100.0, + "x1":ribbon.right()-12.0,"y1":ribbon.bottom()-28.0,"steps":12}), + ); + assert!(h.app.viewport.boxsel.is_none()); + assert!(h.app.session.selection.is_empty()); +} + +#[test] +fn floating_browser_clears_cards_shields_input_and_can_dock_again() { + for mode in [LayoutMode::Floating, LayoutMode::Docked] { + for timeline_left in [false, true] { + let mut h = Harness::new(); + h.call("ui.set", json!({"layout":mode})); + h.app.preferences.timeline_left = timeline_left; + h.app.preferences.browser_float = true; + h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap(); + h.settle(); + let browser = rect_of("browser:float").unwrap(); + let toolbar = rect_of("toolbar").unwrap(); + assert!(browser.top() >= toolbar.bottom(), "{mode:?}: {browser:?} must clear {toolbar:?}"); + assert!(crate::scenario::handle_at("row:Untitled").is_some()); + assert!(crate::scenario::handle_at("left-browser-rail").is_none()); + assert!(covered(&h.app, browser.center())); + let viewport = h.app.viewport.rect.unwrap(); + // A left-side history still takes a column in Docked mode. + if mode == LayoutMode::Floating || !timeline_left { + assert_eq!(viewport.left(), 0.0); + } else { + assert!(viewport.left() > 0.0); + } + if mode == LayoutMode::Floating { + assert_eq!(viewport.top(), toolbar::APP_BAR_HEIGHT); + assert!(!browser.intersect(rect_of("ribbon:card").unwrap()).is_positive()); + } + h.app.fit_view(); + h.settle(); + assert!(!h.app.viewport.layout.as_ref().unwrap().drawable.intersect(browser).is_positive()); + let camera = h.app.cam; + let doc = h.app.session.doc.to_json(); + let at = browser.center(); + h.call("ui.move", json!({"x":at.x,"y":at.y})); + assert!(h.app.viewport.hover.is_none()); + assert!(h.app.viewport.mouse.is_none()); + h.call("ui.scroll", json!({"x":at.x,"y":at.y,"delta":120})); + h.call("ui.drag", json!({"x0":at.x,"y0":at.y,"x1":at.x+20.0,"y1":at.y+10.0,"button":"middle","steps":6})); + assert_eq!(h.app.cam, camera); + assert_eq!(h.app.session.doc.to_json(), doc); + // The browser remains interactive above the shielded viewport. + h.step(&json!({"click":{"chevron":"Origin"}})).unwrap(); + assert!(crate::browser::drawn_rows().iter().any(|(name, _, _, open)| name == "Origin" && *open == Some(true))); + h.app.preferences.browser_float = false; + h.settle(); + assert!(h.app.shell.browser.is_none()); + assert!(rect_of("browser:float").is_none()); + let rail = crate::scenario::handle_at("left-browser-rail").unwrap(); + let width = h.app.preferences.browser_width; + h.call("ui.drag", json!({"x0":rail.x,"y0":rail.y,"x1":rail.x+60.0,"y1":rail.y,"steps":6})); + assert!(h.app.preferences.browser_width > width + 30.0); + } + } +} diff --git a/crates/ui-egui/src/sketch_corner_ui_tests.rs b/crates/ui-egui/src/sketch_corner_ui_tests.rs index 0df701c345..5a2b629fbb 100644 --- a/crates/ui-egui/src/sketch_corner_ui_tests.rs +++ b/crates/ui-egui/src/sketch_corner_ui_tests.rs @@ -495,6 +495,9 @@ fn fully_constrained_center_rectangle_corners_can_be_picked_and_filleted() { h.frames(2); h.step(&json!({"start":"sketch.fillet"})).unwrap(); h.frames(2); + // Fit after opening the dialog so all four corners clear the shell and both panels. + h.app.fit_view(); + h.settle(); for corner in [[-20, -15, 0], [20, -15, 0], [20, 15, 0], [-20, 15, 0]] { h.step(&json!({"click":{"world":corner}})).unwrap(); h.frames(2); diff --git a/crates/ui-egui/src/sketch_guidance_ui_tests.rs b/crates/ui-egui/src/sketch_guidance_ui_tests.rs index 88c8cd3ab9..6b47ec4a36 100644 --- a/crates/ui-egui/src/sketch_guidance_ui_tests.rs +++ b/crates/ui-egui/src/sketch_guidance_ui_tests.rs @@ -124,6 +124,8 @@ fn sketch_dialog_mirror_refuses_external_and_curved_references() { #[test] fn sketch_dialog_box_selection_adds_replaces_and_preserves_reference() { let mut h = fixture(); + // Put every selection box between the left dialog and the right Sketch Palette. + step(&mut h, json!({"call":"ui.camera","params":{"target":[-40,0,0],"distance":150}})); step(&mut h, json!({"call":"engine.execute","params":{"command":"sketch.line","params":{"points":[[-50,-20],[-40,-20]]}}})); step(&mut h, json!({"start":"sketch.mirror"})); step(&mut h, json!({"call":"ui.dialogInput","params":{"index":1}})); diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index 276fccf7ef..355ecd7e7b 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -248,7 +248,7 @@ fn width(vp: egui::Rect) -> f32 { /// The viewport the palette docks into. fn viewport(app: &SolveApp, ctx: &egui::Context) -> egui::Rect { - app.viewport.rect.unwrap_or_else(|| ctx.content_rect()) + crate::shell::content_rect(app, app.viewport.rect.unwrap_or_else(|| ctx.content_rect())) } /// Is a sketch being edited? That is the palette's own condition. @@ -364,8 +364,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let vp = viewport(app, ctx); let bottom = vp.bottom().min(ctx.content_rect().bottom()) - 8.0; // Prefer the space below Axis View, but reclaim it on short/HiDPI views. - let top = (vp.top() + crate::viewport::AXIS_VIEW_CLEARANCE).min((bottom - 360.0).max(vp.top())); - let anchor = egui::pos2(vp.right(), top); + let top = crate::shell::panel_top(app, vp, bottom, crate::frame::Side::Right); + // Area rounds its left edge to a physical pixel; leave room for that rounding. + let anchor = egui::pos2(vp.right() - 0.5 / ctx.pixels_per_point(), top); let frame = crate::frame::dock_frame(ctx, crate::frame::Side::Right); let shown = |k: &str| match k { "grid" => !opts.hide_grid, @@ -402,186 +403,205 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let width = width(vp); ui.set_width(width); ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); - let line = ui.text_style_height(&egui::TextStyle::Body); - let gap = ui.spacing().item_spacing.y; - crate::frame::heading(ui, Level::Title, width, |ui| { - let folded = FOLDED.with(Cell::get); - if ui.add(egui::Button::new(RichText::new(if folded { "+" } else { "−" }).size(14.0)).frame(false)).clicked() { - FOLDED.with(|f| f.set(!folded)); - } - ui.label(RichText::new(app.tr("ui.heading_sketch_palette")).strong().size(12.5)); - }); - if FOLDED.with(Cell::get) { - return; - } - // Feature Options sits above Options: the modes of the creation tool that is running. - if let Some((family, current)) = feature { - let ffold = FEATURE_FOLDED.with(Cell::get); - crate::frame::heading(ui, Level::Section, width, |ui| { - let (tri_rect, tri_resp) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::click()); - disclosure_triangle(ui.painter(), tri_rect.center(), !ffold, t.text); - crate::scenario::publish_handle("sketch_palette:feature_fold", tri_rect.center()); - let label_resp = ui.add(egui::Label::new(RichText::new(app.tr("ui.feature_options")).size(12.0)).sense(egui::Sense::click())); - if tri_resp.clicked() || label_resp.clicked() { - FEATURE_FOLDED.with(|f| f.set(!ffold)); + // The options list normally scrolls on its own to keep Finish Sketch visible. + // When even the headings and footer cannot fit, scroll the whole palette. + let available_height = (bottom - anchor.y - frame.inner_margin.sum().y - 2.0 * frame.stroke.width).max(0.0); + let scroll_palette = bottom - anchor.y < 360.0; + let mut contents = |ui: &mut egui::Ui| { + let content_top = ui.cursor().top(); + let line = ui.text_style_height(&egui::TextStyle::Body); + let gap = ui.spacing().item_spacing.y; + crate::frame::heading(ui, Level::Title, width, |ui| { + let folded = FOLDED.with(Cell::get); + if ui.add(egui::Button::new(RichText::new(if folded { "+" } else { "−" }).size(14.0)).frame(false)).clicked() { + FOLDED.with(|f| f.set(!folded)); } + ui.label(RichText::new(app.tr("ui.heading_sketch_palette")).strong().size(12.5)); }); - if !ffold { - let section = ui.vertical(|ui| feature_row(app, ui, width, family, current)); - start = section.inner; - crate::scenario::publish_rect("sketch_palette:feature_options", section.response.rect); + if FOLDED.with(Cell::get) { + return; } - ui.add_space(2.0); - } - let ofold = OPTIONS_FOLDED.with(Cell::get); - crate::frame::heading(ui, Level::Section, width, |ui| { - let (tri_rect, tri_resp) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::click()); - disclosure_triangle(ui.painter(), tri_rect.center(), !ofold, t.text); - crate::scenario::publish_handle("sketch_palette:options_fold", tri_rect.center()); - let label_resp = ui.add(egui::Label::new(RichText::new(app.tr("ui.options")).size(12.0)).sense(egui::Sense::click())); - if tri_resp.clicked() || label_resp.clicked() { - OPTIONS_FOLDED.with(|f| f.set(!ofold)); - } - }); - if !ofold { - // Keep room for the footer: status heading and line, a line, Finish Sketch, a line. - let ends_rows = if open_ends > 0 { line + gap } else { 0.0 } + if untied_ends > 0 { ui.spacing().interact_size.y + gap } else { 0.0 }; - let footer = 5.0 * line + ui.spacing().interact_size.y + 6.0 * gap + 8.0 + ends_rows; - let options_height = (bottom - ui.cursor().top() - footer).max(24.0); - let options = - egui::ScrollArea::vertical().id_salt("sc_palette_options").max_height(options_height).auto_shrink([false, true]).show(ui, |ui| { - // Every row below: its label at the left, its control right-aligned at the - // same column (`frame::right_aligned`), so every row lines up instead of - // each control sitting wherever its label happened to end. - ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.label(app.tr("ui.linetype")); - // Taller and wider than the old text-only buttons. Construction and - // Centerline need a round dot and a clear gap to read apart. 30 by - // 20 px left too little square room for either. - let btn_w = 36.0; - let group_w = 4.0 * btn_w + 3.0 * ui.spacing().item_spacing.x; - crate::frame::right_aligned(ui, width, group_w, |ui| { - ui.horizontal(|ui| { - // With a selection: its line type, and a click converts it; - // without: the mode new curves are drawn in. Each button - // still carries its old glyph as hidden (transparent) text. - // That keeps its hit target, size and accessible name - // exactly as before. A drawn icon (icons.rs) paints over it. - // Construction and Centerline always draw in the theme's - // construction orange. Normal's icon never changes with the - // accent colour. It stays neutral in every case. - let current = mode(app); - for (i, (label, icon, tip)) in [ - ("—", "normal", "Normal"), - ("- -", "construction", "Construction"), - ("-·-", "centerline", "Centerline"), - ("↔", "infinite", "Infinite"), - ] - .iter() - .enumerate() - { - let on = if has_curves { selected_types.get(i).copied().unwrap_or(false) } else { current == i as u8 }; - let tr_tip = app.tr_label(tip); - let hover = if has_curves { - crate::i18n::format("sketch.make_selection", &[("linetype", tr_tip)]) - } else { - crate::i18n::format("sketch.draw_curves", &[("linetype", tr_tip)]) - }; - let btn = egui::Button::new(RichText::new(*label).color(Color32::TRANSPARENT)) - .selected(on) - .min_size(vec2(btn_w, 28.0)); - let resp = ui.add(btn).on_hover_text(hover); - let accent = if *icon == "normal" { t.icon } else { t.construction_geom }; - // A square icon area. The button's own rect is - // text-shaped, wider than tall. A flattened diagonal - // leaves less room for a dash pattern's gaps to read. - let side = (resp.rect.width().min(resp.rect.height()) - 3.0).max(12.0); - let icon_rect = egui::Rect::from_center_size(resp.rect.center(), vec2(side, side)); - crate::icons::paint(ui.painter(), icon_rect, icon, t.icon, t.icon_fill, accent); - if resp.clicked() { - linetype = Some(i as u8); - } - } - }); - }); - }); - ui.horizontal(|ui| { - // One label, one icon button (the "lookat" glyph already used on the - // viewport's own navigation overlay): not the word "Look At" twice. - // The button carries no visible text of its own; `labelled_by` gives - // it the row's own label as its accessible name, so a screen reader - // still hears "Look At" the way the old text button said it. - let label_resp = ui.label(app.tr("ui.look_at")); - let icon_w = 28.0; - let resp = crate::frame::right_aligned(ui, width, icon_w, |ui| { - let r = ui.add(egui::Button::new("").min_size(vec2(icon_w, 20.0))); - let side = (r.rect.width().min(r.rect.height()) - 4.0).max(12.0); - let icon_rect = egui::Rect::from_center_size(r.rect.center(), vec2(side, side)); - crate::icons::paint(ui.painter(), icon_rect, "lookat", t.icon, t.icon_fill, t.accent); - crate::scenario::publish_handle("sketch_palette:look_at", r.rect.center()); - r.labelled_by(label_resp.id) - }); - let resp = resp.on_hover_text(app.tr("ui.look_at")); - if resp.clicked() { - look = true; - } - }); - }); - for (k, label) in TOGGLES { - ui.horizontal(|ui| { - // The label is still part of the toggle: clicking it flips the - // checkbox too, same as before the checkbox moved to its own column. - let label_resp = ui.add(egui::Label::new(app.tr_label(label)).sense(egui::Sense::click())); - let mut on = shown(k); - let box_w = ui.spacing().interact_size.y; - let cb = crate::frame::right_aligned(ui, width, box_w, |ui| { - ui.add(egui::Checkbox::without_text(&mut on)).labelled_by(label_resp.id) - }); - if cb.changed() { - set = Some((k.to_string(), on)); - } else if label_resp.clicked() { - set = Some((k.to_string(), !on)); - } - }); + // Feature Options sits above Options: the modes of the creation tool that is running. + if let Some((family, current)) = feature { + let ffold = FEATURE_FOLDED.with(Cell::get); + crate::frame::heading(ui, Level::Section, width, |ui| { + let (tri_rect, tri_resp) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::click()); + disclosure_triangle(ui.painter(), tri_rect.center(), !ffold, t.text); + crate::scenario::publish_handle("sketch_palette:feature_fold", tri_rect.center()); + let label_resp = ui.add(egui::Label::new(RichText::new(app.tr("ui.feature_options")).size(12.0)).sense(egui::Sense::click())); + if tri_resp.clicked() || label_resp.clicked() { + FEATURE_FOLDED.with(|f| f.set(!ffold)); } }); - crate::scenario::publish_rect("sketch_palette:options", options.inner_rect); - } - ui.add_space(4.0); - // The status changes when the sketch needs attention, so it gets its own heading. - crate::frame::heading(ui, Level::Section, width, |ui| { - ui.label(RichText::new(app.tr("ui.sketch_status")).size(12.0)); - }); - ui.label(RichText::new(crate::i18n::tr_dyn(&dof_line)).color(if ok { t.sketch_accent } else { t.text_dim })); - // Where a profile can fail (the viewport rings and diamonds them). - if open_ends > 0 { - let text = crate::i18n::format("sketch.open_ends", &[("count", &open_ends.to_string())]); - ui.label(RichText::new(text).color(t.warning)).on_hover_text(app.tr("ui.open_ends_tip")); - } - if untied_ends > 0 { - ui.horizontal(|ui| { - let text = crate::i18n::format("sketch.untied_ends", &[("count", &untied_ends.to_string())]); - ui.label(RichText::new(text).color(t.warning)).on_hover_text(app.tr("ui.untied_ends_tip")); - let button = ui.button(app.tr("ui.tie_ends")).on_hover_text(app.tr("ui.tie_ends_tip")); - crate::scenario::publish_handle("sketch_palette:tie_ends", button.rect.center()); - if button.clicked() { - tie = true; + if !ffold { + let section = ui.vertical(|ui| feature_row(app, ui, width, family, current)); + start = section.inner; + crate::scenario::publish_rect("sketch_palette:feature_options", section.response.rect); + } + ui.add_space(2.0); + } + let ofold = OPTIONS_FOLDED.with(Cell::get); + crate::frame::heading(ui, Level::Section, width, |ui| { + let (tri_rect, tri_resp) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::click()); + disclosure_triangle(ui.painter(), tri_rect.center(), !ofold, t.text); + crate::scenario::publish_handle("sketch_palette:options_fold", tri_rect.center()); + let label_resp = ui.add(egui::Label::new(RichText::new(app.tr("ui.options")).size(12.0)).sense(egui::Sense::click())); + if tri_resp.clicked() || label_resp.clicked() { + OPTIONS_FOLDED.with(|f| f.set(!ofold)); } }); - } - // One line between the status and Finish Sketch, and below it to the edge. - ui.add_space((line - gap).max(0.0)); - // A row as tall as the button, so the gap below it is just the line. - ui.allocate_ui_with_layout(vec2(ui.available_width(), 0.0), egui::Layout::right_to_left(egui::Align::Min), |ui| { - let button = ui.add(egui::Button::new(RichText::new(app.tr("ui.finish_sketch")).color(t.on_accent)).fill(t.accent)); - crate::scenario::publish_rect("sketch_palette:finish", button.rect); - crate::scenario::publish_handle("sketch_palette:finish", button.rect.center()); - if button.clicked() { - finish = true; + if !ofold { + // Keep room for the footer: status heading and line, a line, Finish Sketch, a line. + let ends_rows = if open_ends > 0 { line + gap } else { 0.0 } + if untied_ends > 0 { ui.spacing().interact_size.y + gap } else { 0.0 }; + let footer = 5.0 * line + ui.spacing().interact_size.y + 6.0 * gap + 8.0 + ends_rows; + let options_height = + if scroll_palette { available_height - (ui.cursor().top() - content_top) - footer } else { bottom - ui.cursor().top() - footer } + .max(24.0); + let options = + egui::ScrollArea::vertical().id_salt("sc_palette_options").max_height(options_height).auto_shrink([false, true]).show(ui, |ui| { + // Every row below: its label at the left, its control right-aligned at the + // same column (`frame::right_aligned`), so every row lines up instead of + // each control sitting wherever its label happened to end. + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label(app.tr("ui.linetype")); + // Taller and wider than the old text-only buttons. Construction and + // Centerline need a round dot and a clear gap to read apart. 30 by + // 20 px left too little square room for either. + let btn_w = 36.0; + let group_w = 4.0 * btn_w + 3.0 * ui.spacing().item_spacing.x; + crate::frame::right_aligned(ui, width, group_w, |ui| { + ui.horizontal(|ui| { + // With a selection: its line type, and a click converts it; + // without: the mode new curves are drawn in. Each button + // still carries its old glyph as hidden (transparent) text. + // That keeps its hit target, size and accessible name + // exactly as before. A drawn icon (icons.rs) paints over it. + // Construction and Centerline always draw in the theme's + // construction orange. Normal's icon never changes with the + // accent colour. It stays neutral in every case. + let current = mode(app); + for (i, (label, icon, tip)) in [ + ("—", "normal", "Normal"), + ("- -", "construction", "Construction"), + ("-·-", "centerline", "Centerline"), + ("↔", "infinite", "Infinite"), + ] + .iter() + .enumerate() + { + let on = if has_curves { selected_types.get(i).copied().unwrap_or(false) } else { current == i as u8 }; + let tr_tip = app.tr_label(tip); + let hover = if has_curves { + crate::i18n::format("sketch.make_selection", &[("linetype", tr_tip)]) + } else { + crate::i18n::format("sketch.draw_curves", &[("linetype", tr_tip)]) + }; + let btn = egui::Button::new(RichText::new(*label).color(Color32::TRANSPARENT)) + .selected(on) + .min_size(vec2(btn_w, 28.0)); + let resp = ui.add(btn).on_hover_text(hover); + let accent = if *icon == "normal" { t.icon } else { t.construction_geom }; + // A square icon area. The button's own rect is + // text-shaped, wider than tall. A flattened diagonal + // leaves less room for a dash pattern's gaps to read. + let side = (resp.rect.width().min(resp.rect.height()) - 3.0).max(12.0); + let icon_rect = egui::Rect::from_center_size(resp.rect.center(), vec2(side, side)); + crate::icons::paint(ui.painter(), icon_rect, icon, t.icon, t.icon_fill, accent); + if resp.clicked() { + linetype = Some(i as u8); + } + } + }); + }); + }); + ui.horizontal(|ui| { + // One label, one icon button (the "lookat" glyph already used on the + // viewport's own navigation overlay): not the word "Look At" twice. + // The button carries no visible text of its own; `labelled_by` gives + // it the row's own label as its accessible name, so a screen reader + // still hears "Look At" the way the old text button said it. + let label_resp = ui.label(app.tr("ui.look_at")); + let icon_w = 28.0; + let resp = crate::frame::right_aligned(ui, width, icon_w, |ui| { + let r = ui.add(egui::Button::new("").min_size(vec2(icon_w, 20.0))); + let side = (r.rect.width().min(r.rect.height()) - 4.0).max(12.0); + let icon_rect = egui::Rect::from_center_size(r.rect.center(), vec2(side, side)); + crate::icons::paint(ui.painter(), icon_rect, "lookat", t.icon, t.icon_fill, t.accent); + crate::scenario::publish_handle("sketch_palette:look_at", r.rect.center()); + r.labelled_by(label_resp.id) + }); + let resp = resp.on_hover_text(app.tr("ui.look_at")); + if resp.clicked() { + look = true; + } + }); + }); + for (k, label) in TOGGLES { + ui.horizontal(|ui| { + // The label is still part of the toggle: clicking it flips the + // checkbox too, same as before the checkbox moved to its own column. + let label_resp = ui.add(egui::Label::new(app.tr_label(label)).sense(egui::Sense::click())); + let mut on = shown(k); + let box_w = ui.spacing().interact_size.y; + let cb = crate::frame::right_aligned(ui, width, box_w, |ui| { + ui.add(egui::Checkbox::without_text(&mut on)).labelled_by(label_resp.id) + }); + if cb.changed() { + set = Some((k.to_string(), on)); + } else if label_resp.clicked() { + set = Some((k.to_string(), !on)); + } + }); + } + }); + crate::scenario::publish_rect("sketch_palette:options", options.inner_rect); } - }); - ui.add_space((line - margin_bottom).max(0.0)); + ui.add_space(4.0); + // The status changes when the sketch needs attention, so it gets its own heading. + crate::frame::heading(ui, Level::Section, width, |ui| { + ui.label(RichText::new(app.tr("ui.sketch_status")).size(12.0)); + }); + ui.label(RichText::new(crate::i18n::tr_dyn(&dof_line)).color(if ok { t.sketch_accent } else { t.text_dim })); + // Where a profile can fail (the viewport rings and diamonds them). + if open_ends > 0 { + let text = crate::i18n::format("sketch.open_ends", &[("count", &open_ends.to_string())]); + ui.label(RichText::new(text).color(t.warning)).on_hover_text(app.tr("ui.open_ends_tip")); + } + if untied_ends > 0 { + ui.horizontal(|ui| { + let text = crate::i18n::format("sketch.untied_ends", &[("count", &untied_ends.to_string())]); + ui.label(RichText::new(text).color(t.warning)).on_hover_text(app.tr("ui.untied_ends_tip")); + let button = ui.button(app.tr("ui.tie_ends")).on_hover_text(app.tr("ui.tie_ends_tip")); + crate::scenario::publish_handle("sketch_palette:tie_ends", button.rect.center()); + if button.clicked() { + tie = true; + } + }); + } + // One line between the status and Finish Sketch, and below it to the edge. + ui.add_space((line - gap).max(0.0)); + // A row as tall as the button, so the gap below it is just the line. + ui.allocate_ui_with_layout(vec2(ui.available_width(), 0.0), egui::Layout::right_to_left(egui::Align::Min), |ui| { + let button = ui.add(egui::Button::new(RichText::new(app.tr("ui.finish_sketch")).color(t.on_accent)).fill(t.accent)); + crate::scenario::publish_rect("sketch_palette:finish", button.rect); + crate::scenario::publish_handle("sketch_palette:finish", button.rect.center()); + if button.clicked() { + finish = true; + } + }); + ui.add_space((line - margin_bottom).max(0.0)); + }; + if scroll_palette { + egui::ScrollArea::vertical() + .id_salt("sc_palette_content") + .max_height(available_height) + .min_scrolled_height(0.0) + .auto_shrink([false, true]) + .show(ui, contents); + } else { + contents(ui); + } }); app.viewport.palette_rect = response.map(|r| r.response.rect); if let Some(r) = app.viewport.palette_rect { diff --git a/crates/ui-egui/src/sketch_text_handles.rs b/crates/ui-egui/src/sketch_text_handles.rs index 3f643a5f6b..8c1ac01a42 100644 --- a/crates/ui-egui/src/sketch_text_handles.rs +++ b/crates/ui-egui/src/sketch_text_handles.rs @@ -433,6 +433,9 @@ mod tests { #[test] fn dragging_a_corner_resizes_the_text_in_one_undo_step() { let (mut h, link) = setup(); + // Both the initial handle and the doubled text must stay below the floating ribbon. + h.app.cam.distance = 90.0; + h.frames(3); let before = (h.app.session.doc.to_json(), h.app.session.undo.len()); let f = frame(&h.app, &link).unwrap(); // Pull the top right corner out to twice the frame: the bottom left stays put. @@ -461,7 +464,8 @@ mod tests { let result = h.app.run("sketch.text", json!({"text":"II","at":[0,0],"height":"tall"})).unwrap(); let link = result["link"].as_str().unwrap().to_owned(); h.call("ui.view", json!({"view":"top"})); - h.app.cam.distance = 60.0; + // Keep the rotated resize target clear of the floating ribbon too. + h.app.cam.distance = 90.0; h.app.session.selection = vec![Sel::SketchCurve { id: result["curves"][0].as_str().unwrap().into() }]; h.frames(3); let height_expr = |h: &Harness| { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 853e9227ca..b3f62ce062 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -403,6 +403,11 @@ pub struct Tokens { } impl Tokens { + /// Subtle shell elevation, derived from the semantic application-bar colour. + pub(crate) fn shell_shadow(self) -> egui::Shadow { + egui::Shadow { offset: [0, self.space_2 as i8], blur: self.space_8 as u8, spread: 0, color: self.app_bar.gamma_multiply(0.18) } + } + /// The tokens of the current theme and colour vision mode. pub fn get() -> Tokens { Tokens::themed(is_dark(), vision()) diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index d0665c7fe0..3379cb4fe5 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -92,9 +92,11 @@ pub const COLLAPSED_WIDTH: f32 = 72.0; /// The gap after each panel (its separator). const PANEL_GAP: f32 = 1.0; pub const TAB_ROW_HEIGHT: f32 = 30.0; +/// Distance from the ribbon card's edges to the first tab and the search field. +const RIBBON_TAB_INSET: f32 = 4.0; pub const GROUP_HEIGHT: f32 = 86.0; pub const RIBBON_HEIGHT: f32 = TAB_ROW_HEIGHT + GROUP_HEIGHT + 1.0; -const WORKSPACE_WIDTH: f32 = 88.0; +pub(crate) const WORKSPACE_WIDTH: f32 = 88.0; const BUTTON_HEIGHT: f32 = 60.0; const BUTTON_GAP: f32 = 3.0; const BUTTON_MIN_WIDTH: f32 = 54.0; @@ -791,12 +793,20 @@ fn ribbon_tabs(app: &mut SolveApp, ui: &mut egui::Ui, row: Rect) { + tabs.len().saturating_sub(1) as f32 * 2.0 + tabs.iter().filter(|tab| matches!(**tab, "SKETCH" | "SHEET METAL")).count() as f32 * 6.0; let search_width = if row.width() >= tabs_width + 188.0 + 16.0 { 188.0 } else { 24.0 }; - let search = Rect::from_min_size(pos2((row.right() - search_width - 6.0).max(row.left()), row.top() + 3.0), vec2(search_width, 24.0)); + // The first tab and the search field sit the same distance from the card's top, left and right + // edges, so their outer corners are concentric with the card's corners. + let floating = app.ui.layout == crate::shell::LayoutMode::Floating; + let inset = RIBBON_TAB_INSET; + let corner = if floating { t.radius_lg.saturating_sub(inset as u8).max(2) } else { 5 }; + let search = Rect::from_min_size( + pos2((row.right() - search_width - inset).max(row.left()), row.top() + inset), + vec2(search_width, TAB_ROW_HEIGHT - 2.0 * inset), + ); let resp = ui.interact(search, ui.id().with("ribbon_search"), Sense::click()); crate::scenario::publish_handle("ribbon:search", search.center()); crate::scenario::publish_rect("ribbon:search", search); ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_ribbon_search"), search)); - ui.painter().rect(search, 5.0, t.field, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + ui.painter().rect(search, corner, t.field, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); // The existing zoom glyph is the icon system's magnifier; no new artwork. icons::paint_icon(ui.painter(), Rect::from_center_size(search.left_center() + vec2(12.0, 0.0), vec2(16.0, 16.0)), "zoom", 1.0); let label = app.tr_label("Search commands"); @@ -817,7 +827,7 @@ fn ribbon_tabs(app: &mut SolveApp, ui: &mut egui::Ui, row: Rect) { d.insert_temp(id, state); changed }); - let span = Rect::from_min_max(row.left_top() + vec2(4.0, 0.0), pos2((search.left() - 6.0).max(row.left() + 4.0), row.bottom())); + let span = Rect::from_min_max(row.left_top() + vec2(inset, 0.0), pos2((search.left() - 6.0).max(row.left() + inset), row.bottom())); ui.scope_builder(egui::UiBuilder::new().max_rect(span), |ui| { ui.set_clip_rect(span); egui::ScrollArea::horizontal() @@ -833,7 +843,7 @@ fn ribbon_tabs(app: &mut SolveApp, ui: &mut egui::Ui, row: Rect) { if contextual { x += 6.0; } - let tr = Rect::from_min_size(pos2(x, all.top() + 3.0), vec2(width, 24.0)); + let tr = Rect::from_min_size(pos2(x, all.top() + inset), vec2(width, TAB_ROW_HEIGHT - 2.0 * inset)); let active = app.ui.tab == tab; if active && changed { ui.scroll_to_rect(tr, Some(egui::Align::Center)); @@ -850,7 +860,7 @@ fn ribbon_tabs(app: &mut SolveApp, ui: &mut egui::Ui, row: Rect) { } else { Color32::TRANSPARENT }; - ui.painter().rect_filled(tr, t.radius_sm.saturating_add(1), fill); + ui.painter().rect_filled(tr, corner, fill); ribbon_text( ui, tr.center(), @@ -912,21 +922,43 @@ fn selected_ink(accent: &crate::theme::TabAccent) -> Color32 { pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); crate::workspace::sync_context(app); - egui::Panel::top("sc_toolbar").exact_size(RIBBON_HEIGHT).show_separator_line(false).frame(egui::Frame::NONE.fill(t.toolbar)).show(ui, |ui| { - let r = ui.max_rect(); + let floating = app.ui.layout == crate::shell::LayoutMode::Floating; + let cards = crate::shell::Cards::new(ui.available_rect_before_wrap(), app.ui.layout); + let height = if floating { cards.bottom - ui.available_rect_before_wrap().top() } else { RIBBON_HEIGHT }; + let fill = if floating { Color32::TRANSPARENT } else { t.toolbar }; + egui::Panel::top("sc_toolbar").exact_size(height).show_separator_line(false).frame(egui::Frame::NONE.fill(fill)).show(ui, |ui| { + let r = if floating { Rect::from_min_max(cards.workspace.min, cards.ribbon.max) } else { ui.max_rect() }; + let ws = if floating { cards.workspace } else { Rect::from_min_size(r.left_top(), vec2(WORKSPACE_WIDTH, RIBBON_HEIGHT - 1.0)) }; + let ribbon_left = if floating { cards.ribbon.left() } else { ws.right() + 1.0 }; + if floating { + for card in [ws, cards.ribbon] { + let frame = egui::Frame::NONE.fill(t.toolbar).stroke(Stroke::new(1.0, t.border)).corner_radius(t.radius_lg).shadow(t.shell_shadow()); + ui.painter().add(frame.paint(card)); + // Blank card space owns clicks as well as its buttons. + ui.interact(card, ui.id().with(("shell_card", card.left().to_bits())), Sense::click_and_drag()); + } + crate::scenario::publish_rect("ribbon:card", cards.ribbon); + } let painter = ui.painter().clone(); - let bg = ui.interact(r, ui.id().with("toolbar_bg"), Sense::click()); + let bg = ui.interact(if floating { cards.ribbon } else { r }, ui.id().with("toolbar_bg"), Sense::click()); if bg.secondary_clicked() { crate::context_menu::open_for(app, bg.interact_pointer_pos().unwrap_or(r.left_bottom()), crate::context_menu::Target::Toolbar); } - let ws = Rect::from_min_size(r.left_top(), vec2(WORKSPACE_WIDTH, RIBBON_HEIGHT - 1.0)); let wresp = ui.interact(ws, ui.id().with("workspace_switcher"), Sense::click()); let open = app.viewport.context_menu.is_some() && app.menu.target == crate::context_menu::Target::Workspace; if wresp.hovered() || open { - painter.rect_filled(ws, 0.0, t.hover); + painter.rect_filled(ws, if floating { t.radius_lg } else { 0 }, t.hover); + } + // Keep the accent straight while leaving the floating card's top corners round. + let accent_inset = if floating { f32::from(t.radius_lg) } else { 0.0 }; + painter.rect_filled( + Rect::from_min_size(ws.left_top() + vec2(accent_inset, 0.0), vec2((ws.width() - 2.0 * accent_inset).max(0.0), t.space_3)), + egui::CornerRadius { nw: 0, ne: 0, sw: if floating { 2 } else { 0 }, se: if floating { 2 } else { 0 } }, + t.accent, + ); + if !floating { + painter.line_segment([ws.right_top(), ws.right_bottom()], Stroke::new(1.0, t.border)); } - painter.rect_filled(Rect::from_min_size(ws.left_top(), vec2(WORKSPACE_WIDTH, 3.0)), 0.0, t.accent); - painter.line_segment([ws.right_top(), ws.right_bottom()], Stroke::new(1.0, t.border)); let render = crate::render_workspace::active(app); let workspace_icon = if render { "ws_render" } else { "ws_design" }; icons::paint_icon(&painter, Rect::from_center_size(pos2(ws.center().x, ws.top() + 39.0), vec2(30.0, 30.0)), workspace_icon, 1.0); @@ -942,7 +974,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { crate::scenario::publish_rect("workspace_switcher", ws); crate::scenario::publish_rect("ribbon:workspace", ws); crate::scenario::publish_handle("ribbon:workspace", ws.center()); - ribbon_tabs(app, ui, Rect::from_min_max(pos2(ws.right() + 1.0, r.top()), pos2(r.right(), r.top() + TAB_ROW_HEIGHT))); + ribbon_tabs(app, ui, Rect::from_min_max(pos2(ribbon_left, r.top()), pos2(r.right(), r.top() + TAB_ROW_HEIGHT))); // Panels. let tab = app.ui.tab.clone(); // The command whose tool or dialog is open right now, shown pressed in the toolbar. @@ -953,7 +985,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { (None, Some(_)) => app.last_command.as_ref().map(|(id, _)| id.clone()), _ => None, }; - let mut px = r.left() + WORKSPACE_WIDTH + 1.0; + let mut px = ribbon_left; let top = r.top() + TAB_ROW_HEIGHT; let mut drop: Option<(&'static str, &'static str, Option<&'static str>)> = None; let mut menu: Option<(egui::Pos2, &'static str)> = None; @@ -1199,7 +1231,13 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { } } // A 20 px label row is also the section's existing command-list button. - let lr = Rect::from_min_size(pos2(px + 3.0, top + GROUP_HEIGHT - SECTION_HEIGHT - 3.0), vec2((width - 6.0).max(0.0), SECTION_HEIGHT)); + // The group area ends 1 px above the card's bottom edge, so the highlight is + // RIBBON_TAB_INSET from the card's bottom edge (and from its left edge in the first + // section): its corner is concentric with the card's. + let lr = Rect::from_min_size( + pos2(px + RIBBON_TAB_INSET, top + GROUP_HEIGHT - SECTION_HEIGHT - (RIBBON_TAB_INSET - 1.0)), + vec2((width - 2.0 * RIBBON_TAB_INSET).max(0.0), SECTION_HEIGHT), + ); let lresp = ui.interact(lr, ui.id().with(("panel", panel)), Sense::click()); crate::scenario::publish_handle(&format!("panel:{tab}:{panel}"), lr.center()); crate::scenario::publish_handle(&format!("ribbon:section:{tab}:{panel}"), lr.center()); @@ -1355,7 +1393,9 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { response.response.on_hover_text(crate::i18n::tr_label("More toolbar panels")); }); } - painter.line_segment([pos2(r.left(), r.bottom() - 0.5), pos2(r.right(), r.bottom() - 0.5)], Stroke::new(1.0, t.border)); + if !floating { + painter.line_segment([pos2(r.left(), r.bottom() - 0.5), pos2(r.right(), r.bottom() - 0.5)], Stroke::new(1.0, t.border)); + } // Dragging a command: its icon follows the pointer; a toolbar button dragged off the // toolbar (or into the open drop-down) shows it will be removed. let pointer = ui.input(|i| i.pointer.latest_pos()); @@ -2346,23 +2386,26 @@ mod dropdown_tests { /// reached with the arrow keys and comes into view. #[test] fn a_tall_drop_down_scrolls_inside_a_short_window() { - let mut h = crate::scenario::Harness::new(); - h.app.home.open = false; - h.call("ui.resize", json!({"width":1200,"height":320})); - h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); - h.frames(4); - h.step(&json!({"click":{"handle":"panel:SKETCH:CREATE"}})).unwrap(); - h.frames(4); - let menu = crate::scenario::rect_of("toolbar_dropdown").expect("the drop-down is open"); - assert!(menu.bottom() <= 320.0, "the drop-down runs past the window: {menu:?}"); - assert!(crate::scenario::handle_at("dropdown:sketch.text").is_some_and(|p| !menu.contains(p)), "Text starts below the fold"); - // Up from no focus goes to the last enabled row; it scrolls into view. - h.step(&json!({"key":"ArrowUp"})).unwrap(); - h.frames(10); - let hot = crate::scenario::handles("dropdown_hot:"); - let [(_, at)] = hot.as_slice() else { panic!("one highlighted row: {hot:?}") }; - let menu = crate::scenario::rect_of("toolbar_dropdown").unwrap(); - assert!(menu.contains(*at), "the focused last row is out of view: {at:?} in {menu:?}"); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.set", json!({"layout":mode})); + h.call("ui.resize", json!({"width":1200,"height":320})); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.frames(4); + h.step(&json!({"click":{"handle":"panel:SKETCH:CREATE"}})).unwrap(); + h.frames(4); + let menu = crate::scenario::rect_of("toolbar_dropdown").expect("the drop-down is open"); + assert!(menu.bottom() <= 320.0, "the drop-down runs past the window: {menu:?}"); + assert!(crate::scenario::handle_at("dropdown:sketch.text").is_some_and(|p| !menu.contains(p)), "Text starts below the fold"); + // Up from no focus goes to the last enabled row; it scrolls into view. + h.step(&json!({"key":"ArrowUp"})).unwrap(); + h.frames(10); + let hot = crate::scenario::handles("dropdown_hot:"); + let [(_, at)] = hot.as_slice() else { panic!("one highlighted row: {hot:?}") }; + let menu = crate::scenario::rect_of("toolbar_dropdown").unwrap(); + assert!(menu.contains(*at), "the focused last row is out of view: {at:?} in {menu:?}"); + } } /// Every row of a drop-down is shown when the window has room, even after a shorter list was diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 5d56643c26..cf0840a26b 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -2527,8 +2527,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // A two-point shape being dragged out (#1630) follows the pointer and ends where it is let go, // even over the shape's own dimension box, which rides along beside the pointer. if let Some(from) = app.viewport.draw_drag { - let at = ui.input(|i| i.pointer.latest_pos()).filter(|p| rect.contains(*p)); - if resp.drag_stopped() { + let at = ui.input(|i| i.pointer.latest_pos()).filter(|p| rect.contains(*p) && !crate::shell::covered(app, *p)); + if resp.drag_stopped() || !primary_down { app.viewport.draw_drag = None; if let Some(p) = at && app.tool.as_ref().is_some_and(|t| t.pts.len() == 1) @@ -2884,6 +2884,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if resp.drag_started_by(egui::PointerButton::Primary) && let Some(a) = ui.input(|i| i.pointer.press_origin()) && rect.contains(a) + && !crate::shell::covered(app, a) && !axes.contains(a) { crate::joint_drag::start(app, &proj, a); @@ -2913,10 +2914,16 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if resp.dragged_by(egui::PointerButton::Primary) && let (Some(a), Some(b)) = (origin, hover) && rect.contains(a) + && !crate::shell::covered(app, a) && !axes.contains(a) { app.viewport.boxsel = Some(BoxSel::from_drag(a, b)); } + // A shell card disables its underlying widgets. The release still ends a + // rubber band even when egui no longer sends drag_stopped to the viewport. + if !primary_down && !inside { + app.viewport.boxsel = None; + } if resp.drag_stopped() && let Some(bx) = app.viewport.boxsel.take() { @@ -2975,7 +2982,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if let Some(since) = app.computing_since() { let seconds = ((crate::now_ms() - since) / 1000.0).floor().max(0.0); let text = crate::i18n::format("dialog.computing", &[("seconds", &format!("{seconds:.0}"))]); - let pos = pos2(rect.left() + 12.0, rect.bottom() - 76.0); + let pos = pos2(crate::shell::content_rect(app, rect).left() + 12.0, rect.bottom() - 76.0); let galley = painter.layout_no_wrap(text, FontId::proportional(t.font_label), t.text); let r = Rect::from_min_size(pos, galley.size() + vec2(34.0, 8.0)); painter.rect_filled(r, t.radius_sm, t.overlay); @@ -2986,7 +2993,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // Status chip. if let Some((msg, at, err)) = app.status.clone() { if crate::now_ms() - at < 6000.0 { - let pos = pos2(rect.left() + 12.0, rect.bottom() - 46.0); + let pos = pos2(crate::shell::content_rect(app, rect).left() + 12.0, rect.bottom() - 46.0); let galley = painter.layout_no_wrap(msg, FontId::proportional(t.font_label), if err { t.error } else { t.text }); let r = Rect::from_min_size(pos, galley.size() + vec2(16.0, 8.0)); painter.rect_filled(r, t.radius_sm, t.overlay); @@ -3444,7 +3451,7 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { } loose_end_markers(sk, &ss.ends, &ss.plane, painter, proj, &t); // Sketch status. - if let Some(r) = app.viewport.rect { + if let Some(r) = app.viewport.layout.as_ref().map(|l| l.content) { let msg = if !ss.report.ok() { format!("{} — constraints conflict", ss.name) } else if ss.report.dof == 0 { @@ -3509,12 +3516,13 @@ pub(crate) fn scene_palette(dark: bool, mode: crate::theme::ColorVision) -> soli pub(crate) fn refresh_layout(app: &mut SolveApp, scale: f32) { if let Some(rect) = app.viewport.rect { - let docks: Vec<_> = - [app.viewport.palette_rect.filter(|_| app.session.active_sketch.is_some()), app.viewport.dialog_rect.filter(|_| app.dialog.is_some())] - .into_iter() - .flatten() - .collect(); - app.viewport.layout = Some(crate::viewport_layout::Layout::new(rect, &docks, scale, app.preferences.navigator_style)); + let docks = crate::shell::covered_rects(app); + let content = crate::shell::content_rect(app, rect); + app.viewport.layout = Some(if app.ui.layout == crate::shell::LayoutMode::Docked { + crate::viewport_layout::Layout::new(rect, &docks, scale, app.preferences.navigator_style) + } else { + crate::viewport_layout::Layout::with_content(rect, content, &docks, scale, app.preferences.navigator_style) + }); } } @@ -3555,7 +3563,7 @@ fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool { let p = &i.pointer; (p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos(), i.modifiers.shift) }); - if pressed && ui.rect_contains_pointer(rect) { + if pressed && ui.is_enabled() && ui.rect_contains_pointer(rect) && !pos.is_some_and(|p| crate::shell::covered(app, p)) { app.viewport.axis_drag = origin.filter(|p| match app.preferences.navigator_style { crate::prefs::NavigatorStyle::Axes => axis_rect(app, rect).contains(*p), crate::prefs::NavigatorStyle::Cube => { @@ -3598,7 +3606,7 @@ fn signed_axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let c = axis_center(app, rect); let orbiting = axis_view_orbit(app, ui, rect); let tips = axis_tips(&app.cam, c); - let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect)); + let hover = ui.input(|i| i.pointer.hover_pos()).filter(|p| ui.is_enabled() && ui.rect_contains_pointer(rect) && !crate::shell::covered(app, *p)); let hovered = if orbiting { None } else { hover.and_then(|p| axis_hit(&tips, p)) }; let painter = ui.painter_at(rect); if orbiting || hover.is_some_and(|p| axis_rect(app, rect).contains(p)) { @@ -5892,8 +5900,14 @@ mod tests { h.frames(20); h.app.cam != before }; - for smooth in [true, false] { + for (layout, smooth) in [ + (crate::shell::LayoutMode::Floating, true), + (crate::shell::LayoutMode::Floating, false), + (crate::shell::LayoutMode::Docked, true), + (crate::shell::LayoutMode::Docked, false), + ] { let mut h = Harness::new(); + h.call("ui.set", json!({"layout":layout})); h.app.preferences.smooth_wheel_zoom = smooth; h.app.preferences.zoom_to_cursor = false; h.call("engine.execute", json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20}})); @@ -5931,18 +5945,21 @@ mod tests { h.call("ui.move", field.clone()); h.call("ui.click", field); assert!(handle_at(item).is_none(), "the list must close"); - let (mid, x) = (vp.center().y, vp.left() + vp.width() * 0.25); + // The full floating viewport extends under the browser; its exposed content + // edge is the boundary between the panel and navigable model in either layout. + let content = crate::shell::content_rect(&h.app, vp); + let (mid, x) = (content.center().y, content.left() + content.width() * 0.25); for p in [ pos2(dialog.left() + 2.0, dialog.center().y), pos2(dialog.center().x, dialog.bottom() - 2.0), - pos2(vp.left() - 2.0, mid), + pos2(content.left() - 2.0, mid), pos2(x, vp.bottom() + 2.0), ] { assert!(!wheel(&mut h, p, 60.0), "smooth {smooth}: the wheel at {p:?} off the model ({vp:?}, dialog {dialog:?}) zoomed it"); assert!(h.app.viewport.mouse.is_none(), "the model must not see the pointer at {p:?}"); } // The model itself still zooms right next to them. - for p in [pos2(dialog.left() - 2.0, dialog.center().y), pos2(vp.left() + 2.0, mid), pos2(x, vp.bottom() - 2.0)] { + for p in [pos2(dialog.left() - 2.0, dialog.center().y), pos2(content.left() + 2.0, mid), pos2(x, vp.bottom() - 2.0)] { assert!(wheel(&mut h, p, 60.0), "smooth {smooth}: the wheel at {p:?} on the model ({vp:?}) must zoom it"); assert_eq!(h.app.viewport.mouse, Some(p)); assert!(gestures(&mut h, p), "a pinch and a two-finger scroll at {p:?} on the model must move it"); diff --git a/crates/ui-egui/src/viewport_layout.rs b/crates/ui-egui/src/viewport_layout.rs index 973b0e7e54..1e1c6d672d 100644 --- a/crates/ui-egui/src/viewport_layout.rs +++ b/crates/ui-egui/src/viewport_layout.rs @@ -7,6 +7,7 @@ use solidcraft_engine::render::Camera; pub(crate) struct Layout { pub viewport: Rect, pub drawable: Rect, + pub content: Rect, pub axis_view: Rect, pub navigation: Rect, } @@ -15,7 +16,7 @@ pub(crate) struct Layout { fn free_rect(view: Rect, obstacles: &[Rect]) -> Rect { let mut xs = vec![view.left(), view.right()]; let mut ys = vec![view.top(), view.bottom()]; - let obstacles: Vec<_> = obstacles.iter().take(8).map(|r| r.intersect(view)).filter(|r| r.is_positive() && r.is_finite()).collect(); + let obstacles: Vec<_> = obstacles.iter().take(16).map(|r| r.intersect(view)).filter(|r| r.is_positive() && r.is_finite()).collect(); for r in &obstacles { xs.extend([r.left(), r.right()]); ys.extend([r.top(), r.bottom()]); @@ -40,17 +41,21 @@ fn free_rect(view: Rect, obstacles: &[Rect]) -> Rect { impl Layout { pub fn new(viewport: Rect, docks: &[Rect], scale: f32, style: crate::prefs::NavigatorStyle) -> Self { + Self::with_content(viewport, viewport, docks, scale, style) + } + + pub fn with_content(viewport: Rect, content: Rect, docks: &[Rect], scale: f32, style: crate::prefs::NavigatorStyle) -> Self { let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; - let mut obstacles: Vec<_> = docks.iter().take(6).copied().filter(|r| r.is_finite()).map(|r| r.expand(4.0)).collect(); + let mut obstacles: Vec<_> = docks.iter().take(14).copied().filter(|r| r.is_finite()).map(|r| r.expand(4.0)).collect(); // Navigation is anchored to the viewport, never to transient dialog space. - let available = viewport; + let available = content; let cube_size = super::viewport::axis_view_size(style); let mut axis_view = if available.width() >= cube_size && available.height() >= cube_size { Rect::from_min_size(pos2(available.right() - cube_size, available.top()), vec2(cube_size, cube_size)) } else { Rect::NOTHING }; - let nav_space = viewport; + let nav_space = content; let columns = ((nav_space.width() - 10.0) / 30.0).floor().clamp(1.0, 10.0); let nav_height = (10.0 / columns).ceil() * 30.0; let navigation = if nav_space.width() >= columns * 30.0 + 10.0 && nav_space.height() >= nav_height + 7.0 { @@ -61,9 +66,9 @@ impl Layout { // On tiny viewports keep the bottom controls usable rather than overlapping them. // This decision depends only on viewport size, never on an overlay. if axis_view.intersect(navigation).is_positive() { - let compact = (navigation.top() - viewport.top() - 4.0).min(cube_size); + let compact = (navigation.top() - content.top() - 4.0).min(cube_size); axis_view = if style == crate::prefs::NavigatorStyle::Cube && compact >= 96.0 { - Rect::from_min_size(pos2(viewport.right() - compact, viewport.top()), vec2(compact, compact)) + Rect::from_min_size(pos2(content.right() - compact, content.top()), vec2(compact, compact)) } else { Rect::NOTHING }; @@ -75,7 +80,7 @@ impl Layout { obstacles.push(navigation); } let drawable = free_rect(viewport, &obstacles).shrink(16.0 / scale); - Self { viewport, drawable, axis_view, navigation } + Self { viewport, drawable, content, axis_view, navigation } } } diff --git a/crates/ui-egui/src/viewport_layout_tests.rs b/crates/ui-egui/src/viewport_layout_tests.rs index ff2d81352c..060fcdfb71 100644 --- a/crates/ui-egui/src/viewport_layout_tests.rs +++ b/crates/ui-egui/src/viewport_layout_tests.rs @@ -41,44 +41,55 @@ fn thin_rotated_bounds_fit_using_the_original_full_projection() { #[test] fn measured_palette_fit_exposes_endpoints_and_layout_changes_preserve_manual_view() { - let mut h = Harness::new(); - h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); - h.app.run("sketch.line", json!({"points":[[0,0],[20,0]]})).unwrap(); - h.call("ui.view", json!({"view":"top"})); - h.settle(); - for (scale, width, height) in [(1.0, 1400.0, 900.0), (2.0, 700.0, 475.0), (2.0, 700.0, 385.0), (1.0, 900.0, 720.0)] { - h.call("ui.prefs", json!({"ui_scale":scale})); - h.call("ui.resize", json!({"width":width,"height":height})); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let mut h = Harness::new(); + h.call("ui.set", json!({"layout":mode})); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[0,0],[20,0]]})).unwrap(); + h.call("ui.view", json!({"view":"top"})); h.settle(); - let before = h.app.session.doc.to_json(); - let undo = h.app.session.undo.len(); - h.app.fit_view(); - h.settle(); - let layout = h.app.viewport.layout.as_ref().unwrap(); - let palette = h.app.viewport.palette_rect.unwrap(); - assert!(layout.drawable.width() >= 80.0 && layout.drawable.height() >= 30.0, "{layout:?}"); - assert!(layout.axis_view.is_positive() && layout.navigation.is_positive(), "{layout:?}"); - assert!(palette.width() <= 260.0, "{palette:?}"); - let projection = crate::viewport::projection(&h.app, layout.viewport); - for point in [Vec3::ZERO, Vec3::new(20.0, 0.0, 0.0)] { - let p = projection.to_screen(point).unwrap(); - assert!(layout.drawable.contains(p), "endpoint {p:?} outside {layout:?}"); - assert!(!palette.contains(p)); - assert!(!layout.axis_view.contains(p)); - assert!(!layout.navigation.contains(p)); + for (scale, width, height) in [(1.0, 1400.0, 900.0), (2.0, 700.0, 475.0), (2.0, 700.0, 385.0), (1.0, 900.0, 720.0)] { + h.call("ui.prefs", json!({"ui_scale":scale})); + h.call("ui.resize", json!({"width":width,"height":height})); + h.settle(); + let before = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + h.app.fit_view(); + h.settle(); + let layout = h.app.viewport.layout.as_ref().unwrap(); + let palette = h.app.viewport.palette_rect.unwrap(); + assert!(layout.drawable.width() >= 80.0 && layout.drawable.height() >= 30.0, "{layout:?}"); + // Floating's card margins leave less content height at 385 px. The compass + // intentionally disappears when it cannot fit above the bottom navigation. + assert!( + layout.axis_view.is_positive() + || layout.content.height() + < crate::viewport::axis_view_size(h.app.preferences.navigator_style) + layout.navigation.height() + 11.0, + "{layout:?}" + ); + assert!(layout.navigation.is_positive(), "{layout:?}"); + assert!(palette.width() <= 260.0, "{palette:?}"); + let projection = crate::viewport::projection(&h.app, layout.viewport); + for point in [Vec3::ZERO, Vec3::new(20.0, 0.0, 0.0)] { + let p = projection.to_screen(point).unwrap(); + assert!(layout.drawable.contains(p), "endpoint {p:?} outside {layout:?}"); + assert!(!palette.contains(p)); + assert!(!layout.axis_view.contains(p)); + assert!(!layout.navigation.contains(p)); + } + let fitted = h.app.cam; + h.app.fit_view(); + h.settle(); + assert_eq!(h.app.cam, fitted); + h.app.cancel_view_animation(); + h.app.cam.pan(13.0, -19.0, 500.0); + let manual = h.app.cam; + h.call("ui.resize", json!({"width":width+20.0,"height":height})); + h.settle(); + assert_eq!(h.app.cam, manual); + assert_eq!(h.app.session.doc.to_json(), before); + assert_eq!(h.app.session.undo.len(), undo); } - let fitted = h.app.cam; - h.app.fit_view(); - h.settle(); - assert_eq!(h.app.cam, fitted); - h.app.cancel_view_animation(); - h.app.cam.pan(13.0, -19.0, 500.0); - let manual = h.app.cam; - h.call("ui.resize", json!({"width":width+20.0,"height":height})); - h.settle(); - assert_eq!(h.app.cam, manual); - assert_eq!(h.app.session.doc.to_json(), before); - assert_eq!(h.app.session.undo.len(), undo); } } @@ -98,124 +109,143 @@ fn hostile_or_empty_fit_keeps_the_existing_camera_valid() { #[test] fn fit_home_animation_and_manual_takeover_use_one_layout_solution() { - let mut h = Harness::new(); - h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); - h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":1})).unwrap(); - h.settle(); - for view in ["fit", "home"] { - h.app.preferences.look_at_anim = false; - h.app.animate_view(view); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let mut h = Harness::new(); + h.call("ui.set", json!({"layout":mode})); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":1})).unwrap(); h.settle(); - let immediate = h.app.cam; - h.app.cam.pan(50.0, 10.0, 400.0); - h.app.preferences.look_at_anim = true; - h.app.animate_view(view); + for view in ["fit", "home"] { + h.app.preferences.look_at_anim = false; + h.app.animate_view(view); + h.settle(); + let immediate = h.app.cam; + h.app.cam.pan(50.0, 10.0, 400.0); + h.app.preferences.look_at_anim = true; + h.app.animate_view(view); + h.settle(); + assert_eq!(h.app.cam, immediate); + } + h.app.fit_view(); + h.app.cancel_view_animation(); + h.app.cam.pan(7.0, 13.0, 400.0); + let takeover = h.app.cam; h.settle(); - assert_eq!(h.app.cam, immediate); + assert_eq!(h.app.cam, takeover); } - h.app.fit_view(); - h.app.cancel_view_animation(); - h.app.cam.pan(7.0, 13.0, 400.0); - let takeover = h.app.cam; - h.settle(); - assert_eq!(h.app.cam, takeover); } #[test] fn measured_navigator_targets_home_and_drag_work_beside_the_palette_at_hidpi() { - for style in crate::prefs::NavigatorStyle::ALL { - for (scale, width, height) in [(1.0, 900.0, 720.0), (2.0, 700.0, 475.0), (2.0, 700.0, 385.0)] { - let mut h = Harness::new(); - h.call("ui.prefs", json!({"navigator_style":style})); - h.app.preferences.look_at_anim = false; - h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); - h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":1})).unwrap(); - h.call("ui.prefs", json!({"ui_scale":scale})); - h.call("ui.resize", json!({"width":width,"height":height})); - h.app.fit_view(); - h.settle(); - let baseline = h.app.session.doc.to_json(); - let undo = h.app.session.undo.len(); - h.call("ui.view", json!({"view":"front"})); - let layout = h.app.viewport.layout.as_ref().unwrap().clone(); - let palette = h.app.viewport.palette_rect.unwrap(); - let outer = crate::scenario::rect_of("axis_view:outer").unwrap(); - let home = crate::scenario::rect_of("axis_view:home").unwrap(); - assert_eq!(outer, layout.axis_view); - assert!(outer.contains_rect(home)); - assert!(layout.viewport.contains_rect(outer)); - assert_eq!(outer.right_top(), layout.viewport.right_top()); - if outer.intersect(palette).is_positive() { - // Short palettes may cover the fixed anchor; covered controls never steal input. - let covered = outer.intersect(palette).center(); - let camera = h.app.cam; - h.call("ui.click", json!({"x":covered.x,"y":covered.y})); - assert_eq!(h.app.cam, camera); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + for style in crate::prefs::NavigatorStyle::ALL { + for (scale, width, height) in [(1.0, 900.0, 720.0), (2.0, 700.0, 475.0), (2.0, 700.0, 385.0)] { + let mut h = Harness::new(); + h.call("ui.set", json!({"layout":mode})); + h.call("ui.prefs", json!({"navigator_style":style})); + h.app.preferences.look_at_anim = false; + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":1})).unwrap(); + h.call("ui.prefs", json!({"ui_scale":scale})); + h.call("ui.resize", json!({"width":width,"height":height})); + h.app.fit_view(); + h.settle(); + let baseline = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + h.call("ui.view", json!({"view":"front"})); + let layout = h.app.viewport.layout.as_ref().unwrap().clone(); + let palette = h.app.viewport.palette_rect.unwrap(); + if !layout.axis_view.is_positive() { + // Floating's smaller content at tiny heights cannot fit the compass; + // the navigation bar takes priority in both layouts. + assert!(layout.content.height() < crate::viewport::axis_view_size(style) + layout.navigation.height() + 11.0, "{layout:?}"); + assert!(layout.navigation.is_positive()); + assert!(crate::scenario::rect_of("axis_view:outer").is_none()); + assert_eq!(h.app.session.doc.to_json(), baseline); + assert_eq!(h.app.session.undo.len(), undo); + continue; + } + let outer = crate::scenario::rect_of("axis_view:outer").unwrap(); + let home = crate::scenario::rect_of("axis_view:home").unwrap(); + assert_eq!(outer, layout.axis_view); + assert!(outer.contains_rect(home)); + assert!(layout.viewport.contains_rect(outer)); + assert_eq!(outer.right_top(), layout.content.right_top()); + if outer.intersect(palette).is_positive() { + // Short palettes may cover the fixed anchor; covered controls never steal input. + let covered = outer.intersect(palette).center(); + let camera = h.app.cam; + h.call("ui.click", json!({"x":covered.x,"y":covered.y})); + assert_eq!(h.app.cam, camera); + assert!(h.app.viewport.axis_drag.is_none()); + assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), outer); + assert_eq!(h.app.session.doc.to_json(), baseline); + assert_eq!(h.app.session.undo.len(), undo); + continue; + } + assert!(!layout.navigation.intersect(outer).is_positive()); + if style == crate::prefs::NavigatorStyle::Cube { + let front = crate::scenario::handle_at("navigation_cube:front").unwrap(); + assert!(outer.contains(front)); + let up = crate::scenario::handle_at("navigation_cube:control:up").unwrap(); + h.call("ui.click", json!({"x":up.x,"y":up.y})); + assert!(h.app.cam.back().dist(Vec3::Z) < 1e-5); + let top = crate::scenario::handle_at("navigation_cube:top").unwrap(); + h.call("ui.click", json!({"x":top.x,"y":top.y})); + assert!(h.app.cam.back().dist(Vec3::Z) < 1e-5); + } else { + let z = crate::scenario::handle_at("axis_view:z").unwrap(); + assert!(outer.contains(z)); + h.call("ui.click", json!({"x":z.x,"y":z.y})); + assert!(h.app.cam.back().dist(Vec3::Z) < 1e-5); + let z = crate::scenario::handle_at("axis_view:z").unwrap(); + h.call("ui.click", json!({"x":z.x,"y":z.y})); + assert!(h.app.cam.back().dist(-Vec3::Z) < 1e-5); + } + h.call("ui.view", json!({"view":"front"})); + h.app.viewport.nav = Some(crate::viewport::NavMode::Pan); + let before = h.app.cam; + let c = h.app.viewport.layout.as_ref().unwrap().axis_view.center(); + h.call("ui.drag", json!({"x0":c.x,"y0":c.y,"x1":c.x+24.0,"y1":c.y+8.0,"steps":8})); + assert!((h.app.cam.yaw - before.yaw - 24.0 * 0.008).abs() < 1e-6); + assert!((h.app.cam.pitch - before.pitch - 8.0 * 0.008).abs() < 1e-6); + assert_eq!(h.app.cam.target, before.target); + assert_eq!(h.app.cam.distance, before.distance); assert!(h.app.viewport.axis_drag.is_none()); - assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), outer); + assert!(h.app.viewport.boxsel.is_none()); + let home = crate::scenario::handle_at("axis_view:home").unwrap(); + h.call("ui.click", json!({"x":home.x,"y":home.y})); + let mut iso = h.app.cam; + iso.set_view(StandardView::Iso); + assert_eq!((h.app.cam.yaw, h.app.cam.pitch), (iso.yaw, iso.pitch)); assert_eq!(h.app.session.doc.to_json(), baseline); assert_eq!(h.app.session.undo.len(), undo); - continue; + assert!(h.app.session.selection.is_empty()); } - assert!(!layout.navigation.intersect(outer).is_positive()); - if style == crate::prefs::NavigatorStyle::Cube { - let front = crate::scenario::handle_at("navigation_cube:front").unwrap(); - assert!(outer.contains(front)); - let up = crate::scenario::handle_at("navigation_cube:control:up").unwrap(); - h.call("ui.click", json!({"x":up.x,"y":up.y})); - assert!(h.app.cam.back().dist(Vec3::Z) < 1e-5); - let top = crate::scenario::handle_at("navigation_cube:top").unwrap(); - h.call("ui.click", json!({"x":top.x,"y":top.y})); - assert!(h.app.cam.back().dist(Vec3::Z) < 1e-5); - } else { - let z = crate::scenario::handle_at("axis_view:z").unwrap(); - assert!(outer.contains(z)); - h.call("ui.click", json!({"x":z.x,"y":z.y})); - assert!(h.app.cam.back().dist(Vec3::Z) < 1e-5); - let z = crate::scenario::handle_at("axis_view:z").unwrap(); - h.call("ui.click", json!({"x":z.x,"y":z.y})); - assert!(h.app.cam.back().dist(-Vec3::Z) < 1e-5); - } - h.call("ui.view", json!({"view":"front"})); - h.app.viewport.nav = Some(crate::viewport::NavMode::Pan); - let before = h.app.cam; - let c = h.app.viewport.layout.as_ref().unwrap().axis_view.center(); - h.call("ui.drag", json!({"x0":c.x,"y0":c.y,"x1":c.x+24.0,"y1":c.y+8.0,"steps":8})); - assert!((h.app.cam.yaw - before.yaw - 24.0 * 0.008).abs() < 1e-6); - assert!((h.app.cam.pitch - before.pitch - 8.0 * 0.008).abs() < 1e-6); - assert_eq!(h.app.cam.target, before.target); - assert_eq!(h.app.cam.distance, before.distance); - assert!(h.app.viewport.axis_drag.is_none()); - assert!(h.app.viewport.boxsel.is_none()); - let home = crate::scenario::handle_at("axis_view:home").unwrap(); - h.call("ui.click", json!({"x":home.x,"y":home.y})); - let mut iso = h.app.cam; - iso.set_view(StandardView::Iso); - assert_eq!((h.app.cam.yaw, h.app.cam.pitch), (iso.yaw, iso.pitch)); - assert_eq!(h.app.session.doc.to_json(), baseline); - assert_eq!(h.app.session.undo.len(), undo); - assert!(h.app.session.selection.is_empty()); } } } #[test] fn immediate_standard_view_cancels_fit_before_the_measured_frame() { - let mut h = Harness::new(); - h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap(); - let baseline = h.app.session.doc.to_json(); - let undo = h.app.session.undo.len(); - h.app.cam.set_view(StandardView::Iso); - h.app.fit_view(); - assert!(h.app.pending_fit.is_some(), "Fit awaits the measured overlay frame"); - let mut front = h.app.cam; - front.set_view(StandardView::Front); - h.call("ui.view", json!({"view":"front"})); - h.settle(); - assert!(h.app.pending_fit.is_none()); - assert_eq!(h.app.cam, front, "the later frame must keep the explicitly requested view"); - assert_eq!(h.app.session.doc.to_json(), baseline); - assert_eq!(h.app.session.undo.len(), undo); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let mut h = Harness::new(); + h.call("ui.set", json!({"layout":mode})); + h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap(); + let baseline = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + h.app.cam.set_view(StandardView::Iso); + h.app.fit_view(); + assert!(h.app.pending_fit.is_some(), "Fit awaits the measured overlay frame"); + let mut front = h.app.cam; + front.set_view(StandardView::Front); + h.call("ui.view", json!({"view":"front"})); + h.settle(); + assert!(h.app.pending_fit.is_none()); + assert_eq!(h.app.cam, front, "the later frame must keep the explicitly requested view"); + assert_eq!(h.app.session.doc.to_json(), baseline); + assert_eq!(h.app.session.undo.len(), undo); + } } #[test] @@ -245,34 +275,37 @@ fn navigation_anchors_ignore_positioned_resized_and_covering_dialogs() { #[test] fn opening_different_command_dialogs_keeps_published_navigation_positions() { - let mut h = Harness::new(); - h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap(); - h.settle(); - let axis = crate::scenario::rect_of("axis_view:outer").unwrap(); - let nav = crate::scenario::rect_of("nav:outer").unwrap(); - let camera = h.app.cam; - let baseline = h.app.session.doc.to_json(); - let undo = h.app.session.undo.len(); - for command in ["solid.extrude", "solid.fillet", "inspect.measure"] { - h.app.start(command); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let mut h = Harness::new(); + h.call("ui.set", json!({"layout":mode})); + h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap(); h.settle(); - assert!(h.app.dialog.is_some(), "{command}"); - assert!(h.app.viewport.dialog_rect.is_some(), "dialog must be measured"); - assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), axis, "{command}"); - assert_eq!(crate::scenario::rect_of("nav:outer").unwrap(), nav, "{command}"); - assert_eq!(h.app.cam, camera); - let pan = pos2(nav.left() + 5.0 + 2.0 * 30.0 + 13.0, nav.top() + 15.0); - assert!(!h.app.viewport.dialog_rect.unwrap().contains(pan)); - h.app.viewport.nav = None; - h.call("ui.click", json!({"x":pan.x,"y":pan.y})); - assert_eq!(h.app.viewport.nav, Some(crate::viewport::NavMode::Pan), "fixed navigation stays usable beside {command}"); - h.app.dialog = None; - h.settle(); - assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), axis); - assert_eq!(crate::scenario::rect_of("nav:outer").unwrap(), nav); + let axis = crate::scenario::rect_of("axis_view:outer").unwrap(); + let nav = crate::scenario::rect_of("nav:outer").unwrap(); + let camera = h.app.cam; + let baseline = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + for command in ["solid.extrude", "solid.fillet", "inspect.measure"] { + h.app.start(command); + h.settle(); + assert!(h.app.dialog.is_some(), "{command}"); + assert!(h.app.viewport.dialog_rect.is_some(), "dialog must be measured"); + assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), axis, "{command}"); + assert_eq!(crate::scenario::rect_of("nav:outer").unwrap(), nav, "{command}"); + assert_eq!(h.app.cam, camera); + let pan = pos2(nav.left() + 5.0 + 2.0 * 30.0 + 13.0, nav.top() + 15.0); + assert!(!h.app.viewport.dialog_rect.unwrap().contains(pan)); + h.app.viewport.nav = None; + h.call("ui.click", json!({"x":pan.x,"y":pan.y})); + assert_eq!(h.app.viewport.nav, Some(crate::viewport::NavMode::Pan), "fixed navigation stays usable beside {command}"); + h.app.dialog = None; + h.settle(); + assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), axis); + assert_eq!(crate::scenario::rect_of("nav:outer").unwrap(), nav); + } + assert_eq!(h.app.session.doc.to_json(), baseline); + assert_eq!(h.app.session.undo.len(), undo); } - assert_eq!(h.app.session.doc.to_json(), baseline); - assert_eq!(h.app.session.undo.len(), undo); } #[test] diff --git a/crates/ui-egui/tests/scenarios/s37_construction_geometries_toggle.json b/crates/ui-egui/tests/scenarios/s37_construction_geometries_toggle.json index b2f9cb8233..67d1ce6efc 100644 --- a/crates/ui-egui/tests/scenarios/s37_construction_geometries_toggle.json +++ b/crates/ui-egui/tests/scenarios/s37_construction_geometries_toggle.json @@ -8,6 +8,7 @@ {"call": "engine.execute", "params": {"command": "sketch.construction", "params": {"curves": ["l5"], "value": true}}}, {"call": "engine.execute", "params": {"command": "sketch.centerline", "params": {"curves": ["l6"], "value": true}}}, {"call": "ui.view", "params": {"view": "top"}}, + {"call": "ui.view", "params": {"view": "fit"}}, {"expect": {"shown": "Construction Geometries"}}, {"expect": {"query": {"command": "sketch.options", "path": ["options", "show_construction"], "value": true}}}, diff --git a/crates/ui-egui/tests/scenarios/sketch_pattern_circular_center.json b/crates/ui-egui/tests/scenarios/sketch_pattern_circular_center.json index 8f024092cc..a5f50511ab 100644 --- a/crates/ui-egui/tests/scenarios/sketch_pattern_circular_center.json +++ b/crates/ui-egui/tests/scenarios/sketch_pattern_circular_center.json @@ -5,7 +5,8 @@ {"call":"engine.execute","params":{"command":"sketch.point","params":{"point":[0,0],"id":"axis_center"}}}, {"call":"engine.execute","params":{"command":"sketch.constraint.fix","params":{"entities":["axis_center"]}}}, {"call":"ui.view","params":{"view":"top"}}, - {"call":"ui.camera","params":{"target":[25,0,0],"distance":120}}, + {"note":"Keep the seed and centre exposed beside the left pattern dialog in the full-bleed shell."}, + {"call":"ui.camera","params":{"target":[-20,0,0],"distance":180}}, {"start":"sketch.pattern.circular"}, {"click":{"sketch":[42,0]}}, {"click":{"handle":"dialog:Center"}}, diff --git a/crates/ui-egui/tests/ui_layout.rs b/crates/ui-egui/tests/ui_layout.rs index 0baefd9c84..0b1b423627 100644 --- a/crates/ui-egui/tests/ui_layout.rs +++ b/crates/ui-egui/tests/ui_layout.rs @@ -72,6 +72,7 @@ fn docked_ribbon_geometry_and_search() { use solidcraft_ui_egui::{scenario::handle_at, toolbar}; for theme in ["light", "dark"] { let mut h = harness(); + h.call("ui.set", json!({"layout":"docked"})); h.app.preferences.theme = theme.into(); for width in [1600.0, 640.0, 320.0] { h.call("ui.resize", json!({"width":width,"height":1000})); @@ -85,7 +86,7 @@ fn docked_ribbon_geometry_and_search() { assert_eq!(workspace.width(), 88.0); assert_eq!(workspace.top(), ribbon.top()); assert_eq!(workspace.bottom(), ribbon.bottom() - 1.0); - assert_eq!(search.height(), 24.0); + assert_eq!(search.height(), 22.0); assert_eq!(search.width(), if width == 1600.0 { 188.0 } else { 24.0 }); assert!(ribbon.contains_rect(search)); assert!(search.left() > workspace.right()); @@ -123,19 +124,22 @@ fn docked_ribbon_geometry_and_search() { #[test] fn finish_sketch_stays_at_the_right_when_groups_collapse() { use solidcraft_ui_egui::scenario::handle_at; - let mut h = Harness::new(); - h.call("engine.execute", json!({"command":"sketch.create","params":{"plane":"XY"}})); - for width in [1600.0, 640.0, 320.0] { - h.call("ui.resize", json!({"width":width,"height":1000})); - h.frames(5); - let ribbon = rect_of("toolbar").unwrap(); - let exit = rect_of("ribbon:section:SKETCH:FINISH SKETCH").unwrap(); - let finish = rect_of("button:sketch.finish").unwrap(); - assert_eq!(exit.right(), ribbon.right() - 3.0); - assert!(ribbon.contains_rect(finish)); - assert!(finish.bottom() < exit.top()); - assert!(handle_at("tab:SKETCH").is_some(), "the selected contextual tab scrolls into view"); + for layout in ["docked", "floating"] { + let mut h = Harness::new(); + h.call("ui.set", json!({"layout":layout})); + h.call("engine.execute", json!({"command":"sketch.create","params":{"plane":"XY"}})); + for width in [1600.0, 640.0, 320.0] { + h.call("ui.resize", json!({"width":width,"height":1000})); + h.frames(5); + let ribbon = rect_of("toolbar").unwrap(); + let exit = rect_of("ribbon:section:SKETCH:FINISH SKETCH").unwrap(); + let finish = rect_of("button:sketch.finish").unwrap(); + assert_eq!(exit.right(), ribbon.right() - 4.0); + assert!(ribbon.contains_rect(finish)); + assert!(finish.bottom() < exit.top()); + assert!(handle_at("tab:SKETCH").is_some(), "the selected contextual tab scrolls into view"); + } + h.step(&json!({"click":{"handle":"button:sketch.finish"}})).unwrap(); + assert!(h.app.session.active_sketch.is_none()); } - h.step(&json!({"click":{"handle":"button:sketch.finish"}})).unwrap(); - assert!(h.app.session.active_sketch.is_none()); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f2c29879b9..dcf72886f5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -122,7 +122,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox. | Method | Params | Notes | |---|---|---| | `ui.inspect` | — | UI state, pending file picker (`file_picker_busy`; compatibility alias `file_dialog_open`, no path), what is hidden (`visibility`: bodies, origin and construction items, sketches, joints (`hidden_joints`, joint ids); set it with the `browser.visibility` command), viewport rect, camera, active tool/dialog, the status line (`status`: text and whether it is an error), hover, renderer, timings, live preview (`active`, `busy`, `error`, `ms`, bodies it stands in for) | -| `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `dark`, …), and `hiddenBodies`, `hiddenOrigin`, `hiddenSketches`, `shownSketches`, `hiddenJoints`, `hiddenFolders` (whole lists; `browser.visibility` changes single items) | `dark: false` switches to the light theme | +| `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `dark`, `layout`, …), and `hiddenBodies`, `hiddenOrigin`, `hiddenSketches`, `shownSketches`, `hiddenJoints`, `hiddenFolders` (whole lists; `browser.visibility` changes single items) | `dark: false` switches to the light theme; `layout: "floating"` (default) or `"docked"` selects the transient shell layout without saving a preference. Scenarios use `{"call":"ui.set","params":{"layout":"docked"}}` | | `ui.view` | `view`: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`, `home`, `fit`; `animate` (bool, default false: snap) | | | `ui.camera` | `target?`: `[x, y, z]` (mm), `yaw?`, `pitch?`, `roll?` (radians), `distance?` (mm), `fov?`, `up?`; `fit?`: `true`, `false` or `[occurrence id or name…]` (`root`: the bodies outside every component) | the viewport's orbit camera: it looks at `target` from `distance` away; `yaw` 0 looks along +Y (from the front), `pitch` is the elevation (±π/2: straight down or up), `roll` turns the image. Fields given are set first (the others keep their values), then `fit` frames the listed occurrences (or everything), keeping the direction. The reply, passed back, sets the same view again, so a framing can be stored and replayed. `fov` and `up` follow the perspective setting (`ui.set`) and the up axis preference (`ui.prefs`) and can't change here; a refused field changes nothing | | `ui.start` | `command`, `params?` | like a toolbar click: starts the sketch tool or dialog for the command. `params` sets what the dialog opens on, and nothing runs until OK: `joint.drive` takes `joint` (id or name; without it, the joint picked in the browser, else the first joint that moves) and `values` (numbers in degrees or millimetres, or expressions); `motion.study` takes `study` (id or name), which opens that study to edit and chart. A command that takes no params is refused before it starts; a param the dialog doesn't take is refused and the dialog closes | From 845edeba5f9951b01d03dac3398f6a02288481f6 Mon Sep 17 00:00:00 2001 From: Nathan Dane Date: Tue, 20 Oct 2026 09:36:18 +0200 Subject: [PATCH 02/16] Floating browser card and history strip (step 3 of #2016, rebased onto step 2) --- ATTRIBUTION.md | 6 +- assets/icons/ui/design/review.json | 2 - .../ui/design/svg/flat/disclosure_closed.svg | 4 + .../ui/design/svg/flat/disclosure_open.svg | 4 + assets/icons/ui/design/svg/flat/eye.svg | 5 + assets/icons/ui/design/svg/flat/suppress.svg | 12 + assets/icons/ui/design/svg/model/eye.svg | 8 - assets/icons/ui/design/svg/model/suppress.svg | 8 - assets/locales/cs.json | 7 +- assets/locales/de.json | 7 +- assets/locales/en.json | 7 +- assets/locales/es.json | 7 +- assets/locales/fr.json | 7 +- assets/locales/gl.json | 7 +- assets/locales/it.json | 7 +- assets/locales/ja.json | 7 +- assets/locales/ko.json | 7 +- assets/locales/ms.json | 7 +- assets/locales/pl.json | 7 +- assets/locales/pt-BR.json | 7 +- assets/locales/ru.json | 7 +- assets/locales/tr.json | 7 +- assets/locales/uk.json | 7 +- assets/locales/zh.json | 7 +- crates/ui-egui/src/browser.rs | 168 +++-- crates/ui-egui/src/browser_card_tests.rs | 169 +++++ crates/ui-egui/src/dialogs_sheet.rs | 3 +- crates/ui-egui/src/dialogs_sheet_tests.rs | 12 + crates/ui-egui/src/edit_clipboard.rs | 3 +- crates/ui-egui/src/frame.rs | 6 + crates/ui-egui/src/i18n.rs | 7 +- crates/ui-egui/src/i18n_ui_tests.rs | 23 +- crates/ui-egui/src/icons.rs | 16 +- crates/ui-egui/src/lib.rs | 37 +- crates/ui-egui/src/shell.rs | 6 +- crates/ui-egui/src/shell_tests.rs | 68 +- crates/ui-egui/src/shortcut_box.rs | 60 ++ .../ui-egui/src/sketch_guidance_ui_tests.rs | 4 + crates/ui-egui/src/timeline.rs | 607 ++++++++++-------- crates/ui-egui/src/timeline_left.rs | 31 +- crates/ui-egui/src/viewport.rs | 27 +- crates/ui-egui/src/viewport_layout_tests.rs | 26 + crates/ui-egui/src/workspace.rs | 2 +- .../tests/scenarios/s37_browser_chevrons.json | 27 + .../tests/scenarios/sketch_mirror_dialog.json | 2 + crates/ui-egui/tests/window_widths.rs | 4 +- docs/ribbon-layout.md | 11 +- 47 files changed, 1078 insertions(+), 402 deletions(-) create mode 100644 assets/icons/ui/design/svg/flat/disclosure_closed.svg create mode 100644 assets/icons/ui/design/svg/flat/disclosure_open.svg create mode 100644 assets/icons/ui/design/svg/flat/eye.svg create mode 100644 assets/icons/ui/design/svg/flat/suppress.svg delete mode 100644 assets/icons/ui/design/svg/model/eye.svg delete mode 100644 assets/icons/ui/design/svg/model/suppress.svg create mode 100644 crates/ui-egui/src/browser_card_tests.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index a9a38d33d1..4b936d05ed 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -138,6 +138,8 @@ Editable drawing code, native projects and shipped exports are collected under ` | assets/icons/ui/design/svg/flat/bug_report.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/flat/chevron.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/flat/close.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/disclosure_closed.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/disclosure_open.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/flat/fit.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/flat/folder.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/flat/help.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | @@ -232,7 +234,7 @@ Editable drawing code, native projects and shipped exports are collected under ` | assets/icons/ui/design/svg/model/export.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/model/extend.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/model/extrude.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | -| assets/icons/ui/design/svg/model/eye.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/eye.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/model/fastener.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/model/fillet.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/model/filter.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | @@ -336,7 +338,7 @@ Editable drawing code, native projects and shipped exports are collected under ` | assets/icons/ui/design/svg/model/status_error.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/model/status_warning.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/model/stitch.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | -| assets/icons/ui/design/svg/model/suppress.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/suppress.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/model/surface.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/model/svg.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/model/sweep.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | diff --git a/assets/icons/ui/design/review.json b/assets/icons/ui/design/review.json index d3583be579..784b14c3a6 100644 --- a/assets/icons/ui/design/review.json +++ b/assets/icons/ui/design/review.json @@ -99,7 +99,6 @@ "export": "c8c2abf007e43e896479d78798e3976ba9eda809507c820bb38538f77db696ac", "extend": "28c63df352c77d02feffe44cf09fb0fc1d48f7905aaabbfa6dba601dbb70f8e8", "extrude": "9cc1538971c5d25467aaed23f739899d9d102fd07f86472ac3197c54ca2e1144", - "eye": "5d90a25f5d07e6e36a2a8faf5e2ed1425b2a787834ab2291b1b1c521057ec1c2", "fastener": "fcff4e4be50907f712e527b373771afafd92e50096fc7a4b8aba5d86448e1e42", "fillet": "11ec232dc111597211e3a4fe61b69616b5d28b4b1b2732052ddcd486cdfc6129", "filter": "4d456a56416089574bd3732fa44c0c1b27fd2478b369448808a27f0144d15d8a", @@ -251,7 +250,6 @@ "status_warning": "1ac4f0ccfac2c01f6f66cb42188f9ae053c4aef9c5bbd249f1fb09611eb4d825", "stitch": "6c3205d64007b771ed78d6f8055867578439fb25d591563b32dbd3343c98732d", "style": "6c2b04234b82ea4858c80190cc7208c60f549358253005afc35ab4e29506b578", - "suppress": "6c033a6672a3b6ca926e3d7a75a758c9db7e1144210cd360be043a9fdf999ede", "surface": "ba0e839ce0ee03e7ff95a2389a5ecb20bd80c713064d9b1bd5f461f4a80d1921", "surface_blend": "0642f3b4af7d22f379a3c74f54c7b1c9eef0bdda19372ce4df73c0e56bdd7bbc", "surface_boundary": "fb8501bae192e2416118cc2e88f544e0e29197f6720bccc541c967ca0848d8d3", diff --git a/assets/icons/ui/design/svg/flat/disclosure_closed.svg b/assets/icons/ui/design/svg/flat/disclosure_closed.svg new file mode 100644 index 0000000000..33f723348a --- /dev/null +++ b/assets/icons/ui/design/svg/flat/disclosure_closed.svg @@ -0,0 +1,4 @@ + + Disclosure closed + + diff --git a/assets/icons/ui/design/svg/flat/disclosure_open.svg b/assets/icons/ui/design/svg/flat/disclosure_open.svg new file mode 100644 index 0000000000..ce48c1db7f --- /dev/null +++ b/assets/icons/ui/design/svg/flat/disclosure_open.svg @@ -0,0 +1,4 @@ + + Disclosure open + + diff --git a/assets/icons/ui/design/svg/flat/eye.svg b/assets/icons/ui/design/svg/flat/eye.svg new file mode 100644 index 0000000000..4f6920ad49 --- /dev/null +++ b/assets/icons/ui/design/svg/flat/eye.svg @@ -0,0 +1,5 @@ + + Eye + + + diff --git a/assets/icons/ui/design/svg/flat/suppress.svg b/assets/icons/ui/design/svg/flat/suppress.svg new file mode 100644 index 0000000000..3a7b168577 --- /dev/null +++ b/assets/icons/ui/design/svg/flat/suppress.svg @@ -0,0 +1,12 @@ + + Suppress + + + + + + + + + + diff --git a/assets/icons/ui/design/svg/model/eye.svg b/assets/icons/ui/design/svg/model/eye.svg deleted file mode 100644 index a7cc82f280..0000000000 --- a/assets/icons/ui/design/svg/model/eye.svg +++ /dev/null @@ -1,8 +0,0 @@ - - Eye - - - - - - diff --git a/assets/icons/ui/design/svg/model/suppress.svg b/assets/icons/ui/design/svg/model/suppress.svg deleted file mode 100644 index 9d369a54c6..0000000000 --- a/assets/icons/ui/design/svg/model/suppress.svg +++ /dev/null @@ -1,8 +0,0 @@ - - Suppress - - - - - - diff --git a/assets/locales/cs.json b/assets/locales/cs.json index 786444d524..a90c4b2f24 100644 --- a/assets/locales/cs.json +++ b/assets/locales/cs.json @@ -1028,6 +1028,11 @@ "ui.bodies": "Tělesa", "ui.box": "Kvádr", "ui.break": "Přerušit", + "ui.browser_bodies_one": "{count} těleso", + "ui.browser_bodies_other": "{count} těles", + "ui.browser_collapse": "Sbalit prohlížeč", + "ui.browser_expand": "Rozbalit prohlížeč", + "ui.browser_title": "Prohlížeč", "ui.calibrate": "Kalibrovat", "ui.cancel": "Zrušit", "ui.canvases": "Plátna", @@ -1068,7 +1073,6 @@ "ui.combine": "Kombinovat", "ui.coming_later": "připravujeme", "ui.command_done": "{command} hotovo", - "ui.command_palette_hint": "Zadejte příkaz, např. {example} — nebo stiskněte S pro hledání", "ui.concentric": "Soustřednost", "ui.configurations": "Konfigurace", "ui.conic_curve": "Kuželosečka", @@ -1240,6 +1244,7 @@ "ui.hideOrigin": "Skrýt počátek", "ui.hidePlanes": "Skrýt roviny", "ui.hideProfile": "Skrýt profil", + "ui.history_title": "Historie", "ui.hole": "Otvor", "ui.hole_tap_type": "Typ závitu otvoru", "ui.hole_type": "Typ otvoru", diff --git a/assets/locales/de.json b/assets/locales/de.json index a440878745..0a5d325322 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -819,6 +819,11 @@ "ui.bodies_all_if_none": "Körper (alle, wenn keine)", "ui.box": "Block", "ui.break": "Unterbrechen", + "ui.browser_bodies_one": "{count} Körper", + "ui.browser_bodies_other": "{count} Körper", + "ui.browser_collapse": "Browser einklappen", + "ui.browser_expand": "Browser ausklappen", + "ui.browser_title": "Browser", "ui.calibrate": "Kalibrieren", "ui.cancel": "Abbrechen", "ui.canvases": "Kanvas", @@ -856,7 +861,6 @@ "ui.combine": "Kombinieren", "ui.coming_later": "bald verfügbar", "ui.command_done": "{command} fertig", - "ui.command_palette_hint": "Befehl eingeben, z. B. {example} — oder S drücken, um zu suchen", "ui.concentric": "Konzentrisch", "ui.configurations": "Konfigurationen", "ui.conic_curve": "Kegelschnitt", @@ -1029,6 +1033,7 @@ "ui.hideOrigin": "Ursprung ausblenden", "ui.hidePlanes": "Ebenen ausblenden", "ui.hideProfile": "Profil ausblenden", + "ui.history_title": "Verlauf", "ui.hole": "Bohrung", "ui.hole_tap_type": "Gewindetyp", "ui.hole_type": "Bohrungstyp", diff --git a/assets/locales/en.json b/assets/locales/en.json index c04b52ad38..932f107ed2 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -1160,6 +1160,11 @@ "ui.bodies_all_if_none": "Bodies (all if none)", "ui.box": "Box", "ui.break": "Break", + "ui.browser_bodies_one": "{count} body", + "ui.browser_bodies_other": "{count} bodies", + "ui.browser_collapse": "Collapse the browser", + "ui.browser_expand": "Expand the browser", + "ui.browser_title": "Browser", "ui.calibrate": "Calibrate", "ui.cancel": "Cancel", "ui.canvases": "Canvases", @@ -1200,7 +1205,6 @@ "ui.combine": "Combine", "ui.coming_later": "coming later", "ui.command_done": "{command} done", - "ui.command_palette_hint": "Type a command, e.g. {example} — or press S to search", "ui.concentric": "Concentric", "ui.configurations": "Configurations", "ui.conic_curve": "Conic Curve", @@ -1383,6 +1387,7 @@ "ui.hideOrigin": "Hide Origin", "ui.hidePlanes": "Hide Planes", "ui.hideProfile": "Hide Profile", + "ui.history_title": "History", "ui.hole": "Hole", "ui.hole_tap_type": "Hole Tap Type", "ui.hole_type": "Hole Type", diff --git a/assets/locales/es.json b/assets/locales/es.json index bfe8938f9e..a98c609dc2 100644 --- a/assets/locales/es.json +++ b/assets/locales/es.json @@ -1047,6 +1047,11 @@ "ui.bodies": "Cuerpos", "ui.box": "Caja", "ui.break": "Romper", + "ui.browser_bodies_one": "{count} cuerpo", + "ui.browser_bodies_other": "{count} cuerpos", + "ui.browser_collapse": "Contraer el navegador", + "ui.browser_expand": "Expandir el navegador", + "ui.browser_title": "Navegador", "ui.calibrate": "Calibrar", "ui.cancel": "Cancelar", "ui.canvases": "Lienzos", @@ -1087,7 +1092,6 @@ "ui.combine": "Combinar", "ui.coming_later": "próximamente", "ui.command_done": "{command} completado", - "ui.command_palette_hint": "Escribe un comando, p. ej., {example}, o pulsa S para buscar", "ui.concentric": "Concéntrico", "ui.configurations": "Configuraciones", "ui.conic_curve": "Curva cónica", @@ -1264,6 +1268,7 @@ "ui.hideOrigin": "Ocultar origen", "ui.hidePlanes": "Ocultar planos", "ui.hideProfile": "Ocultar perfil", + "ui.history_title": "Historial", "ui.hole": "Agujero", "ui.hole_tap_type": "Tipo de roscado del agujero", "ui.hole_type": "Tipo de agujero", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 9387ef3f9f..2c748ac630 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -1098,6 +1098,11 @@ "ui.bodies_all_if_none": "Corps (tous si aucun)", "ui.box": "Boîte", "ui.break": "Fractionner", + "ui.browser_bodies_one": "{count} corps", + "ui.browser_bodies_other": "{count} corps", + "ui.browser_collapse": "Réduire le navigateur", + "ui.browser_expand": "Développer le navigateur", + "ui.browser_title": "Navigateur", "ui.calibrate": "Étalonner", "ui.cancel": "Annuler", "ui.canvases": "Canevas", @@ -1138,7 +1143,6 @@ "ui.combine": "Combiner", "ui.coming_later": "bientôt disponible", "ui.command_done": "{command} terminé", - "ui.command_palette_hint": "Tapez une commande, ex. {example} — ou S pour chercher", "ui.concentric": "Concentricité", "ui.configurations": "Configurations", "ui.conic_curve": "Courbe conique", @@ -1321,6 +1325,7 @@ "ui.hideOrigin": "Masquer l'origine", "ui.hidePlanes": "Masquer les plans", "ui.hideProfile": "Masquer le profil", + "ui.history_title": "Historique", "ui.hole": "Perçage", "ui.hole_tap_type": "Type de taraudage", "ui.hole_type": "Type de perçage", diff --git a/assets/locales/gl.json b/assets/locales/gl.json index fd4ec7c6bc..1f5e26f3b6 100644 --- a/assets/locales/gl.json +++ b/assets/locales/gl.json @@ -1019,6 +1019,11 @@ "ui.bodies": "Corpos", "ui.box": "Caixa", "ui.break": "Romper", + "ui.browser_bodies_one": "{count} corpo", + "ui.browser_bodies_other": "{count} corpos", + "ui.browser_collapse": "Contraer o navegador", + "ui.browser_expand": "Expandir o navegador", + "ui.browser_title": "Navegador", "ui.calibrate": "Calibrar", "ui.cancel": "Cancelar", "ui.canvases": "Lenzos", @@ -1059,7 +1064,6 @@ "ui.combine": "Combinar", "ui.coming_later": "máis adiante", "ui.command_done": "{command} feito", - "ui.command_palette_hint": "Escribe unha orde, p. ex. {example} — ou preme S para buscar", "ui.concentric": "Concéntrica", "ui.configurations": "Configuracións", "ui.conic_curve": "Curva cónica", @@ -1231,6 +1235,7 @@ "ui.hideOrigin": "Ocultar a orixe", "ui.hidePlanes": "Ocultar os planos", "ui.hideProfile": "Ocultar o perfil", + "ui.history_title": "Historial", "ui.hole": "Furado", "ui.hole_tap_type": "Tipo de roscado do furado", "ui.hole_type": "Tipo de furado", diff --git a/assets/locales/it.json b/assets/locales/it.json index b2e7bfd7e7..62cbd03b16 100644 --- a/assets/locales/it.json +++ b/assets/locales/it.json @@ -1030,6 +1030,11 @@ "ui.bodies": "Corpi", "ui.box": "Parallelepipedo", "ui.break": "Spezza", + "ui.browser_bodies_one": "{count} corpo", + "ui.browser_bodies_other": "{count} corpi", + "ui.browser_collapse": "Comprimi la struttura", + "ui.browser_expand": "Espandi la struttura", + "ui.browser_title": "Struttura", "ui.calibrate": "Calibra", "ui.cancel": "Annulla", "ui.canvases": "Immagini di riferimento", @@ -1070,7 +1075,6 @@ "ui.combine": "Combina", "ui.coming_later": "in arrivo", "ui.command_done": "{command} completato", - "ui.command_palette_hint": "Digita un comando, ad es. {example} — oppure premi S per cercare", "ui.concentric": "Concentrico", "ui.configurations": "Configurazioni", "ui.conic_curve": "Curva conica", @@ -1247,6 +1251,7 @@ "ui.hideOrigin": "Nascondi origine", "ui.hidePlanes": "Nascondi piani", "ui.hideProfile": "Nascondi profilo", + "ui.history_title": "Cronologia", "ui.hole": "Foro", "ui.hole_tap_type": "Tipo di maschiatura del foro", "ui.hole_type": "Tipo di foro", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 14bcd45b07..feec1edefd 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -1160,6 +1160,11 @@ "ui.bodies_all_if_none": "ボディ(未選択ならすべて)", "ui.box": "直方体", "ui.break": "分割", + "ui.browser_bodies_one": "{count} ボディ", + "ui.browser_bodies_other": "{count} ボディ", + "ui.browser_collapse": "ブラウザを折りたたむ", + "ui.browser_expand": "ブラウザを展開", + "ui.browser_title": "ブラウザ", "ui.calibrate": "校正", "ui.cancel": "キャンセル", "ui.canvases": "キャンバス", @@ -1200,7 +1205,6 @@ "ui.combine": "結合", "ui.coming_later": "準備中", "ui.command_done": "{command} 完了", - "ui.command_palette_hint": "コマンドを入力(例: {example})— または S キーで検索", "ui.concentric": "同心", "ui.configurations": "構成", "ui.conic_curve": "円錐曲線", @@ -1383,6 +1387,7 @@ "ui.hideOrigin": "原点を非表示", "ui.hidePlanes": "平面を非表示", "ui.hideProfile": "プロファイルを非表示", + "ui.history_title": "履歴", "ui.hole": "穴", "ui.hole_tap_type": "ねじ穴タイプ", "ui.hole_type": "穴タイプ", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 6bcb068c03..a4f225dc32 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -819,6 +819,11 @@ "ui.bodies_all_if_none": "바디 (없으면 전체)", "ui.box": "박스", "ui.break": "끊기", + "ui.browser_bodies_one": "{count}개 바디", + "ui.browser_bodies_other": "{count}개 바디", + "ui.browser_collapse": "브라우저 접기", + "ui.browser_expand": "브라우저 펼치기", + "ui.browser_title": "브라우저", "ui.calibrate": "캘리브레이션", "ui.cancel": "취소", "ui.canvases": "캔버스", @@ -856,7 +861,6 @@ "ui.combine": "결합", "ui.coming_later": "추후 지원 예정", "ui.command_done": "{command} 완료", - "ui.command_palette_hint": "명령 입력(예: {example}) — 또는 S 키를 눌러 검색", "ui.concentric": "동심", "ui.configurations": "구성", "ui.conic_curve": "원뿔 곡선", @@ -1029,6 +1033,7 @@ "ui.hideOrigin": "원점 숨기기", "ui.hidePlanes": "평면 숨기기", "ui.hideProfile": "윤곽 숨기기", + "ui.history_title": "기록", "ui.hole": "구멍", "ui.hole_tap_type": "탭 유형", "ui.hole_type": "구멍 유형", diff --git a/assets/locales/ms.json b/assets/locales/ms.json index 11765f89a6..1767deb878 100644 --- a/assets/locales/ms.json +++ b/assets/locales/ms.json @@ -1015,6 +1015,11 @@ "ui.bodies": "Jasad", "ui.box": "Kotak", "ui.break": "Putuskan", + "ui.browser_bodies_one": "{count} jasad", + "ui.browser_bodies_other": "{count} jasad", + "ui.browser_collapse": "Runtuhkan pelayar", + "ui.browser_expand": "Kembangkan pelayar", + "ui.browser_title": "Pelayar", "ui.calibrate": "Tentukur", "ui.cancel": "Batal", "ui.canvases": "Kanvas", @@ -1055,7 +1060,6 @@ "ui.combine": "Gabung", "ui.coming_later": "akan datang", "ui.command_done": "{command} selesai", - "ui.command_palette_hint": "Taip arahan, cth. {example} — atau tekan S untuk mencari", "ui.concentric": "Sepusat", "ui.configurations": "Konfigurasi", "ui.conic_curve": "Lengkung Kon", @@ -1232,6 +1236,7 @@ "ui.hideOrigin": "Sembunyi Titik Asal", "ui.hidePlanes": "Sembunyi Satah", "ui.hideProfile": "Sembunyi Profil", + "ui.history_title": "Sejarah", "ui.hole": "Lubang", "ui.hole_tap_type": "Jenis Bebenang Lubang", "ui.hole_type": "Jenis Lubang", diff --git a/assets/locales/pl.json b/assets/locales/pl.json index 8507e9f071..a42500712b 100644 --- a/assets/locales/pl.json +++ b/assets/locales/pl.json @@ -1018,6 +1018,11 @@ "ui.bodies": "Bryły", "ui.box": "Prostopadłościan", "ui.break": "Podziel", + "ui.browser_bodies_one": "{count} bryła", + "ui.browser_bodies_other": "{count} brył", + "ui.browser_collapse": "Zwiń przeglądarkę", + "ui.browser_expand": "Rozwiń przeglądarkę", + "ui.browser_title": "Przeglądarka", "ui.calibrate": "Kalibruj", "ui.cancel": "Anuluj", "ui.canvases": "Płótna", @@ -1058,7 +1063,6 @@ "ui.combine": "Kombinacja", "ui.coming_later": "wkrótce", "ui.command_done": "Wykonano: {command}", - "ui.command_palette_hint": "Wpisz polecenie, np. {example} — lub naciśnij S, aby wyszukać", "ui.concentric": "Współśrodkowość", "ui.configurations": "Konfiguracje", "ui.conic_curve": "Krzywa stożkowa", @@ -1235,6 +1239,7 @@ "ui.hideOrigin": "Ukryj początek układu", "ui.hidePlanes": "Ukryj płaszczyzny", "ui.hideProfile": "Ukryj profil", + "ui.history_title": "Historia", "ui.hole": "Otwór", "ui.hole_tap_type": "Typ gwintu otworu", "ui.hole_type": "Typ otworu", diff --git a/assets/locales/pt-BR.json b/assets/locales/pt-BR.json index 4ad1c0a484..4dae07958f 100644 --- a/assets/locales/pt-BR.json +++ b/assets/locales/pt-BR.json @@ -1002,6 +1002,11 @@ "ui.bodies": "Corpos", "ui.box": "Caixa", "ui.break": "Quebrar", + "ui.browser_bodies_one": "{count} corpo", + "ui.browser_bodies_other": "{count} corpos", + "ui.browser_collapse": "Recolher o navegador", + "ui.browser_expand": "Expandir o navegador", + "ui.browser_title": "Navegador", "ui.calibrate": "Calibrar", "ui.cancel": "Cancelar", "ui.canvases": "Telas de referência", @@ -1042,7 +1047,6 @@ "ui.combine": "Combinar", "ui.coming_later": "em breve", "ui.command_done": "{command} concluído", - "ui.command_palette_hint": "Digite um comando, p. ex. {example} — ou pressione S para pesquisar", "ui.concentric": "Concêntrico", "ui.configurations": "Configurações", "ui.conic_curve": "Curva cônica", @@ -1214,6 +1218,7 @@ "ui.hideOrigin": "Ocultar origem", "ui.hidePlanes": "Ocultar planos", "ui.hideProfile": "Ocultar perfil", + "ui.history_title": "Histórico", "ui.hole": "Furo", "ui.hole_tap_type": "Tipo de rosca do furo", "ui.hole_type": "Tipo de furo", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 73cf196674..9e94cd002a 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -811,6 +811,11 @@ "ui.bodies": "Тела", "ui.box": "Бокс", "ui.break": "Разорвать", + "ui.browser_bodies_one": "{count} тело", + "ui.browser_bodies_other": "{count} тел", + "ui.browser_collapse": "Свернуть браузер", + "ui.browser_expand": "Развернуть браузер", + "ui.browser_title": "Браузер", "ui.calibrate": "Калибровать", "ui.cancel": "Отмена", "ui.canvases": "Подложки", @@ -851,7 +856,6 @@ "ui.combine": "Объединение", "ui.coming_later": "появится позже", "ui.command_done": "{command} — готово", - "ui.command_palette_hint": "Введите команду, напр. {example} — или нажмите S для поиска", "ui.concentric": "Концентричность", "ui.configurations": "Конфигурации", "ui.conic_curve": "Коническая кривая", @@ -1022,6 +1026,7 @@ "ui.hideOrigin": "Скрыть начало координат", "ui.hidePlanes": "Скрыть плоскости", "ui.hideProfile": "Скрыть профиль", + "ui.history_title": "История", "ui.hole": "Отверстие", "ui.hole_tap_type": "Тип резьбы отверстия", "ui.hole_type": "Тип отверстия", diff --git a/assets/locales/tr.json b/assets/locales/tr.json index 907022a7c9..2747eef9ed 100644 --- a/assets/locales/tr.json +++ b/assets/locales/tr.json @@ -949,6 +949,11 @@ "ui.bodies": "Gövdeler", "ui.box": "Kutu", "ui.break": "Kır", + "ui.browser_bodies_one": "{count} gövde", + "ui.browser_bodies_other": "{count} gövde", + "ui.browser_collapse": "Tarayıcıyı daralt", + "ui.browser_expand": "Tarayıcıyı genişlet", + "ui.browser_title": "Tarayıcı", "ui.calibrate": "Kalibre et", "ui.cancel": "İptal", "ui.canvases": "Tuvaller", @@ -989,7 +994,6 @@ "ui.combine": "Birleştir", "ui.coming_later": "yakında", "ui.command_done": "{command} tamamlandı", - "ui.command_palette_hint": "Bir komut yazın (ör. {example}) — veya aramak için S tuşuna basın", "ui.concentric": "Eş merkezli", "ui.configurations": "Konfigürasyonlar", "ui.conic_curve": "Konik eğri", @@ -1160,6 +1164,7 @@ "ui.hideOrigin": "Orijini gizle", "ui.hidePlanes": "Düzlemleri gizle", "ui.hideProfile": "Profili gizle", + "ui.history_title": "Geçmiş", "ui.hole": "Delik", "ui.hole_tap_type": "Diş türü", "ui.hole_type": "Delik türü", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index 05a8972d2d..6d7571acdd 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -1098,6 +1098,11 @@ "ui.bodies_all_if_none": "Тіла (усі, якщо не вибрано)", "ui.box": "Куб", "ui.break": "Розірвати", + "ui.browser_bodies_one": "{count} тіло", + "ui.browser_bodies_other": "{count} тіл", + "ui.browser_collapse": "Згорнути браузер", + "ui.browser_expand": "Розгорнути браузер", + "ui.browser_title": "Браузер", "ui.calibrate": "Калібрувати", "ui.cancel": "Скасувати", "ui.canvases": "Полотна", @@ -1138,7 +1143,6 @@ "ui.combine": "Об'єднати", "ui.coming_later": "з'явиться пізніше", "ui.command_done": "{command} виконано", - "ui.command_palette_hint": "Введіть команду, напр. {example} — або натисніть S для пошуку", "ui.concentric": "Концентричність", "ui.configurations": "Конфігурації", "ui.conic_curve": "Конічна крива", @@ -1321,6 +1325,7 @@ "ui.hideOrigin": "Приховати початок координат", "ui.hidePlanes": "Приховати площини", "ui.hideProfile": "Приховати профіль", + "ui.history_title": "Історія", "ui.hole": "Отвір", "ui.hole_tap_type": "Тип різьби отвору", "ui.hole_type": "Тип отвору", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 219f035915..7033e195ae 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -1160,6 +1160,11 @@ "ui.bodies_all_if_none": "实体(未选择则全部)", "ui.box": "长方体", "ui.break": "打断", + "ui.browser_bodies_one": "{count} 个实体", + "ui.browser_bodies_other": "{count} 个实体", + "ui.browser_collapse": "折叠浏览器", + "ui.browser_expand": "展开浏览器", + "ui.browser_title": "浏览器", "ui.calibrate": "校准", "ui.cancel": "取消", "ui.canvases": "画布", @@ -1200,7 +1205,6 @@ "ui.combine": "组合", "ui.coming_later": "即将推出", "ui.command_done": "{command} 完成", - "ui.command_palette_hint": "输入命令,例如 {example} — 或按 S 搜索", "ui.concentric": "同心", "ui.configurations": "配置", "ui.conic_curve": "圆锥曲线", @@ -1383,6 +1387,7 @@ "ui.hideOrigin": "隐藏原点", "ui.hidePlanes": "隐藏平面", "ui.hideProfile": "隐藏轮廓", + "ui.history_title": "历史", "ui.hole": "孔", "ui.hole_tap_type": "孔攻丝类型", "ui.hole_type": "孔类型", diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 50b54e0536..d895670271 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -59,6 +59,8 @@ pub fn drawn_occurrences() -> Vec<(Vec, String, Rect, bool)> { /// Browser state kept between frames. #[derive(Default)] pub struct TreeState { + /// The floating browser card is folded to its header. + pub panel_collapsed: bool, /// Geometry and assembly queries, independent of the camera, selection and row visibility. model: Option, /// Folders closed by the user (keys like `c0/bodies`). @@ -274,7 +276,11 @@ pub fn group_action(app: &mut SolveApp, action: &str, p: &Value) { // Drawing // --------------------------------------------------------------------------------------------- -const ROW_H: f32 = 22.0; +const ROW_H: f32 = 26.0; +/// The floating card's inner margin: the header highlight sits this far from the card's top, left +/// and right edges, so its corners are concentric with the card's. +const CARD_INSET: f32 = 4.0; +const HEADER_H: f32 = 26.0; const INDENT: f32 = 14.0; /// Opacity of a dimmed row's icon (a hidden body, sketch, origin item, …). const DIM_ICON_OPACITY: f32 = 0.4; @@ -388,8 +394,8 @@ pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { // Fold arrow. if let Some(open) = row.fold { let ar = Rect::from_center_size(pos2(x + 6.0, cy), vec2(12.0, 12.0)); - let ink = if hover_on == 1 { t.text } else { t.text_dim }; - crate::icons::glyphs::disclosure(ui.painter(), ar, open, ink); + let strength = if hover_on == 1 { 1.0 } else { 0.7 }; + icons::paint_icon(ui.painter(), ar, if open { "disclosure_open" } else { "disclosure_closed" }, strength); out.fold = resp.clicked() && click_on == 1; crate::scenario::publish_handle(&format!("fold:{}", row.label), fold_r.center()); } @@ -397,11 +403,8 @@ pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { // Eye. if let Some(vis) = row.eye { let er = Rect::from_center_size(pos2(x + 8.0, cy), vec2(15.0, 15.0)); - // A closed eye, or the eye of a faded row, draws faint like the row's icon. - icons::paint_icon(ui.painter(), er, "eye", if vis && !row.dim { 1.0 } else { DIM_ICON_OPACITY }); - if !vis { - ui.painter().line_segment([er.left_bottom() + vec2(2.0, -2.0), er.right_top() + vec2(-2.0, 2.0)], Stroke::new(1.4, t.text_dim)); - } + // Hidden or faded rows keep main's dimming, with the rounded suppression asset. + icons::paint_icon(ui.painter(), er, if vis { "eye" } else { "suppress" }, if vis && !row.dim { 0.85 } else { DIM_ICON_OPACITY }); out.eye = resp.clicked() && click_on == 2; crate::scenario::publish_handle(&format!("eye:{}", row.label), er.center()); } @@ -423,7 +426,8 @@ pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { icons::paint_icon(ui.painter(), ir, row.icon, if row.dim { DIM_ICON_OPACITY } else { 1.0 }); x += 21.0; } - let col = row.color.unwrap_or(if row.dim { t.text_dim } else { t.text }); + let col = + if row.selected { crate::theme::TabAccent::current(ui.ctx()).color } else { row.color.unwrap_or(if row.dim { t.text_dim } else { t.text }) }; let label_display = if row.name { crate::i18n::tr_name(row.label) } else { crate::i18n::tr_dyn(row.label) }; let galley = ui.painter().layout_no_wrap(label_display, FontId::proportional(t.font_label), col); let lw = galley.size().x; @@ -669,8 +673,7 @@ fn browser_panel(app: &mut SolveApp, ui: &mut egui::Ui, timeline_above: bool) { DRAWN.with(|d| d.borrow_mut().clear()); OCCURRENCES.with(|d| d.borrow_mut().clear()); let mut acts = Actions::default(); - let remembered = app.preferences.browser_width; - let width = if remembered.is_finite() { remembered.clamp(MIN_BROWSER_WIDTH, MAX_BROWSER_WIDTH) } else { crate::prefs::DEFAULT_BROWSER_WIDTH }; + let width = browser_width(app); let panel = egui::Panel::left("sc_browser") .exact_size(width) .resizable(false) @@ -712,64 +715,125 @@ fn browser_panel(app: &mut SolveApp, ui: &mut egui::Ui, timeline_above: bool) { browser_rows(app, ui, &mut acts); } } else { - browser_contents(app, ui, &mut acts); + browser_contents(app, ui, &mut acts, false); } }); - // The sidebar's right edge is a drag handle; the width it is dragged to is remembered. - let rect = panel.response.rect; - let rail = egui::Rect::from_x_y_ranges(rect.right() - BROWSER_RAIL..=rect.right(), rect.y_range()); - let response = ui.interact(rail, egui::Id::new("sc_browser_rail"), egui::Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + resize_browser(app, ui, panel.response.rect, width); + finish_browser(app, ui, acts); +} + +fn browser_width(app: &SolveApp) -> f32 { + let remembered = app.preferences.browser_width; + if remembered.is_finite() { remembered.clamp(MIN_BROWSER_WIDTH, MAX_BROWSER_WIDTH) } else { crate::prefs::DEFAULT_BROWSER_WIDTH } +} + +fn resize_browser(app: &mut SolveApp, ui: &egui::Ui, rect: Rect, width: f32) { + let rail = Rect::from_x_y_ranges(rect.right() - BROWSER_RAIL..=rect.right(), rect.y_range()); + let response = ui.interact(rail, egui::Id::new("sc_browser_rail"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); if response.dragged() { let delta = ui.input(|input| input.pointer.delta().x); if delta.is_finite() { app.preferences.browser_width = (width + delta).clamp(MIN_BROWSER_WIDTH, MAX_BROWSER_WIDTH); } } - crate::scenario::publish_handle("left-browser-rail", egui::pos2(rect.right() - BROWSER_RAIL / 2.0, rect.center().y)); - finish_browser(app, ui, acts); + crate::scenario::publish_handle("left-browser-rail", pos2(rect.right() - BROWSER_RAIL / 2.0, rect.center().y)); } -/// The tree on a translucent card over the workspace, instead of a column that takes width from -/// it (Preferences: "Float it over the workspace"). The card occupies the content below the -/// ribbon in either shell layout, leaving the viewport projection unchanged. -pub fn floating(app: &mut SolveApp, ui: &mut egui::Ui, viewport: egui::Rect) { +/// One browser card for the floating shell and the docked shell's optional overlay. +/// The preference keeps its translucent overlay semantics; the floating shell always has a card. +pub fn floating(app: &mut SolveApp, ui: &mut egui::Ui, available: Rect) { let t = Tokens::get(); app.tree.order.clear(); DRAWN.with(|d| d.borrow_mut().clear()); OCCURRENCES.with(|d| d.borrow_mut().clear()); let mut acts = Actions::default(); - let remembered = app.preferences.browser_width; - let width = if remembered.is_finite() { remembered.clamp(MIN_BROWSER_WIDTH, MAX_BROWSER_WIDTH) } else { crate::prefs::DEFAULT_BROWSER_WIDTH }; - // Tall enough for the tree, short enough to leave the view room; the rows scroll inside. - let height = (viewport.height() * 0.7).clamp(160.0, 560.0); - let card = crate::frame::area(egui::Id::new("sc_browser_float")).fixed_pos(viewport.min + vec2(8.0, 8.0)).order(egui::Order::Middle).show( - ui.ctx(), - |ui| { - egui::Frame::new() - .fill(t.panel.gamma_multiply(BROWSER_FLOAT_ALPHA)) - .stroke(egui::Stroke::new(1.0, t.border)) - .corner_radius(6) - .inner_margin(egui::Margin::symmetric(8, 6)) - .show(ui, |ui| { - ui.set_width(width - 16.0); - ui.set_max_height(height); - ui.label(RichText::new(crate::i18n::tr_label("BROWSER")).size(11.0).strong().color(t.text_dim)); - ui.add_space(4.0); - // The card hugs the tree, so it hides as little of the view as it can. - browser_rows_hugging(app, ui, &mut acts); - }); - }, - ); - app.shell.browser = Some(card.response.rect); - crate::scenario::publish_rect("browser:float", card.response.rect); + let gap = crate::shell::SHELL_GAP; + let top_gap = if app.ui.layout == crate::shell::LayoutMode::Docked { gap } else { 0.0 }; + let min = available.min + vec2(gap, top_gap); + let right = (available.right() - gap).max(min.x); + let bottom = app.shell.history.map_or(available.bottom() - gap, |r| r.top() - gap).max(min.y); + let width = browser_width(app); + let bounds = Rect::from_min_max(min, pos2((min.x + width).min(right), bottom)); + app.shell.browser = None; + let min_height = HEADER_H + 2.0 * (CARD_INSET + 1.0); + if bounds.is_positive() && bounds.height() >= min_height { + let fill = if app.preferences.browser_float { t.panel.gamma_multiply(BROWSER_FLOAT_ALPHA) } else { t.panel }; + let frame = crate::frame::shell_card(fill).inner_margin(CARD_INSET as i8); + let inner_width = (bounds.width() - frame.total_margin().sum().x).max(0.0); + let inner_height = (bounds.height() - frame.total_margin().sum().y).max(0.0); + let card = crate::frame::area(egui::Id::new("sc_browser_card")).fixed_pos(bounds.min).order(egui::Order::Middle).show(ui.ctx(), |ui| { + let response = frame.show(ui, |ui| { + ui.set_width(inner_width); + ui.set_max_height(inner_height); + browser_contents(app, ui, &mut acts, true); + }); + resize_browser(app, ui, response.response.rect, width); + }); + let rect = card.response.rect; + app.shell.browser = Some(rect); + crate::scenario::publish_rect("browser:card", rect); + // Preserve the overlay handle used by main's automation. + if app.preferences.browser_float { + crate::scenario::publish_rect("browser:float", rect); + } + } finish_browser(app, ui, acts); } -fn browser_contents(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions) { +fn browser_contents(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, collapsible: bool) { let t = Tokens::get(); - ui.label(RichText::new(crate::i18n::tr_label("BROWSER")).size(t.font_small).strong().color(t.text_dim)); - ui.add_space(t.space_4); - browser_rows(app, ui, acts); + // Only the floating card folds; the docked column keeps its full height. + browser_header(app, ui, collapsible); + if collapsible && app.tree.panel_collapsed { + return; + } + ui.painter().hline(ui.max_rect().x_range(), ui.cursor().top() + 1.0, egui::Stroke::new(1.0, t.toolbar.lerp_to_gamma(t.border, 0.5))); + ui.add_space(t.space_3); + if collapsible { + browser_rows_hugging(app, ui, acts); + } else { + browser_rows(app, ui, acts); + } +} + +/// The header: the title, the body count and (on the floating card) a chevron that folds the +/// card to just this row. The whole row is the button. +fn browser_header(app: &mut SolveApp, ui: &mut egui::Ui, collapsible: bool) { + let t = Tokens::get(); + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER_H), Sense::hover()); + let response = ui.interact(rect, ui.id().with("browser_header"), if collapsible { Sense::click() } else { Sense::hover() }); + crate::scenario::publish_handle("browser:header", rect.center()); + crate::scenario::publish_rect("browser:header", rect); + let collapsed = collapsible && app.tree.panel_collapsed; + if collapsible && (response.hovered() || response.has_focus()) { + ui.painter().rect_filled(rect, t.radius_lg.saturating_sub(CARD_INSET as u8), t.hover); + } + let title = crate::i18n::tr("ui.browser_title").to_string(); + let kind = if collapsible { egui::WidgetType::Button } else { egui::WidgetType::Label }; + response.widget_info(|| egui::WidgetInfo::labeled(kind, true, &title)); + let galley = ui.painter().layout_no_wrap(title, FontId::proportional(t.font_heading), t.text); + ui.painter().galley(pos2(rect.left() + t.space_6, rect.center().y - galley.size().y / 2.0), galley, t.text); + let mut right = rect.right() - t.space_6; + if collapsible { + let chevron = Rect::from_center_size(pos2(right - 5.0, rect.center().y), vec2(12.0, 12.0)); + if collapsed { + crate::icons::paint_ink(ui.painter(), chevron, "chevron", t.text_dim); + } else { + crate::icons::paint_ink_flipped(ui.painter(), chevron, "chevron", t.text_dim); + } + right -= 18.0; + } + let bodies = app.session.model.state().bodies.len(); + let key = if bodies == 1 { "ui.browser_bodies_one" } else { "ui.browser_bodies_other" }; + let count = crate::i18n::format(key, &[("count", &bodies.to_string())]); + let galley = ui.painter().layout_no_wrap(count, FontId::proportional(t.font_body), t.text_dim); + ui.painter().galley(pos2(right - galley.size().x, rect.center().y - galley.size().y / 2.0), galley, t.text_dim); + if collapsible { + let tip = crate::i18n::tr(if collapsed { "ui.browser_expand" } else { "ui.browser_collapse" }); + if response.on_hover_text(tip).clicked() { + app.tree.panel_collapsed = !app.tree.panel_collapsed; + } + } } fn prepare_model(app: &mut SolveApp) { @@ -2372,3 +2436,7 @@ pub(crate) fn body_rows(app: &SolveApp) -> Vec { #[cfg(test)] #[path = "section_browser_tests.rs"] mod section_browser_tests; + +#[cfg(test)] +#[path = "browser_card_tests.rs"] +mod card_tests; diff --git a/crates/ui-egui/src/browser_card_tests.rs b/crates/ui-egui/src/browser_card_tests.rs new file mode 100644 index 0000000000..6f3308687a --- /dev/null +++ b/crates/ui-egui/src/browser_card_tests.rs @@ -0,0 +1,169 @@ +use super::*; +use crate::scenario::{Harness, handle_at, rect_of}; +use crate::shell::{LayoutMode, SHELL_GAP}; + +#[test] +fn browser_card_shrinks_and_scrolls_without_covering_history() { + let mut h = Harness::new(); + h.call("ui.resize", json!({"width":1400,"height":900})); + h.settle(); + let short = rect_of("browser:card").unwrap(); + let history = rect_of("history:card").unwrap(); + assert_eq!(short.left(), SHELL_GAP); + assert_eq!(short.width(), 250.0); + assert_eq!(short.top(), rect_of("ribbon:card").unwrap().bottom() + SHELL_GAP); + assert!(short.bottom() < history.top() - SHELL_GAP); + assert!(drawn_rows().iter().all(|(_, rect, _, _)| rect.height() == 26.0)); + + for i in 0..35 { + h.app.run("sketch.create", json!({"plane":"XY", "name":format!("Sketch{i}")})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + } + h.settle(); + let tall = rect_of("browser:card").unwrap(); + assert!(tall.height() > short.height()); + assert!((tall.bottom() - (history.top() - SHELL_GAP)).abs() < 1.0, "{tall:?} {history:?}"); + assert!(h.app.viewport.rect.unwrap().contains_rect(tall)); + assert!(handle_at("row:Sketch34").is_none(), "last row starts below the scroll clip"); + let camera = h.app.cam; + h.call("ui.scroll", json!({"x":tall.center().x,"y":tall.center().y,"delta":-1800})); + h.settle(); + assert!(handle_at("row:Sketch34").is_some(), "scrolling must reveal the last row"); + assert_eq!(h.app.cam, camera); + assert_eq!(rect_of("browser:card").unwrap(), tall); + + h.call("ui.set", json!({"layout":"docked"})); + h.settle(); + assert!(rect_of("browser:card").is_none()); + assert_eq!(h.app.viewport.rect.unwrap().left(), 250.0); + assert!(drawn_rows().iter().all(|(_, rect, _, _)| rect.height() == 26.0)); + h.call("ui.set", json!({"layout":"floating"})); + h.app.tree.collapsed.insert("c0".into()); + h.settle(); + assert!(rect_of("browser:card").unwrap().height() < short.height()); +} + +#[test] +fn selected_row_paints_tab_soft_fill_and_accent_text_in_both_layouts() { + for mode in [LayoutMode::Floating, LayoutMode::Docked] { + for tab in ["MODEL", "SURFACE", "SKETCH"] { + let ctx = egui::Context::default(); + let mut app = SolveApp::new(Default::default(), Default::default()); + app.ui.layout = mode; + app.ui.tab = tab.into(); + crate::theme::TabAccent::publish(&app, &ctx); + let accent = crate::theme::TabAccent::for_tab(&Tokens::get(), tab); + let mut row_rect = Rect::NOTHING; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let row = Row { selected: true, label: "Selected body", dim: true, color: Some(Tokens::get().error), ..Default::default() }; + row_rect = draw_row(ui, egui::Id::new("selected-row"), &row).rect.unwrap(); + }); + out.textures_delta.clear(); + assert_eq!(row_rect.height(), 26.0); + assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.rect == row_rect && r.fill == accent.soft))); + assert!( + out.shapes + .iter() + .any(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == "Selected body" && t.fallback_color == accent.color)) + ); + } + } +} + +#[test] +fn card_and_column_keep_body_eyes_menus_and_group_drops() { + for mode in [LayoutMode::Floating, LayoutMode::Docked] { + let mut h = Harness::new(); + h.app.ui.layout = mode; + for (name, x) in [("One", 0), ("Two", 20)] { + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[x,0,0],"operation":"new_body","body_name":name})).unwrap(); + } + let group = + h.app.run("browser.group", json!({"component":0,"folder":"bodies","items":["One"],"name":"Group"})).unwrap()["group"].as_u64().unwrap(); + h.settle(); + h.step(&json!({"click":{"handle":"eye:Two"}})).unwrap(); + assert!(h.app.session.visibility.hidden_bodies.iter().any(|name| name == "Two")); + h.step(&json!({"click":{"handle":"eye:Two"}})).unwrap(); + assert!(!h.app.session.visibility.hidden_bodies.iter().any(|name| name == "Two")); + h.step(&json!({"click":{"handle":"row:Two"},"button":"right"})).unwrap(); + assert!(matches!(&h.app.menu.target, crate::context_menu::Target::Body { name } if name == "Two")); + h.step(&json!({"key":"Escape"})).unwrap(); + h.step(&json!({"drag":[{"handle":"row:Two"},{"handle":"row:Group (1)"}]})).unwrap(); + h.settle(); + let group = h.app.session.doc.browser_groups.iter().find(|g| g.id == group).unwrap(); + assert!(group.items.iter().any(|name| name == "Two"), "body drop into the folder failed in {mode:?}"); + } +} + +#[test] +fn browser_header_folds_the_floating_card_to_its_header_and_back() { + let mut h = Harness::new(); + h.call("ui.resize", json!({"width":1400,"height":900})); + h.settle(); + let open = rect_of("browser:card").unwrap(); + assert!(!h.app.tree.panel_collapsed); + let at = handle_at("browser:header").unwrap(); + h.call("ui.click", json!({"x":at.x,"y":at.y})); + h.settle(); + let folded = rect_of("browser:card").unwrap(); + assert!(h.app.tree.panel_collapsed); + assert!(folded.height() < 60.0 && folded.height() < open.height(), "{folded:?} {open:?}"); + assert_eq!(folded.top(), open.top()); + assert!(drawn_rows().is_empty() || handle_at("row:Plate").is_none()); + h.call("ui.click", json!({"x":at.x,"y":at.y})); + h.settle(); + assert!(!h.app.tree.panel_collapsed); + assert!((rect_of("browser:card").unwrap().height() - open.height()).abs() < 1.0); +} + +/// Tiny or odd window sizes never panic: the cards leave out what cannot fit. +#[test] +fn tiny_floating_windows_never_panic() { + for (w, h) in [(100, 60), (240, 90), (320, 140), (700, 150), (700, 300), (1400, 120), (180, 800)] { + for scale in [1.0, 2.0] { + let mut harness = Harness::new(); + harness.call("ui.resize", json!({"width": w, "height": h})); + harness.call("ui.prefs", json!({"ui_scale": scale})); + harness.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + harness.settle(); + for mode in [LayoutMode::Floating, LayoutMode::Docked] { + harness.app.ui.layout = mode; + harness.settle(); + } + } + } +} + +/// Both shell layouts share one tree and one saved width. Docked can switch its card +/// back into a column; Floating keeps its card when the overlay preference is cleared. +#[test] +fn one_browser_keeps_its_resized_width_across_layouts_and_overlay_preferences() { + let mut h = Harness::new(); + h.call("ui.resize", json!({"width":1400,"height":900})); + for mode in [LayoutMode::Floating, LayoutMode::Docked] { + for overlay in [false, true] { + h.app.ui.layout = mode; + h.app.preferences.browser_float = overlay; + h.app.preferences.browser_width = 310.0; + h.settle(); + let card = mode == LayoutMode::Floating || overlay; + assert_eq!(rect_of("browser:card").is_some(), card); + assert_eq!(crate::scenario::handles("browser:header").len(), 1, "only one browser header"); + assert_eq!(drawn_rows().iter().filter(|(name, ..)| name == "Untitled").count(), 1, "only one tree"); + if card { + assert!((rect_of("browser:card").unwrap().width() - 310.0).abs() < 1.0); + } else { + assert_eq!(h.app.viewport.rect.unwrap().left(), 310.0); + } + let rail = handle_at("left-browser-rail").unwrap(); + h.call("ui.drag", json!({"x0":rail.x,"y0":rail.y,"x1":rail.x+60.0,"y1":rail.y,"steps":6})); + h.settle(); + let width = h.app.preferences.browser_width; + assert!(width > 340.0 && width <= MAX_BROWSER_WIDTH, "{mode:?}, overlay={overlay}: {width}"); + h.app.ui.layout = LayoutMode::Docked; + h.app.preferences.browser_float = false; + h.settle(); + assert!((h.app.viewport.rect.unwrap().left() - width).abs() < 1.0, "the column uses the card's saved width"); + } + } +} diff --git a/crates/ui-egui/src/dialogs_sheet.rs b/crates/ui-egui/src/dialogs_sheet.rs index 0de88ad6b2..d3eb6cedcd 100644 --- a/crates/ui-egui/src/dialogs_sheet.rs +++ b/crates/ui-egui/src/dialogs_sheet.rs @@ -873,7 +873,8 @@ pub fn overlay(app: &mut SolveApp, painter: &egui::Painter, rect: Rect, d: &Dial .memory(|m| m.area_rect(egui::Id::new(("sc_dialog", "FLAT PATTERN")))) .map(|panel| panel.left()) .unwrap_or(rect.right() - crate::frame::DIALOG_MAX_W); - let area = Rect::from_min_max(rect.min + vec2(40.0, 60.0), pos2(panel_left.min(rect.right()) - 20.0, rect.bottom() - 60.0)); + let content = crate::shell::content_rect(app, rect); + let area = Rect::from_min_max(content.min + vec2(40.0, 60.0), pos2(panel_left.min(content.right()) - 20.0, content.bottom() - 60.0)); if area.width() <= 0.0 || area.height() <= 0.0 { return true; } diff --git a/crates/ui-egui/src/dialogs_sheet_tests.rs b/crates/ui-egui/src/dialogs_sheet_tests.rs index 92646293a4..20fd1d8b1a 100644 --- a/crates/ui-egui/src/dialogs_sheet_tests.rs +++ b/crates/ui-egui/src/dialogs_sheet_tests.rs @@ -205,6 +205,12 @@ fn flat_pattern_islands_fit_before_the_actual_command_panel() { let mut dialog = app.dialog.clone().unwrap(); let Kind::Sheet(Sm::FlatPattern { data, .. }) = &mut dialog.kind else { panic!("flat dialog") }; *data = Some(Ok(flat)); + let viewport = Rect::from_min_max(pos2(250.0, crate::toolbar::APP_BAR_HEIGHT + crate::toolbar::RIBBON_HEIGHT), pos2(1400.0, 832.0)); + let browser = Rect::from_min_max(viewport.min, viewport.min + vec2(250.0, 450.0)); + let history = Rect::from_min_max(pos2(viewport.left(), viewport.bottom() - 40.0), viewport.max); + if mode == crate::shell::LayoutMode::Floating { + app.shell.content = Some(Rect::from_min_max(pos2(browser.right(), viewport.top()), pos2(viewport.right(), history.top()))); + } let ctx = egui::Context::default(); let id = egui::Id::new(("sc_dialog", "FLAT PATTERN")); let mut output = egui::FullOutput::default(); @@ -241,6 +247,12 @@ fn flat_pattern_islands_fit_before_the_actual_command_panel() { outlines.iter().flat_map(|p| &p.points).all(|p| p.x <= panel.left() - 19.0), "both complete islands must remain exposed before the command panel: {panel:?}" ); + if mode == crate::shell::LayoutMode::Floating { + assert!( + outlines.iter().flat_map(|p| &p.points).all(|p| !browser.contains(*p) && !history.contains(*p)), + "flat pattern fits clear of the shell cards" + ); + } } } diff --git a/crates/ui-egui/src/edit_clipboard.rs b/crates/ui-egui/src/edit_clipboard.rs index 312167a4e3..d5a41a8206 100644 --- a/crates/ui-egui/src/edit_clipboard.rs +++ b/crates/ui-egui/src/edit_clipboard.rs @@ -126,7 +126,8 @@ pub fn cut(app: &mut SolveApp) -> bool { fn sketch_point(app: &SolveApp, at: Option) -> Option<[f64; 2]> { let at = at?; let rect = app.viewport.rect?; - if !rect.contains(at) { + // Under a card or panel floating over the viewport is not over the sketch. + if !rect.contains(at) || crate::shell::covered(app, at) { return None; } let proj = crate::viewport::projection(app, rect); diff --git a/crates/ui-egui/src/frame.rs b/crates/ui-egui/src/frame.rs index d80da2b255..dacd1d309c 100644 --- a/crates/ui-egui/src/frame.rs +++ b/crates/ui-egui/src/frame.rs @@ -239,6 +239,12 @@ pub fn right_aligned(ui: &mut Ui, total: f32, width: f32, add: impl FnOnce(&m add(ui) } +/// Shared rounded surface for floating shell cards. +pub(crate) fn shell_card(fill: egui::Color32) -> egui::Frame { + let t = crate::theme::Tokens::get(); + egui::Frame::NONE.fill(fill).stroke(egui::Stroke::new(1.0, t.border)).corner_radius(t.radius_lg).shadow(t.shell_shadow()) +} + #[cfg(test)] mod tests { #[test] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 90dabc876d..e3c630b717 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -457,10 +457,13 @@ mod tests { assert_eq!(it.given_name("Fillet"), "Fillet"); assert_eq!(it.label("solid.extrude"), "solid.extrude"); assert_eq!(it.format("document.save_changes", &[("name", "Staffa {v2}")]), "Salvare le modifiche a Staffa {v2}?"); + // The bottom command-line hint retired with that surface. A live save prompt + // still proves that a substituted value keeps literal JSON braces intact. assert_eq!( - it.format("ui.command_palette_hint", &[("example", "solid.extrude {\"distance\": 20}")]), - "Digita un comando, ad es. solid.extrude {\"distance\": 20} — oppure premi S per cercare" + it.format("document.save_changes", &[("name", "solid.extrude {\"distance\": 20}")]), + "Salvare le modifiche a solid.extrude {\"distance\": 20}?" ); + assert_eq!(it.format("ui.browser_bodies_other", &[("count", "2")]), "2 corpi"); } /// `label()`'s id fallback (an argument that matches no other entry's English text is diff --git a/crates/ui-egui/src/i18n_ui_tests.rs b/crates/ui-egui/src/i18n_ui_tests.rs index 6b07df6ffd..3d1ac2ab05 100644 --- a/crates/ui-egui/src/i18n_ui_tests.rs +++ b/crates/ui-egui/src/i18n_ui_tests.rs @@ -81,12 +81,12 @@ fn window_chrome_renders_in_the_active_language() { for (lang, browser, filter, faces) in [ ("zh", "浏览器", "选择筛选", "面"), ("ja", "ブラウザ", "選択フィルター", "面"), - ("de", "BROWSER", "Auswahlfilter", "Flächen"), + ("de", "Browser", "Auswahlfilter", "Flächen"), ("ko", "브라우저", "선택 필터", "면"), - ("ru", "БРАУЗЕР", "Фильтр выделения", "Грани"), - ("fr", "NAVIGATEUR", "Filtre de sélection", "Faces"), - ("it", "STRUTTURA", "Filtro di selezione", "Facce"), - ("uk", "БРАУЗЕР", "Фільтр виділення", "Грані"), + ("ru", "Браузер", "Фильтр выделения", "Грани"), + ("fr", "Navigateur", "Filtre de sélection", "Faces"), + ("it", "Struttura", "Filtro di selezione", "Facce"), + ("uk", "Браузер", "Фільтр виділення", "Грані"), ] { let mut h = Harness::new(); h.app.preferences.language = lang.into(); @@ -213,19 +213,6 @@ fn home_page_renders_in_the_active_language() { } } -/// The command palette's hint embeds a JSON example, so its braces must not be mistaken for a -/// placeholder name by the catalog's parser, and the example must survive interpolation whole. -#[test] -fn command_palette_hint_keeps_its_json_example_intact() { - let mut h = Harness::new(); - h.app.preferences.language = "zh".into(); - h.app.home.open = false; - h.frames(2); - let hint = crate::i18n::format("ui.command_palette_hint", &[("example", "Extrude {\"distance\": 20}")]); - assert!(hint.contains("Extrude {\"distance\": 20}"), "the example is inserted literally: {hint:?}"); - assert!(!hint.contains("{example}"), "the placeholder is substituted: {hint:?}"); -} - #[test] fn translated_dialog_buttons_do_not_wrap() { fn cancel_rows(shape: &egui::Shape) -> Option { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 4822471b09..5d9cb6ba60 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -39,20 +39,25 @@ pub fn paint_icon(p: &Painter, r: Rect, name: &str, opacity: f32) { /// Draw the icon `name` for a surface that is always dark (the application bar), whatever the /// theme: `dark: Some(true)`. `None` follows the theme. pub fn paint_on(p: &Painter, r: Rect, name: &str, dark: Option, opacity: f32) { - paint_with_ink(p, r, name, dark, opacity, None); + paint_with_ink(p, r, name, dark, opacity, None, false); } /// Monochrome flat controls on the application bar use its text token exactly. pub(crate) fn paint_app_bar(p: &Painter, r: Rect, name: &str, opacity: f32) { - paint_with_ink(p, r, name, Some(true), opacity, Some(crate::theme::Tokens::get().app_bar_text)); + paint_with_ink(p, r, name, Some(true), opacity, Some(crate::theme::Tokens::get().app_bar_text), false); } /// Paint a single-colour icon in `ink`. pub(crate) fn paint_ink(p: &Painter, r: Rect, name: &str, ink: Color32) { - paint_with_ink(p, r, name, Some(true), 1.0, Some(ink)); + paint_with_ink(p, r, name, Some(true), 1.0, Some(ink), false); } -fn paint_with_ink(p: &Painter, r: Rect, name: &str, dark: Option, opacity: f32, ink: Option) { +/// Paint a single-colour icon in `ink`, mirrored top to bottom (a chevron pointing up). +pub(crate) fn paint_ink_flipped(p: &Painter, r: Rect, name: &str, ink: Color32) { + paint_with_ink(p, r, name, Some(true), 1.0, Some(ink), true); +} + +fn paint_with_ink(p: &Painter, r: Rect, name: &str, dark: Option, opacity: f32, ink: Option, flip_y: bool) { let ctx = p.ctx(); let ppp = ctx.pixels_per_point().max(0.5); let side = r.width().min(r.height()); @@ -78,7 +83,8 @@ fn paint_with_ink(p: &Painter, r: Rect, name: &str, dark: Option, opacity: let size = px as f32 / ppp; let min = pos2(((r.center().x - size / 2.0) * ppp).round() / ppp, ((r.center().y - size / 2.0) * ppp).round() / ppp); let tint = Color32::from_white_alpha((opacity.clamp(0.0, 1.0) * 255.0).round() as u8); - p.image(tex.id(), Rect::from_min_size(min, egui::vec2(size, size)), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), tint); + let uv = if flip_y { Rect::from_min_max(pos2(0.0, 1.0), pos2(1.0, 0.0)) } else { Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)) }; + p.image(tex.id(), Rect::from_min_size(min, egui::vec2(size, size)), uv, tint); } #[cfg(test)] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 57d861d527..fd995697e0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -136,7 +136,6 @@ mod fillet_spline_handle_tests; pub mod mouse_gesture; pub(crate) mod navigation; pub(crate) mod open_meshing; -pub mod palette; pub mod params_dialog; pub(crate) mod pick_buffer; pub(crate) mod pick_index; @@ -354,7 +353,6 @@ pub struct SolveApp { /// The Bill of Materials window, when open. pub bom: Option, material_card_review: Option, - pub palette: palette::Palette, /// Context menus, rename box and Properties window. pub menu: context_menu::MenuState, /// Mouse gestures: the ring of tools and its customization window. @@ -508,7 +506,6 @@ impl SolveApp { design_properties: None, bom: None, material_card_review: None, - palette: palette::Palette::default(), menu: context_menu::MenuState::default(), gesture: mouse_gesture::State::default(), tree: browser::TreeState::default(), @@ -1001,7 +998,8 @@ impl SolveApp { Some(d) => self.dialog = Some(d), None => { let _ = dialogs::roll_marker(self, marker); - self.palette.text = format!("timeline.edit {{\"feature\": {id}, \"set\": {{}}}}"); + // No dialog for this feature: open the search box with the command to edit it. + self.sbox.text = format!("timeline.edit {{\"feature\": {id}, \"set\": {{}}}}"); self.ui.palette_open = true; } } @@ -1500,23 +1498,30 @@ impl SolveApp { toolbar::toolbar(self, ui); } theme::TabAccent::publish(self, ui.ctx()); - if show_timeline && !left_timeline { + if !floating && show_timeline && !left_timeline { timeline::timeline(self, ui); } - palette::palette_bar(self, ui); // A floating browser keeps the workspace the whole width and covers viewport input. - let browser_float = self.ui.show_browser && self.preferences.browser_float; + let browser_float = self.ui.show_browser && (self.preferences.browser_float || (floating && !left_timeline)); if !browser_float { self.shell.browser = None; } + self.shell.history = None; let full = ui.available_rect_before_wrap(); if floating { let cards = shell::Cards::new(full, self.ui.layout); self.shell.cards = vec![cards.workspace, cards.ribbon]; + self.shell.history = (show_timeline && !left_timeline).then(|| timeline::floating_rect(full)); // Draw the scene first, then let the unchanged panel layouts cover it. The // viewport keeps its full projection even underneath the browser and cards. preview::update(self, ui.ctx()); - let blocked = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| shell::covered(self, p)); + // A camera drag that began on the model keeps capture across cards. New + // gestures over chrome are blocked, as are selection drags into the ribbon. + let continuing_navigation = ui.input(|i| { + (i.pointer.middle_down() || i.pointer.secondary_down()) + && i.pointer.press_origin().is_some_and(|p| full.contains(p) && !shell::covered(self, p)) + }); + let blocked = !continuing_navigation && ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| shell::covered(self, p)); let mut viewport_ui = ui.new_child(egui::UiBuilder::new().max_rect(full).id_salt("floating_viewport")); if blocked { // Disabled only to shield the scene from input under a card or menu; it must @@ -1529,6 +1534,9 @@ impl SolveApp { toolbar::toolbar(self, ui); theme::TabAccent::publish(self, ui.ctx()); } + if floating && show_timeline && !left_timeline { + timeline::timeline(self, ui); + } if self.ui.show_browser && !browser_float { if left_timeline { browser::browser_below_timeline(self, ui); @@ -1549,11 +1557,18 @@ impl SolveApp { } egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { if floating { - let content = ui.max_rect(); + let panel_content = ui.max_rect(); + let mut content = panel_content; + if let Some(history) = self.shell.history { + content.max.y = (history.top() - shell::SHELL_GAP).max(content.top()); + } + if let Some(browser) = self.shell.browser { + content.min.x = (browser.right() + shell::SHELL_GAP).min(content.right()); + } self.shell.content = Some(content); self.shell.panels = [ - egui::Rect::from_min_max(egui::pos2(full.left(), content.top()), egui::pos2(content.left(), full.bottom())), - egui::Rect::from_min_max(egui::pos2(content.right(), content.top()), full.max), + egui::Rect::from_min_max(egui::pos2(full.left(), panel_content.top()), egui::pos2(panel_content.left(), full.bottom())), + egui::Rect::from_min_max(egui::pos2(panel_content.right(), panel_content.top()), full.max), ] .into_iter() .filter(|r| r.is_positive()) diff --git a/crates/ui-egui/src/shell.rs b/crates/ui-egui/src/shell.rs index 3f4cd186af..56753ceb31 100644 --- a/crates/ui-egui/src/shell.rs +++ b/crates/ui-egui/src/shell.rs @@ -78,8 +78,9 @@ impl Cards { pub(crate) struct State { pub cards: Vec, pub panels: Vec, - pub content: Option, pub browser: Option, + pub history: Option, + pub content: Option, } /// One source of occlusion for navigation, picking, overlays and fit. @@ -88,8 +89,9 @@ pub(crate) fn covered_rects(app: &SolveApp) -> Vec { if app.ui.layout == LayoutMode::Floating { rects.extend(app.shell.cards.iter().copied()); rects.extend(app.shell.panels.iter().copied()); + rects.extend(app.shell.history); } - rects.extend(app.shell.browser.filter(|_| app.ui.show_browser && app.preferences.browser_float)); + rects.extend(app.shell.browser.filter(|_| app.ui.show_browser)); rects.extend(app.viewport.palette_rect.filter(|_| app.session.active_sketch.is_some())); rects.extend(app.viewport.dialog_rect.filter(|_| app.dialog.is_some())); rects.retain(|r| r.is_finite() && r.is_positive()); diff --git a/crates/ui-egui/src/shell_tests.rs b/crates/ui-egui/src/shell_tests.rs index 870878128c..0705d22299 100644 --- a/crates/ui-egui/src/shell_tests.rs +++ b/crates/ui-egui/src/shell_tests.rs @@ -198,7 +198,7 @@ fn floating_browser_clears_cards_shields_input_and_can_dock_again() { let toolbar = rect_of("toolbar").unwrap(); assert!(browser.top() >= toolbar.bottom(), "{mode:?}: {browser:?} must clear {toolbar:?}"); assert!(crate::scenario::handle_at("row:Untitled").is_some()); - assert!(crate::scenario::handle_at("left-browser-rail").is_none()); + assert!(crate::scenario::handle_at("left-browser-rail").is_some()); assert!(covered(&h.app, browser.center())); let viewport = h.app.viewport.rect.unwrap(); // A left-side history still takes a column in Docked mode. @@ -229,8 +229,10 @@ fn floating_browser_clears_cards_shields_input_and_can_dock_again() { assert!(crate::browser::drawn_rows().iter().any(|(name, _, _, open)| name == "Origin" && *open == Some(true))); h.app.preferences.browser_float = false; h.settle(); - assert!(h.app.shell.browser.is_none()); + assert_eq!(h.app.shell.browser.is_some(), mode == LayoutMode::Floating && !timeline_left); assert!(rect_of("browser:float").is_none()); + assert_eq!(rect_of("browser:card").is_some(), mode == LayoutMode::Floating && !timeline_left); + // The saved width follows both the floating card and the docked column. let rail = crate::scenario::handle_at("left-browser-rail").unwrap(); let width = h.app.preferences.browser_width; h.call("ui.drag", json!({"x0":rail.x,"y0":rail.y,"x1":rail.x+60.0,"y1":rail.y,"steps":6})); @@ -238,3 +240,65 @@ fn floating_browser_clears_cards_shields_input_and_can_dock_again() { } } } + +#[test] +fn browser_and_history_cards_block_input_while_their_gaps_and_space_below_browser_do_not() { + let mut h = Harness::new(); + h.call("ui.resize", json!({"width":1400,"height":900})); + h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap(); + h.settle(); + let browser = rect_of("browser:card").unwrap(); + let history = rect_of("history:card").unwrap(); + let viewport = h.app.viewport.rect.unwrap(); + assert_eq!(history.left(), viewport.left() + SHELL_GAP); + assert_eq!(history.right(), viewport.right() - SHELL_GAP); + assert_eq!(history.bottom(), viewport.bottom() - SHELL_GAP); + let layout = h.app.viewport.layout.as_ref().unwrap(); + assert!(layout.navigation.bottom() < history.top()); + assert!(layout.axis_view.top() >= rect_of("ribbon:card").unwrap().bottom()); + for r in [browser, history] { + assert!(covered(&h.app, r.center())); + assert!(!layout.drawable.intersect(r).is_positive()); + } + for at in [pos2(browser.left() + 2.0, browser.top() + 2.0), pos2(history.right() - 30.0, history.center().y)] { + let camera = h.app.cam; + h.call("ui.move", json!({"x":at.x,"y":at.y})); + assert!(h.app.viewport.mouse.is_none()); + assert!(h.app.viewport.hover.is_none()); + h.call("ui.scroll", json!({"x":at.x,"y":at.y,"delta":120})); + h.call("ui.drag", json!({"x0":at.x,"y0":at.y,"x1":at.x+10.0,"y1":at.y+2.0,"button":"middle","steps":6})); + assert_eq!(h.app.cam, camera); + } + for at in [pos2(3.0, browser.center().y), pos2(browser.center().x, browser.bottom() + SHELL_GAP), pos2(3.0, history.center().y)] { + assert!(!covered(&h.app, at)); + h.call("ui.move", json!({"x":at.x,"y":at.y})); + assert_eq!(h.app.viewport.mouse, Some(at)); + } + h.app.ui.show_browser = false; + h.app.ui.show_timeline = false; + h.settle(); + assert!(h.app.shell.browser.is_none() && h.app.shell.history.is_none()); + assert!(!covered(&h.app, browser.center())); + assert!(!covered(&h.app, history.center())); +} + +#[test] +fn left_history_keeps_the_existing_column_in_both_modes() { + for mode in [LayoutMode::Floating, LayoutMode::Docked] { + let mut h = Harness::new(); + h.app.ui.layout = mode; + h.app.preferences.timeline_left = true; + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"name":"First"})).unwrap(); + h.settle(); + assert!(rect_of("history:card").is_none()); + assert!(rect_of("browser:card").is_none()); + assert!(crate::scenario::handle_at("timeline:First").is_some()); + let viewport = h.app.viewport.rect.unwrap(); + let content = content_rect(&h.app, viewport); + assert_eq!(content.left(), 250.0); + assert_eq!(content.bottom(), viewport.bottom()); + if mode == LayoutMode::Floating { + assert!(covered(&h.app, pos2(125.0, content.center().y))); + } + } +} diff --git a/crates/ui-egui/src/shortcut_box.rs b/crates/ui-egui/src/shortcut_box.rs index dbb2ea4b66..8a5763032e 100644 --- a/crates/ui-egui/src/shortcut_box.rs +++ b/crates/ui-egui/src/shortcut_box.rs @@ -167,6 +167,28 @@ pub fn open(app: &mut SolveApp, at: Option) { app.sbox.focus = true; } +/// A line typed as `command.id {"param": value}`: the command runs at once with those parameters +/// (the same entry the bottom command line used to take). +fn run_typed(app: &mut SolveApp, line: &str) { + let line = line.trim(); + app.ui.palette_open = false; + app.sbox.at = None; + app.sbox.text.clear(); + let split = line.find(|c: char| c.is_whitespace() || c == '{').unwrap_or(line.len()); + let (id, rest) = (line.get(..split).unwrap_or_default(), line.get(split..).unwrap_or_default().trim()); + let Some(command) = solidcraft_engine::find_command(id) else { + app.set_status(format!("no command `{id}`; the text before the JSON must be a command id"), true); + return; + }; + match serde_json::from_str::(rest) { + Ok(params) => { + app.sbox.used(command.id); + let _ = app.run(command.id, params); + } + Err(e) => app.set_status(format!("parameters must be JSON: {e}"), true), + } +} + fn run(app: &mut SolveApp, id: &str) { app.sbox.used(id); app.ui.palette_open = false; @@ -307,6 +329,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { crate::context_menu::open_for(app, at, crate::context_menu::Target::ToolbarCommand { id: id.to_string() }); return; } + if enter && picked.is_none() && app.sbox.text.contains('{') { + let line = app.sbox.text.clone(); + run_typed(app, &line); + return; + } if enter && picked.is_none() && let Some((id, ..)) = list.get(app.sbox.index) @@ -414,4 +441,37 @@ mod tests { let row = search(&app, "center rect").into_iter().find(|r| r.0 == "sketch.rectangle.center").unwrap(); assert_eq!((row.1, row.3), ("Center Rectangle", Some(("SKETCH", "CREATE")))); } + + #[test] + fn a_line_typed_with_json_parameters_runs_the_command() { + let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); + let before = app.session.doc.features.len(); + app.ui.palette_open = true; + app.sbox.text = r#"solid.box {"length": 10, "width": 10, "height": 10}"#.into(); + let line = app.sbox.text.clone(); + run_typed(&mut app, &line); + assert_eq!(app.session.doc.features.len(), before + 1, "{:?}", app.status); + assert!(!app.ui.palette_open && app.sbox.text.is_empty(), "the box closes"); + assert_eq!(app.sbox.recent.first().map(String::as_str), Some("solid.box")); + // Bad JSON and an unknown id report instead of running anything. + run_typed(&mut app, r#"solid.box {"length": "#); + assert!(app.status.as_ref().is_some_and(|(m, _, err)| *err && m.contains("JSON")), "{:?}", app.status); + run_typed(&mut app, r#"not.a.command {}"#); + assert!(app.status.as_ref().is_some_and(|(m, _, err)| *err && m.contains("no command")), "{:?}", app.status); + assert_eq!(app.session.doc.features.len(), before + 1); + } + + #[test] + fn typing_json_in_the_open_box_and_pressing_enter_runs_it() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.frames(2); + let before = h.app.session.doc.features.len(); + h.step(&json!({"key": "S"})).unwrap(); + h.step(&json!({"text": r#"solid.box {"length": 10, "width": 10, "height": 10}"#})).unwrap(); + h.step(&json!({"key": "Enter"})).unwrap(); + h.frames(2); + assert_eq!(h.app.session.doc.features.len(), before + 1, "{:?}", h.app.status); + assert!(!h.app.ui.palette_open); + } } diff --git a/crates/ui-egui/src/sketch_guidance_ui_tests.rs b/crates/ui-egui/src/sketch_guidance_ui_tests.rs index 6b47ec4a36..6512ee6d59 100644 --- a/crates/ui-egui/src/sketch_guidance_ui_tests.rs +++ b/crates/ui-egui/src/sketch_guidance_ui_tests.rs @@ -32,6 +32,8 @@ fn preview(h: &mut Harness, curves: usize) { #[test] fn sketch_dialog_mirror_toggles_clears_references_previews_and_cancels() { let mut h = fixture(); + // Keep the source curves and reference between the floating dialog and Sketch Palette. + step(&mut h, json!({"call":"ui.camera","params":{"target":[-40,0,0],"distance":150}})); let original = h.app.session.doc.to_json(); let undo = h.app.session.undo.len(); let revision = h.app.session.revision; @@ -93,6 +95,8 @@ fn sketch_dialog_patterns_update_counts_and_place_an_unsnapped_centre() { #[test] fn sketch_dialog_offset_precise_distance_flip_and_editable_dimension() { let mut h = fixture(); + // The floating browser shifts the dialog over the source curve at the default camera. + step(&mut h, json!({"call":"ui.camera","params":{"target":[-40,0,0],"distance":150}})); step(&mut h, json!({"start":"sketch.offset"})); pick(&mut h, [-22.5, 10.0], false); if let crate::dialogs::Kind::SketchModify(Mod::Offset { distance, flip, chain, .. }) = &mut h.app.dialog.as_mut().unwrap().kind { diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index c9fe7edde2..389951a277 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -250,270 +250,307 @@ fn toggle_feature(app: &mut SolveApp, id: u64) { pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); - let accent = crate::theme::TabAccent::for_tab(&t, &app.ui.tab); - egui::Panel::bottom("sc_timeline").exact_size(40.0).frame(egui::Frame::NONE.fill(t.timeline)).show(ui, |ui| { - let r = ui.max_rect(); - let p = ui.painter().clone(); - p.line_segment([r.left_top(), r.right_top()], Stroke::new(1.0, t.border)); - let n = app.session.doc.features.len(); - let marker = app.session.doc.marker.unwrap_or(n); - // Playback controls. - let mut x = r.left() + 10.0; - let controls = [("first", Some(0usize)), ("back", Some(marker.saturating_sub(1))), ("forward", Some((marker + 1).min(n))), ("last", None)]; - for (what, target) in controls { - let br = Rect::from_center_size(pos2(x + 11.0, r.center().y), vec2(22.0, 22.0)); - let resp = ui.interact(br, ui.id().with(("tl", what)), Sense::click()); - if resp.hovered() { - p.rect_filled(br, 3.0, t.hover); - } - transport_glyph(&p, br.center(), what, t.icon); - if resp - .on_hover_text(match what { - "first" => "Roll back to the start", - "back" => "Step back one feature", - "forward" => "Step forward one feature", - _ => "Play to the end", - }) - .clicked() - { - let pos = match target { - Some(k) if k < n => json!({"position": k}), - _ => json!({}), - }; - let _ = app.run_heavy("timeline.roll_to", pos); - } - x += 24.0; + if app.ui.layout == crate::shell::LayoutMode::Floating { + let rect = floating_rect(ui.available_rect_before_wrap()); + // A window too short for the card (the ribbon already fills it) leaves the history out + // rather than drawing into a negative-size rectangle. + if !rect.is_finite() || rect.width() < 1.0 || rect.height() < 30.0 { + app.shell.history = None; + return; } - x += 14.0; - // The features scroll horizontally in the space right of the playback controls. - let strip = Rect::from_min_max(pos2(x, r.top()), r.right_bottom()); - ui.scope_builder(UiBuilder::new().max_rect(strip), |ui| { - // A plain mouse wheel scrolls the strip sideways too, not only Shift + wheel (#3356). - ui.style_mut().always_scroll_the_only_direction = true; - egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| { - let p = ui.painter().clone(); - let mut x = ui.max_rect().left(); - let origin = x; - let feats: Vec<_> = app - .session - .doc - .features - .iter() - .map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name(), TimelineKind::of(&f.kind))) - .collect(); - // The feature whose body is under the cursor in the viewport. - let st = app.session.world_state(); - let from_view = match &app.viewport.hover { - Some( - crate::viewport::Hit::Face { body, .. } | crate::viewport::Hit::Edge { body, .. } | crate::viewport::Hit::Vertex { body, .. }, - ) => st.body(body).map(|b| b.feature), - _ => None, + app.shell.history = Some(rect); + crate::scenario::publish_rect("history:card", rect); + let mut child = ui.new_child(UiBuilder::new().id_salt("sc_timeline").max_rect(rect)); + ui.painter().add(crate::frame::shell_card(t.timeline).paint(rect)); + child.set_clip_rect(rect); + timeline_contents(app, &mut child, true); + } else { + let response = egui::Panel::bottom("sc_timeline").exact_size(40.0).frame(egui::Frame::NONE.fill(t.timeline)).show(ui, |ui| { + timeline_contents(app, ui, false); + }); + crate::scenario::publish_rect("history:strip", response.response.rect); + } +} + +pub(crate) fn floating_rect(available: Rect) -> Rect { + let gap = crate::shell::SHELL_GAP; + let inset = available.shrink(gap); + Rect::from_min_max(pos2(inset.left(), (inset.bottom() - 40.0).max(inset.top())), inset.max) +} + +fn timeline_contents(app: &mut SolveApp, ui: &mut egui::Ui, floating: bool) { + let t = Tokens::get(); + let accent = crate::theme::TabAccent::for_tab(&t, &app.ui.tab); + let r = ui.max_rect(); + let p = ui.painter().clone(); + if !floating { + p.line_segment([r.left_top(), r.right_top()], Stroke::new(1.0, t.border)); + } + let n = app.session.doc.features.len(); + let marker = app.session.doc.marker.unwrap_or(n); + // Playback controls. + let mut x = r.left() + t.space_10; + if floating { + let label = p.layout_no_wrap(crate::i18n::tr("ui.history_title").to_string(), egui::FontId::proportional(t.font_label), t.text); + p.galley(pos2(x, r.center().y - label.size().y / 2.0), label.clone(), t.text); + x += label.size().x + t.space_12; + } + let controls = [("first", Some(0usize)), ("back", Some(marker.saturating_sub(1))), ("forward", Some((marker + 1).min(n))), ("last", None)]; + for (what, target) in controls { + let br = Rect::from_center_size(pos2(x + 11.0, r.center().y), vec2(22.0, 22.0)); + crate::scenario::publish_handle(&format!("history:{what}"), br.center()); + let resp = ui.interact(br, ui.id().with(("tl", what)), Sense::click()); + if resp.hovered() { + p.rect_filled(br, 3.0, t.hover); + } + transport_glyph(&p, br.center(), what, t.icon); + if resp + .on_hover_text(match what { + "first" => "Roll back to the start", + "back" => "Step back one feature", + "forward" => "Step forward one feature", + _ => "Play to the end", + }) + .clicked() + { + let pos = match target { + Some(k) if k < n => json!({"position": k}), + _ => json!({}), + }; + let _ = app.run_heavy("timeline.roll_to", pos); + } + x += 24.0; + } + x += 14.0; + // The features scroll horizontally in the space right of the playback controls. + let strip = Rect::from_min_max(pos2(x, r.top()), r.right_bottom()); + ui.scope_builder(UiBuilder::new().max_rect(strip), |ui| { + // A plain mouse wheel scrolls the strip sideways too (#3356). + ui.style_mut().always_scroll_the_only_direction = true; + egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| { + let p = ui.painter().clone(); + let mut x = ui.max_rect().left(); + let origin = x; + let feats: Vec<_> = app + .session + .doc + .features + .iter() + .map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name(), TimelineKind::of(&f.kind))) + .collect(); + // The feature whose body is under the cursor in the viewport. + let st = app.session.world_state(); + let from_view = match &app.viewport.hover { + Some( + crate::viewport::Hit::Face { body, .. } | crate::viewport::Hit::Edge { body, .. } | crate::viewport::Hit::Vertex { body, .. }, + ) => st.body(body).map(|b| b.feature), + _ => None, + }; + let drag_id = ui.id().with("tl_drag"); + let mut drag: Option = ui.data(|d| d.get_temp(drag_id)); + let pointer = ui.input(|i| i.pointer.hover_pos()); + let mut xs: Vec = Vec::new(); + let mut hovered: Option = None; + let mut edit: Option = None; + let mut marker_rect: Option = None; + for (i, (id, name, icon, suppressed, ty, kind)) in feats.iter().enumerate() { + if i == marker { + marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0))); + x += 10.0; + } + xs.push(x); + let br = Rect::from_min_size(pos2(x, r.top() + 6.0), vec2(28.0, 28.0)); + crate::scenario::publish_handle(&format!("timeline:{name}"), br.center()); + let resp = ui.interact(br, ui.id().with(("feat", *id)), Sense::click_and_drag()); + let res = app.session.model.result(*id); + let err = res.and_then(|r| r.error.clone()); + let warn = res.and_then(|r| r.warning.clone()); + let rolled = i >= marker; + let is_it = |s: &solidcraft_engine::Sel| matches!(s, solidcraft_engine::Sel::Feature { id: f } if f == id); + let sel = app.session.selection.iter().any(is_it) || app.dialog.as_ref().is_some_and(|d| d.items().iter().any(is_it)); + if resp.hovered() { + hovered = Some(*id); + } + let bg = if err.is_some() { + t.timeline_item_error + } else if sel || (floating && marker > 0 && i == marker - 1) { + accent.soft + } else if resp.hovered() || from_view == Some(*id) { + t.hover + } else { + t.timeline_item }; - let drag_id = ui.id().with("tl_drag"); - let mut drag: Option = ui.data(|d| d.get_temp(drag_id)); - let pointer = ui.input(|i| i.pointer.hover_pos()); - let mut xs: Vec = Vec::new(); - let mut hovered: Option = None; - let mut edit: Option = None; - let mut marker_rect: Option = None; - for (i, (id, name, icon, suppressed, ty, kind)) in feats.iter().enumerate() { - if i == marker { - marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0))); - x += 10.0; - } - xs.push(x); - let br = Rect::from_min_size(pos2(x, r.top() + 6.0), vec2(28.0, 28.0)); - crate::scenario::publish_handle(&format!("timeline:{name}"), br.center()); - let resp = ui.interact(br, ui.id().with(("feat", *id)), Sense::click_and_drag()); - let res = app.session.model.result(*id); - let err = res.and_then(|r| r.error.clone()); - let warn = res.and_then(|r| r.warning.clone()); - let rolled = i >= marker; - let is_it = |s: &solidcraft_engine::Sel| matches!(s, solidcraft_engine::Sel::Feature { id: f } if f == id); - let sel = app.session.selection.iter().any(is_it) || app.dialog.as_ref().is_some_and(|d| d.items().iter().any(is_it)); - if resp.hovered() { - hovered = Some(*id); - } - let bg = if err.is_some() { - t.timeline_item_error - } else if sel { - accent.soft - } else if resp.hovered() || from_view == Some(*id) { - t.hover - } else { - t.timeline_item - }; - let edge = if err.is_some() { - t.error - } else if from_view == Some(*id) { - accent.color - } else { - t.border - }; - p.rect(br, 3.0, bg, Stroke::new(1.0, edge), egui::StrokeKind::Inside); - let ink = if rolled || *suppressed { t.border } else { t.icon }; + let edge = if err.is_some() { + t.error + } else if from_view == Some(*id) || (floating && marker > 0 && i == marker - 1) { + accent.color + } else { + t.border + }; + p.rect(br, 3.0, bg, Stroke::new(1.0, edge), egui::StrokeKind::Inside); + let ink = if rolled || *suppressed { t.border } else { t.icon }; + icons::paint( + &p, + br.shrink(4.0), + icon, + ink, + if rolled { t.panel_header } else { t.icon_fill }, + if rolled { t.border } else { t.accent }, + ); + if *suppressed { + p.line_segment([br.left_bottom() + vec2(4.0, -4.0), br.right_top() + vec2(-4.0, 4.0)], Stroke::new(1.5, t.text_dim)); + } + // A failed feature has a mark of its own, not only its colour (#972). + if err.is_some() { + icons::paint(&p, Rect::from_min_size(br.right_top() - vec2(10.0, 0.0), vec2(10.0, 10.0)), "error", t.panel, t.error, t.error); + } + if warn.is_some() && err.is_none() { icons::paint( &p, - br.shrink(4.0), - icon, - ink, - if rolled { t.panel_header } else { t.icon_fill }, - if rolled { t.border } else { t.accent }, + Rect::from_min_size(br.right_top() - vec2(10.0, 0.0), vec2(10.0, 10.0)), + "warning", + t.icon, + t.warning, + t.warning, ); - if *suppressed { - p.line_segment([br.left_bottom() + vec2(4.0, -4.0), br.right_top() + vec2(-4.0, 4.0)], Stroke::new(1.5, t.text_dim)); - } - // A failed feature has a mark of its own, not only its colour (#972). - if err.is_some() { - icons::paint(&p, Rect::from_min_size(br.right_top() - vec2(10.0, 0.0), vec2(10.0, 10.0)), "error", t.panel, t.error, t.error); - } - if warn.is_some() && err.is_none() { - icons::paint( - &p, - Rect::from_min_size(br.right_top() - vec2(10.0, 0.0), vec2(10.0, 10.0)), - "warning", - t.icon, - t.warning, - t.warning, - ); - } - let mut tip = format!("{name} ({ty})"); - if let Some(e) = &err { - tip += &format!("\nError: {e}"); - } - if let Some(w) = &warn { - tip += &format!("\nWarning: {w}"); - } - if let Some(r) = res { - tip += &format!("\n{:.1} ms", r.ms); - } - tip += "\nDouble-click to edit · drag to reorder · right-click for more"; - if resp.drag_started() { - drag = Some(Drag::Feature(*id)); - } - let resp = resp.on_hover_text(tip); - if resp.clicked() { - // A dialog input that takes features (pattern objects) gets the click. - let takes = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::FEATURES != 0); - if takes { - if let Some(mut d) = app.dialog.take() { - d.pick(&app.session, solidcraft_engine::Sel::Feature { id: *id }); - app.dialog = Some(d); - } - } else if ui.input(|i| i.modifiers.command || i.modifiers.ctrl) { - toggle_feature(app, *id); - } else { - app.tree.picked_joints.clear(); - app.tree.picked_timeline_groups.clear(); - let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": id}]})); - } - } - if resp.double_clicked() { - edit = Some(*id); - } - let sketch_of = kind.profile_sketch(); - resp.context_menu(|ui| { - if ui.button(crate::i18n::tr_label("Edit Feature")).clicked() { - edit = Some(*id); - ui.close(); - } - if let Some(sk) = sketch_of - && ui.button(crate::i18n::tr_label("Edit Profile Sketch")).clicked() - { - app.edit_sketch(sk.0); - ui.close(); - } - if ui.button(crate::i18n::tr_label("Rename")).clicked() { - app.dialog = Some(crate::dialogs::Dialog::rename(*id, name)); - ui.close(); - } - if ui.button(if *suppressed { "Unsuppress Features" } else { "Suppress Features" }).clicked() { - let _ = app.run_heavy("timeline.suppress", json!({"feature": id})); - ui.close(); - } - if ui.button(crate::i18n::tr_label("Roll History Marker Here")).clicked() { - let _ = app.run_heavy("timeline.roll_to", json!({ "feature": id })); - ui.close(); - } - if ui.button(crate::i18n::tr_label("Find in Browser")).clicked() { - find_in_browser(app, *id); - ui.close(); - } - ui.separator(); - clipboard_buttons(app, ui, *id); - message_button(app, ui, *id); - ui.separator(); - if ui.button("Delete").clicked() { - delete_feature(app, *id); - ui.close(); - } - }); - x += 32.0; } - let end = x; - if marker >= feats.len() { - marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0))); + let mut tip = format!("{name} ({ty})"); + if let Some(e) = &err { + tip += &format!("\nError: {e}"); } - // The history marker: drag it to roll the model back or forward. - if let Some(mr) = marker_rect { - let resp = ui.interact(mr.expand2(vec2(3.0, 0.0)), ui.id().with("tl_marker"), Sense::drag()); - if resp.drag_started() { - drag = Some(Drag::Marker); - } - let col = if resp.hovered() || drag == Some(Drag::Marker) { - accent.color - } else if accent.color == t.accent { - t.timeline_marker + if let Some(w) = &warn { + tip += &format!("\nWarning: {w}"); + } + if let Some(r) = res { + tip += &format!("\n{:.1} ms", r.ms); + } + tip += "\nDouble-click to edit · drag to reorder · right-click for more"; + if resp.drag_started() { + drag = Some(Drag::Feature(*id)); + } + let resp = resp.on_hover_text(tip); + if resp.clicked() { + // A dialog input that takes features (pattern objects) gets the click. + let takes = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::FEATURES != 0); + if takes { + if let Some(mut d) = app.dialog.take() { + d.pick(&app.session, solidcraft_engine::Sel::Feature { id: *id }); + app.dialog = Some(d); + } + } else if ui.input(|i| i.modifiers.command || i.modifiers.ctrl) { + toggle_feature(app, *id); } else { - accent.color - }; - p.rect_filled(mr, 1.0, col); - resp.on_hover_text("History marker: drag to roll the model back; new features go here"); - } - let content_end = crate::dialogs_assembly::timeline_joints(app, ui, &p, end + 10.0, r); - // The scroll range follows the drawn content (the buttons don't take layout space themselves). - ui.allocate_exact_size(vec2((content_end - origin).max(0.0), r.height()), Sense::hover()); - // Drag feedback, and the drop. - if let (Some(dg), Some(pp)) = (drag.as_ref(), pointer) { - let slot = slot_at(&xs, end, pp.x); - let sx = xs.get(slot).copied().unwrap_or(end) - 4.0; - p.line_segment( - [pos2(sx, r.top() + 3.0), pos2(sx, r.bottom() - 3.0)], - Stroke::new(2.0, if matches!(dg, Drag::Marker) { accent.color } else { t.sketch_accent }), - ); - } - let released = ui.input(|i| i.pointer.any_released()); - // (Taking the drag ends it even when the pointer has left the window.) - if released - && let Some(dg) = drag.take() - && let Some(pp) = pointer - { - let slot = slot_at(&xs, end, pp.x); - match dg { - Drag::Marker => { - let _ = app.run_heavy("timeline.roll_to", if slot >= n { json!({}) } else { json!({ "position": slot }) }); - } - Drag::Feature(id) => { - let from = app.session.doc.feature_index(id).unwrap_or(0); - // Dropping after itself means the slot shifts by one. - let to = if slot > from { slot - 1 } else { slot }; - if to != from { - let _ = app.run_heavy("timeline.reorder", json!({ "feature": id, "position": to })); - } - } + app.tree.picked_joints.clear(); + app.tree.picked_timeline_groups.clear(); + let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": id}]})); } } - ui.data_mut(|d| match drag { - Some(dg) => { - d.insert_temp(drag_id, dg.clone()); + if resp.double_clicked() { + edit = Some(*id); + } + let sketch_of = kind.profile_sketch(); + resp.context_menu(|ui| { + if ui.button(crate::i18n::tr_label("Edit Feature")).clicked() { + edit = Some(*id); + ui.close(); + } + if let Some(sk) = sketch_of + && ui.button(crate::i18n::tr_label("Edit Profile Sketch")).clicked() + { + app.edit_sketch(sk.0); + ui.close(); + } + if ui.button(crate::i18n::tr_label("Rename")).clicked() { + app.dialog = Some(crate::dialogs::Dialog::rename(*id, name)); + ui.close(); + } + if ui.button(if *suppressed { "Unsuppress Features" } else { "Suppress Features" }).clicked() { + let _ = app.run_heavy("timeline.suppress", json!({"feature": id})); + ui.close(); + } + if ui.button(crate::i18n::tr_label("Roll History Marker Here")).clicked() { + let _ = app.run_heavy("timeline.roll_to", json!({ "feature": id })); + ui.close(); + } + if ui.button(crate::i18n::tr_label("Find in Browser")).clicked() { + find_in_browser(app, *id); + ui.close(); + } + ui.separator(); + clipboard_buttons(app, ui, *id); + message_button(app, ui, *id); + ui.separator(); + if ui.button("Delete").clicked() { + delete_feature(app, *id); + ui.close(); } - None => d.remove::(drag_id), }); - app.viewport.hover_feature = hovered; - if let Some(id) = edit { - app.edit_feature(id); + x += 32.0; + } + let end = x; + if marker >= feats.len() { + marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0))); + } + // The history marker: drag it to roll the model back or forward. + if let Some(mr) = marker_rect { + crate::scenario::publish_rect("history:marker", mr); + let resp = ui.interact(mr.expand2(vec2(3.0, 0.0)), ui.id().with("tl_marker"), Sense::drag()); + if resp.drag_started() { + drag = Some(Drag::Marker); } + let col = if resp.hovered() || drag == Some(Drag::Marker) { + accent.color + } else if !floating && accent.color == t.accent { + t.timeline_marker + } else { + accent.color + }; + p.rect_filled(mr, 1.0, col); + resp.on_hover_text("History marker: drag to roll the model back; new features go here"); + } + let content_end = crate::dialogs_assembly::timeline_joints(app, ui, &p, end + 10.0, r); + // The scroll range follows the drawn content (the buttons don't take layout space themselves). + ui.allocate_exact_size(vec2((content_end - origin).max(0.0), r.height()), Sense::hover()); + // Drag feedback, and the drop. + if let (Some(dg), Some(pp)) = (drag.as_ref(), pointer) { + let slot = slot_at(&xs, end, pp.x); + let sx = xs.get(slot).copied().unwrap_or(end) - 4.0; + p.line_segment( + [pos2(sx, r.top() + 3.0), pos2(sx, r.bottom() - 3.0)], + Stroke::new(2.0, if matches!(dg, Drag::Marker) { accent.color } else { t.sketch_accent }), + ); + } + let released = ui.input(|i| i.pointer.any_released()); + // (Taking the drag ends it even when the pointer has left the window.) + if released + && let Some(dg) = drag.take() + && let Some(pp) = pointer + { + let slot = slot_at(&xs, end, pp.x); + match dg { + Drag::Marker => { + let _ = app.run_heavy("timeline.roll_to", if slot >= n { json!({}) } else { json!({ "position": slot }) }); + } + Drag::Feature(id) => { + let from = app.session.doc.feature_index(id).unwrap_or(0); + // Dropping after itself means the slot shifts by one. + let to = if slot > from { slot - 1 } else { slot }; + if to != from { + let _ = app.run_heavy("timeline.reorder", json!({ "feature": id, "position": to })); + } + } + } + } + ui.data_mut(|d| match drag { + Some(dg) => { + d.insert_temp(drag_id, dg.clone()); + } + None => d.remove::(drag_id), }); + app.viewport.hover_feature = hovered; + if let Some(id) = edit { + app.edit_feature(id); + } }); }); } @@ -748,4 +785,74 @@ mod tests { let first = h.app.session.doc.find_feature("First").map(|f| f.id).unwrap(); assert_eq!(super::selected_feature_ids(&h.app), [first]); } + #[test] + fn both_strips_keep_selection_reorder_marker_and_transport() { + use crate::scenario::{handle_at, rect_of}; + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let mut h = two_boxes(false); + h.app.ui.layout = mode; + h.settle(); + let viewport = h.app.viewport.rect.unwrap(); + if mode == crate::shell::LayoutMode::Floating { + assert_eq!(rect_of("history:card").unwrap().bottom(), viewport.bottom() - crate::shell::SHELL_GAP); + } else { + let strip = rect_of("history:strip").unwrap(); + assert_eq!(strip.top(), viewport.bottom()); + assert_eq!(strip.bottom(), h.ctx.content_rect().bottom()); + assert_eq!(strip.left(), 0.0); + assert_eq!(strip.height(), 40.0); + } + h.step(&json!({"click":{"handle":"timeline:First"}})).unwrap(); + h.step(&json!({"click":{"handle":"timeline:Second"},"ctrl":true})).unwrap(); + assert_eq!(super::selected_feature_ids(&h.app).len(), 2); + let first = handle_at("timeline:First").unwrap(); + let second = handle_at("timeline:Second").unwrap(); + h.step(&json!({"drag":[[second.x,second.y],[first.x-12.0,first.y]]})).unwrap(); + h.settle(); + assert_eq!(h.app.session.doc.features.first().unwrap().name, "Second"); + let first = handle_at("timeline:Second").unwrap(); + let marker = rect_of("history:marker").unwrap().center(); + h.step(&json!({"drag":[[marker.x,marker.y],[first.x-12.0,first.y]]})).unwrap(); + h.settle(); + assert_eq!(h.app.session.doc.marker, Some(0)); + h.step(&json!({"click":{"handle":"history:forward"}})).unwrap(); + h.settle(); + assert_eq!(h.app.session.doc.marker, Some(1)); + h.step(&json!({"click":{"handle":"history:last"}})).unwrap(); + h.settle(); + assert_eq!(h.app.session.doc.marker, None); + } + } + #[test] + fn floating_strip_has_only_history_text_and_accents_the_last_done_chip_and_marker() { + let mut h = two_boxes(false); + h.app.ui.tab = "SURFACE".into(); + let t = crate::theme::Tokens::get(); + let accent = crate::theme::TabAccent::for_tab(&t, "SURFACE"); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::timeline(&mut h.app, ui)); + out.textures_delta.clear(); + let texts: Vec<_> = out + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(text) => Some(text.galley.text()), + _ => None, + }) + .collect(); + assert_eq!(texts, ["History"]); + let chips: Vec<_> = out + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Rect(r) if r.rect.size() == egui::vec2(28.0, 28.0) => Some(r), + _ => None, + }) + .collect(); + assert_eq!(chips.len(), 2); + assert_eq!(chips.last().unwrap().fill, accent.soft); + assert_eq!(chips.last().unwrap().stroke.color, accent.color); + let marker = crate::scenario::rect_of("history:marker").unwrap(); + assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.rect == marker && r.fill == accent.color))); + } } diff --git a/crates/ui-egui/src/timeline_left.rs b/crates/ui-egui/src/timeline_left.rs index dfd22df38a..c4428dab69 100644 --- a/crates/ui-egui/src/timeline_left.rs +++ b/crates/ui-egui/src/timeline_left.rs @@ -979,18 +979,27 @@ mod tests { #[test] fn floating_browser_sits_over_the_workspace_and_docks_again() { - let mut harness = Harness::new(); - harness.app.preferences.browser_float = true; - harness.frames(4); - let card = crate::scenario::rect_of("browser:float").expect("the floating browser is drawn"); - assert!(card.min.x < 40.0, "the card sits at the view's left edge: {card:?}"); - assert!(handle_at("left-browser-rail").is_none(), "a floating browser must not take a column of the window"); - assert!(handle_at("row:Untitled").is_some(), "the tree is still drawn and usable"); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let mut harness = Harness::new(); + harness.app.ui.layout = mode; + harness.app.preferences.browser_float = true; + harness.frames(4); + let card = crate::scenario::rect_of("browser:float").expect("the floating browser is drawn"); + assert!(card.min.x < 40.0, "the card sits at the view's left edge: {card:?}"); + assert_eq!(harness.app.viewport.rect.unwrap().left(), 0.0, "a floating browser must not take a column"); + assert!(handle_at("left-browser-rail").is_some(), "the card retains the saved-width drag grip"); + assert!(handle_at("row:Untitled").is_some(), "the tree is still drawn and usable"); - harness.app.preferences.browser_float = false; - harness.frames(4); - assert!(crate::scenario::rect_of("browser:float").is_none(), "docking again removes the card"); - assert!(handle_at("left-browser-rail").is_some(), "the docked sidebar is back"); + harness.app.preferences.browser_float = false; + harness.frames(4); + assert!(crate::scenario::rect_of("browser:float").is_none(), "the explicit overlay is off"); + assert_eq!(crate::scenario::rect_of("browser:card").is_some(), mode == crate::shell::LayoutMode::Floating); + assert_eq!( + harness.app.viewport.rect.unwrap().left(), + if mode == crate::shell::LayoutMode::Docked { harness.app.preferences.browser_width } else { 0.0 } + ); + assert!(handle_at("left-browser-rail").is_some(), "both card and sidebar resize"); + } } #[test] diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index cf0840a26b..3e04865cff 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -2982,7 +2982,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if let Some(since) = app.computing_since() { let seconds = ((crate::now_ms() - since) / 1000.0).floor().max(0.0); let text = crate::i18n::format("dialog.computing", &[("seconds", &format!("{seconds:.0}"))]); - let pos = pos2(crate::shell::content_rect(app, rect).left() + 12.0, rect.bottom() - 76.0); + let pos = pos2(crate::shell::content_rect(app, rect).left() + 12.0, crate::shell::content_rect(app, rect).bottom() - 76.0); let galley = painter.layout_no_wrap(text, FontId::proportional(t.font_label), t.text); let r = Rect::from_min_size(pos, galley.size() + vec2(34.0, 8.0)); painter.rect_filled(r, t.radius_sm, t.overlay); @@ -2993,7 +2993,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // Status chip. if let Some((msg, at, err)) = app.status.clone() { if crate::now_ms() - at < 6000.0 { - let pos = pos2(crate::shell::content_rect(app, rect).left() + 12.0, rect.bottom() - 46.0); + let pos = pos2(crate::shell::content_rect(app, rect).left() + 12.0, crate::shell::content_rect(app, rect).bottom() - 46.0); let galley = painter.layout_no_wrap(msg, FontId::proportional(t.font_label), if err { t.error } else { t.text }); let r = Rect::from_min_size(pos, galley.size() + vec2(16.0, 8.0)); painter.rect_filled(r, t.radius_sm, t.overlay); @@ -5945,21 +5945,28 @@ mod tests { h.call("ui.move", field.clone()); h.call("ui.click", field); assert!(handle_at(item).is_none(), "the list must close"); - // The full floating viewport extends under the browser; its exposed content - // edge is the boundary between the panel and navigable model in either layout. - let content = crate::shell::content_rect(&h.app, vp); - let (mid, x) = (content.center().y, content.left() + content.width() * 0.25); + // Floating cards sit inside the full viewport. Probe their actual edges; the + // content anchor also reserves gaps and space below the short browser card. + // Docked panels end at the viewport boundary instead. + let (browser_edge, mid, history_edge) = if layout == crate::shell::LayoutMode::Floating { + let browser = h.app.shell.browser.expect("the floating browser card"); + let history = h.app.shell.history.expect("the floating history card"); + (browser.right(), browser.center().y, history.top()) + } else { + (vp.left(), vp.center().y, vp.bottom()) + }; + let x = vp.center().x; for p in [ pos2(dialog.left() + 2.0, dialog.center().y), pos2(dialog.center().x, dialog.bottom() - 2.0), - pos2(content.left() - 2.0, mid), - pos2(x, vp.bottom() + 2.0), + pos2(browser_edge - 2.0, mid), + pos2(x, history_edge + 2.0), ] { assert!(!wheel(&mut h, p, 60.0), "smooth {smooth}: the wheel at {p:?} off the model ({vp:?}, dialog {dialog:?}) zoomed it"); assert!(h.app.viewport.mouse.is_none(), "the model must not see the pointer at {p:?}"); } // The model itself still zooms right next to them. - for p in [pos2(dialog.left() - 2.0, dialog.center().y), pos2(content.left() + 2.0, mid), pos2(x, vp.bottom() - 2.0)] { + for p in [pos2(dialog.left() - 2.0, dialog.center().y), pos2(browser_edge + 2.0, mid), pos2(x, history_edge - 2.0)] { assert!(wheel(&mut h, p, 60.0), "smooth {smooth}: the wheel at {p:?} on the model ({vp:?}) must zoom it"); assert_eq!(h.app.viewport.mouse, Some(p)); assert!(gestures(&mut h, p), "a pinch and a two-finger scroll at {p:?} on the model must move it"); @@ -6214,6 +6221,8 @@ mod tests { h.app.ui.perspective = perspective; h.app.run("solid.box", json!({"length":80,"width":50,"height":20})).unwrap(); h.app.fit_view(); + // Fit resolves after measuring the floating browser and history cards. + h.frames(2); let rect = h.app.viewport.rect.unwrap(); let at = rect.center() + offset; let proj = projection(&h.app, rect); diff --git a/crates/ui-egui/src/viewport_layout_tests.rs b/crates/ui-egui/src/viewport_layout_tests.rs index 060fcdfb71..7372feffa5 100644 --- a/crates/ui-egui/src/viewport_layout_tests.rs +++ b/crates/ui-egui/src/viewport_layout_tests.rs @@ -326,3 +326,29 @@ fn tiny_viewports_never_overlap_navigation_and_axis_view() { } } } + +#[test] +fn floating_fit_uses_the_measured_browser_and_history_cards_at_each_size() { + for (width, height) in [(1400, 900), (900, 720), (700, 475)] { + let mut h = Harness::new(); + h.call("ui.resize", json!({"width":width,"height":height})); + h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap(); + h.settle(); + h.app.fit_view(); + h.settle(); + let layout = h.app.viewport.layout.as_ref().unwrap(); + for name in ["browser:card", "history:card"] { + let card = crate::scenario::rect_of(name).unwrap(); + assert!(layout.viewport.contains_rect(card), "{name}: {card:?} {layout:?}"); + assert!(!layout.drawable.intersect(card).is_positive(), "{name}: {card:?} {layout:?}"); + } + let projection = crate::viewport::projection(&h.app, layout.viewport); + for x in [0.0, 40.0] { + for y in [0.0, 30.0] { + for z in [0.0, 20.0] { + assert!(layout.drawable.contains(projection.to_screen(Vec3::new(x, y, z)).unwrap()), "{layout:?}"); + } + } + } + } +} diff --git a/crates/ui-egui/src/workspace.rs b/crates/ui-egui/src/workspace.rs index b6c2ca324f..1b86c6d970 100644 --- a/crates/ui-egui/src/workspace.rs +++ b/crates/ui-egui/src/workspace.rs @@ -664,7 +664,7 @@ mod tests { h.step(&json!({"key":"Enter"})).unwrap(); assert_eq!(h.app.ui.tab, "MANAGE"); h.step(&json!({"key":"Escape"})).unwrap(); - crate::palette::submit(&mut h.app, "parameters.change"); + h.app.start("parameters.change"); assert_eq!(h.app.ui.tab, "MANAGE"); h.frames(2); for tab in TABS { diff --git a/crates/ui-egui/tests/scenarios/s37_browser_chevrons.json b/crates/ui-egui/tests/scenarios/s37_browser_chevrons.json index cff6376757..c151e7ae9d 100644 --- a/crates/ui-egui/tests/scenarios/s37_browser_chevrons.json +++ b/crates/ui-egui/tests/scenarios/s37_browser_chevrons.json @@ -318,6 +318,17 @@ ] } }, + { + "note": "Expanded rows now scroll within the floating card, above the History strip." + }, + { + "call": "ui.scroll", + "params": { + "x": 150, + "y": 500, + "delta": -220 + } + }, { "expect": { "shown_handles": [ @@ -435,6 +446,14 @@ "handle": "fold:Component2:1" } }, + { + "call": "ui.scroll", + "params": { + "x": 150, + "y": 500, + "delta": -180 + } + }, { "expect": { "shown_handles": [ @@ -473,6 +492,14 @@ ] } }, + { + "call": "ui.scroll", + "params": { + "x": 150, + "y": 500, + "delta": 1200 + } + }, { "expect": { "shown_handles": [ diff --git a/crates/ui-egui/tests/scenarios/sketch_mirror_dialog.json b/crates/ui-egui/tests/scenarios/sketch_mirror_dialog.json index df7b2b6e79..ae729b9f19 100644 --- a/crates/ui-egui/tests/scenarios/sketch_mirror_dialog.json +++ b/crates/ui-egui/tests/scenarios/sketch_mirror_dialog.json @@ -7,6 +7,8 @@ {"call":"ui.view","params":{"view":"top"}}, {"start":"sketch.mirror"}, {"expect":{"dialog":"SketchModify(Mirror)","tool":null}}, + {"note":"Fit after the floating panels are measured so both entity picks remain exposed beside the left dialog."}, + {"call":"ui.view","params":{"view":"fit"}}, {"click":{"sketch":[-22.5,10]}}, {"click":{"sketch":[-15,17.5]}}, {"click":{"sketch":[-22.5,10]},"ctrl":true}, diff --git a/crates/ui-egui/tests/window_widths.rs b/crates/ui-egui/tests/window_widths.rs index 2f704929d5..ef624db767 100644 --- a/crates/ui-egui/tests/window_widths.rs +++ b/crates/ui-egui/tests/window_widths.rs @@ -5,13 +5,13 @@ use serde_json::json; use solidcraft_ui_egui::frame::{DIALOG_MAX_W, DIALOG_MIN_W, DIALOG_WIDE_MAX_W, WINDOW_WIDE_MAX_W}; use solidcraft_ui_egui::scenario::Harness; -/// Widths of the windows on screen (their areas in the middle layer). +/// Widths of dialogs and windows on screen; the browser card is workspace chrome. fn window_widths(h: &Harness) -> Vec<(String, f32)> { h.ctx.memory(|m| { m.areas() .visible_layer_ids() .into_iter() - .filter(|l| l.order == egui::Order::Middle) + .filter(|l| l.order == egui::Order::Middle && l.id != egui::Id::new("sc_browser_card")) .filter_map(|l| m.area_rect(l.id).map(|r| (format!("{:?}", l.id), r.width()))) .collect() }) diff --git a/docs/ribbon-layout.md b/docs/ribbon-layout.md index ed095e2dfc..9dff1b837e 100644 --- a/docs/ribbon-layout.md +++ b/docs/ribbon-layout.md @@ -134,7 +134,7 @@ Section labels retain `panel::
` and collapsed sections retain `col The ribbon is a full-width surface immediately below the 34 px application bar, with no outer gap or rounding. `toolbar::{TAB_ROW_HEIGHT,GROUP_HEIGHT,RIBBON_HEIGHT}` reserve 30 + 86 + 1 px; browser and viewport placement follows the panel allocation. The workspace column is 88 px wide. Promoted buttons are 54 × 60 px with 3 px gaps (Sketch Create retains wider family selectors). Section labels occupy a 20 px row, inset 3 px, and retain the existing command lists, pin actions and drag targets. -Tab labels use normal word casing. Selected standard tabs use their soft accent; contextual tabs use their solid accent and add a row-wide line when selected. The existing `TabAccent` helper supplies all selection colours. Search is a 188 × 24 px launcher for the S box, using the existing `zoom` magnifier; it shrinks to 24 px before the tabs scroll horizontally. The selected tab scrolls into view after a context change or resize. Clicking Search or pressing S anchors the existing search popup below the field. The bottom command line remains available. +Tab labels use normal word casing. Selected standard tabs use their soft accent; contextual tabs use their solid accent and add a row-wide line when selected. The existing `TabAccent` helper supplies all selection colours. Search is a 188 × 24 px launcher for the S box, using the existing `zoom` magnifier; it shrinks to 24 px before the tabs scroll horizontally. The selected tab scrolls into view after a context change or resize. Clicking Search or pressing S anchors the existing search popup below the field. The bottom command line is gone: the S box (and the ribbon search field) runs a command id typed with JSON parameters, e.g. `solid.extrude {"distance": 20}`, when you press Enter. Sketch's Finish group reserves space at the far right even when other groups collapse. Its unfilled Finish Sketch button uses the existing `finish` icon. The chevron-free Exit row still opens the existing command list, preserving Finish with Auto-Constrain and saved pin customizations. @@ -147,3 +147,12 @@ The workspace popup is 470 px wide, anchored below the workspace column. Design Section lists use 30 px command rows, 20 px semantic icons, monospace shortcuts, hover pins and a pinning footer. `workspace::groups` optionally supplies headings and command order. MODEL / MODIFY uses EDGES, FACES, BODIES and SPECIALISE; unlisted commands follow the final heading in their original order. This ordering does not change promoted buttons or saved pins. Ribbon tooltips show the full command name in bold and its registered shortcut in a key cap. Existing Render catalog descriptions are reused; commands without prose descriptions show the header alone (and their existing disabled reason when applicable). Registry parameter syntax is not presented as a description. + + +The floating shell uses one Browser card with a title, body count and fold button. Its right +edge resizes the same saved browser width as the Docked sidebar. The Browser preference +“Float it over the workspace” switches the Docked sidebar to this card; in the Floating shell +it keeps the card and enables the translucent overlay surface. With the left history enabled, +the browser stays below that history unless the overlay preference is enabled, in which case +only the history keeps its column. The bottom History card leaves a gap beneath the browser; +the full viewport remains available through the gaps and below a short browser. From 837dbe66dfba436ef4045f8c17ed15d8428c7411 Mon Sep 17 00:00:00 2001 From: Nathan Dane Date: Tue, 20 Oct 2026 09:37:05 +0200 Subject: [PATCH 03/16] Compass and viewport overlays (step 4 of #2016, rebased onto step 3) --- assets/icons/ui/source/navigation.rs | 60 ++- crates/ui-egui/src/sketch3d.rs | 2 +- crates/ui-egui/src/sketch3d_frame_ui_tests.rs | 4 + crates/ui-egui/src/sketch_tools.rs | 4 +- crates/ui-egui/src/theme.rs | 8 +- crates/ui-egui/src/tools.rs | 2 +- crates/ui-egui/src/viewport.rs | 238 ++++++++---- crates/ui-egui/src/viewport_layout.rs | 5 +- crates/ui-egui/src/viewport_overlays_tests.rs | 346 ++++++++++++++++++ 9 files changed, 579 insertions(+), 90 deletions(-) create mode 100644 crates/ui-egui/src/viewport_overlays_tests.rs diff --git a/assets/icons/ui/source/navigation.rs b/assets/icons/ui/source/navigation.rs index 54e6ca9802..7192a18148 100644 --- a/assets/icons/ui/source/navigation.rs +++ b/assets/icons/ui/source/navigation.rs @@ -148,7 +148,8 @@ pub(crate) fn curved_arrow(painter: &egui::Painter, at: Pos2, clockwise: bool, h arrow(painter, *tip, vec2(-end.sin(), end.cos()) * (sign * scale), hover, t); } } -pub(crate) const AXIS_RADIUS: f32 = 11.0; +/// The pointer target around a compass end: larger than the drawn disc, so it is easy to hit. +pub(crate) const AXIS_RADIUS: f32 = 15.0; pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens) { let col = if d.x != 0.0 { t.triad[0] @@ -158,9 +159,56 @@ pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, a t.triad[2] }; let positive = !name.starts_with('-'); - let ink = if depth < -0.01 { col.gamma_multiply(0.55) } else { col }; - painter.line_segment([c, at], Stroke::new(if positive { 2.0 } else { 1.0 }, ink)); - painter.circle_filled(at, AXIS_RADIUS, if hovered { t.accent_soft } else { t.overlay }); - painter.circle_stroke(at, AXIS_RADIUS, Stroke::new(if hovered { 2.0 } else { 1.0 }, if hovered { t.accent } else { ink })); - painter.text(at, Align2::CENTER_CENTER, name.to_uppercase(), FontId::proportional(11.0), ink); + let light = t.panel.r() > 128; + // Positive shafts are the full axis colour; negative ones are a dimmed tint (and any end + // pointing away from the viewer dims further). + let ink = if !positive || depth < -0.01 { col.gamma_multiply(0.55) } else { col }; + let radius = if positive { 11.0 } else { 4.8 }; + let direction = (at - c).normalized(); + let width = 3.6; + // A negative shaft stops at the outer edge of its ring; a positive one runs under its disc. + let end = if positive { at - direction * radius } else { at - direction * (radius + 1.2) }; + painter.line_segment([c, end], Stroke::new(width, ink)); + // The round cap sits at the origin; the other end is covered by the disc or ring. + painter.circle_filled(c, width / 2.0, ink); + if positive { + painter.circle_filled(at, radius, col); + } + // Negative tips are hollow: neither the axis shaft nor a fill covers their + // centres, so the translucent compass background shows through. A hovered tip of either + // kind turns fully white. + if hovered { + // Same outer size as the resting tip: a ring's outline is half its stroke outside. + painter.circle_filled(at, if positive { radius } else { radius + 1.2 }, t.on_accent); + // On the light theme a white disc vanishes into the compass, so it carries an outline in + // the axis colour, drawn inside the disc so it does not grow. + if light && positive { + painter.circle_stroke(at, radius - 1.0, Stroke::new(2.0, col)); + } + } + if positive { + // The letters are heavy: egui's regular face gets a small overstrike for weight. On a + // hovered (white) disc the letter takes the axis colour. + let letter = if hovered { col } else { t.on_accent }; + let label = name.to_uppercase(); + for dx in [-0.45, 0.45] { + painter.text(at + vec2(dx, 0.0), Align2::CENTER_CENTER, &label, FontId::proportional(13.5), letter); + } + } + if !positive { + painter.circle_stroke( + at, + radius, + Stroke::new( + 2.4, + if !hovered { + ink + } else if light { + col + } else { + t.on_accent + }, + ), + ); + } } diff --git a/crates/ui-egui/src/sketch3d.rs b/crates/ui-egui/src/sketch3d.rs index 086ba813f4..b4ab561d7a 100644 --- a/crates/ui-egui/src/sketch3d.rs +++ b/crates/ui-egui/src/sketch3d.rs @@ -197,7 +197,7 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) - return false; } let tk = Tokens::get(); - let stroke = Stroke::new(1.5, tk.rubber_band); + let stroke = Stroke::new(1.5, tk.sketch_accent); let pts = if CHAIN.with(|c| c.borrow().0) == Some(t.cmd) { chain() } else { Vec::new() }; let hover = HOVER.with(|h| *h.borrow()); let mut all: Vec = pts.iter().filter_map(|p| proj.to_screen(*p)).collect(); diff --git a/crates/ui-egui/src/sketch3d_frame_ui_tests.rs b/crates/ui-egui/src/sketch3d_frame_ui_tests.rs index 7662c67208..a89e9aba00 100644 --- a/crates/ui-egui/src/sketch3d_frame_ui_tests.rs +++ b/crates/ui-egui/src/sketch3d_frame_ui_tests.rs @@ -151,6 +151,10 @@ fn second_occurrence_drawn_wire_fit_picks_and_world_triad_drag_stay_aligned() { h.call("ui.view", json!({"view":"fit"})); h.app.start("sketch.line"); h.frames(2); + // The clickable Line instruction toast covers the fitted lower endpoint. This scenario + // tests model picks, so dismiss the transient instruction before clicking through that area. + h.app.status = None; + h.frames(2); for world in [Vec3::new(17.0, 32.0, 45.0), Vec3::new(17.0, 32.0, 55.0)] { let [x, y] = h.at(&json!({"world":[world.x,world.y,world.z]})).unwrap(); let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap()); diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 896561d6cd..47555ee4de 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -1080,7 +1080,7 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { // In the error colour, thicker than a hover: it is what the click deletes. crate::tools::paint_sketch_path(&world, 0, painter, proj, Stroke::new(4.5, tk.error)); } - let stroke = Stroke::new(1.5, tk.rubber_band); + let stroke = Stroke::new(1.5, tk.sketch_accent); // A Trim drag: the pieces it will remove, then the path drawn so far. if t.cmd == "sketch.trim" && !t.pts.is_empty() { TRIM_REMOVED.with(|r| { @@ -1098,7 +1098,7 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { { painter.circle_stroke(sp, if snap.is_some() { 6.0 } else { 3.0 }, stroke); } - let faint = Stroke::new(1.0, tk.rubber_band.gamma_multiply(0.6)); + let faint = Stroke::new(1.0, tk.sketch_accent.gamma_multiply(0.6)); let mode = crate::sketch_palette::mode(app); for w in t.pts.windows(2) { crate::tools::paint_sketch_path(&[ss.plane.to_world(w[0].0), ss.plane.to_world(w[1].0)], mode, painter, proj, faint); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b3f62ce062..54e3a70ced 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -71,6 +71,7 @@ impl TabAccent { let accent = Self::for_tab(&Tokens::get(), &app.ui.tab); ctx.data_mut(|d| { d.insert_temp(egui::Id::new("sc_tab_accent"), accent); + d.insert_temp(egui::Id::new("sc_sketch_open"), app.session.active_sketch.is_some()); d.insert_temp(egui::Id::new("sc_tool_accent"), app.tool.is_some() || app.dialog.is_some()); }); } @@ -78,10 +79,11 @@ impl TabAccent { /// Paint only the focused numeric editor, without changing sibling buttons or checkboxes. pub fn number_focus(ui: &egui::Ui, response: &egui::Response) { let active = ui.ctx().data(|d| d.get_temp::(egui::Id::new("sc_tool_accent"))).unwrap_or(false); - let accent = Self::current(ui.ctx()); let t = Tokens::get(); - if active && response.has_focus() && accent.color != t.accent { - ui.painter().rect_stroke(response.rect, t.radius_sm, Stroke::new(1.0, accent.color), egui::StrokeKind::Inside); + let sketch = ui.ctx().data(|d| d.get_temp::(egui::Id::new("sc_sketch_open"))).unwrap_or(false); + let color = if sketch { t.sketch_accent } else { Self::current(ui.ctx()).color }; + if active && response.has_focus() && color != t.accent { + ui.painter().rect_stroke(response.rect, t.radius_sm, Stroke::new(1.0, color), egui::StrokeKind::Inside); } } } diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 31e413f26c..70c1a73871 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -850,7 +850,7 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { let Some(ss) = st.sketch(sid) else { return }; let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p)); let tk = crate::theme::Tokens::get(); - let stroke = Stroke::new(1.5, tk.rubber_band); + let stroke = Stroke::new(1.5, tk.sketch_accent); if let Some((hp, snap)) = &t.hover && let Some(sp) = to(*hp) { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 3e04865cff..e4c413c2c8 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -20,6 +20,10 @@ use crate::{SolveApp, icons}; #[path = "viewport_appearance_tests.rs"] mod appearance_tests; +#[cfg(test)] +#[path = "viewport_overlays_tests.rs"] +mod overlays_tests; + #[cfg(test)] #[path = "viewport_removed_later_tests.rs"] mod removed_later_tests; @@ -2957,16 +2961,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { ); } if let Some(bx) = app.viewport.boxsel { - let col = if bx.crossing { t.box_crossing } else { t.box_window }; - painter.rect_filled(bx.rect, 0.0, col.gamma_multiply(0.12)); - if bx.crossing { - let c = [bx.rect.left_top(), bx.rect.right_top(), bx.rect.right_bottom(), bx.rect.left_bottom()]; - for i in 0..4 { - painter.add(Shape::dashed_line(&[c[i], c[(i + 1) % 4]], Stroke::new(1.2, col), 6.0, 4.0)); - } - } else { - painter.rect_stroke(bx.rect, 0.0, Stroke::new(1.2, col), egui::StrokeKind::Inside); - } + paint_box_selection(&painter, bx, app.session.active_sketch.is_some()); } if let Some(tl) = app.tool.as_ref() { crate::tools::preview(app, tl, &painter, &proj); @@ -2977,37 +2972,94 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { crate::select_other::show(app, ui.ctx()); axis_view(app, ui, rect); nav_bar(app, ui, rect); - // A command recomputing the history off the window thread (the marker moved, undo): the - // window goes on, and says so above the status chip. - if let Some(since) = app.computing_since() { - let seconds = ((crate::now_ms() - since) / 1000.0).floor().max(0.0); - let text = crate::i18n::format("dialog.computing", &[("seconds", &format!("{seconds:.0}"))]); - let pos = pos2(crate::shell::content_rect(app, rect).left() + 12.0, crate::shell::content_rect(app, rect).bottom() - 76.0); - let galley = painter.layout_no_wrap(text, FontId::proportional(t.font_label), t.text); - let r = Rect::from_min_size(pos, galley.size() + vec2(34.0, 8.0)); - painter.rect_filled(r, t.radius_sm, t.overlay); - egui::Spinner::new().size(12.0).paint_at(ui, Rect::from_center_size(pos2(r.left() + 13.0, r.center().y), vec2(12.0, 12.0))); - painter.galley(pos + vec2(26.0, 4.0), galley, t.text); - ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + transient_messages(app, ui, rect); +} + +fn paint_box_selection(painter: &egui::Painter, bx: BoxSel, sketch: bool) { + let t = Tokens::get(); + let col = if sketch { + t.sketch_accent + } else if bx.crossing { + t.box_crossing + } else { + t.box_window + }; + painter.rect_filled(bx.rect, 0.0, col.gamma_multiply(0.12)); + if bx.crossing { + let c = [bx.rect.left_top(), bx.rect.right_top(), bx.rect.right_bottom(), bx.rect.left_bottom()]; + for i in 0..4 { + painter.add(Shape::dashed_line(&[c[i], c[(i + 1) % 4]], Stroke::new(1.2, col), 6.0, 4.0)); + } + } else { + painter.rect_stroke(bx.rect, 0.0, Stroke::new(1.2, col), egui::StrokeKind::Inside); } - // Status chip. - if let Some((msg, at, err)) = app.status.clone() { +} + +/// A single visual pattern for ongoing work and the existing six-second status. +fn transient_messages(app: &mut SolveApp, ui: &egui::Ui, viewport: Rect) { + if app.status.is_none() && app.computing_since().is_none() { + return; + } + let t = Tokens::get(); + let mut obstacles = crate::shell::covered_rects(app); + if let Some(layout) = &app.viewport.layout { + obstacles.extend([layout.navigation, layout.axis_view]); + } + let mut free = crate::viewport_layout::free_rect(crate::shell::content_rect(app, viewport), &obstacles).shrink(t.space_8); + if let Some((msg, at, error)) = app.status.clone() { if crate::now_ms() - at < 6000.0 { - let pos = pos2(crate::shell::content_rect(app, rect).left() + 12.0, crate::shell::content_rect(app, rect).bottom() - 46.0); - let galley = painter.layout_no_wrap(msg, FontId::proportional(t.font_label), if err { t.error } else { t.text }); - let r = Rect::from_min_size(pos, galley.size() + vec2(16.0, 8.0)); - painter.rect_filled(r, t.radius_sm, t.overlay); - painter.galley(pos + vec2(8.0, 4.0), galley, t.text); - // A click opens every message of the run, to read and copy (#2763). - crate::scenario::publish_handle("status:chip", r.center()); - if ui.interact(r, egui::Id::new("sc_status_chip"), Sense::click()).on_hover_text(crate::i18n::tr("messages.chip_hint")).clicked() { - app.messages.open = true; + if let Some(r) = paint_toast(ui, free, msg, error, false) { + crate::scenario::publish_rect("toast", r); + free.max.y = r.top() - t.space_8; + // A click opens every message of the run, to read and copy (#2763). + crate::scenario::publish_handle("status:chip", r.center()); + if ui.interact(r, egui::Id::new("sc_status_chip"), Sense::click()).on_hover_text(crate::i18n::tr("messages.chip_hint")).clicked() { + app.messages.open = true; + } } ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); } else { app.status = None; } } + if let Some(since) = app.computing_since() { + let seconds = ((crate::now_ms() - since) / 1000.0).floor().max(0.0); + let text = crate::i18n::format("dialog.computing", &[("seconds", &format!("{seconds:.0}"))]); + if let Some(r) = paint_toast(ui, free, text, false, true) { + crate::scenario::publish_rect("toast:computing", r); + if app.status.is_none() { + crate::scenario::publish_rect("toast", r); + } + } + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + } +} + +fn paint_toast(ui: &egui::Ui, free: Rect, text: String, error: bool, computing: bool) -> Option { + if !free.is_finite() || !free.is_positive() || free.width() <= 40.0 { + return None; + } + let t = Tokens::get(); + let painter = ui.painter().with_clip_rect(free); + let galley = painter.layout(text, FontId::proportional(t.font_label), t.text, (free.width() - 40.0).min(440.0)); + let size = galley.size() + vec2(40.0, 16.0); + if !size.is_finite() || size.x > free.width() || size.y > free.height() { + return None; + } + let r = Rect::from_center_size(pos2(free.center().x, free.bottom() - size.y * 0.5), size); + if !r.is_finite() { + return None; + } + let frame = egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.border)).corner_radius(t.radius_md).shadow(t.shell_shadow()); + ui.painter().add(frame.paint(r.shrink(frame.stroke.width))); + let dot = pos2(r.left() + 14.0, r.center().y); + if computing { + egui::Spinner::new().size(12.0).color(t.text).paint_at(ui, Rect::from_center_size(dot, vec2(12.0, 12.0))); + } else { + painter.circle_filled(dot, 3.0, if error { t.error } else { t.text }); + } + painter.galley(r.min + vec2(28.0, 8.0), galley, t.text); + Some(r) } /// A click without a command: select the candidate (replacing the selection, or adding / @@ -3528,9 +3580,15 @@ pub(crate) fn refresh_layout(app: &mut SolveApp, scale: f32) { /// Orientation controls share the layout reservation used by model fitting. fn axis_center(app: &SolveApp, rect: Rect) -> Pos2 { - axis_rect(app, rect).center() + let c = axis_rect(app, rect).center(); + // The compass sits a little higher in its reserved box (less margin above, the same on the + // right); the box itself is unchanged. + if app.preferences.navigator_style == crate::prefs::NavigatorStyle::Axes { c - vec2(0.0, AXIS_CENTER_LIFT) } else { c } } +/// How far the signed-axis compass is lifted inside its reserved box. +pub(crate) const AXIS_CENTER_LIFT: f32 = 4.0; + pub(crate) fn axis_rect(app: &SolveApp, rect: Rect) -> Rect { app.viewport.layout.as_ref().filter(|l| l.viewport == rect).map(|l| l.axis_view).unwrap_or_else(|| { let size = axis_view_size(app.preferences.navigator_style); @@ -3539,6 +3597,8 @@ pub(crate) fn axis_rect(app: &SolveApp, rect: Rect) -> Rect { } const AXIS_LENGTH: f64 = 38.0; +/// The artwork sits inside the pointer reach so the compass keeps breathing room in its circle. +const AXIS_ART_SCALE: f64 = 0.84; use crate::icons::navigation::AXIS_RADIUS; /// Six signed world axes, sorted back to front so overlapping tips have one owner. @@ -3546,7 +3606,9 @@ fn axis_tips(cam: &Camera, c: Pos2) -> Vec<(&'static str, Vec3, Pos2, f64)> { let (r, u, b) = cam.basis(); let mut tips: Vec<_> = [("x", Vec3::X), ("-x", -Vec3::X), ("y", Vec3::Y), ("-y", -Vec3::Y), ("z", Vec3::Z), ("-z", -Vec3::Z)] .into_iter() - .map(|(name, d)| (name, d, c + vec2((d.dot(r) * AXIS_LENGTH) as f32, -(d.dot(u) * AXIS_LENGTH) as f32), d.dot(b))) + .map(|(name, d)| { + (name, d, c + vec2((d.dot(r) * AXIS_LENGTH * AXIS_ART_SCALE) as f32, -(d.dot(u) * AXIS_LENGTH * AXIS_ART_SCALE) as f32), d.dot(b)) + }) .collect(); tips.sort_by(|a, b| a.3.total_cmp(&b.3)); tips @@ -3596,7 +3658,7 @@ pub(crate) fn axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { } fn signed_axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { - if !axis_rect(app, rect).is_positive() { + if !axis_rect(app, rect).is_positive() || !axis_rect(app, rect).is_finite() { app.viewport.axis_drag = None; crate::scenario::publish_count("axis_view_highlights", 0.0); return; @@ -3609,10 +3671,15 @@ fn signed_axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let hover = ui.input(|i| i.pointer.hover_pos()).filter(|p| ui.is_enabled() && ui.rect_contains_pointer(rect) && !crate::shell::covered(app, *p)); let hovered = if orbiting { None } else { hover.and_then(|p| axis_hit(&tips, p)) }; let painter = ui.painter_at(rect); - if orbiting || hover.is_some_and(|p| axis_rect(app, rect).contains(p)) { - painter.circle_filled(c, 55.0, t.overlay); - } + // Anywhere in the compass circle drags the view; the circle turns fully opaque under the pointer. + let in_circle = hover.is_some_and(|p| p.distance(c) <= 49.5); + let fill = if in_circle || orbiting { 1.0 } else { 0.35 }; + painter.circle(c, 49.5, t.overlay.gamma_multiply(fill), Stroke::new(1.0, t.border.gamma_multiply(0.75))); for (i, (name, d, at, depth)) in tips.iter().enumerate() { + if i == 3 { + // The origin dot sits behind the three front axes and over the three back ones. + painter.circle(c, 3.6, t.text.gamma_multiply(0.9), Stroke::new(1.4, t.panel)); + } crate::icons::navigation::axis_tip(&painter, c, name, d, *at, *depth, hovered == Some(i), t); crate::scenario::publish_handle(&format!("axis_view:{name}"), *at); } @@ -3641,11 +3708,22 @@ fn signed_axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { } } } - let home = Rect::from_center_size(pos2(c.x - 44.0, c.y - 44.0), vec2(18.0, 18.0)); + let home = Rect::from_center_size(pos2(c.x - 37.0, c.y - 37.0), vec2(18.0, 18.0)); crate::scenario::publish_rect("axis_view:home", home); crate::scenario::publish_handle("axis_view:home", home.center()); let hr = ui.interact(home, ui.id().with("axis_view_home"), Sense::click()); + // The home button sits in its own small round chip. + painter.circle(home.center(), 12.0, t.overlay.gamma_multiply(if hr.hovered() { 0.9 } else { 0.5 }), Stroke::new(1.0, t.border)); icons::paint(&painter, home, "home", t.icon, if hr.hovered() { t.accent_soft } else { t.overlay }, t.accent); + // Cursors: a grabbing hand while orbiting, an open hand over the circle, a pointer on the home + // chip (the axis ends keep their pointing hand). + if hr.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } else if orbiting { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } else if in_circle && hovered.is_none() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } if hr.on_hover_text(crate::i18n::tr_label("Home view")).clicked() { app.animate_view("home"); } @@ -4278,17 +4356,19 @@ mod tests { for mode in [0, 1, 2] { crate::sketch_palette::set_mode(&mut h.app, mode); let ctx = egui::Context::default(); - let output = ctx.run_ui(egui::RawInput::default(), |ui| { + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { let painter = ui.ctx().layer_painter(egui::LayerId::background()); crate::tools::preview(&h.app, &draw, &painter, &proj); }); + output.textures_delta.clear(); let preview: f32 = output.shapes.iter().map(|s| length(&s.shape)).sum(); let r = h.app.run("sketch.line", json!({"points":[[0,0],[20,8]]})).unwrap(); let id = r["curves"][0].as_str().unwrap().to_string(); h.app.viewport.hover = Some(Hit::SketchCurve { sketch: h.app.session.active_sketch.unwrap(), id, straight: true }); - let output = ctx.run_ui(egui::RawInput::default(), |ui| { + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { hover_highlight(&h.app, &ui.ctx().layer_painter(egui::LayerId::background()), &proj); }); + output.textures_delta.clear(); let hover: f32 = output.shapes.iter().map(|s| length(&s.shape)).sum(); assert!((preview - hover).abs() < 0.01, "mode {mode}: preview {preview}, hover {hover}"); if mode == 0 { @@ -6005,44 +6085,50 @@ mod tests { #[test] fn floating_dialog_blocks_axis_view_highlights_and_clicks() { - let mut h = crate::scenario::Harness::new(); - h.call("ui.resize", json!({"width": 800, "height": 700})); - let p = crate::scenario::handle_at("axis_view:z").unwrap(); - h.call("ui.move", json!({"x": p.x, "y": p.y})); - assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(1.0)); - h.call("ui.prefs", json!({"open": "Navigation"})); - let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap(); - assert!(window.contains(p), "the floating dialog must cover the axis target"); - let camera = serde_json::to_value(h.app.cam).unwrap(); - h.call("ui.move", json!({"x": p.x, "y": p.y})); - assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(0.0)); - h.call("ui.click", json!({"x": p.x, "y": p.y})); - assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog clicks must not turn the axis view"); - h.call("ui.drag", json!({"x0": p.x, "y0": p.y, "x1": p.x + 20.0, "y1": p.y, "steps": 8})); - assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog drags must not orbit the axis gizmo"); - assert!(h.app.viewport.axis_drag.is_none()); - h.call("ui.prefs", json!({"button": "cancel"})); - h.call("ui.move", json!({"x": p.x, "y": p.y})); - assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(1.0)); - h.call("ui.click", json!({"x": p.x, "y": p.y})); - assert_ne!(serde_json::to_value(h.app.cam).unwrap(), camera); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + let mut h = crate::scenario::Harness::new(); + h.call("ui.set", json!({"layout":mode})); + h.call("ui.resize", json!({"width": 800, "height": 700})); + let p = crate::scenario::handle_at("axis_view:z").unwrap(); + h.call("ui.move", json!({"x": p.x, "y": p.y})); + assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(1.0)); + h.call("ui.prefs", json!({"open": "Navigation"})); + let window = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_prefs"))).unwrap(); + assert!(window.contains(p), "the floating dialog must cover the axis target"); + let camera = serde_json::to_value(h.app.cam).unwrap(); + h.call("ui.move", json!({"x": p.x, "y": p.y})); + assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(0.0)); + h.call("ui.click", json!({"x": p.x, "y": p.y})); + assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog clicks must not turn the axis view"); + h.call("ui.drag", json!({"x0": p.x, "y0": p.y, "x1": p.x + 20.0, "y1": p.y, "steps": 8})); + assert_eq!(serde_json::to_value(h.app.cam).unwrap(), camera, "dialog drags must not orbit the axis gizmo"); + assert!(h.app.viewport.axis_drag.is_none()); + h.call("ui.prefs", json!({"button": "cancel"})); + h.call("ui.move", json!({"x": p.x, "y": p.y})); + assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(1.0)); + h.call("ui.click", json!({"x": p.x, "y": p.y})); + assert_ne!(serde_json::to_value(h.app.cam).unwrap(), camera); + } } #[test] fn view_axis_drag_owns_navigation_and_keeps_the_view_centre() { - for nav in [None, Some(NavMode::Orbit), Some(NavMode::Pan)] { - let mut h = crate::scenario::Harness::new(); - h.call("ui.view", json!({"view": "front"})); - h.app.viewport.nav = nav; - let before = h.app.cam; - let p = crate::scenario::handle_at("axis_view:-y").unwrap(); - h.call("ui.drag", json!({"x0": p.x, "y0": p.y, "x1": p.x + 48.0, "y1": p.y, "steps": 8})); - assert!((h.app.cam.yaw - 48.0 * 0.008).abs() < 1e-6, "{nav:?}: axis drag must orbit exactly once"); - assert_eq!(h.app.cam.target, before.target, "{nav:?}: axis drag must not pan"); - assert_eq!(h.app.cam.distance, before.distance); - assert_eq!(h.app.cam.pitch, before.pitch); - assert!(h.app.viewport.axis_drag.is_none(), "release clears the axis gesture"); - assert!(h.app.viewport.boxsel.is_none()); + for mode in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + for nav in [None, Some(NavMode::Orbit), Some(NavMode::Pan)] { + let mut h = crate::scenario::Harness::new(); + h.call("ui.set", json!({"layout":mode})); + h.call("ui.view", json!({"view": "front"})); + h.app.viewport.nav = nav; + let before = h.app.cam; + let p = crate::scenario::handle_at("axis_view:-y").unwrap(); + h.call("ui.drag", json!({"x0": p.x, "y0": p.y, "x1": p.x + 48.0, "y1": p.y, "steps": 8})); + assert!((h.app.cam.yaw - 48.0 * 0.008).abs() < 1e-6, "{nav:?}: axis drag must orbit exactly once"); + assert_eq!(h.app.cam.target, before.target, "{nav:?}: axis drag must not pan"); + assert_eq!(h.app.cam.distance, before.distance); + assert_eq!(h.app.cam.pitch, before.pitch); + assert!(h.app.viewport.axis_drag.is_none(), "release clears the axis gesture"); + assert!(h.app.viewport.boxsel.is_none()); + } } } diff --git a/crates/ui-egui/src/viewport_layout.rs b/crates/ui-egui/src/viewport_layout.rs index 1e1c6d672d..cf04bc81da 100644 --- a/crates/ui-egui/src/viewport_layout.rs +++ b/crates/ui-egui/src/viewport_layout.rs @@ -13,7 +13,10 @@ pub(crate) struct Layout { } /// Enumerate rectangles whose edges meet the viewport or a bounded number of overlays. -fn free_rect(view: Rect, obstacles: &[Rect]) -> Rect { +pub(crate) fn free_rect(view: Rect, obstacles: &[Rect]) -> Rect { + if !view.is_finite() || !view.is_positive() { + return Rect::NOTHING; + } let mut xs = vec![view.left(), view.right()]; let mut ys = vec![view.top(), view.bottom()]; let obstacles: Vec<_> = obstacles.iter().take(16).map(|r| r.intersect(view)).filter(|r| r.is_positive() && r.is_finite()).collect(); diff --git a/crates/ui-egui/src/viewport_overlays_tests.rs b/crates/ui-egui/src/viewport_overlays_tests.rs new file mode 100644 index 0000000000..b07fbd424c --- /dev/null +++ b/crates/ui-egui/src/viewport_overlays_tests.rs @@ -0,0 +1,346 @@ +use super::*; +use crate::{scenario::Harness, shell::LayoutMode}; + +// These tests inspect shapes without submitting their texture uploads to a renderer. +fn draw(ctx: &egui::Context, paint: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { + let mut output = ctx.run_ui(egui::RawInput::default(), paint); + output.textures_delta.clear(); + output +} + +fn sketch_stroke(shape: &Shape, color: Color32) -> bool { + match shape { + Shape::LineSegment { stroke, .. } => stroke.color == color, + Shape::Path(path) => matches!(path.stroke.color, egui::epaint::ColorMode::Solid(c) if c == color), + Shape::Vec(shapes) => shapes.iter().any(|s| sketch_stroke(s, color)), + _ => false, + } +} + +#[test] +fn compass_keeps_signed_targets_hover_and_clicks_at_both_scales() { + for mode in [LayoutMode::Floating, LayoutMode::Docked] { + for scale in [1.0, 2.0] { + let mut h = Harness::new(); + h.call("ui.set", json!({"layout": mode})); + h.call("ui.prefs", json!({"ui_scale": scale})); + h.app.preferences.look_at_anim = false; + h.settle(); + let outer = crate::scenario::rect_of("axis_view:outer").unwrap(); + assert_eq!(outer.size(), vec2(116.0, 116.0)); + for (name, direction) in [("x", Vec3::X), ("-x", -Vec3::X), ("y", Vec3::Y), ("-y", -Vec3::Y), ("z", Vec3::Z), ("-z", -Vec3::Z)] { + h.call("ui.view", json!({"view":"home"})); + h.settle(); + let centre = outer.center() - vec2(0.0, crate::viewport::AXIS_CENTER_LIFT); + let tips = axis_tips(&h.app.cam, centre); + let (r, u, _) = h.app.cam.basis(); + let p = centre + vec2((direction.dot(r) * 38.0 * 0.84) as f32, -(direction.dot(u) * 38.0 * 0.84) as f32); + assert_eq!(crate::scenario::handle_at(&format!("axis_view:{name}")), Some(p)); + for offset in [vec2(0.0, 0.0), vec2(14.9, 0.0), vec2(15.1, 0.0)] { + let sample = p + offset; + let expected = tips.iter().enumerate().rev().find(|(_, tip)| tip.2.distance(sample) <= 15.0).map(|(i, _)| i); + assert_eq!(axis_hit(&tips, sample), expected); + h.call("ui.move", json!({"x":sample.x,"y":sample.y})); + assert_eq!(crate::scenario::count_of("axis_view_highlights"), Some(f64::from(u8::from(expected.is_some())))); + if let Some(i) = expected { + assert!(crate::scenario::handle_at(&format!("axis_view_hover:{}", tips[i].0)).is_some()); + } + } + h.call("ui.click", json!({"x":p.x,"y":p.y})); + assert!(h.app.cam.back().dist(direction) < 1e-5, "{mode:?}, {scale}, {name}"); + let p = crate::scenario::handle_at(&format!("axis_view:{name}")).unwrap(); + h.call("ui.click", json!({"x":p.x,"y":p.y})); + assert!(h.app.cam.back().dist(-direction) < 1e-5); + assert_eq!(crate::scenario::rect_of("axis_view:outer"), Some(outer)); + } + } + } +} + +#[test] +fn compass_negative_ends_are_hollow_and_positive_ends_are_chunkier() { + for dark in [false, true] { + let t = Tokens::themed(dark, crate::theme::ColorVision::Off); + for (name, direction, positive) in [("x", Vec3::X, true), ("-x", -Vec3::X, false)] { + let ctx = egui::Context::default(); + let c = pos2(100.0, 100.0); + let original = c + vec2(38.0, 0.0); + let painted = c + (original - c) * 0.84; + let output = draw(&ctx, |ui| { + crate::icons::navigation::axis_tip(ui.painter(), c, name, &direction, painted, 0.5, false, t); + }); + let circles: Vec<_> = output.shapes.iter().filter_map(|s| if let Shape::Circle(c) = &s.shape { Some(c) } else { None }).collect(); + let end = circles.iter().find(|circle| circle.center == painted).unwrap(); + if positive { + assert_eq!(end.fill, t.triad[0]); + assert_eq!(end.radius, 11.0); + assert!(output.shapes.iter().any(|s| matches!(&s.shape, Shape::Text(text) if text.galley.text() == "X"))); + } else { + assert_eq!(end.fill, Color32::TRANSPARENT); + assert_eq!(end.stroke.width, 2.4); + assert_eq!(end.radius, 4.8); + assert!(!output.shapes.iter().any(|s| matches!(&s.shape, Shape::Text(_)))); + } + for shape in &output.shapes { + if let Shape::LineSegment { points, stroke } = &shape.shape { + assert!(points[1].distance(painted) >= end.radius - 0.001, "shaft must stop at the ring"); + assert!(stroke.width >= 2.5); + } + } + assert!(painted.distance(c) + end.radius < 49.5 - 3.0); + } + } +} + +#[test] +fn status_toast_keeps_the_messages_handle_and_opens_the_message_history() { + for mode in [LayoutMode::Floating, LayoutMode::Docked] { + for error in [false, true] { + for sketch in [false, true] { + let mut h = Harness::new(); + h.call("ui.set", json!({"layout": mode})); + if sketch { + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.app.start("sketch.line"); + } + h.app.set_status("Viewport message", error); + h.frames(2); + let toast = crate::scenario::rect_of("toast").unwrap(); + let chip = crate::scenario::handle_at("status:chip").unwrap(); + assert_eq!(chip, toast.center()); + h.call("ui.click", json!({"x": chip.x, "y": chip.y})); + h.frames(2); + assert!(h.app.messages.open, "{mode:?}, error={error}: the toast opens Messages"); + assert!(crate::scenario::handle_at("messages:copy").is_some()); + let out = h.call("ui.messages", json!({})); + let message = out["result"]["messages"].as_array().unwrap().last().unwrap(); + assert_eq!(message["text"], "Viewport message"); + assert_eq!(message["error"], error); + } + } + } +} + +#[test] +fn toast_stays_in_free_content_above_history_and_navigation() { + for mode in [LayoutMode::Floating, LayoutMode::Docked] { + for scale in [1.0, 2.0] { + let mut h = Harness::new(); + h.call("ui.set", json!({"layout": mode})); + h.call("ui.prefs", json!({"ui_scale": scale})); + h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap(); + for (width, height) in [(1400, 900), (900, 720), (700, 475), (320, 240), (1, 1)] { + h.call("ui.resize", json!({"width":width,"height":height})); + for dialog in [false, true] { + if dialog { + h.app.start("solid.fillet"); + } else { + h.app.dialog = None; + } + h.app.status = Some(("Viewport message".into(), crate::now_ms(), false)); + h.frames(3); + let layout = h.app.viewport.layout.as_ref().unwrap(); + let content = crate::shell::content_rect(&h.app, layout.viewport); + let mut obstacles = crate::shell::covered_rects(&h.app); + obstacles.extend([layout.navigation, layout.axis_view]); + let free = crate::viewport_layout::free_rect(content, &obstacles).shrink(Tokens::get().space_8); + if let Some(toast) = crate::scenario::rect_of("toast") { + assert!(toast.is_finite() && toast.is_positive()); + assert!(content.contains_rect(toast), "{mode:?}, {scale}, {width}x{height}: {toast:?}"); + for obstacle in obstacles { + assert!(!toast.intersect(obstacle).is_positive(), "{toast:?} intersects {obstacle:?}"); + } + assert!((toast.center().x - free.center().x).abs() < 0.01); + assert!((toast.bottom() - free.bottom()).abs() < 0.01); + } else { + assert!(free.width() < 200.0 || free.height() < 40.0, "toast unexpectedly absent: {free:?}"); + } + } + } + h.app.status = Some(("Expired".into(), crate::now_ms() - 6001.0, true)); + h.frame(); + assert!(h.app.status.is_none()); + assert!(crate::scenario::rect_of("toast").is_none()); + } + } +} + +#[test] +fn toast_card_uses_shell_tokens_wraps_and_rejects_hostile_rects() { + let ctx = egui::Context::default(); + let t = Tokens::get(); + let free = Rect::from_min_size(pos2(30.0, 30.0), vec2(200.0, 200.0)); + for (error, computing) in [(false, false), (true, false), (false, true)] { + let output = draw(&ctx, |ui| { + let r = paint_toast(ui, free, "A message that wraps across several short lines".into(), error, computing).unwrap(); + assert!(free.contains_rect(r)); + assert!(r.height() > 35.0); + for bad in [ + Rect::NOTHING, + Rect::EVERYTHING, + Rect::from_min_size(Pos2::ZERO, vec2(1.0, 1.0)), + Rect::from_min_size(Pos2::ZERO, vec2(41.0, 1.0)), + Rect::from_min_max(pos2(f32::MAX * 0.75, 0.0), pos2(f32::MAX, 100.0)), + Rect::from_min_size(Pos2::ZERO, vec2(f32::NAN, 90.0)), + ] { + assert!(paint_toast(ui, bad, "Message".into(), error, computing).is_none()); + } + }); + fn card(shape: &Shape, t: Tokens) -> bool { + match shape { + Shape::Rect(r) => r.fill == t.panel && r.stroke.color == t.border && r.corner_radius == t.radius_md.into(), + Shape::Vec(shapes) => shapes.iter().any(|s| card(s, t)), + _ => false, + } + } + assert!(output.shapes.iter().any(|s| card(&s.shape, t))); + if !computing { + assert!( + output + .shapes + .iter() + .any(|s| matches!(&s.shape, Shape::Circle(c) if c.radius == 3.0 && c.fill == if error { t.error } else { t.text })) + ); + } + } +} + +#[test] +fn sketch_previews_selection_band_and_number_focus_follow_sketch_accent() { + for mode in [LayoutMode::Floating, LayoutMode::Docked] { + for dark in [false, true] { + let mut h = Harness::new(); + h.call("ui.set", json!({"layout":mode})); + h.call("ui.prefs", json!({"theme":if dark {"dark"} else {"light"}})); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + h.settle(); + let proj = display_proj(&h.app); + let t = Tokens::get(); + for cmd in ["sketch.line", "sketch.rectangle.two_point", "sketch.circle.center", "sketch.spline.fit_point"] { + let mut tool = crate::tools::Tool::for_command(cmd).unwrap(); + tool.pts.push((Vec2::ZERO, None)); + tool.hover = Some((Vec2::new(20.0, 8.0), None)); + let output = draw(&h.ctx, |ui| crate::tools::preview(&h.app, &tool, ui.painter(), &proj)); + assert!( + output.shapes.iter().any(|s| match &s.shape { + Shape::LineSegment { stroke, .. } => stroke.color == t.sketch_accent, + Shape::Path(p) => matches!(p.stroke.color, egui::epaint::ColorMode::Solid(color) if color == t.sketch_accent), + _ => false, + }), + "{cmd}: no Sketch preview stroke" + ); + } + let mut extended = crate::tools::Tool::for_command("sketch.line").unwrap(); + extended.kind = crate::tools::Kind::Ext; + extended.pts.push((Vec2::ZERO, None)); + extended.hover = Some((Vec2::new(20.0, 8.0), None)); + let output = draw(&h.ctx, |ui| crate::sketch_tools::preview(&h.app, &extended, ui.painter(), &proj)); + assert!(output.shapes.iter().any(|s| sketch_stroke(&s.shape, t.sketch_accent))); + for sketch in [false, true] { + for crossing in [false, true] { + let bx = BoxSel { rect: Rect::from_min_size(pos2(20.0, 20.0), vec2(100.0, 80.0)), crossing }; + let output = draw(&h.ctx, |ui| paint_box_selection(ui.painter(), bx, sketch)); + let color = if sketch { + t.sketch_accent + } else if crossing { + t.box_crossing + } else { + t.box_window + }; + assert!(output.shapes.iter().any(|s| match &s.shape { + Shape::Rect(r) => r.stroke.color == color, + Shape::Vec(shapes) => shapes.iter().any(|s| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == color)), + _ => false, + })); + } + } + h.app.ui.tab = "MODEL".into(); // A sketch remains open when another ribbon tab is selected. + h.app.tool = crate::tools::Tool::for_command("sketch.line"); + crate::theme::TabAccent::publish(&h.app, &h.ctx); + let output = draw(&h.ctx, |ui| { + let r = ui.add(egui::TextEdit::singleline(&mut String::from("20"))); + r.request_focus(); + crate::theme::TabAccent::number_focus(ui, &r); + }); + assert!(output.shapes.iter().any(|s| matches!(&s.shape, Shape::Rect(r) if r.stroke.color == t.sketch_accent))); + } + } +} + +#[test] +fn three_dimensional_sketch_rubber_band_uses_sketch_accent() { + for mode in [LayoutMode::Floating, LayoutMode::Docked] { + let mut h = Harness::new(); + h.call("ui.set", json!({"layout": mode})); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.options", json!({"sketch_3d":true})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + h.app.start("sketch.line"); + h.settle(); + h.step(&json!({"click":{"world":[0,0,0]}})).unwrap(); + h.step(&json!({"move":{"world":[20,8,0]}})).unwrap(); + let proj = display_proj(&h.app); + let output = draw(&h.ctx, |ui| { + assert!(crate::sketch3d::preview(&h.app, h.app.tool.as_ref().unwrap(), ui.painter(), &proj)); + }); + assert!(output.shapes.iter().any(|s| sketch_stroke(&s.shape, Tokens::get().sketch_accent))); + } +} + +#[test] +fn a_hovered_compass_end_turns_white_and_its_letter_takes_the_axis_colour() { + for dark in [false, true] { + let t = Tokens::themed(dark, crate::theme::ColorVision::Off); + for (name, direction, positive) in [("x", Vec3::X, true), ("-x", -Vec3::X, false)] { + let ctx = egui::Context::default(); + let c = pos2(100.0, 100.0); + let original = c + vec2(38.0, 0.0); + let painted = c + (original - c) * 0.84; + let output = draw(&ctx, |ui| { + crate::icons::navigation::axis_tip(ui.painter(), c, name, &direction, painted, 0.5, true, t); + }); + let circles: Vec<_> = output.shapes.iter().filter_map(|s| if let Shape::Circle(c) = &s.shape { Some(c) } else { None }).collect(); + assert!(circles.iter().any(|circle| circle.center == painted && circle.fill == t.on_accent), "{name}: the hovered end is filled white"); + if !dark { + // White would vanish on the light compass: an axis-coloured outline keeps it visible. + assert!( + circles.iter().any(|circle| circle.center == painted && circle.stroke.color == t.triad[0] && circle.stroke.width >= 2.0), + "{name}: the hovered end is outlined in its axis colour on the light theme" + ); + } + let letters: Vec<_> = output.shapes.iter().filter_map(|s| if let Shape::Text(text) = &s.shape { Some(text) } else { None }).collect(); + if positive { + assert!( + !letters.is_empty() + && letters.iter().all(|text| text.override_text_color == Some(t.triad[0]) + || text.galley.job.sections.iter().all(|s| s.format.color == t.triad[0])), + "the letter takes the axis colour" + ); + } else { + assert!(letters.is_empty()); + } + } + } +} + +#[test] +fn compass_circle_grabs_on_hover_and_the_home_chip_points() { + let mut h = Harness::new(); + h.call("ui.set", json!({"layout": "floating"})); + h.settle(); + let outer = crate::scenario::rect_of("axis_view:outer").unwrap(); + let centre = outer.center() - vec2(0.0, crate::viewport::AXIS_CENTER_LIFT); + // A point inside the circle that is clear of every axis end and of the home chip. + let probe = centre + vec2(-36.0, 30.0); + h.call("ui.move", json!({"x": probe.x, "y": probe.y})); + h.settle(); + assert_eq!(h.output.cursor_icon, egui::CursorIcon::Grab, "an open hand over the circle"); + let home = crate::scenario::handle_at("axis_view:home").unwrap(); + h.call("ui.move", json!({"x": home.x, "y": home.y})); + h.settle(); + assert_eq!(h.output.cursor_icon, egui::CursorIcon::PointingHand, "a pointer on the home chip"); + h.call("ui.move", json!({"x": centre.x - 200.0, "y": centre.y + 200.0})); + h.settle(); + assert_ne!(h.output.cursor_icon, egui::CursorIcon::Grab, "no hand away from the compass"); +} From 1122cd44b8f7c5c0c7703f118977484110b5c44a Mon Sep 17 00:00:00 2001 From: Nathan Dane Date: Tue, 20 Oct 2026 09:55:48 +0200 Subject: [PATCH 04/16] UI library, tokens, fonts and icons (step 5 of #2016, after #2287) --- ATTRIBUTION.md | 8 + Cargo.lock | 1 + .../ibm-plex-mono/IBMPlexMono-Regular.ttf | Bin 0 -> 135580 bytes assets/fonts/ibm-plex-mono/OFL.txt | 93 ++ .../ibm-plex-sans/IBMPlexSans-Variable.ttf | Bin 0 -> 537244 bytes assets/fonts/ibm-plex-sans/OFL.txt | 93 ++ assets/icons/ui/AGENTS.md | 6 + assets/icons/ui/README.md | 29 +- assets/icons/ui/STYLE_GUIDE.md | 6 + assets/icons/ui/design/aliases.toml | 8 + assets/icons/ui/design/palette.toml | 48 +- assets/icons/ui/design/requested_icons.txt | 282 ++++ assets/icons/ui/design/review.json | 88 +- .../ui/design/svg/flat/chevron_right.svg | 4 + assets/icons/ui/design/svg/model/axis.svg | 2 +- .../design/svg/model/axis_normal_to_face.svg | 2 +- assets/icons/ui/design/svg/model/break.svg | 2 +- .../ui/design/svg/model/c_coincident.svg | 4 +- assets/icons/ui/design/svg/model/c_fix.svg | 2 +- .../icons/ui/design/svg/model/c_midpoint.svg | 2 +- .../ui/design/svg/model/c_perpendicular.svg | 2 +- .../icons/ui/design/svg/model/c_polygon.svg | 4 +- assets/icons/ui/design/svg/model/c_smooth.svg | 4 +- .../icons/ui/design/svg/model/c_tangent.svg | 2 +- .../icons/ui/design/svg/model/centerline.svg | 10 +- .../ui/design/svg/model/construction.svg | 7 +- .../icons/ui/design/svg/model/dimension.svg | 2 +- assets/icons/ui/design/svg/model/dots.svg | 6 +- assets/icons/ui/design/svg/model/graph.svg | 2 +- assets/icons/ui/design/svg/model/infinite.svg | 10 +- .../ui/design/svg/model/joint_origin.svg | 2 +- .../icons/ui/design/svg/model/motion_link.svg | 4 +- assets/icons/ui/design/svg/model/normal.svg | 2 +- .../ui/design/svg/model/offset_symmetric.svg | 6 + .../ui/design/svg/model/offset_two_sides.svg | 6 + assets/icons/ui/design/svg/model/origin.svg | 8 +- .../ui/design/svg/model/plane_along_path.svg | 4 +- .../design/svg/model/plane_perpendicular.svg | 2 +- .../ui/design/svg/model/plane_two_edges.svg | 4 +- .../ui/design/svg/model/point_along_path.svg | 2 +- .../ui/design/svg/model/point_edge_plane.svg | 4 +- .../ui/design/svg/model/point_two_edges.svg | 2 +- assets/icons/ui/design/svg/model/select.svg | 2 +- .../icons/ui/design/svg/model/sheet_rules.svg | 2 +- .../design/svg/model/sketch_chamfer_angle.svg | 2 +- .../model/sketch_chamfer_two_distances.svg | 2 +- assets/icons/ui/design/svg/model/thicken.svg | 10 +- .../ui/design/svg/workspace/ws_animate.svg | 4 +- assets/icons/ui/design/tools/icon_status.py | 26 +- assets/icons/ui/design/tools/lint_icons.py | 32 +- assets/icons/ui/design/tools/preview.py | 2 +- .../icons/ui/design/tools/review/guide.html | 2 +- .../icons/ui/design/tools/review/review.css | 5 +- assets/icons/ui/source/markers.rs | 2 +- assets/icons/ui/source/navigation.rs | 15 +- assets/icons/ui/source/pointer.rs | 2 +- assets/locales/cs.json | 14 + assets/locales/de.json | 14 + assets/locales/en.json | 14 + assets/locales/es.json | 14 + assets/locales/fr.json | 14 + assets/locales/gl.json | 14 + assets/locales/it.json | 15 +- assets/locales/ja.json | 14 + assets/locales/ko.json | 14 + assets/locales/ms.json | 1 + assets/locales/pl.json | 14 + assets/locales/pt-BR.json | 14 + assets/locales/ru.json | 14 + assets/locales/tr.json | 14 + assets/locales/uk.json | 14 + assets/locales/zh.json | 14 + assets/styles/ui/palettes.rs | 76 + crates/ui-egui/Cargo.toml | 4 + crates/ui-egui/build.rs | 1 + .../ui-egui/src/agent_chat/markdown_view.rs | 28 +- crates/ui-egui/src/agent_chat/panel.rs | 159 ++- crates/ui-egui/src/agent_chat/settings.rs | 40 +- crates/ui-egui/src/agent_chat/tests.rs | 10 +- crates/ui-egui/src/bom_panel.rs | 2 +- crates/ui-egui/src/browser.rs | 55 +- crates/ui-egui/src/browser_card_tests.rs | 73 +- crates/ui-egui/src/canvas.rs | 6 +- crates/ui-egui/src/colour_tokens_tests.rs | 25 +- crates/ui-egui/src/colour_vision_tests.rs | 40 +- crates/ui-egui/src/configuration_rules_ui.rs | 3 +- crates/ui-egui/src/context_menu.rs | 201 ++- crates/ui-egui/src/control.rs | 21 +- crates/ui-egui/src/crash_ui.rs | 13 +- crates/ui-egui/src/design_properties.rs | 8 +- crates/ui-egui/src/dialogs.rs | 1267 +++++------------ crates/ui-egui/src/dialogs_all_tests.rs | 322 +++++ crates/ui-egui/src/dialogs_assembly.rs | 230 +-- crates/ui-egui/src/dialogs_assembly_tests.rs | 6 +- crates/ui-egui/src/dialogs_cam.rs | 42 +- crates/ui-egui/src/dialogs_cam_tests.rs | 2 +- crates/ui-egui/src/dialogs_coupling.rs | 77 +- crates/ui-egui/src/dialogs_coupling_tests.rs | 4 +- crates/ui-egui/src/dialogs_dense.rs | 1110 +++++++++++++++ crates/ui-egui/src/dialogs_library.rs | 4 +- crates/ui-egui/src/dialogs_library_tests.rs | 8 +- crates/ui-egui/src/dialogs_motion.rs | 16 +- crates/ui-egui/src/dialogs_motion_plot.rs | 2 +- crates/ui-egui/src/dialogs_move.rs | 14 +- crates/ui-egui/src/dialogs_plastic.rs | 95 +- crates/ui-egui/src/dialogs_save_mesh.rs | 12 +- crates/ui-egui/src/dialogs_sheet.rs | 210 +-- crates/ui-egui/src/dialogs_sheet_tests.rs | 20 +- crates/ui-egui/src/dialogs_sketch_tests.rs | 209 +++ crates/ui-egui/src/dialogs_surface.rs | 181 ++- crates/ui-egui/src/dialogs_sweep.rs | 37 +- crates/ui-egui/src/dialogs_thread.rs | 106 +- crates/ui-egui/src/dialogs_trace.rs | 65 +- crates/ui-egui/src/dim_view.rs | 5 +- crates/ui-egui/src/documents.rs | 25 +- crates/ui-egui/src/drag_snap.rs | 2 +- crates/ui-egui/src/expression_complete.rs | 2 +- crates/ui-egui/src/file_load.rs | 16 +- crates/ui-egui/src/file_load_ui_tests.rs | 2 +- crates/ui-egui/src/file_picker.rs | 2 +- crates/ui-egui/src/font_literals_tests.rs | 188 +++ crates/ui-egui/src/fonts.rs | 399 ++++-- crates/ui-egui/src/frame_rate.rs | 2 +- crates/ui-egui/src/help.rs | 29 +- crates/ui-egui/src/home.rs | 19 +- crates/ui-egui/src/i18n_ui_tests.rs | 14 +- crates/ui-egui/src/icon_svg.rs | 54 + crates/ui-egui/src/icons.rs | 16 +- crates/ui-egui/src/issue_report.rs | 13 +- crates/ui-egui/src/keymap.rs | 7 +- crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/material_card_review.rs | 9 +- crates/ui-egui/src/navigation.rs | 9 +- crates/ui-egui/src/navigation_cube.rs | 18 +- crates/ui-egui/src/palette.rs | 4 +- crates/ui-egui/src/params_dialog.rs | 119 +- crates/ui-egui/src/plugins.rs | 14 +- crates/ui-egui/src/prefs.rs | 18 +- crates/ui-egui/src/preselect_tests.rs | 12 +- crates/ui-egui/src/preview.rs | 2 +- crates/ui-egui/src/primitive_units_tests.rs | 6 +- crates/ui-egui/src/recovery_ui.rs | 56 +- crates/ui-egui/src/render_workspace.rs | 8 +- crates/ui-egui/src/render_workspace_tests.rs | 12 +- crates/ui-egui/src/revolve_edit_tests.rs | 2 +- .../ui-egui/src/revolve_manipulator_tests.rs | 2 +- crates/ui-egui/src/scenario.rs | 10 +- crates/ui-egui/src/select_other.rs | 16 +- crates/ui-egui/src/shell.rs | 2 +- crates/ui-egui/src/shell_tests.rs | 11 +- crates/ui-egui/src/shortcut_box.rs | 14 +- crates/ui-egui/src/sketch3d.rs | 10 +- crates/ui-egui/src/sketch3d_frame_ui_tests.rs | 5 +- crates/ui-egui/src/sketch_attachment_ui.rs | 269 ++-- .../ui-egui/src/sketch_attachment_ui_tests.rs | 33 +- crates/ui-egui/src/sketch_drag_ui_tests.rs | 12 +- crates/ui-egui/src/sketch_modify_ui.rs | 170 +-- crates/ui-egui/src/sketch_modify_ui_tests.rs | 6 +- crates/ui-egui/src/sketch_palette.rs | 666 ++++----- crates/ui-egui/src/sketch_text.rs | 174 +-- crates/ui-egui/src/sketch_text_handles.rs | 2 +- crates/ui-egui/src/sketch_text_move.rs | 14 +- crates/ui-egui/src/sketch_tools.rs | 30 +- crates/ui-egui/src/space_mouse.rs | 2 +- crates/ui-egui/src/sweep_dialog_tests.rs | 2 + crates/ui-egui/src/theme.rs | 83 +- crates/ui-egui/src/theme_files.rs | 14 +- crates/ui-egui/src/timeline.rs | 22 +- crates/ui-egui/src/timeline_left.rs | 37 +- crates/ui-egui/src/timeline_marks_tests.rs | 2 +- crates/ui-egui/src/titlebar.rs | 2 +- crates/ui-egui/src/toolbar.rs | 175 ++- crates/ui-egui/src/toolbar_custom.rs | 17 +- crates/ui-egui/src/tools.rs | 4 +- crates/ui-egui/src/ui_kit/actions.rs | 250 ++++ crates/ui-egui/src/ui_kit/actions_tests.rs | 74 + crates/ui-egui/src/ui_kit/choice.rs | 297 ++++ crates/ui-egui/src/ui_kit/choice_tests.rs | 45 + crates/ui-egui/src/ui_kit/fields.rs | 248 ++++ crates/ui-egui/src/ui_kit/fields_tests.rs | 78 + crates/ui-egui/src/ui_kit/gallery.rs | 432 ++++++ crates/ui-egui/src/ui_kit/layout.rs | 200 +++ crates/ui-egui/src/ui_kit/layout_tests.rs | 104 ++ crates/ui-egui/src/ui_kit/metrics.rs | 59 + crates/ui-egui/src/ui_kit/metrics_tests.rs | 23 + crates/ui-egui/src/ui_kit/mod.rs | 26 + crates/ui-egui/src/ui_kit/panel.rs | 257 ++++ crates/ui-egui/src/ui_kit/panel_tests.rs | 194 +++ crates/ui-egui/src/ui_kit/status.rs | 47 + .../ui-egui/src/ui_kit/test_support_tests.rs | 51 + crates/ui-egui/src/ui_kit/text.rs | 21 + crates/ui-egui/src/ui_kit_guard_tests.rs | 140 ++ crates/ui-egui/src/update.rs | 47 +- crates/ui-egui/src/viewport.rs | 44 +- crates/ui-egui/src/viewport_overlays_tests.rs | 20 +- crates/ui-egui/tests/icon_audit.rs | 469 ++++++ .../tests/scenarios/s18_3d_sketch_pipe.json | 1 + .../tests/scenarios/s30_pattern_a_hole.json | 3 +- .../scenarios/s35_construction_mode.json | 6 +- .../scenarios/s36_sketch_palette_visible.json | 17 +- .../tests/scenarios/s37_browser_chevrons.json | 4 + .../s37_construction_geometries_toggle.json | 1 + .../s49_sheet_metal_flange_per_edge.json | 1 + .../sketch_dialog_keeps_the_palette.json | 11 +- .../sketch_palette_linetype_icons.json | 6 +- .../scenarios/sketch_pattern_rect_window.json | 4 +- docs/book/src/SUMMARY.md | 1 + docs/book/src/dev/ui-kit.md | 1 + docs/control-protocol.md | 5 +- docs/fonts.md | 42 + docs/sketch-tool-panel-review.md | 214 +++ docs/ui-kit.md | 110 ++ tests/tools/test_icon_review.py | 18 + xtask/src/licences.rs | 8 +- 214 files changed, 9436 insertions(+), 2937 deletions(-) create mode 100644 assets/fonts/ibm-plex-mono/IBMPlexMono-Regular.ttf create mode 100644 assets/fonts/ibm-plex-mono/OFL.txt create mode 100644 assets/fonts/ibm-plex-sans/IBMPlexSans-Variable.ttf create mode 100644 assets/fonts/ibm-plex-sans/OFL.txt create mode 100644 assets/icons/ui/design/requested_icons.txt create mode 100644 assets/icons/ui/design/svg/flat/chevron_right.svg create mode 100644 assets/icons/ui/design/svg/model/offset_symmetric.svg create mode 100644 assets/icons/ui/design/svg/model/offset_two_sides.svg create mode 100644 crates/ui-egui/src/dialogs_all_tests.rs create mode 100644 crates/ui-egui/src/dialogs_dense.rs create mode 100644 crates/ui-egui/src/dialogs_sketch_tests.rs create mode 100644 crates/ui-egui/src/font_literals_tests.rs create mode 100644 crates/ui-egui/src/ui_kit/actions.rs create mode 100644 crates/ui-egui/src/ui_kit/actions_tests.rs create mode 100644 crates/ui-egui/src/ui_kit/choice.rs create mode 100644 crates/ui-egui/src/ui_kit/choice_tests.rs create mode 100644 crates/ui-egui/src/ui_kit/fields.rs create mode 100644 crates/ui-egui/src/ui_kit/fields_tests.rs create mode 100644 crates/ui-egui/src/ui_kit/gallery.rs create mode 100644 crates/ui-egui/src/ui_kit/layout.rs create mode 100644 crates/ui-egui/src/ui_kit/layout_tests.rs create mode 100644 crates/ui-egui/src/ui_kit/metrics.rs create mode 100644 crates/ui-egui/src/ui_kit/metrics_tests.rs create mode 100644 crates/ui-egui/src/ui_kit/mod.rs create mode 100644 crates/ui-egui/src/ui_kit/panel.rs create mode 100644 crates/ui-egui/src/ui_kit/panel_tests.rs create mode 100644 crates/ui-egui/src/ui_kit/status.rs create mode 100644 crates/ui-egui/src/ui_kit/test_support_tests.rs create mode 100644 crates/ui-egui/src/ui_kit/text.rs create mode 100644 crates/ui-egui/src/ui_kit_guard_tests.rs create mode 100644 crates/ui-egui/tests/icon_audit.rs create mode 100644 docs/book/src/dev/ui-kit.md create mode 100644 docs/fonts.md create mode 100644 docs/sketch-tool-panel-review.md create mode 100644 docs/ui-kit.md diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 4b936d05ed..c4afb4b30d 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -52,6 +52,8 @@ Editable drawing code, native projects and shipped exports are collected under ` | docs/book/src/generated/enclosure.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 | | docs/book/src/generated/revolve.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/revolve.json`; gitignored) | MIT OR Apache-2.0 | | docs/book/src/generated/sheet-metal.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/sheet-metal.json`; gitignored) | MIT OR Apache-2.0 | +| assets/fonts/ibm-plex-mono/IBMPlexMono-Regular.ttf | IBM Corp. (IBM Plex Mono) | https://github.com/IBM/plex (via google/fonts), unmodified; licence text in assets/fonts/ibm-plex-mono/OFL.txt; the interface monospace font | SIL OFL 1.1 | +| assets/fonts/ibm-plex-sans/IBMPlexSans-Variable.ttf | IBM Corp. (IBM Plex Sans, variable wdth and wght) | https://github.com/IBM/plex (via google/fonts), unmodified; licence text in assets/fonts/ibm-plex-sans/OFL.txt; the default interface font | SIL OFL 1.1 | | docs/book/src/generated/tool-preview.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/tool-preview.json`; gitignored) | MIT OR Apache-2.0 | | assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 | | assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in assets/fonts/biz-udp-gothic/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 | @@ -78,6 +80,8 @@ Editable drawing code, native projects and shipped exports are collected under ` | assets/appearances/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 | | assets/fonts/biz-udp-gothic/OFL.txt | The BIZ UDGothic Project Authors | Unmodified accompanying font licence | SIL OFL 1.1 | +| assets/fonts/ibm-plex-mono/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 | +| assets/fonts/ibm-plex-sans/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 | | assets/fonts/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 | | assets/icons/app/linux/exports/hicolor/128x128/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 | | assets/icons/app/linux/exports/hicolor/16x16/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 | @@ -137,6 +141,7 @@ Editable drawing code, native projects and shipped exports are collected under ` | assets/icons/ui/design/palette.toml | SolidCraft contributors | icon colour roles (light and dark) and accents, original SolidCraft data | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/flat/bug_report.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/flat/chevron.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/flat/chevron_right.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/flat/close.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/flat/disclosure_closed.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/flat/disclosure_open.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | @@ -287,6 +292,8 @@ Editable drawing code, native projects and shipped exports are collected under ` | assets/icons/ui/design/svg/model/silhouette.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/flat/assistant.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/flat/agent_link.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/offset_two_sides.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/offset_symmetric.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/model/placeholder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/model/plane.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/model/plane_angle.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | @@ -422,6 +429,7 @@ Editable drawing code, native projects and shipped exports are collected under ` | assets/icons/ui/design/tools/lint_icons.py | SolidCraft contributors | icon lint, status page or preview tool (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 | | assets/icons/ui/design/tools/preview.py | SolidCraft contributors | icon lint, status page or preview tool (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 | | assets/icons/ui/design/tools/review_state.py | SolidCraft contributors | icon review state helper (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 | +| assets/icons/ui/design/requested_icons.txt | SolidCraft contributors | Runtime icon requests collected by the headless menu audit, original SolidCraft data | MIT OR Apache-2.0 | | assets/icons/ui/design/review.json | SolidCraft contributors | icon review state (accepted icons and notes), original SolidCraft data | MIT OR Apache-2.0 | | assets/icons/ui/design/tools/author/lib.py | SolidCraft contributors | icon authoring script (standard-library Python), original SolidCraft code | MIT OR Apache-2.0 | diff --git a/Cargo.lock b/Cargo.lock index f3891452ce..3860bdafc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4379,6 +4379,7 @@ dependencies = [ "solidcraft-engine", "solidcraft-gpu", "solidcraft-space-mouse", + "syn 2.0.119", "tiny-skia", "toml", "ttf-parser", diff --git a/assets/fonts/ibm-plex-mono/IBMPlexMono-Regular.ttf b/assets/fonts/ibm-plex-mono/IBMPlexMono-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..0c9770d5183ba60dc4350d3e011b782a320761ae GIT binary patch literal 135580 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 K5Q|D9=l=lr4cw*x literal 0 HcmV?d00001 diff --git a/assets/fonts/ibm-plex-mono/OFL.txt b/assets/fonts/ibm-plex-mono/OFL.txt new file mode 100644 index 0000000000..c35c4c618f --- /dev/null +++ b/assets/fonts/ibm-plex-mono/OFL.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/ibm-plex-sans/IBMPlexSans-Variable.ttf b/assets/fonts/ibm-plex-sans/IBMPlexSans-Variable.ttf new file mode 100644 index 0000000000000000000000000000000000000000..30f837e8b02e8ecddce6cbebc425b1f422392515 GIT binary patch literal 537244 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 z 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�P!LDx*h6AXUzQrScybn#${#HP!+ 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, one per line). After deliberately +changing menu coverage or assignments, regenerate and review its diff: + +```sh +UPDATE_ICON_MANIFEST=1 cargo test -p solidcraft-ui-egui --test icon_audit +``` + +The review page includes every manifest id, even ids used only in tables or helper +arguments. A true placeholder flag, missing master or broken alias is reported as +missing; runtime collection never changes acceptance hashes. The live control +method `ui.icon_audit` can diagnose requests in a running UI (see +[control protocol](../../../docs/control-protocol.md)). Keep the Harness traversal +current when adding menus. A successful traversal covers only the contexts it +actually opens; document unreachable or untested contexts in the audit report. diff --git a/assets/icons/ui/STYLE_GUIDE.md b/assets/icons/ui/STYLE_GUIDE.md index 00fa94798e..33c91585af 100644 --- a/assets/icons/ui/STYLE_GUIDE.md +++ b/assets/icons/ui/STYLE_GUIDE.md @@ -207,3 +207,9 @@ All artwork must be original SolidCraft work or an explicitly licensed resource with provenance. Commercial CAD artwork, screenshots and extracted assets are forbidden even as hidden references. This guide and the local finished set provide all required visual context for future agents. + +Runtime discovery is also checked by `crates/ui-egui/tests/icon_audit.rs`. New menu +surfaces must extend that Harness traversal. Regenerate the committed +`design/requested_icons.txt` with `UPDATE_ICON_MANIFEST=1 cargo test -p +solidcraft-ui-egui --test icon_audit`; review the sorted id/placeholder diff. The +review tool reads this fixture in addition to source scans, masters and aliases. diff --git a/assets/icons/ui/design/aliases.toml b/assets/icons/ui/design/aliases.toml index f3443077c4..adb998138c 100644 --- a/assets/icons/ui/design/aliases.toml +++ b/assets/icons/ui/design/aliases.toml @@ -35,4 +35,12 @@ sketch_scale = { master = "scale", accent = "sketch" } sketch_text = { master = "text", accent = "sketch" } surface_fair = { master = "spline_cv", accent = "surface" } mesh_export = { master = "export", accent = "mesh" } + +# Legacy feature-history id for the same Split Body operation. +split = "split_body" + +# Sketch dialog choices share the corresponding finished tool artwork. +sketch_chamfer_equal = { master = "sketch_chamfer", accent = "sketch" } +sketch_chamfer_two = { master = "sketch_chamfer_two_distances", accent = "sketch" } +offset_one_side = { master = "offset", accent = "sketch" } bom = "list" diff --git a/assets/icons/ui/design/palette.toml b/assets/icons/ui/design/palette.toml index e648a81282..aaed95b248 100644 --- a/assets/icons/ui/design/palette.toml +++ b/assets/icons/ui/design/palette.toml @@ -10,7 +10,7 @@ [roles.ink] # outlines and details drawn over a face source = "#34465b" light = "#34465b" -dark = "#d8dfeb" +dark = "#1f2021" [roles.line] # line art that sits directly on the UI background (SVG: currentColor) source = "#34465b" light = "#34465b" @@ -66,13 +66,13 @@ parameters = 4.2 [accents.surface.active-1] light = "#f1d2af" -dark = "#ba8f6a" +dark = "#cc9362" [accents.surface.active-2] light = "#c06f00" -dark = "#b86f04" +dark = "#bd750d" [accents.surface.active-3] light = "#a45000" -dark = "#95682d" +dark = "#955a0e" [accents.form.active-1] light = "#decff8" @@ -85,41 +85,41 @@ light = "#7349cd" dark = "#7b63a6" [accents.mesh.active-1] -light = "#e5d99b" -dark = "#a9985f" +light = "#ffec8e" +dark = "#ffe282" [accents.mesh.active-2] -light = "#a28200" -dark = "#9a8100" +light = "#bb9600" +dark = "#ab8f00" [accents.mesh.active-3] -light = "#826700" -dark = "#857100" +light = "#e0b200" +dark = "#c6a800" [accents.sketch.active-1] light = "#bfe2cc" -dark = "#81a28e" +dark = "#7ab792" [accents.sketch.active-2] light = "#439a65" -dark = "#459665" +dark = "#3a965e" [accents.sketch.active-3] light = "#057f45" -dark = "#27864e" +dark = "#128d45" [accents.constraint.active-1] light = "#f0cfca" dark = "#b98c88" [accents.constraint.active-2] light = "#c1685f" -dark = "#b66b61" +dark = "#c27267" [accents.constraint.active-3] -light = "#af4038" -dark = "#a05c54" +light = "#ca473d" +dark = "#c16358" [accents.construction.active-1] light = "#ecd3b6" -dark = "#b19373" +dark = "#b99a78" [accents.construction.active-2] light = "#b8741f" -dark = "#b4711c" +dark = "#ad7023" [accents.construction.active-3] light = "#9c5600" dark = "#a75d00" @@ -134,12 +134,16 @@ dark = "#1d7f89" [variants.red-green.construction.active-3] light = "#b97300" dark = "#a75d00" +# The constraint red stays darker than the sketch green for a weak red sense. +[variants.red-green.constraint.active-3] +light = "#af4038" +dark = "#c16358" [variants.blue-yellow.construction.active-3] light = "#b97300" dark = "#c37d00" [variants.blue-yellow.constraint.active-3] light = "#af4038" -dark = "#986255" +dark = "#85554a" # Default accent by requested icon id. Explicit alias accents in aliases.toml win. # Deliberately not inherited from a master: ground reuses c_fix but is not a constraint. @@ -153,9 +157,9 @@ sketch = [ "arc3", "arc_center", "arc_slot", "arc_slot_center", "arc_tangent", "blend", "circle", "circle2", "circle3", "circle_tt", "circle_ttt", "conic", "constrainer", "dimension", "ellipse", - "extend", "finish", "fit_section", "include", "infinite", + "extend", "finish", "fit_section", "include", "intersect", "intersection_curve", "iso_curve", "line", "line_mid", - "offset", "point", "polygon", "polygon_circumscribed", "polygon_edge", + "offset", "offset_two_sides", "offset_symmetric", "point", "polygon", "polygon_circumscribed", "polygon_edge", "project", "project_surface", "rect", "rect3", "rect_center", "rect_rounded", "sketch", "sketch_chamfer", "sketch_chamfer_angle", "sketch_chamfer_two_distances", "sketch_fillet", "sketch_mirror", "slot", "slot_center", "slot_overall", @@ -168,5 +172,5 @@ constraint = [ "c_polygon", "c_smooth", "c_symmetry", "c_tangent", ] construction = [ - "centerline", "construction", + "centerline", "construction", "infinite", ] diff --git a/assets/icons/ui/design/requested_icons.txt b/assets/icons/ui/design/requested_icons.txt new file mode 100644 index 0000000000..57761b34ec --- /dev/null +++ b/assets/icons/ui/design/requested_icons.txt @@ -0,0 +1,282 @@ +accessibility false +activate false +align false +appearance false +arc3 false +arc_center false +arc_slot false +arc_slot_center false +arc_tangent false +as_built false +assistant false +auto_constrain false +axis false +axis_edge false +axis_normal_to_face false +axis_through_cylinder false +axis_two_planes false +axis_two_points false +bendtable false +blend false +body false +bom false +boss false +boundary_fill false +bounding_solid false +box false +break false +bug_report false +c_coincident false +c_collinear false +c_concentric false +c_equal false +c_fix false +c_hv false +c_midpoint false +c_parallel false +c_perpendicular false +c_polygon false +c_smooth false +c_symmetry false +c_tangent false +cam false +camera false +canvas false +capture false +center_of_mass false +centerline false +chain false +chamfer false +checkpoint false +chevron false +chevron_right false +circle false +circle2 false +circle3 false +circle_tt false +circle_ttt false +close false +coil false +combine false +compare false +component false +compute false +configuration_table false +conic false +constrainer false +construction false +contact_all false +contact_new false +contact_off false +contact_sets false +continuity false +convert_sheet false +copy false +curvature_comb false +curvature_map false +cut false +cylinder false +decal false +delete false +delete_face false +dimension false +disclosure_closed false +disclosure_open false +draft false +draft_analysis false +drive false +dxf false +edit false +ellipse false +emboss false +environment_map false +error false +explode false +export false +extend false +extrude false +eye false +fastener false +fillet false +finish false +fit false +fit_section false +flange false +flat false +fold false +folder false +gear false +graph false +ground false +help false +hem false +hole false +home false +import false +include false +infinite false +inspect false +interference false +intersect false +intersection_curve false +iso_analysis false +iso_curve false +joint false +joint_origin false +library false +line false +line_mid false +lip false +list false +loft false +lookat false +material false +measure false +mesh_export false +min_radius false +mirror false +motion_link false +move false +new false +normal false +offset false +offset_face false +offset_one_side false +offset_symmetric false +offset_two_sides false +open false +orbit false +origin false +pan false +params false +paste false +pattern_center false +pattern_circ false +pattern_flip false +pattern_path false +pattern_rect false +perforate false +perspective false +pipe false +plane false +plane_along_path false +plane_angle false +plane_midplane false +plane_perpendicular false +plane_tangent false +plane_three_points false +plane_two_edges false +plastic_assign false +plastic_rule false +play false +plus false +point false +point_along_path false +point_center false +point_edge_plane false +point_three_planes false +point_two_edges false +point_vertex false +polygon false +polygon_circumscribed false +polygon_edge false +presspull false +project false +project_surface false +rack_pinion false +reach false +rect false +rect3 false +rect_center false +rect_rounded false +redo false +refold false +rename false +render_image false +render_scene false +reorder false +replace_face false +rest false +restore false +revert false +revolve false +rib false +rigid_group false +rollback false +save false +scale false +screw false +section false +select false +settings false +sheet_rules false +shell false +silhouette false +sketch false +sketch_break false +sketch_chamfer false +sketch_chamfer_angle false +sketch_chamfer_equal false +sketch_chamfer_two false +sketch_chamfer_two_distances false +sketch_circ_pattern false +sketch_dxf false +sketch_fillet false +sketch_mirror false +sketch_move false +sketch_rect_pattern false +sketch_scale false +sketch_text false +slot false +slot_center false +slot_overall false +snap false +snap_fit false +sphere false +spin false +spline false +spline_cv false +split false +split_body false +split_face false +spun false +star false +status_error false +style false +suppress false +surface_blend false +surface_boundary false +surface_extend false +surface_extrude false +surface_fair false +surface_fillet false +surface_loft false +surface_offset false +surface_patch false +surface_reverse_normal false +surface_revolve false +surface_ruled false +surface_stitch false +surface_sweep false +surface_thicken false +surface_trim false +surface_unstitch false +surface_untrim false +svg false +sweep false +text false +thread false +torus false +trace false +trim false +undo false +unfold false +update false +warning false +web false +ws_animate false +ws_design false +ws_drawing false +ws_render false +zebra false +zoom false diff --git a/assets/icons/ui/design/review.json b/assets/icons/ui/design/review.json index 784b14c3a6..eb886ca08b 100644 --- a/assets/icons/ui/design/review.json +++ b/assets/icons/ui/design/review.json @@ -15,9 +15,9 @@ "as_built": "0f481932deca5586a8a8ea3d707efca0747f5c59559d53830b694d3458531013", "assistant": "8fac392b16b4f13c6a399c59962b97c1912282e0ec909976496959472f1d642d", "auto_constrain": "012e5da4ff83449f6a3a92ee2d9924998cbeea545281bdf97873710b7436183d", - "axis": "66ebabe43a7b1b73b59a3a03b7bd92ab54d899947d0cc2d93ad6e6aac4d76501", + "axis": "acdab0eb7b99b1579d64ae190082df48b65174e7b8ad3104967bcc59f6154206", "axis_edge": "5a65898bc38bdaaaf5b04f2c2f7556915b5b7b1fdc4de9708fcc30f64aa1ab18", - "axis_normal_to_face": "0824bb37e80928e9ac880af5ca5fdb3b52084af9e73db73a5a5a7bc8f1f899f7", + "axis_normal_to_face": "597b5b96ce787b70791133267108c5196aca09d5e3f94e7dc92199a63fe49ca6", "axis_through_cylinder": "804eb75631f4ee71ac8db51209237bb19d82bafd326abdc81b124b875c0874b4", "axis_two_planes": "80162aa79ca7baf6d60490414a4fb53d87e6f2b85e901c452875a5f3ed86d35a", "axis_two_points": "5064f7544ce41ae7c1f05a90c958222bbcee03723df67d34cd3e209d25a1f71f", @@ -30,36 +30,38 @@ "boundary_fill": "17ccb419a46c04a44ec864af968a32984dc81f6b3196a821bfff7f54141dfdd1", "bounding_solid": "e2deb6fff292f0f295022583ba7ddb86896eb79d9dd7268e653713b9c62f0580", "box": "72cd44c3c4fc5ae71242fca8c60af47371930201276df5b7db9d2f16a2739c31", - "break": "430582243311f304dbab397ce58c8c9aef9c78ed88d3e921a57e584ddb95d94c", + "break": "67a5fab332616e6d6a9aeae3bedc04ee0768179143ba34b795bdd98cea04bb24", "bug_report": "8cf7afbe0d3c34988fd2748de148f56a7e939fd8273ba01a79a976f879c74831", - "c_coincident": "32fb535b3dd7631dc2e9ea284f445ad54ea0e5c8b0e1bb27a9984c849746917d", + "c_coincident": "a93cc58acc5090c24c973cb558920bff4e35c8f99071135f16a115256d41bf98", "c_collinear": "3bcf1dc6b221a1fa9a4445ee8afc7d858dc28a5270d22d10fef86fa4997f1bdf", "c_concentric": "90e9b35b6186ea4fcb3debc24808f302c0bf7354b7afae86e339574ef8e5103a", "c_equal": "c0f50c02db7d15a6392d2178e28df5c100e67fab7732f8e8474b888d218d0673", - "c_fix": "b91c6af7ea9740efb8cf196409b11d16fad52c51b732a356e5f844bd4e2f08e0", + "c_fix": "23e2471a9d6872e83028e0e219e330cef1930a4d89456a0fefa1bda8a7e3eb81", "c_hv": "b5c44d60a4b03b9942fbcf3c706f797ce4dbddf9a67eb1c98d6aaf3234e9bc18", - "c_midpoint": "fd4efc26da1336f811a9016b02d7675af2a7c95cd1ffa2f091507b22434f5435", + "c_midpoint": "aaea530b8a04aa80efc99ed2cf42347a91c65b160286bae3e2882f5a0bd09a51", "c_parallel": "aaa80258502af091b596511662aca392546dd0d7bf69cec4db98b6e0ae7591f0", - "c_perpendicular": "692ba7a77cd77108c33c101386b60b7c957ce7d2202937d9696d18dcf3d4cf8e", - "c_polygon": "defac40b4c6ef323494f49acbb94b896502f78f6a99e750975693c602164a44b", - "c_smooth": "dccf5effdb8977ac04e6c437c280c83ed0f117d81b64c57fae03f5f7f7f2b9c0", + "c_perpendicular": "0ccf0383176ace2d4ab30c2efe853815ca1df20f2537020f369f496d8bf124b5", + "c_polygon": "02c76b288b42d6be8eb77f502af95bb840557f43fed3def2b1a381f6ac920375", + "c_smooth": "87aeb504aa330a191a8680d9eab3e86844f6d7be0a9caa10fdf0a657e76f45ee", "c_symmetry": "8646a7deb7447514bcc455562d8639f8b4c482df8c971fd2870c05965ce71f78", - "c_tangent": "a011f5d43d5e09f68de332c60c5acee737a3ebc94d107f521871225c3841a064", + "c_tangent": "ef8b1e502c2a939958b5e8de9ef2716442e32b2a394555eb7b51101c415b966e", "cam": "a35342e36ca4deae55e98c54df630b5c2da591c06d979eb2b8ce6a13f6db66d0", "camera": "fcd867877ea4984318dbff36a166b48682f26bb9d44e70383dde63d9a7ab87c8", "canvas": "512d7721f0b8b14a46f22b9588b437b0cd8c2c425330d31b3b156c0d1334bcef", "capture": "23ef84e50b45c06532b3e842b72f6da5498d0e6b8391e1bff55e84afa8359f37", "center_of_mass": "24401b1a19cb45ddb028de0acf3c9815b47abecbde9e133f473adac3d27ae346", - "centerline": "a1889b9092ef5a7aa9592df994b6541b53ea9afb03b2c56c7678a61df335f4d7", + "centerline": "850283497bad77e233f934e8effc0d0d7b01ccde83fa66f6abb539a33a8a2c80", "chain": "9cdc78486b5c4b5b2a378e09bbd41733eb167513f9f2bc4b1aad629dd6c4c753", "chamfer": "487c90ae65e87b74d7ddee7d9ce9f088b63e9b5b85c2a390364855a2649f17e8", "checkpoint": "a3bc0700a40b791b5f3bf176d820bdabd2cf94a526908609d50136c8d0918d6a", "chevron": "61887736af4b1e6bef857c4d2e3846657824e84f1c8026a56525ddba2d2a8083", + "chevron_right": "d28a52dcc51fd9eb988f8f83e89d74370a31100049c910a886732a48ec1342ca", "circle": "145544ff1cd133971f537dd247092126ef8d91a88fe31b795222e76840d0c4d0", "circle2": "f1b6e8e2157279cfe1e608f913b377e44a70f7555f5010298138de8a887bf586", "circle3": "aaf6b914e630688675abf4dcbf7d43096317881ced0e22520a4ef229013e0de2", "circle_tt": "94996320b671c8c48f239b8c48f1b2f1cd5cc33a7d8ea1daaa61aa9203803cae", "circle_ttt": "ac3c211a6fe17c3625060f695ecd93937730e2bc2dd57921bcbbcaa984b9badf", + "close": "5833954dfba3d70a9a3a7519f2796a7213899548b0345bd8fe8ea9a321d82be2", "coil": "4c549fd63506457165f53a7940cc0f3dcacdbe075e935ea299edbf4985104411", "combine": "f9d4211f0153779369c9da68b266c2c9a857d11bc033ff4a96ede794e470e38e", "compare": "c24b6c18f18413ae6f3344aaa6e04b42fae8b63e7cd1552a81398462eb68eb96", @@ -68,7 +70,7 @@ "configuration_table": "1a142e76b9e4cd47412a307cc0ad9b8c589bb97c8930fc445dd5bb8d74ecf707", "conic": "808445fd697ff76f9074e7bbf877146e634995c295dce045604991fa4cec26a0", "constrainer": "774cb158897eb91767541b5b1a25a46f3a731cbea0661006ce5472634ab6b0e3", - "construction": "ad962cbcd0aaef9167744abfd0bfe1d7ef55e48b4ff42f2b4b05f66204b58acb", + "construction": "f13e3851cdf7b30a7118802a1c17e8852fcb9893fd1d65c7098a5c3fc35c8396", "contact_all": "6ad4d245592815a3e38e70ae324ad3971ab614daba7ca3cf8fcb64cb8e2be077", "contact_new": "603410785fff408cc12dc72bef2f09885b694c5ddb2571c7819ae1301fb89dc6", "contact_off": "54dc3bd2267ebb084d8dc7efee85e9f21d8c59a57ad37b96d78095bc75e73fa0", @@ -83,8 +85,10 @@ "decal": "acc258e40b29ffb6b6007057f0b70f487995f573704c7ef4e4f08f209d52bddd", "delete": "d14203f06b8b1b87328e7f198c7aa41595e9a66b5d8a7fbd2a3ec2e35cd99afd", "delete_face": "dcbf8ab59bd37bd5a0cdd7a528b29c2d19daa5f3fc130ef5cff0c20034f061a4", - "dimension": "8246dde8b0a140d1f533c1e45f2e8b5af35c05d3504dccf50bd7e7726a93389e", - "dots": "aa38423c547594462f49cc355f3c9f5b41e28a7515f466a6a8cffda487ba1715", + "dimension": "a926b7c64bc8ca99d8d6dcbb81b95235bd3fc34fbed049aca8b01b4d1147c15d", + "disclosure_closed": "49a350c6f2efae78de0be96c524ebee1fd1291ea449a84224b88d37164a4e807", + "disclosure_open": "d13ce3f97e3e362a9904fa2bf3412f3f00e4136bc23538b92d41483961d280de", + "dots": "600f7ff209d93145fc938bc4f6a1e34473e6b9ee22a5b4718f133bc7da5ee58d", "draft": "9c0fc361cacd0ad95ccd54794f4825dfc9901a83a3de752a588faec365d0ee09", "draft_analysis": "eb39c0f90e9cf226da6307365a76015e900dd692dc3f0fdc8f02fe717226261f", "drive": "cf9e924acb2531efd5a1b90915d59f0cb532c844b754fd3a0e376ef6f706c47d", @@ -99,6 +103,7 @@ "export": "c8c2abf007e43e896479d78798e3976ba9eda809507c820bb38538f77db696ac", "extend": "28c63df352c77d02feffe44cf09fb0fc1d48f7905aaabbfa6dba601dbb70f8e8", "extrude": "9cc1538971c5d25467aaed23f739899d9d102fd07f86472ac3197c54ca2e1144", + "eye": "2449167c8410826dc1106b687c12a309d5bea8f7817b897943e80f182a163c2d", "fastener": "fcff4e4be50907f712e527b373771afafd92e50096fc7a4b8aba5d86448e1e42", "fillet": "11ec232dc111597211e3a4fe61b69616b5d28b4b1b2732052ddcd486cdfc6129", "filter": "4d456a56416089574bd3732fa44c0c1b27fd2478b369448808a27f0144d15d8a", @@ -110,15 +115,15 @@ "fold": "90344fb5e5799378117fbd79ef28a84f27fba5723af5414c4ea30a2ac3a4f21e", "folder": "c19f5230df45a1bda1e92e89de38bba21c91e6eca0517eb15847a7712012d1e5", "gear": "25a1ca48a587850da1104a46005a330873ed196246e3d04e12cf61f26ad2b0d5", - "graph": "25646bdf4d1a534c51e1897a8ae641a21f871ed70b24ff25652e1fe957c9fa00", - "ground": "b91c6af7ea9740efb8cf196409b11d16fad52c51b732a356e5f844bd4e2f08e0", + "graph": "7156caedd2dbfa2ae53da18b7a2204b8db66c45126859d36caf049a3fd4335a4", + "ground": "23e2471a9d6872e83028e0e219e330cef1930a4d89456a0fefa1bda8a7e3eb81", "help": "8ce4060577483dbdb510be147d2827de50ac20029354008fca55abc0c7edf05d", "hem": "f9b1aafbedcf3dc90c26be308814638e0095fbe31b0647092507343fa60af011", "hole": "f82d47751f826e08682db847a270897474661bfe9f35b6d3678f49a203172563", "home": "ab5f1c41d377687e4c5a808dfdccf2757b33dd43f934d44df43d6e9118176853", "import": "e343540eb49fdb1e53c4d54b9e60ae513b3103df6c9133048bed878e58067455", "include": "f5b80ad3e06e3b863fcfbe415ec912e4e09653dd519f9ac207819339fe34730f", - "infinite": "bff57a1c56d9b47131d7c8ff85f008a53c612c63706052e7e19d7aaea93cf5b1", + "infinite": "53fb781131078d2f8e71be23aa610d9f9d9c8bb79ba5f97fa397475e9ff8c3bd", "inspect": "d2b4644b2d5f1aa50cb576b9265e275fa91201e296c2bc79865f810067d538e9", "interference": "b3d729d0e822e8d95485c8ef50649847572def10d4c713fff0e073b8031c5f5d", "intersect": "9ab4f6551848a2be546f55502a77075bed2d3994f89d91b721eab92bcee61d61", @@ -126,7 +131,7 @@ "iso_analysis": "dacd34ac254ec4fbb6932350f62d4d92626d922a2bfc62d2caecdeb17303e896", "iso_curve": "015ff2e26346ccaf149f75cf6a0652d9201c1b23a14e6bb62625acfa599dc002", "joint": "72b71a608e713f6cf51a1cb84edb011d4ccbd8bfe41d917fa2a1d13345dc0b2b", - "joint_origin": "5b0761122eb4295ea220314e8db1efd1251e3482653d6cdcc47252d0268864a8", + "joint_origin": "e12529af1fce8c5ffdf746e71ce2f5db5eb170e53778469cea8fd30344291574", "library": "ac0b1798e4c94340f9c854daa3829371c1a9f3ffea7679d955e6cfa139cafdc8", "line": "cd1177ea659548f03d43c7fa7dcc209a0542b1ba4bd0153c5113576cb7a96a81", "line_mid": "edb8ebc463479ae9878e54b930c124153ca880270b7cf6c0e4edba6336ba2854", @@ -140,17 +145,20 @@ "min_radius": "92bd79b7d557aba6ddfccc76a4c6d0cfb579061b3c57172f9171d28af675711c", "mirror": "56637068a319dc2972c5552b25e4f43151aa9bd5055c2fef08204dd7cc0829cf", "moon": "3ba5adb92bf0f49e92d46682dbf6905e555aa6eebc8e2b3c5288f949cda29322", - "motion_link": "19698bbe15b7c2d2601208bbcdc26627d1aa7724aee017b7da8874ad87fb9d1f", + "motion_link": "27832f827146446557609336a9b9e48e649ed84e58c45bd9032a93abbd454104", "move": "4132e270ada8d0c79b477095c84d656d6d354e200c1748ce7fc425f36d0b7031", "nav_settings": "b93dd46d3a3d074e0d8834167f977dab417fbc1a721bc4db5e840732304e4193", "new": "c64c78fd8445bc7b2aa8765d0507b9adf23e35cbf52828d3f1987edccce0389f", - "normal": "1148cef2d60a858b69406a2e2527d4ea126f45846de7cc2fb3a96640e9ee226f", + "normal": "3c000d707837d4e9c865aefa43c1e610bfce5a06a38510f82e4db11fc13ab31f", "offset": "094607afcfdcb9c1410114d9f8d22ca367f8e32ab4c61f71affe44ad5d09adbe", "offset_face": "83d39308ec9f3ead78329c62841c8899bbc76ce08e17d539016d18c91d9d4ea7", + "offset_one_side": "094607afcfdcb9c1410114d9f8d22ca367f8e32ab4c61f71affe44ad5d09adbe", "offset_surface": "b501cfb56b8404cdb2275935d780ce7dbb67e87e29ade3782f0859c59070a2b1", + "offset_symmetric": "67f3489402af3afa055181bbdb2a32b9fe2e0d96dccbe0f28df087577978ac9c", + "offset_two_sides": "ef69f955c5bef15471e9d77a4498e9337bf938ad75dcbc1e95af2515b6fd13da", "open": "f1880d25489753f7e40390902037bbd3195fbd099a5e9dd175797d832abff727", "orbit": "4adcd400cdde909139386e78579433354f65f5a77a0594447bf6dda663b3b1b4", - "origin": "9b9360edc16ef9f059bff7d863f1bc0e3abac6acf559d76c4ea39dc374ab2844", + "origin": "636f9f41654a6da7a5a708bcddef85672f2d77bfec2267b0134e8c5a59768b40", "pan": "2465eda7a39d4458d7818f2eb2b033992a68f80d9505bff538b41eb4511bb632", "params": "d0d9d0b3e786880e59b985dc2e3a7d593cf3771484a8fd2924d69155195c230f", "paste": "e1b3c089adafacb6f7b78b86ec7aba0876b189c81d01031045dfee2c47ae8edc", @@ -165,22 +173,23 @@ "perspective": "5fa45ba9b75dbe9fdad9eae331ca7ff35c6ad018b8a1fc4c8ae7bcfc13ed0c3f", "pipe": "b9ecf1ae9f6790e2ae6c677f290446f70eba1773a10247fe33d5283b360b0403", "plane": "3e7fa0a9d57891f3c1ec03d7422bdb29353d3487deed12e8ec5737aeb33c2f3a", - "plane_along_path": "020e25969a7f4e7e3358003addee7df8b1991cde8401df0b4df8295b19497994", + "plane_along_path": "db4686ea02c3c3b8ad0d9eac203cc050c395cff6358358922a79c00e955e6852", "plane_angle": "2f0914955663b393a377fda080bc03dc5a2133d42c506c1908c53b2fd609b156", "plane_midplane": "66bd880821e7b122f130824313b7070d72d79168a52df6a126106b6eb5816474", - "plane_perpendicular": "90e1d9b8e75743821aa1082596d9c3f288ab3baddf087fbae3f2025481e58a6e", + "plane_perpendicular": "0055fb18ee39044bfc9f2ac53ba55fe73d8fe59e8f4cf225ee2c723a883d3085", "plane_tangent": "c3872b9f1d0f0b2493a5488dfdad50049c3422c29023250088eeda9bbcc6e795", "plane_three_points": "08e2dbaf606d165ef4f788869fc794a4fbbbb2ee86a60017b76e467af6da338a", - "plane_two_edges": "1588b8d4741382d9e8fec38212419ac1c05b1bc6036f276d3b5c62db66f8ebdb", + "plane_two_edges": "21de401ec5bab8ae37510da6fa0a6910325f858c17460da0041fd26897338d30", "plastic_assign": "bbfdb2d2c07629c047e8e671c7c032654668a67b4b8a934f74cbb3d7a00cc5fe", "plastic_rule": "51b8591d92907a3ffb76a47a0503d7ba86706d129dd990515a364dbf1ab8cb48", "play": "d1c17ebc16e9a4ee5ae2a8cc37b515ffcc8c53a2ab17117959e9b21278df8def", + "plus": "4f324157bb2348ac9f09346b669b08d66ed96cb1c694f33db6f21134f18904b5", "point": "fd19dbcd26e2a898267bab54b9d89779d97894209c1771317d7debef5a6f8f5d", - "point_along_path": "1fd58d45285fb5cabeddbd35bc8485af1b6d7f1414e302202b4e73de593d1b7a", + "point_along_path": "17abd16dec5f70e9f8449c927aa995cfed10fca552ec7cd8f51b209c52012595", "point_center": "0ad755896f7d0154a854bda16d3059da78d1fae57962e13a3bb1b31e284b7e79", - "point_edge_plane": "548a6c1c944caabe44ab0318e85ae82f39c11f74fbedda35f57bc6615755f249", + "point_edge_plane": "a277bc0b87f2dd39eb613835d0f961b3372483e22053e6a522a06c535ad530c4", "point_three_planes": "b33c7508ffa67b66a57b72c6ea7900e93ff16362a29d0b2688e0b1db9bfad041", - "point_two_edges": "35dcb67b36c8e41041cc836b01056731ab6f14c5e3b4bdd836fbee2a1dd897e3", + "point_two_edges": "da329241706e405b877cec20639dc0a78505ca7212c7c47ad1f1f34aa26f4666", "point_vertex": "649bb63caf65a3fbc00a1222cc642294832b5e5d8c4d33f812e09a70684c4abe", "polygon": "4919268cca7d21ce98dbf4ca01ee832350e793afe4213e257d3c46e94ea88ad1", "polygon_circumscribed": "7e4ad16cc1f42c599287b186f26f4ff3c108736877a571a5513bfc72d0cbb23f", @@ -214,17 +223,19 @@ "scale": "a66f69d9a1d46c8be30da0a15827bc7e38cba72d6271537763b888c2c7e2b3fa", "screw": "5e30570785360d6a591ea22862a82f1cf62228385c1e96382536db8a453ac791", "section": "cdd13f99f15f6d59bfa21c747810608d6afd00cb8207faf0337928c8521e7eae", - "select": "a0c716234ef1d599be9a7654d6b2911ebe4b2de479383ba33aeab14992443a2f", + "select": "fdf6431270006ca7409c60c37cfed59572de04229f104ec723e6010ffa2b7163", "settings": "9193dd3a1007c43660d0d47b6499cb2571338b67949e1a99df112057b9bf2b8a", "sheet": "774e30923ef6f37dd9c0b2bc3e45705e83b3a76700bf071cd41155a365d0daa4", - "sheet_rules": "b8c3a67c82be20fc00c109decc566e8755d70167e04e0a9fb92941f7b27aa993", + "sheet_rules": "5d3a2f37d3b4a4262b3ddbdae53d61f29e0f8e284dd54006da25a7acebfa1114", "shell": "a1959ea0d0441a479bf654b6e549e2e44a0bfdc9cda4eb46fe1fbfd884de7e38", "silhouette": "2a848aed2f247b1c0439f830a34a513c93bc5493c40044c821d493d863ad996e", "sketch": "b52e5862b1709a9a7e3ca00d7436d126fbfc01b38080006fa4e17dc581dfa103", - "sketch_break": "430582243311f304dbab397ce58c8c9aef9c78ed88d3e921a57e584ddb95d94c", + "sketch_break": "67a5fab332616e6d6a9aeae3bedc04ee0768179143ba34b795bdd98cea04bb24", "sketch_chamfer": "80ab27a9929f9cf0ab2f827757e007173d74ddca6c19af238931c40d4cd874b4", - "sketch_chamfer_angle": "b853f5e7c3b008c0d56ac445a9a28f713987c919ea284945113ad35e7665e2a7", - "sketch_chamfer_two_distances": "2b70d658e341f4388838f3c8c579ec5f4f3a7fddcfa2ce7a3f89fc3db6422d6e", + "sketch_chamfer_angle": "6750268d05e0b0fd74c2d0368bf4703b09da825def668dc3dfd05deef3ba9adb", + "sketch_chamfer_equal": "80ab27a9929f9cf0ab2f827757e007173d74ddca6c19af238931c40d4cd874b4", + "sketch_chamfer_two": "1f5ecf1283e2b021e067ed2e4e2e97aa4e1ad3c7c98d8609bb81c5f811988f78", + "sketch_chamfer_two_distances": "1f5ecf1283e2b021e067ed2e4e2e97aa4e1ad3c7c98d8609bb81c5f811988f78", "sketch_circ_pattern": "c5ff904237a3072f858f66c2777febc4facd2fee630bbf979047c5685d9a05ce", "sketch_dxf": "dddce44d4dd10f75a221208fcf431b36b3f0cafce6611fc695ac73afb4bf5789", "sketch_fillet": "e8fa170e5f6899abf75788b9384d49f2b9d77ba9b644f79309284a9e65629fe1", @@ -242,6 +253,7 @@ "spin": "a5bebeff8276dd12ab7e42ddf0051370b9063fb602607298acf22e77044434d1", "spline": "a0dcb25614e5fe1a8f17bc3bddbb11b61cf82b461dd46443ab28cddd6e4e32e3", "spline_cv": "353f6e411c0ce611f660cef2e4fc4f8f55edc2c82288de91948dc638cd2b5f15", + "split": "eb9619e74c6ee1b1989d35a2e31d2d33a7adac210c4cd424e5382902945ac68b", "split_body": "eb9619e74c6ee1b1989d35a2e31d2d33a7adac210c4cd424e5382902945ac68b", "split_face": "c8b89072118bb7a28b243d38e26b81a796781a631f30b3b1fc296ba23e4d908d", "spun": "b4cfa093ffb43611ee124f4a39fb44c609deb9cde5e05e4dfbaf893fe197c27b", @@ -250,6 +262,7 @@ "status_warning": "1ac4f0ccfac2c01f6f66cb42188f9ae053c4aef9c5bbd249f1fb09611eb4d825", "stitch": "6c3205d64007b771ed78d6f8055867578439fb25d591563b32dbd3343c98732d", "style": "6c2b04234b82ea4858c80190cc7208c60f549358253005afc35ab4e29506b578", + "suppress": "c54fbc01c12c0175d78cd63f692e8aaf73fc2cac79752b2a3ad821ac32734343", "surface": "ba0e839ce0ee03e7ff95a2389a5ecb20bd80c713064d9b1bd5f461f4a80d1921", "surface_blend": "0642f3b4af7d22f379a3c74f54c7b1c9eef0bdda19372ce4df73c0e56bdd7bbc", "surface_boundary": "fb8501bae192e2416118cc2e88f544e0e29197f6720bccc541c967ca0848d8d3", @@ -265,14 +278,14 @@ "surface_ruled": "c3f0202d3beaacef0368765ce771eb9e3b0e3e157f86564c72d81f0fad9a3cab", "surface_stitch": "6c3205d64007b771ed78d6f8055867578439fb25d591563b32dbd3343c98732d", "surface_sweep": "6d7f18c12b2e4c1dbe3632fe08239687f6f027b29b6dddbf8c8e2d9151c7baf3", - "surface_thicken": "676eb73a99d708214e8951ebe2c4d88d60de2d752c725d8106d12fbd812e461a", + "surface_thicken": "35aaa8e4fdbcd220a3a05165d5230a7ce576fbf5a6a5772d27162ab61bb71538", "surface_trim": "ae269721b386bd1264184b9838df3240b5b6ba02b99c9993384ef21200fb7398", "surface_unstitch": "bd8302fe95a89eb6f9bbf35ef0ee98c8a2186e160cff3d091f8cf2a0a75d4473", "surface_untrim": "24de5d558519e0629280d11919ba5661c92ccdccdce03f8f3d83f2d7867c2173", "svg": "7da77d53c84db18ce95737ad633c105d1009444b3192a5e0aae76873cfc9cc12", "sweep": "6d7f18c12b2e4c1dbe3632fe08239687f6f027b29b6dddbf8c8e2d9151c7baf3", "text": "9d31b72b16644681c8a86ac257511847ad69ce4351cf6960442222e94b168c4a", - "thicken": "676eb73a99d708214e8951ebe2c4d88d60de2d752c725d8106d12fbd812e461a", + "thicken": "35aaa8e4fdbcd220a3a05165d5230a7ce576fbf5a6a5772d27162ab61bb71538", "thread": "158728c3dcf6623bb1584d162398bb12c28d18770e0d47f5e6328355ce31b37c", "torus": "5ae13fc213582e0844118b910a3a26efd9e4b177d21500363fbae230803cb4fd", "trace": "5f2ecc61d4591d3c3b0ecccd35296b8613fce7beb2ae214fb906160c31d89187", @@ -284,7 +297,7 @@ "update": "c675e16df4c78589a00be4a34f98627500681672ee8a668e929aec50982da3cc", "warning": "1ac4f0ccfac2c01f6f66cb42188f9ae053c4aef9c5bbd249f1fb09611eb4d825", "web": "be5cd95bebcd0ebe257f9eb862112124429b381ef4a2f0849938fac7c9ccc619", - "ws_animate": "d36a0834ad989a6e16e2b0db493ad8867c590caedf0442c51eff19e934c887f8", + "ws_animate": "f34f187850789cc4f141b1ace114082be04edec8e7771d6730fec1cef19c7f64", "ws_design": "7b1dd678a2d018892b965a78932c047bbfff6b4439d7bd9fe85c09a995f97581", "ws_drawing": "61e99c7179f3448a31b8c76392334352ec3d5e39b0b92fe5244940146d0986cb", "ws_render": "1bfe474d1742623ad2142129e4d6a40015e674813eb1db401d2e88e55b1f2a03", @@ -292,6 +305,9 @@ "zoom": "89c121fa85df034d70bd9f06e20cbd54d163691d4df03b13a9ac5372e951aeda" }, "notes": { - "pattern_flip": "This feels sparse. Improve the composition" + "construction": "Fix the bottom dot's position", + "infinite": "Make the infinity symbol use the 'Line art' colour", + "pattern_flip": "This feels sparse. Improve the composition", + "sheet_rules": "Make the top line also blue" } } diff --git a/assets/icons/ui/design/svg/flat/chevron_right.svg b/assets/icons/ui/design/svg/flat/chevron_right.svg new file mode 100644 index 0000000000..e07c0d022f --- /dev/null +++ b/assets/icons/ui/design/svg/flat/chevron_right.svg @@ -0,0 +1,4 @@ + + Chevron right + + diff --git a/assets/icons/ui/design/svg/model/axis.svg b/assets/icons/ui/design/svg/model/axis.svg index 79434aeb90..6090eb91f6 100644 --- a/assets/icons/ui/design/svg/model/axis.svg +++ b/assets/icons/ui/design/svg/model/axis.svg @@ -4,5 +4,5 @@ - + diff --git a/assets/icons/ui/design/svg/model/axis_normal_to_face.svg b/assets/icons/ui/design/svg/model/axis_normal_to_face.svg index efddcd5677..aa60b7a8da 100644 --- a/assets/icons/ui/design/svg/model/axis_normal_to_face.svg +++ b/assets/icons/ui/design/svg/model/axis_normal_to_face.svg @@ -6,5 +6,5 @@ - + diff --git a/assets/icons/ui/design/svg/model/break.svg b/assets/icons/ui/design/svg/model/break.svg index 614bed30dc..43d5f0a88c 100644 --- a/assets/icons/ui/design/svg/model/break.svg +++ b/assets/icons/ui/design/svg/model/break.svg @@ -3,5 +3,5 @@ - + diff --git a/assets/icons/ui/design/svg/model/c_coincident.svg b/assets/icons/ui/design/svg/model/c_coincident.svg index 01bc385e4c..23712c129a 100644 --- a/assets/icons/ui/design/svg/model/c_coincident.svg +++ b/assets/icons/ui/design/svg/model/c_coincident.svg @@ -1,6 +1,6 @@ Coincident - - + + diff --git a/assets/icons/ui/design/svg/model/c_fix.svg b/assets/icons/ui/design/svg/model/c_fix.svg index 7de87c238d..fce9b3ce30 100644 --- a/assets/icons/ui/design/svg/model/c_fix.svg +++ b/assets/icons/ui/design/svg/model/c_fix.svg @@ -2,5 +2,5 @@ Fixed - + diff --git a/assets/icons/ui/design/svg/model/c_midpoint.svg b/assets/icons/ui/design/svg/model/c_midpoint.svg index e2a504a6ea..37e0989829 100644 --- a/assets/icons/ui/design/svg/model/c_midpoint.svg +++ b/assets/icons/ui/design/svg/model/c_midpoint.svg @@ -1,6 +1,6 @@ Midpoint - + diff --git a/assets/icons/ui/design/svg/model/c_perpendicular.svg b/assets/icons/ui/design/svg/model/c_perpendicular.svg index 56bfc50ce2..8b7280e040 100644 --- a/assets/icons/ui/design/svg/model/c_perpendicular.svg +++ b/assets/icons/ui/design/svg/model/c_perpendicular.svg @@ -2,5 +2,5 @@ Perpendicular - + diff --git a/assets/icons/ui/design/svg/model/c_polygon.svg b/assets/icons/ui/design/svg/model/c_polygon.svg index 918d59de22..fa36be4067 100644 --- a/assets/icons/ui/design/svg/model/c_polygon.svg +++ b/assets/icons/ui/design/svg/model/c_polygon.svg @@ -1,6 +1,6 @@ Regular polygon - - + + diff --git a/assets/icons/ui/design/svg/model/c_smooth.svg b/assets/icons/ui/design/svg/model/c_smooth.svg index 972a625e23..999f01d21d 100644 --- a/assets/icons/ui/design/svg/model/c_smooth.svg +++ b/assets/icons/ui/design/svg/model/c_smooth.svg @@ -1,6 +1,6 @@ Curvature - + - + diff --git a/assets/icons/ui/design/svg/model/c_tangent.svg b/assets/icons/ui/design/svg/model/c_tangent.svg index 8965660299..5bd8392e8c 100644 --- a/assets/icons/ui/design/svg/model/c_tangent.svg +++ b/assets/icons/ui/design/svg/model/c_tangent.svg @@ -1,5 +1,5 @@ Tangent - + diff --git a/assets/icons/ui/design/svg/model/centerline.svg b/assets/icons/ui/design/svg/model/centerline.svg index d839cff649..bd1b7b06d5 100644 --- a/assets/icons/ui/design/svg/model/centerline.svg +++ b/assets/icons/ui/design/svg/model/centerline.svg @@ -1,9 +1,9 @@ Centerline - + - - - - + + + + diff --git a/assets/icons/ui/design/svg/model/construction.svg b/assets/icons/ui/design/svg/model/construction.svg index 76d509885b..bae8ebc36b 100644 --- a/assets/icons/ui/design/svg/model/construction.svg +++ b/assets/icons/ui/design/svg/model/construction.svg @@ -1,7 +1,6 @@ Construction - - - - + + + diff --git a/assets/icons/ui/design/svg/model/dimension.svg b/assets/icons/ui/design/svg/model/dimension.svg index fb51381dbc..a54f43b823 100644 --- a/assets/icons/ui/design/svg/model/dimension.svg +++ b/assets/icons/ui/design/svg/model/dimension.svg @@ -1,6 +1,6 @@ Dimension - + diff --git a/assets/icons/ui/design/svg/model/dots.svg b/assets/icons/ui/design/svg/model/dots.svg index 0aa05f3105..3e8f0a1363 100644 --- a/assets/icons/ui/design/svg/model/dots.svg +++ b/assets/icons/ui/design/svg/model/dots.svg @@ -1,6 +1,6 @@ More - - - + + + diff --git a/assets/icons/ui/design/svg/model/graph.svg b/assets/icons/ui/design/svg/model/graph.svg index 215ce75530..2de6c208fd 100644 --- a/assets/icons/ui/design/svg/model/graph.svg +++ b/assets/icons/ui/design/svg/model/graph.svg @@ -1,6 +1,6 @@ Dependency graph - + diff --git a/assets/icons/ui/design/svg/model/infinite.svg b/assets/icons/ui/design/svg/model/infinite.svg index 55bca1e955..20619d8d79 100644 --- a/assets/icons/ui/design/svg/model/infinite.svg +++ b/assets/icons/ui/design/svg/model/infinite.svg @@ -1,9 +1,9 @@ Infinite line - - - - + + + + - + diff --git a/assets/icons/ui/design/svg/model/joint_origin.svg b/assets/icons/ui/design/svg/model/joint_origin.svg index aa8114d0c5..24799a9ef0 100644 --- a/assets/icons/ui/design/svg/model/joint_origin.svg +++ b/assets/icons/ui/design/svg/model/joint_origin.svg @@ -13,5 +13,5 @@ - + diff --git a/assets/icons/ui/design/svg/model/motion_link.svg b/assets/icons/ui/design/svg/model/motion_link.svg index 5fa9c62663..e48e5eaa01 100644 --- a/assets/icons/ui/design/svg/model/motion_link.svg +++ b/assets/icons/ui/design/svg/model/motion_link.svg @@ -14,6 +14,6 @@ - - + + diff --git a/assets/icons/ui/design/svg/model/normal.svg b/assets/icons/ui/design/svg/model/normal.svg index 97f3d7fb10..84d15407e1 100644 --- a/assets/icons/ui/design/svg/model/normal.svg +++ b/assets/icons/ui/design/svg/model/normal.svg @@ -1,4 +1,4 @@ Normal line - + diff --git a/assets/icons/ui/design/svg/model/offset_symmetric.svg b/assets/icons/ui/design/svg/model/offset_symmetric.svg new file mode 100644 index 0000000000..e767ce99d7 --- /dev/null +++ b/assets/icons/ui/design/svg/model/offset_symmetric.svg @@ -0,0 +1,6 @@ + + Symmetric offset + + + + diff --git a/assets/icons/ui/design/svg/model/offset_two_sides.svg b/assets/icons/ui/design/svg/model/offset_two_sides.svg new file mode 100644 index 0000000000..75f9555eeb --- /dev/null +++ b/assets/icons/ui/design/svg/model/offset_two_sides.svg @@ -0,0 +1,6 @@ + + Two independent offset sides + + + + diff --git a/assets/icons/ui/design/svg/model/origin.svg b/assets/icons/ui/design/svg/model/origin.svg index 1379189ce9..23af9e99ff 100644 --- a/assets/icons/ui/design/svg/model/origin.svg +++ b/assets/icons/ui/design/svg/model/origin.svg @@ -2,10 +2,10 @@ Origin - - - - + + + + diff --git a/assets/icons/ui/design/svg/model/plane_along_path.svg b/assets/icons/ui/design/svg/model/plane_along_path.svg index 193935e43c..36ea58973a 100644 --- a/assets/icons/ui/design/svg/model/plane_along_path.svg +++ b/assets/icons/ui/design/svg/model/plane_along_path.svg @@ -1,6 +1,6 @@ Plane along path - + - + diff --git a/assets/icons/ui/design/svg/model/plane_perpendicular.svg b/assets/icons/ui/design/svg/model/plane_perpendicular.svg index d205e87da5..1e9abb9f6b 100644 --- a/assets/icons/ui/design/svg/model/plane_perpendicular.svg +++ b/assets/icons/ui/design/svg/model/plane_perpendicular.svg @@ -3,5 +3,5 @@ - + diff --git a/assets/icons/ui/design/svg/model/plane_two_edges.svg b/assets/icons/ui/design/svg/model/plane_two_edges.svg index 89e80fe6d4..cd6c34b9d8 100644 --- a/assets/icons/ui/design/svg/model/plane_two_edges.svg +++ b/assets/icons/ui/design/svg/model/plane_two_edges.svg @@ -1,6 +1,6 @@ Plane through two edges - - + + diff --git a/assets/icons/ui/design/svg/model/point_along_path.svg b/assets/icons/ui/design/svg/model/point_along_path.svg index 37b4bd66ac..53acd98e45 100644 --- a/assets/icons/ui/design/svg/model/point_along_path.svg +++ b/assets/icons/ui/design/svg/model/point_along_path.svg @@ -1,6 +1,6 @@ Point along path - + diff --git a/assets/icons/ui/design/svg/model/point_edge_plane.svg b/assets/icons/ui/design/svg/model/point_edge_plane.svg index 8b8b93035b..ec94d02ecd 100644 --- a/assets/icons/ui/design/svg/model/point_edge_plane.svg +++ b/assets/icons/ui/design/svg/model/point_edge_plane.svg @@ -1,9 +1,9 @@ Point at edge and plane - + - + diff --git a/assets/icons/ui/design/svg/model/point_two_edges.svg b/assets/icons/ui/design/svg/model/point_two_edges.svg index b4844e8845..9f81d196f2 100644 --- a/assets/icons/ui/design/svg/model/point_two_edges.svg +++ b/assets/icons/ui/design/svg/model/point_two_edges.svg @@ -1,6 +1,6 @@ Point through two edges - + diff --git a/assets/icons/ui/design/svg/model/select.svg b/assets/icons/ui/design/svg/model/select.svg index 461b436e74..b9528a63d9 100644 --- a/assets/icons/ui/design/svg/model/select.svg +++ b/assets/icons/ui/design/svg/model/select.svg @@ -1,4 +1,4 @@ Select - + diff --git a/assets/icons/ui/design/svg/model/sheet_rules.svg b/assets/icons/ui/design/svg/model/sheet_rules.svg index bc73919939..b72d7bce35 100644 --- a/assets/icons/ui/design/svg/model/sheet_rules.svg +++ b/assets/icons/ui/design/svg/model/sheet_rules.svg @@ -6,7 +6,7 @@ - + diff --git a/assets/icons/ui/design/svg/model/sketch_chamfer_angle.svg b/assets/icons/ui/design/svg/model/sketch_chamfer_angle.svg index bcebd81aff..b38ccb1dfb 100644 --- a/assets/icons/ui/design/svg/model/sketch_chamfer_angle.svg +++ b/assets/icons/ui/design/svg/model/sketch_chamfer_angle.svg @@ -2,7 +2,7 @@ Distance and angle sketch chamfer - + diff --git a/assets/icons/ui/design/svg/model/sketch_chamfer_two_distances.svg b/assets/icons/ui/design/svg/model/sketch_chamfer_two_distances.svg index 323fbd9ea8..76d5f717c6 100644 --- a/assets/icons/ui/design/svg/model/sketch_chamfer_two_distances.svg +++ b/assets/icons/ui/design/svg/model/sketch_chamfer_two_distances.svg @@ -2,7 +2,7 @@ Two distance sketch chamfer - + diff --git a/assets/icons/ui/design/svg/model/thicken.svg b/assets/icons/ui/design/svg/model/thicken.svg index e72ea9e498..089485534f 100644 --- a/assets/icons/ui/design/svg/model/thicken.svg +++ b/assets/icons/ui/design/svg/model/thicken.svg @@ -2,8 +2,8 @@ Thicken - - + + @@ -11,9 +11,9 @@ - + - - + + diff --git a/assets/icons/ui/design/svg/workspace/ws_animate.svg b/assets/icons/ui/design/svg/workspace/ws_animate.svg index d53cfbb81a..048485c9c2 100644 --- a/assets/icons/ui/design/svg/workspace/ws_animate.svg +++ b/assets/icons/ui/design/svg/workspace/ws_animate.svg @@ -1,9 +1,9 @@ Animate workspace - + - + diff --git a/assets/icons/ui/design/tools/icon_status.py b/assets/icons/ui/design/tools/icon_status.py index f0c9fbaab0..f2c08d1cd0 100644 --- a/assets/icons/ui/design/tools/icon_status.py +++ b/assets/icons/ui/design/tools/icon_status.py @@ -9,7 +9,7 @@ --out FILE Self-contained static review page (default target/icon-status.html) Read assets/icons/ui/STYLE_GUIDE.md before editing artwork. SVGs are authoritative. -Every discovered non-final icon is Open; no inventory file controls discovery. +Every discovered non-final icon is Open; artwork, source and runtime requests are discovery sources. """ import sys, os, re, glob, html, json, hashlib, collections, tomllib, uuid, datetime @@ -36,6 +36,7 @@ def art_rev(): paths += glob.glob(ROOT + '/crates/ui-egui/src/**/*.rs', recursive=True) paths += glob.glob(HERE + '/*.py') paths += [D + '/palette.toml', D + '/aliases.toml', __file__] + paths += glob.glob(D + '/requested_icons.txt') for path in sorted(paths): # Equal-length edits can share an mtime. Only the content identifies what # the reviewer saw; file metadata must never authorize stale acceptance. @@ -58,6 +59,24 @@ def ui_icon_names(source): names.update(re.findall(r'\("[^"]*",\s*"([a-z][a-z0-9_]*)",\s*"[^"]*"\)', table.group(1))) return names +def requested_icons(): + """Runtime ids recorded by the Harness audit; flags describe its last actual render.""" + path = os.path.join(D, 'requested_icons.txt') + if not os.path.exists(path): + return {} + requested = {} + with open(path, encoding='utf-8') as manifest: + for number, line in enumerate(manifest, 1): + if not line.strip() or line.startswith('#'): + continue + fields = line.strip().split('\t') + if len(fields) != 2 or not re.fullmatch(r'[a-z][a-z0-9_]*', fields[0]) or fields[1] not in ('true', 'false'): + raise ValueError(f'{path}:{number}: expected icon_idtrue|false') + if fields[0] in requested: + raise ValueError(f'{path}:{number}: duplicate requested icon {fields[0]}') + requested[fields[0]] = fields[1] == 'true' + return requested + def scan(): """Everything derived from the repo: rows, summary.""" pal = tomllib.load(open(D + '/palette.toml', 'rb')) @@ -86,6 +105,9 @@ def scan(): t = open(f, encoding='utf-8').read() for name in ui_icon_names(t): uses[name].append('ui:' + os.path.relpath(f, ROOT + '/crates/ui-egui/src').replace(os.sep, '/')) + runtime = requested_icons() + for name in runtime: + uses[name].append('ui:runtime audit') mk = ROOT + '/assets/icons/ui/source/markers.rs' if os.path.exists(mk): for name in re.findall(r'=> "(c_[a-z_]+)"', open(mk, encoding='utf-8').read()): uses[name].append('ui:sketch constraint glyph') @@ -95,7 +117,7 @@ def scan(): rows = [] for n in sorted(set(uses) | set(masters) | set(alias_raw)): m, acc = alias_of(n) - if m in masters and n != 'placeholder': status = 'done' + if m in masters and n != 'placeholder' and not runtime.get(n, False): status = 'done' else: status, m = 'placeholder', 'placeholder' cmds = [u for u in uses.get(n, []) if not u.startswith('ui:')] rows.append(dict(id=n, status=status, master=m, accent=acc, commands=len(cmds), example=cmds[0] if cmds else '', diff --git a/assets/icons/ui/design/tools/lint_icons.py b/assets/icons/ui/design/tools/lint_icons.py index 6c7fe532cd..fd95ad704d 100644 --- a/assets/icons/ui/design/tools/lint_icons.py +++ b/assets/icons/ui/design/tools/lint_icons.py @@ -15,7 +15,7 @@ files = sorted(glob.glob(d + '/svg/*/*.svg')) for path in Path(d).rglob('*'): if not path.is_file(): continue rel = path.relative_to(d).as_posix() - allowed = (rel in {'palette.toml', 'aliases.toml', 'review.json', 'references.toml'} + allowed = (rel in {'palette.toml', 'aliases.toml', 'review.json', 'references.toml', 'requested_icons.txt'} or path.suffix == '.md' or (rel.startswith('svg/') and path.suffix == '.svg') or (path.parent in {Path(d) / 'tools', Path(d) / 'tools/author'} and path.suffix == '.py') @@ -79,6 +79,36 @@ if os.path.exists(references): if name not in names and name not in al: err(references, name + ': reference artwork is missing') for message in icon_palette.validation_errors(pal, names | set(al)): err(d + '/palette.toml', message) +# the review page lies its icons on the real app surfaces: its colours must be the app's tokens +def app_tokens(): + src = open(os.path.join(d, '..', '..', '..', 'styles', 'ui', 'palettes.rs'), encoding='utf-8').read() + lo, hi = src.index('pub const fn light'), src.index('pub const fn dark') + light_src, dark_src = src[lo:hi], src[hi:] + def pick(block, name, dark): + pat = (r'\bt\.%s = Color32::from_rgb\((\d+), (\d+), (\d+)\)' if dark else r'\b%s: Color32::from_rgb\((\d+), (\d+), (\d+)\)') % name + m = re.search(pat, block) + return None if not m else '#%02x%02x%02x' % tuple(int(x) for x in m.groups()) + out = {} + for name in ('toolbar', 'panel', 'border', 'text', 'text_dim', 'viewport_top'): + out[name] = (pick(light_src, name, False), pick(dark_src, name, True) or pick(light_src, name, False)) + return out +css_path = os.path.join(d, 'tools', 'review', 'review.css') +if os.path.exists(css_path): + try: + tok = app_tokens() + css = open(css_path, encoding='utf-8').read() + want = { + ':root{': {'--bg': tok['panel'][0], '--fg': tok['text'][0], '--card': tok['toolbar'][0], '--line': tok['border'][0], '--mut': tok['text_dim'][0]}, + 'body.dark{': {'--bg': tok['viewport_top'][1], '--fg': tok['text'][1], '--card': tok['toolbar'][1], '--line': tok['border'][1], '--mut': tok['text_dim'][1]}, + } + for block, values in want.items(): + line = next((l for l in css.split('\n') if l.startswith(block)), '') + for var, hexv in values.items(): + m = re.search(re.escape(var) + r':(#[0-9a-fA-F]{6})', line) + if not m or m.group(1).lower() != hexv: + err(css_path, '%s%s is %s, the app token is %s (palettes.rs)' % (block, var, m.group(1) if m else 'missing', hexv)) + except (OSError, ValueError) as e: + err(css_path, 'cannot compare with palettes.rs: %s' % e) # an icon accepted as final on the review page must not change (un-accept it there to work on it again) import json, review_state rp = d + '/review.json' diff --git a/assets/icons/ui/design/tools/preview.py b/assets/icons/ui/design/tools/preview.py index 400fe8df1b..82da8936f1 100644 --- a/assets/icons/ui/design/tools/preview.py +++ b/assets/icons/ui/design/tools/preview.py @@ -9,7 +9,7 @@ aliases = tomllib.load(open(d + '/aliases.toml', 'rb')).get('alias', {}) def themed(svg, theme, icon, family, size): _, accent = icon_palette.resolve(palette, aliases, icon) return icon_palette.themed(svg, palette, theme, accent, namespace='preview-' + family + '-' + icon + '-' + theme + '-' + str(size)) -h = '' +h = '' for theme in ('light', 'dark'): for fam in sorted(os.listdir(d + '/svg')): h += '

%s - %s

' % (theme, fam, theme) diff --git a/assets/icons/ui/design/tools/review/guide.html b/assets/icons/ui/design/tools/review/guide.html index 062199381c..7719b42f40 100644 --- a/assets/icons/ui/design/tools/review/guide.html +++ b/assets/icons/ui/design/tools/review/guide.html @@ -1,6 +1,6 @@ SolidCraft icon style references - +

Icon style references

Return to review. The binding specification is assets/icons/ui/STYLE_GUIDE.md. Read it before editing. These are original SolidCraft bases and finished visual references, shown directly from the current SVG masters.

64-unit grid · 2.55 outlines · 4.25 action strokes · round caps and joins · standard 30° isometric axes · top light, left medium, right dark.

diff --git a/assets/icons/ui/design/tools/review/review.css b/assets/icons/ui/design/tools/review/review.css index f6aa119e96..6c3fe03177 100644 --- a/assets/icons/ui/design/tools/review/review.css +++ b/assets/icons/ui/design/tools/review/review.css @@ -1,5 +1,6 @@ -:root{--bg:#f4f6f9;--fg:#1d2733;--card:#fff;--line:#d5dbe3;--mut:#6b7a8a;--sz:48px} -body.dark{--bg:#1e2530;--fg:#dbe4ee;--card:#262f3c;--line:#3a4657;--mut:#8a9db0} +/* App surfaces (keep in sync with assets/styles/ui/palettes.rs; lint_icons.py checks): --card is the ribbon surface `toolbar` the icons lie on, --bg the page (`panel` in light, `viewport_top` in dark), --line `border`, --fg `text`, --mut `text_dim`. */ +:root{--bg:#fafbfc;--fg:#262a32;--card:#f6f7f9;--line:#d0d4dc;--mut:#6e7480;--sz:48px} +body.dark{--bg:#2e3440;--fg:#dee2e8;--card:#272b34;--line:#3f4552;--mut:#949ba8} body{font:12px system-ui,sans-serif;margin:0;background:var(--bg);color:var(--fg)} header{position:sticky;top:0;background:var(--card);border-bottom:1px solid var(--line);padding:6px 12px;z-index:5;display:flex;flex-wrap:wrap;gap:4px 10px;align-items:center} h1{font-size:14px;margin:0}.sum{color:var(--mut)} diff --git a/assets/icons/ui/source/markers.rs b/assets/icons/ui/source/markers.rs index 1cf8ec8436..3560a31351 100644 --- a/assets/icons/ui/source/markers.rs +++ b/assets/icons/ui/source/markers.rs @@ -49,7 +49,7 @@ pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option, kind: Sn // What it snaps to (hovered), or which component it is (picked): a small label. if let Some(tag) = tag { let t = Tokens::get(); - let font = egui::FontId::proportional(11.5); + let font = egui::FontId::proportional(crate::theme::Tokens::get().font_caption); let g = painter.layout_no_wrap(tag.to_string(), font, t.text); let at = c + vec2(12.0, 10.0); let r = egui::Rect::from_min_size(at, g.size()).expand2(vec2(5.0, 2.0)); diff --git a/assets/icons/ui/source/navigation.rs b/assets/icons/ui/source/navigation.rs index 7192a18148..611c3f558a 100644 --- a/assets/icons/ui/source/navigation.rs +++ b/assets/icons/ui/source/navigation.rs @@ -1,6 +1,6 @@ //! Navigation drawing definitions. Camera projection and picking remain in the UI widgets. use crate::theme::Tokens; -use egui::{Align2, Color32, FontId, Pos2, Rect, Shape, Stroke, pos2, vec2}; +use egui::{Color32, FontId, Pos2, Rect, Shape, Stroke, pos2, vec2}; use solidcraft_engine::geom::{Ring, Vec3}; pub(crate) struct Facet { pub(crate) direction: Vec3, @@ -120,7 +120,7 @@ pub(crate) fn paint_cube(painter: &egui::Painter, faces: &[Facet], hovered: Opti } if labels { for face in faces { - face_text(painter, face, t.cube_text, 14.0 * scale); + face_text(painter, face, t.cube_text, t.font_heading * scale); } } } @@ -187,13 +187,14 @@ pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, a } } if positive { - // The letters are heavy: egui's regular face gets a small overstrike for weight. On a - // hovered (white) disc the letter takes the axis colour. + // On a hovered (white) disc the letter takes the axis colour. let letter = if hovered { col } else { t.on_accent }; let label = name.to_uppercase(); - for dx in [-0.45, 0.45] { - painter.text(at + vec2(dx, 0.0), Align2::CENTER_CENTER, &label, FontId::proportional(13.5), letter); - } + let galley = painter.layout_no_wrap(label, crate::fonts::bold(crate::theme::Tokens::get().font_compass), letter); + let origin = at - galley.size() / 2.0; + let ppp = painter.ctx().pixels_per_point().max(0.5); + let origin = pos2((origin.x * ppp).round() / ppp, (origin.y * ppp).round() / ppp); + painter.galley(origin, galley, letter); } if !positive { painter.circle_stroke( diff --git a/assets/icons/ui/source/pointer.rs b/assets/icons/ui/source/pointer.rs index b637f75340..dd16e2d4da 100644 --- a/assets/icons/ui/source/pointer.rs +++ b/assets/icons/ui/source/pointer.rs @@ -11,7 +11,7 @@ pub(crate) fn paint(painter: &egui::Painter, p: Pos2) { [(0.0, 0.0), (0.0, 17.0), (4.5, 13.0), (7.5, 20.0), (10.5, 18.8), (7.5, 12.0), (13.0, 12.0)].iter().map(|(x, y)| p + vec2(*x, *y)).collect(); painter.add(Shape::convex_polygon(pts.clone(), ic.agent, Stroke::NONE)); painter.add(Shape::closed_line(pts, Stroke::new(1.5, ic.highlight))); - let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(11.0), ic.highlight); + let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(crate::theme::Tokens::get().font_small), ic.highlight); let r = egui::Rect::from_min_size(p + vec2(15.0, 18.0), label.size() + vec2(10.0, 4.0)); painter.rect_filled(r, 4.0, ic.agent); painter.galley(r.min + vec2(5.0, 2.0), label, ic.highlight); diff --git a/assets/locales/cs.json b/assets/locales/cs.json index a90c4b2f24..b7e183ada4 100644 --- a/assets/locales/cs.json +++ b/assets/locales/cs.json @@ -816,8 +816,18 @@ "settings.reset_title": "Nastavení byla změněna", "settings.supersampling_note": "Vykresluje pohled ve vyšším rozlišení a poté jej zmenšuje: hladší hrany, čáry a mřížka na obrazovkách pod 4K. Stojí výkon GPU: asi 2,25× více pixelů při 1,5× a 4× při 2×.", "sketch.degrees_of_freedom": "Stupně volnosti: {count}", + "sketch.dof_left": "Zbývající stupně volnosti: {count}", + "sketch.dof_left_one": "Zbývá {count} stupeň volnosti", "sketch.draw_curves": "Kreslit jako {linetype}", "sketch.make_selection": "Převést výběr na {linetype}", + "sketch_panel.attached": "Připojeno k {body} / {path}", + "sketch_panel.author": "Umístění autora: {path}", + "sketch_panel.dimension_hint": "Zadejte hodnotu nebo výraz a poté stiskněte OK.", + "sketch_panel.move_hint": "Zadejte vzdálenosti a úhel a poté stiskněte OK.", + "sketch_panel.redefine_title": "{command}: {name}", + "sketch_panel.sheet_attachment": "Připojeno k rovinné ploše plechového tělesa; při změně plechu sleduje tento panel.", + "sketch_panel.text_hint": "Upravte text a jeho umístění a poté stiskněte OK.", + "sketch_panel.use_plane": "Použít rovinu {plane}", "sketch_text.align_center": "Na střed", "sketch_text.align_left": "Vlevo", "sketch_text.align_right": "Vpravo", @@ -1073,6 +1083,7 @@ "ui.combine": "Kombinovat", "ui.coming_later": "připravujeme", "ui.command_done": "{command} hotovo", + "ui.command_palette_hint": "Zadejte příkaz, např. {example} — nebo stiskněte S pro hledání", "ui.concentric": "Soustřednost", "ui.configurations": "Konfigurace", "ui.conic_curve": "Kuželosečka", @@ -1328,8 +1339,11 @@ "ui.orbit_around": "Otáčet kolem", "ui.orientation": "Orientace", "ui.origin": "Počátek", + "ui.palette_construction_mode": "Konstrukční režim (X)", + "ui.palette_more": "Další", "ui.palette_points": "Body", "ui.palette_profile": "Profil", + "ui.palette_sketch_options": "Možnosti náčrtu", "ui.pan": "Posun", "ui.pan_zoom_orbit": "Posun, přiblížení, otáčení", "ui.parallel": "Rovnoběžnost", diff --git a/assets/locales/de.json b/assets/locales/de.json index 0a5d325322..a9b92f76fd 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -623,8 +623,18 @@ "settings.reset_title": "Einstellungen wurden geändert", "settings.supersampling_note": "Zeichnet die Ansicht in höherer Auflösung und rechnet sie herunter: glattere Kanten, Linien und Raster auf Bildschirmen unter 4K. Kostet GPU-Zeit: etwa 2,25-mal so viele Pixel bei 1,5× und 4-mal bei 2×.", "sketch.degrees_of_freedom": "{count} Freiheitsgrade", + "sketch.dof_left": "{count} Freiheitsgrade übrig", + "sketch.dof_left_one": "{count} Freiheitsgrad übrig", "sketch.draw_curves": "Als {linetype} zeichnen", "sketch.make_selection": "Auswahl als {linetype} markieren", + "sketch_panel.attached": "Angehängt an {body} / {path}", + "sketch_panel.author": "Autorenplatzierung: {path}", + "sketch_panel.dimension_hint": "Einen Wert oder Ausdruck eingeben, dann OK drücken.", + "sketch_panel.move_hint": "Abstände und einen Winkel eingeben, dann OK drücken.", + "sketch_panel.redefine_title": "{command}: {name}", + "sketch_panel.sheet_attachment": "An eine ebene Fläche eines Blechkörpers angehängt; folgt diesem Bereich bei Änderungen am Blech.", + "sketch_panel.text_hint": "Text und Platzierung bearbeiten, dann OK drücken.", + "sketch_panel.use_plane": "Ebene {plane} verwenden", "sketch_text.align_center": "Mitte", "sketch_text.align_left": "Links", "sketch_text.align_right": "Rechts", @@ -861,6 +871,7 @@ "ui.combine": "Kombinieren", "ui.coming_later": "bald verfügbar", "ui.command_done": "{command} fertig", + "ui.command_palette_hint": "Befehl eingeben, z. B. {example} — oder S drücken, um zu suchen", "ui.concentric": "Konzentrisch", "ui.configurations": "Konfigurationen", "ui.conic_curve": "Kegelschnitt", @@ -1115,8 +1126,11 @@ "ui.orbit_around": "Umkreisen um", "ui.orientation": "Ausrichtung", "ui.origin": "Ursprung", + "ui.palette_construction_mode": "Konstruktionsmodus (X)", + "ui.palette_more": "Mehr", "ui.palette_points": "Punkte", "ui.palette_profile": "Profil", + "ui.palette_sketch_options": "Skizzenoptionen", "ui.pan": "Verschieben", "ui.pan_zoom_orbit": "Verschieben, zoomen, umkreisen", "ui.parallel": "Parallel", diff --git a/assets/locales/en.json b/assets/locales/en.json index 932f107ed2..3adf554da0 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -905,10 +905,20 @@ "settings.reset_title": "Settings were changed", "settings.supersampling_note": "Draws the view at a higher resolution and filters it down: smoother edges, lines and grid on screens below 4K. It costs GPU time: about 2.25× the pixels at 1.5× and 4× at 2×.", "sketch.degrees_of_freedom": "{count} degrees of freedom", + "sketch.dof_left": "{count} degrees of freedom left", + "sketch.dof_left_one": "{count} degree of freedom left", "sketch.draw_curves": "Draw as {linetype}", "sketch.make_selection": "Convert the selection to {linetype}", "sketch.open_ends": "Open {count}", "sketch.untied_ends": "Untied {count}", + "sketch_panel.attached": "Attached to {body} / {path}", + "sketch_panel.author": "Author placement: {path}", + "sketch_panel.dimension_hint": "Enter a value or expression, then press OK.", + "sketch_panel.move_hint": "Enter distances and an angle, then press OK.", + "sketch_panel.redefine_title": "{command}: {name}", + "sketch_panel.sheet_attachment": "Attached to a flat face of a sheet metal body; it follows that panel when the sheet changes.", + "sketch_panel.text_hint": "Edit the text and its placement, then press OK.", + "sketch_panel.use_plane": "Use {plane} plane", "sketch_text.align_center": "Center", "sketch_text.align_left": "Left", "sketch_text.align_right": "Right", @@ -1205,6 +1215,7 @@ "ui.combine": "Combine", "ui.coming_later": "coming later", "ui.command_done": "{command} done", + "ui.command_palette_hint": "Type a command, e.g. {example} — or press S to search", "ui.concentric": "Concentric", "ui.configurations": "Configurations", "ui.conic_curve": "Conic Curve", @@ -1475,8 +1486,11 @@ "ui.orbit_around": "Orbit around", "ui.orientation": "Orientation", "ui.origin": "Origin", + "ui.palette_construction_mode": "Construction mode (X)", + "ui.palette_more": "More", "ui.palette_points": "Points", "ui.palette_profile": "Profile", + "ui.palette_sketch_options": "Sketch options", "ui.pan": "Pan", "ui.pan_zoom_orbit": "Pan, zoom, orbit", "ui.parallel": "Parallel", diff --git a/assets/locales/es.json b/assets/locales/es.json index a98c609dc2..1d815a4db1 100644 --- a/assets/locales/es.json +++ b/assets/locales/es.json @@ -831,8 +831,18 @@ "settings.reset_title": "Se han cambiado los ajustes", "settings.supersampling_note": "Dibuja la vista a mayor resolución y después la reduce: bordes, líneas y cuadrícula más suaves en pantallas inferiores a 4K. Consume recursos de GPU: unos 2,25× píxeles a 1,5× y 4× a 2×.", "sketch.degrees_of_freedom": "{count} grados de libertad", + "sketch.dof_left": "Quedan {count} grados de libertad", + "sketch.dof_left_one": "Queda {count} grado de libertad", "sketch.draw_curves": "Dibujar como {linetype}", "sketch.make_selection": "Convertir la selección a {linetype}", + "sketch_panel.attached": "Vinculado a {body} / {path}", + "sketch_panel.author": "Ubicación de autoría: {path}", + "sketch_panel.dimension_hint": "Introduzca un valor o una expresión y pulse Aceptar.", + "sketch_panel.move_hint": "Introduzca las distancias y un ángulo y pulse Aceptar.", + "sketch_panel.redefine_title": "{command}: {name}", + "sketch_panel.sheet_attachment": "Vinculado a una cara plana de un cuerpo de chapa; sigue ese panel cuando cambia la chapa.", + "sketch_panel.text_hint": "Edite el texto y su posición y pulse Aceptar.", + "sketch_panel.use_plane": "Usar plano {plane}", "sketch_text.align_center": "Centrar", "sketch_text.align_left": "Alinear a la izquierda", "sketch_text.align_right": "Alinear a la derecha", @@ -1092,6 +1102,7 @@ "ui.combine": "Combinar", "ui.coming_later": "próximamente", "ui.command_done": "{command} completado", + "ui.command_palette_hint": "Escribe un comando, p. ej., {example}, o pulsa S para buscar", "ui.concentric": "Concéntrico", "ui.configurations": "Configuraciones", "ui.conic_curve": "Curva cónica", @@ -1352,8 +1363,11 @@ "ui.orbit_around": "Orbitar alrededor de", "ui.orientation": "Orientación", "ui.origin": "Origen", + "ui.palette_construction_mode": "Modo de construcción (X)", + "ui.palette_more": "Más", "ui.palette_points": "Puntos", "ui.palette_profile": "Perfil", + "ui.palette_sketch_options": "Opciones de croquis", "ui.pan": "Desplazar", "ui.pan_zoom_orbit": "Desplazar, zoom y orbitar", "ui.parallel": "Paralelo", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 2c748ac630..93ccd9f1b5 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -845,10 +845,20 @@ "settings.reset_title": "Des réglages ont été modifiés", "settings.supersampling_note": "Dessine la vue à une résolution plus élevée puis la réduit : arêtes, lignes et grille plus lisses sur les écrans de définition inférieure à la 4K. Cela coûte du temps GPU : environ 2,25× plus de pixels à 1,5× et 4× plus à 2×.", "sketch.degrees_of_freedom": "{count} degrés de liberté", + "sketch.dof_left": "{count} degrés de liberté restants", + "sketch.dof_left_one": "{count} degré de liberté restant", "sketch.draw_curves": "Dessiner en {linetype}", "sketch.make_selection": "Convertir la sélection en {linetype}", "sketch.open_ends": "{count} ouvertes", "sketch.untied_ends": "{count} sans coïncidence", + "sketch_panel.attached": "Attaché à {body} / {path}", + "sketch_panel.author": "Placement d’origine : {path}", + "sketch_panel.dimension_hint": "Saisissez une valeur ou une expression, puis appuyez sur OK.", + "sketch_panel.move_hint": "Saisissez les distances et un angle, puis appuyez sur OK.", + "sketch_panel.redefine_title": "{command} : {name}", + "sketch_panel.sheet_attachment": "Attaché à une face plane d’un corps de tôlerie ; suit ce panneau lorsque la tôle change.", + "sketch_panel.text_hint": "Modifiez le texte et son placement, puis appuyez sur OK.", + "sketch_panel.use_plane": "Utiliser le plan {plane}", "sketch_text.align_center": "Centre", "sketch_text.align_left": "Gauche", "sketch_text.align_right": "Droite", @@ -1143,6 +1153,7 @@ "ui.combine": "Combiner", "ui.coming_later": "bientôt disponible", "ui.command_done": "{command} terminé", + "ui.command_palette_hint": "Tapez une commande, ex. {example} — ou S pour chercher", "ui.concentric": "Concentricité", "ui.configurations": "Configurations", "ui.conic_curve": "Courbe conique", @@ -1411,8 +1422,11 @@ "ui.orbit_around": "Orbite autour de", "ui.orientation": "Orientation", "ui.origin": "Origine", + "ui.palette_construction_mode": "Mode construction (X)", + "ui.palette_more": "Plus", "ui.palette_points": "Points", "ui.palette_profile": "Profil", + "ui.palette_sketch_options": "Options d’esquisse", "ui.pan": "Panoramique", "ui.pan_zoom_orbit": "Panoramique, zoom, orbite", "ui.parallel": "Parallélisme", diff --git a/assets/locales/gl.json b/assets/locales/gl.json index 1f5e26f3b6..5d4efc3bcf 100644 --- a/assets/locales/gl.json +++ b/assets/locales/gl.json @@ -807,8 +807,18 @@ "settings.reset_title": "Cambiáronse os axustes", "settings.supersampling_note": "Debuxa a vista a unha resolución maior e fíltraa cara abaixo: arestas, liñas e grella máis suaves en pantallas por debaixo de 4K. Consome tempo de GPU: uns 2,25× píxeles a 1,5× e 4× a 2×.", "sketch.degrees_of_freedom": "{count} graos de liberdade", + "sketch.dof_left": "Quedan {count} graos de liberdade", + "sketch.dof_left_one": "Queda {count} grao de liberdade", "sketch.draw_curves": "Debuxar como {linetype}", "sketch.make_selection": "Converter a selección en {linetype}", + "sketch_panel.attached": "Ligado a {body} / {path}", + "sketch_panel.author": "Localización de autoría: {path}", + "sketch_panel.dimension_hint": "Introduza un valor ou unha expresión e prema Aceptar.", + "sketch_panel.move_hint": "Introduza as distancias e un ángulo e prema Aceptar.", + "sketch_panel.redefine_title": "{command}: {name}", + "sketch_panel.sheet_attachment": "Ligado a unha cara plana dun corpo de chapa; segue ese panel cando cambia a chapa.", + "sketch_panel.text_hint": "Edite o texto e a súa posición e prema Aceptar.", + "sketch_panel.use_plane": "Usar plano {plane}", "sketch_text.align_center": "Centro", "sketch_text.align_left": "Esquerda", "sketch_text.align_right": "Dereita", @@ -1064,6 +1074,7 @@ "ui.combine": "Combinar", "ui.coming_later": "máis adiante", "ui.command_done": "{command} feito", + "ui.command_palette_hint": "Escribe unha orde, p. ex. {example} — ou preme S para buscar", "ui.concentric": "Concéntrica", "ui.configurations": "Configuracións", "ui.conic_curve": "Curva cónica", @@ -1319,8 +1330,11 @@ "ui.orbit_around": "Orbitar arredor de", "ui.orientation": "Orientación", "ui.origin": "Orixe", + "ui.palette_construction_mode": "Modo de construción (X)", + "ui.palette_more": "Máis", "ui.palette_points": "Puntos", "ui.palette_profile": "Perfil", + "ui.palette_sketch_options": "Opcións do esbozo", "ui.pan": "Desprazar", "ui.pan_zoom_orbit": "Desprazar, zoom, orbitar", "ui.parallel": "Paralela", diff --git a/assets/locales/it.json b/assets/locales/it.json index 62cbd03b16..3bbdd118a7 100644 --- a/assets/locales/it.json +++ b/assets/locales/it.json @@ -168,7 +168,6 @@ "No matching appearances": "Nessun aspetto corrispondente", "No visible bodies to export.": "Nessun corpo visibile da esportare.", "None": "Nessuno", - "Normal to sketch": "Normale allo schizzo", "Not available yet": "Non ancora disponibile", "Notch": "Intaglio", "Opacity": "Opacità", @@ -812,10 +811,20 @@ "settings.reset_title": "Le impostazioni sono state modificate", "settings.supersampling_note": "Disegna la vista a una risoluzione maggiore e la riduce tramite filtro: spigoli, linee e griglia più uniformi sugli schermi sotto il 4K. Richiede tempo GPU: circa 2.25× i pixel a 1.5× e 4× a 2×.", "sketch.degrees_of_freedom": "{count} gradi di libertà", + "sketch.dof_left": "{count} gradi di libertà rimanenti", + "sketch.dof_left_one": "{count} grado di libertà rimanente", "sketch.draw_curves": "Disegna come {linetype}", "sketch.make_selection": "Converti la selezione in {linetype}", "sketch.open_ends": "Aperte {count}", "sketch.untied_ends": "Non vincolate {count}", + "sketch_panel.attached": "Collegato a {body} / {path}", + "sketch_panel.author": "Posizionamento originale: {path}", + "sketch_panel.dimension_hint": "Inserisci un valore o un’espressione, poi premi OK.", + "sketch_panel.move_hint": "Inserisci distanze e angolo, poi premi OK.", + "sketch_panel.redefine_title": "{command}: {name}", + "sketch_panel.sheet_attachment": "Collegato a una faccia piana di un corpo in lamiera; segue il pannello quando la lamiera cambia.", + "sketch_panel.text_hint": "Modifica il testo e la sua posizione, poi premi OK.", + "sketch_panel.use_plane": "Usa il piano {plane}", "sketch_text.align_center": "Centro", "sketch_text.align_left": "Sinistra", "sketch_text.align_right": "Destra", @@ -1075,6 +1084,7 @@ "ui.combine": "Combina", "ui.coming_later": "in arrivo", "ui.command_done": "{command} completato", + "ui.command_palette_hint": "Digita un comando, ad es. {example} — oppure premi S per cercare", "ui.concentric": "Concentrico", "ui.configurations": "Configurazioni", "ui.conic_curve": "Curva conica", @@ -1336,8 +1346,11 @@ "ui.orbit_around": "Orbita attorno a", "ui.orientation": "Orientamento", "ui.origin": "Origine", + "ui.palette_construction_mode": "Modalità costruzione (X)", + "ui.palette_more": "Altro", "ui.palette_points": "Punti", "ui.palette_profile": "Profilo", + "ui.palette_sketch_options": "Opzioni schizzo", "ui.pan": "Panoramica", "ui.pan_zoom_orbit": "Panoramica, zoom, orbita", "ui.parallel": "Parallelo", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index feec1edefd..0c8a593d6b 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -905,10 +905,20 @@ "settings.reset_title": "設定が変更されました", "settings.supersampling_note": "ビューを高い解像度で描画してから縮小します。4K 未満の画面でエッジ、線、グリッドが滑らかになります。GPU の負荷が増えます(1.5× で約 2.25 倍、2× で 4 倍のピクセル)。", "sketch.degrees_of_freedom": "自由度 {count}", + "sketch.dof_left": "残り自由度 {count}", + "sketch.dof_left_one": "残り自由度 {count}", "sketch.draw_curves": "{linetype}として描く", "sketch.make_selection": "選択を{linetype}にする", "sketch.open_ends": "開放 {count}", "sketch.untied_ends": "未拘束 {count}", + "sketch_panel.attached": "接続先: {body} / {path}", + "sketch_panel.author": "作成時の配置: {path}", + "sketch_panel.dimension_hint": "値または式を入力し、OK を押してください。", + "sketch_panel.move_hint": "距離と角度を入力し、OK を押してください。", + "sketch_panel.redefine_title": "{command}:{name}", + "sketch_panel.sheet_attachment": "板金ボディの平面に接続されています。板金が変更されると、そのパネルに追従します。", + "sketch_panel.text_hint": "テキストと配置を編集し、OK を押してください。", + "sketch_panel.use_plane": "{plane} 平面を使用", "sketch_text.align_center": "中央", "sketch_text.align_left": "左", "sketch_text.align_right": "右", @@ -1205,6 +1215,7 @@ "ui.combine": "結合", "ui.coming_later": "準備中", "ui.command_done": "{command} 完了", + "ui.command_palette_hint": "コマンドを入力(例: {example})— または S キーで検索", "ui.concentric": "同心", "ui.configurations": "構成", "ui.conic_curve": "円錐曲線", @@ -1475,8 +1486,11 @@ "ui.orbit_around": "回転中心", "ui.orientation": "向き", "ui.origin": "原点", + "ui.palette_construction_mode": "補助線モード (X)", + "ui.palette_more": "その他", "ui.palette_points": "点", "ui.palette_profile": "プロファイル", + "ui.palette_sketch_options": "スケッチオプション", "ui.pan": "パン", "ui.pan_zoom_orbit": "パン、ズーム、オービット", "ui.parallel": "平行", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index a4f225dc32..5b008cc2e6 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -623,8 +623,18 @@ "settings.reset_title": "설정이 변경되었습니다", "settings.supersampling_note": "뷰를 더 높은 해상도로 그린 뒤 축소합니다. 4K 미만 화면에서 모서리, 선, 그리드가 더 매끄러워집니다. GPU 시간이 듭니다: 1.5×에서 약 2.25배, 2×에서 4배의 픽셀.", "sketch.degrees_of_freedom": "자유도 {count}개", + "sketch.dof_left": "남은 자유도 {count}개", + "sketch.dof_left_one": "남은 자유도 {count}개", "sketch.draw_curves": "{linetype}(으)로 그리기", "sketch.make_selection": "선택 항목을 {linetype}(으)로 변경", + "sketch_panel.attached": "부착 대상: {body} / {path}", + "sketch_panel.author": "작성 배치: {path}", + "sketch_panel.dimension_hint": "값이나 수식을 입력한 다음 확인을 누르세요.", + "sketch_panel.move_hint": "거리와 각도를 입력한 다음 확인을 누르세요.", + "sketch_panel.redefine_title": "{command}: {name}", + "sketch_panel.sheet_attachment": "판금 바디의 평평한 면에 부착되어 있으며 판금이 변경되면 해당 패널을 따릅니다.", + "sketch_panel.text_hint": "텍스트와 배치를 편집한 다음 확인을 누르세요.", + "sketch_panel.use_plane": "{plane} 평면 사용", "sketch_text.align_center": "가운데", "sketch_text.align_left": "왼쪽", "sketch_text.align_right": "오른쪽", @@ -861,6 +871,7 @@ "ui.combine": "결합", "ui.coming_later": "추후 지원 예정", "ui.command_done": "{command} 완료", + "ui.command_palette_hint": "명령 입력(예: {example}) — 또는 S 키를 눌러 검색", "ui.concentric": "동심", "ui.configurations": "구성", "ui.conic_curve": "원뿔 곡선", @@ -1115,8 +1126,11 @@ "ui.orbit_around": "궤도 회전 기준", "ui.orientation": "배향", "ui.origin": "원점", + "ui.palette_construction_mode": "보조선 모드 (X)", + "ui.palette_more": "더 보기", "ui.palette_points": "점", "ui.palette_profile": "프로파일", + "ui.palette_sketch_options": "스케치 옵션", "ui.pan": "이동", "ui.pan_zoom_orbit": "이동, 확대/축소, 궤도 회전", "ui.parallel": "평행", diff --git a/assets/locales/ms.json b/assets/locales/ms.json index 1767deb878..510fea6c2f 100644 --- a/assets/locales/ms.json +++ b/assets/locales/ms.json @@ -1060,6 +1060,7 @@ "ui.combine": "Gabung", "ui.coming_later": "akan datang", "ui.command_done": "{command} selesai", + "ui.command_palette_hint": "Taip arahan, cth. {example} — atau tekan S untuk mencari", "ui.concentric": "Sepusat", "ui.configurations": "Konfigurasi", "ui.conic_curve": "Lengkung Kon", diff --git a/assets/locales/pl.json b/assets/locales/pl.json index a42500712b..3b8c02fc04 100644 --- a/assets/locales/pl.json +++ b/assets/locales/pl.json @@ -798,10 +798,20 @@ "settings.reset_title": "Zmieniono ustawienia", "settings.supersampling_note": "Rysuje widok w wyższej rozdzielczości i go zmniejsza: gładsze krawędzie, linie i siatka na ekranach poniżej 4K. Kosztuje czas GPU: ok. 2,25× więcej pikseli przy 1,5× i 4× przy 2×.", "sketch.degrees_of_freedom": "Stopnie swobody: {count}", + "sketch.dof_left": "Pozostałe stopnie swobody: {count}", + "sketch.dof_left_one": "Pozostał {count} stopień swobody", "sketch.draw_curves": "Rysuj jako {linetype}", "sketch.make_selection": "Zamień zaznaczenie na {linetype}", "sketch.open_ends": "Otwarte: {count}", "sketch.untied_ends": "Niezwiązane: {count}", + "sketch_panel.attached": "Dołączony do {body} / {path}", + "sketch_panel.author": "Pierwotne położenie: {path}", + "sketch_panel.dimension_hint": "Wprowadź wartość lub wyrażenie, a następnie naciśnij OK.", + "sketch_panel.move_hint": "Wprowadź odległości i kąt, a następnie naciśnij OK.", + "sketch_panel.redefine_title": "{command}: {name}", + "sketch_panel.sheet_attachment": "Dołączony do płaskiej ściany bryły blaszanej; podąża za tym panelem przy zmianach blachy.", + "sketch_panel.text_hint": "Edytuj tekst i jego położenie, a następnie naciśnij OK.", + "sketch_panel.use_plane": "Użyj płaszczyzny {plane}", "sketch_text.align_center": "Do środka", "sketch_text.align_left": "Do lewej", "sketch_text.align_right": "Do prawej", @@ -1063,6 +1073,7 @@ "ui.combine": "Kombinacja", "ui.coming_later": "wkrótce", "ui.command_done": "Wykonano: {command}", + "ui.command_palette_hint": "Wpisz polecenie, np. {example} — lub naciśnij S, aby wyszukać", "ui.concentric": "Współśrodkowość", "ui.configurations": "Konfiguracje", "ui.conic_curve": "Krzywa stożkowa", @@ -1324,8 +1335,11 @@ "ui.orbit_around": "Obracaj wokół", "ui.orientation": "Orientacja", "ui.origin": "Początek układu", + "ui.palette_construction_mode": "Tryb konstrukcyjny (X)", + "ui.palette_more": "Więcej", "ui.palette_points": "Punkty", "ui.palette_profile": "Profil", + "ui.palette_sketch_options": "Opcje szkicu", "ui.pan": "Przesuwanie", "ui.pan_zoom_orbit": "Przesuwanie, powiększanie, obracanie", "ui.parallel": "Równoległość", diff --git a/assets/locales/pt-BR.json b/assets/locales/pt-BR.json index 4dae07958f..5ae9a02802 100644 --- a/assets/locales/pt-BR.json +++ b/assets/locales/pt-BR.json @@ -790,8 +790,18 @@ "settings.reset_title": "As configurações foram alteradas", "settings.supersampling_note": "Desenha a vista em resolução maior e a filtra para baixo: arestas, linhas e grade mais suaves em telas abaixo de 4K. Custa tempo de GPU: cerca de 2,25× os pixels em 1,5× e 4× em 2×.", "sketch.degrees_of_freedom": "{count} graus de liberdade", + "sketch.dof_left": "{count} graus de liberdade restantes", + "sketch.dof_left_one": "{count} grau de liberdade restante", "sketch.draw_curves": "Desenhar como {linetype}", "sketch.make_selection": "Converter a seleção em {linetype}", + "sketch_panel.attached": "Anexado a {body} / {path}", + "sketch_panel.author": "Posicionamento de autoria: {path}", + "sketch_panel.dimension_hint": "Insira um valor ou uma expressão e pressione OK.", + "sketch_panel.move_hint": "Insira as distâncias e um ângulo e pressione OK.", + "sketch_panel.redefine_title": "{command}: {name}", + "sketch_panel.sheet_attachment": "Anexado a uma face plana de um corpo de chapa; acompanha esse painel quando a chapa muda.", + "sketch_panel.text_hint": "Edite o texto e seu posicionamento e pressione OK.", + "sketch_panel.use_plane": "Usar plano {plane}", "sketch_text.align_center": "Centro", "sketch_text.align_left": "Esquerda", "sketch_text.align_right": "Direita", @@ -1047,6 +1057,7 @@ "ui.combine": "Combinar", "ui.coming_later": "em breve", "ui.command_done": "{command} concluído", + "ui.command_palette_hint": "Digite um comando, p. ex. {example} — ou pressione S para pesquisar", "ui.concentric": "Concêntrico", "ui.configurations": "Configurações", "ui.conic_curve": "Curva cônica", @@ -1302,8 +1313,11 @@ "ui.orbit_around": "Orbitar em torno de", "ui.orientation": "Orientação", "ui.origin": "Origem", + "ui.palette_construction_mode": "Modo de construção (X)", + "ui.palette_more": "Mais", "ui.palette_points": "Pontos", "ui.palette_profile": "Perfil", + "ui.palette_sketch_options": "Opções de esboço", "ui.pan": "Deslocar", "ui.pan_zoom_orbit": "Deslocar, zoom, orbitar", "ui.parallel": "Paralelo", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 9e94cd002a..3a7d82f0f3 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -752,8 +752,18 @@ "settings.reset_intro": "Эти настройки не работают на этом компьютере и могли бы помешать запуску SolidCraft, поэтому они были изменены:", "settings.reset_title": "Настройки были изменены", "sketch.degrees_of_freedom": "Степеней свободы: {count}", + "sketch.dof_left": "Осталось степеней свободы: {count}", + "sketch.dof_left_one": "Осталась {count} степень свободы", "sketch.draw_curves": "Рисовать как {linetype}", "sketch.make_selection": "Преобразовать выделение в {linetype}", + "sketch_panel.attached": "Привязано к {body} / {path}", + "sketch_panel.author": "Исходное размещение: {path}", + "sketch_panel.dimension_hint": "Введите значение или выражение, затем нажмите OK.", + "sketch_panel.move_hint": "Введите расстояния и угол, затем нажмите OK.", + "sketch_panel.redefine_title": "{command}: {name}", + "sketch_panel.sheet_attachment": "Привязано к плоской грани листового тела; следует за этой панелью при изменении листа.", + "sketch_panel.text_hint": "Измените текст и его размещение, затем нажмите OK.", + "sketch_panel.use_plane": "Использовать плоскость {plane}", "sketch_text.align_center": "По центру", "sketch_text.align_left": "По левому краю", "sketch_text.align_right": "По правому краю", @@ -856,6 +866,7 @@ "ui.combine": "Объединение", "ui.coming_later": "появится позже", "ui.command_done": "{command} — готово", + "ui.command_palette_hint": "Введите команду, напр. {example} — или нажмите S для поиска", "ui.concentric": "Концентричность", "ui.configurations": "Конфигурации", "ui.conic_curve": "Коническая кривая", @@ -1110,8 +1121,11 @@ "ui.orbit_around": "Вращать вокруг", "ui.orientation": "Ориентация", "ui.origin": "Начало координат", + "ui.palette_construction_mode": "Вспомогательный режим (X)", + "ui.palette_more": "Ещё", "ui.palette_points": "Точки", "ui.palette_profile": "Профиль", + "ui.palette_sketch_options": "Параметры эскиза", "ui.pan": "Панорамирование", "ui.pan_zoom_orbit": "Панорама, масштаб, орбита", "ui.parallel": "Параллельность", diff --git a/assets/locales/tr.json b/assets/locales/tr.json index 2747eef9ed..84aba852cd 100644 --- a/assets/locales/tr.json +++ b/assets/locales/tr.json @@ -755,8 +755,18 @@ "settings.reset_title": "Ayarlar değiştirildi", "settings.supersampling_note": "Görünümü daha yüksek çözünürlükte çizer ve küçültür: 4K altındaki ekranlarda kenarlar, çizgiler ve ızgaralar daha pürüzsüz görünür. GPU süresi gerektirir: 1,5× ayarında yaklaşık 2,25 kat, 2× ayarında 4 kat daha fazla piksel işlenir.", "sketch.degrees_of_freedom": "{count} serbestlik derecesi", + "sketch.dof_left": "Kalan serbestlik derecesi: {count}", + "sketch.dof_left_one": "Kalan serbestlik derecesi: {count}", "sketch.draw_curves": "{linetype} olarak çiz", "sketch.make_selection": "Seçimi {linetype} olarak işaretle", + "sketch_panel.attached": "Bağlı olduğu: {body} / {path}", + "sketch_panel.author": "Yazar yerleşimi: {path}", + "sketch_panel.dimension_hint": "Bir değer veya ifade girip Tamam’a basın.", + "sketch_panel.move_hint": "Uzaklıkları ve bir açı girip Tamam’a basın.", + "sketch_panel.redefine_title": "{command}: {name}", + "sketch_panel.sheet_attachment": "Sac gövdenin düz bir yüzüne bağlıdır; sac değiştiğinde bu paneli izler.", + "sketch_panel.text_hint": "Metni ve yerleşimini düzenleyip Tamam’a basın.", + "sketch_panel.use_plane": "{plane} düzlemini kullan", "sketch_text.align_center": "Orta", "sketch_text.align_left": "Sol", "sketch_text.align_right": "Sağ", @@ -994,6 +1004,7 @@ "ui.combine": "Birleştir", "ui.coming_later": "yakında", "ui.command_done": "{command} tamamlandı", + "ui.command_palette_hint": "Bir komut yazın (ör. {example}) — veya aramak için S tuşuna basın", "ui.concentric": "Eş merkezli", "ui.configurations": "Konfigürasyonlar", "ui.conic_curve": "Konik eğri", @@ -1247,8 +1258,11 @@ "ui.orbit_around": "Etrafında döndür", "ui.orientation": "Yönlendirme", "ui.origin": "Orijin", + "ui.palette_construction_mode": "Yardımcı çizim modu (X)", + "ui.palette_more": "Diğer", "ui.palette_points": "Noktalar", "ui.palette_profile": "Profil", + "ui.palette_sketch_options": "Eskiz seçenekleri", "ui.pan": "Kaydır", "ui.pan_zoom_orbit": "Kaydır, yakınlaştır, döndür", "ui.parallel": "Paralel", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index 6d7571acdd..4b76a43024 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -845,10 +845,20 @@ "settings.reset_title": "Налаштування було змінено", "settings.supersampling_note": "Малює вид у вищій роздільності й потім зменшує його: плавніші ребра, лінії та сітка на екранах до 4K. Це коштує часу GPU: приблизно 2,25× пікселів за 1,5× і 4× за 2×.", "sketch.degrees_of_freedom": "{count} ступенів вільності", + "sketch.dof_left": "Залишилося ступенів свободи: {count}", + "sketch.dof_left_one": "Залишився {count} ступінь свободи", "sketch.draw_curves": "Малювати як {linetype}", "sketch.make_selection": "Перетворити виділене на {linetype}", "sketch.open_ends": "Відкритих: {count}", "sketch.untied_ends": "Незв'язаних: {count}", + "sketch_panel.attached": "Прив’язано до {body} / {path}", + "sketch_panel.author": "Початкове розміщення: {path}", + "sketch_panel.dimension_hint": "Введіть значення або вираз, потім натисніть OK.", + "sketch_panel.move_hint": "Введіть відстані та кут, потім натисніть OK.", + "sketch_panel.redefine_title": "{command}: {name}", + "sketch_panel.sheet_attachment": "Прив’язано до плоскої грані листового тіла; слідує за цією панеллю під час зміни листа.", + "sketch_panel.text_hint": "Змініть текст і його розміщення, потім натисніть OK.", + "sketch_panel.use_plane": "Використати площину {plane}", "sketch_text.align_center": "По центру", "sketch_text.align_left": "Ліворуч", "sketch_text.align_right": "Праворуч", @@ -1143,6 +1153,7 @@ "ui.combine": "Об'єднати", "ui.coming_later": "з'явиться пізніше", "ui.command_done": "{command} виконано", + "ui.command_palette_hint": "Введіть команду, напр. {example} — або натисніть S для пошуку", "ui.concentric": "Концентричність", "ui.configurations": "Конфігурації", "ui.conic_curve": "Конічна крива", @@ -1410,8 +1421,11 @@ "ui.orbit_around": "Обертати навколо", "ui.orientation": "Орієнтація", "ui.origin": "Початок координат", + "ui.palette_construction_mode": "Допоміжний режим (X)", + "ui.palette_more": "Більше", "ui.palette_points": "Точки", "ui.palette_profile": "Профіль", + "ui.palette_sketch_options": "Параметри ескізу", "ui.pan": "Панорамувати", "ui.pan_zoom_orbit": "Панорамування, масштаб, обертання", "ui.parallel": "Паралельність", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 7033e195ae..c6d893c26e 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -905,10 +905,20 @@ "settings.reset_title": "设置已更改", "settings.supersampling_note": "以更高分辨率绘制视图再缩小:在低于 4K 的屏幕上边、线和网格更平滑。会消耗 GPU 时间:1.5× 时约 2.25 倍像素,2× 时 4 倍。", "sketch.degrees_of_freedom": "{count} 个自由度", + "sketch.dof_left": "剩余 {count} 个自由度", + "sketch.dof_left_one": "剩余 {count} 个自由度", "sketch.draw_curves": "绘制为 {linetype}", "sketch.make_selection": "将选择转换为 {linetype}", "sketch.open_ends": "开放 {count}", "sketch.untied_ends": "未约束 {count}", + "sketch_panel.attached": "附着到 {body} / {path}", + "sketch_panel.author": "创建时的放置:{path}", + "sketch_panel.dimension_hint": "输入数值或表达式,然后按“确定”。", + "sketch_panel.move_hint": "输入距离和角度,然后按“确定”。", + "sketch_panel.redefine_title": "{command}:{name}", + "sketch_panel.sheet_attachment": "附着到钣金实体的平面上;钣金变化时会跟随该面板。", + "sketch_panel.text_hint": "编辑文字及其位置,然后按“确定”。", + "sketch_panel.use_plane": "使用 {plane} 平面", "sketch_text.align_center": "居中", "sketch_text.align_left": "左对齐", "sketch_text.align_right": "右对齐", @@ -1205,6 +1215,7 @@ "ui.combine": "组合", "ui.coming_later": "即将推出", "ui.command_done": "{command} 完成", + "ui.command_palette_hint": "输入命令,例如 {example} — 或按 S 搜索", "ui.concentric": "同心", "ui.configurations": "配置", "ui.conic_curve": "圆锥曲线", @@ -1475,8 +1486,11 @@ "ui.orbit_around": "环绕", "ui.orientation": "定向", "ui.origin": "原点", + "ui.palette_construction_mode": "构造模式 (X)", + "ui.palette_more": "更多", "ui.palette_points": "点", "ui.palette_profile": "轮廓", + "ui.palette_sketch_options": "草图选项", "ui.pan": "平移", "ui.pan_zoom_orbit": "平移、缩放、环绕", "ui.parallel": "平行", diff --git a/assets/styles/ui/palettes.rs b/assets/styles/ui/palettes.rs index c2052dec1d..edcdf12726 100644 --- a/assets/styles/ui/palettes.rs +++ b/assets/styles/ui/palettes.rs @@ -31,6 +31,35 @@ impl Tokens { font_label: 12.5, font_button: 13.0, font_heading: 14.0, + font_triad: 8.0, + font_compass: 13.5, + font_subtitle: 15.0, + font_intro: 16.0, + font_article_heading: 17.0, + font_title: 22.0, + font_display: 26.0, + control_height: 24.0, + control_radius: 5, + form_label_width: 70.0, + form_gap_y: 5.0, + form_footer_height: 38.0, + form_header_height: 30.0, + form_card_width: 264.0, + form_icon_size: 18.0, + form_close_size: 22.0, + form_inset: 7.0, + form_icon_inset: 4.5, + form_section_icon_size: 11.0, + form_checkbox_height: 22.0, + form_button_height: 26.0, + sketch_palette_width: 232.0, + sketch_mode_size: 36.0, + sketch_mode_radius: 7, + sketch_mode_border: 1.5, + sketch_palette_alpha: 0.96, + sketch_mode_tint: 0.12, + sketch_footer_icon_size: 15.0, + font_section_tracking: 0.04, app_bar: Color32::from_rgb(52, 56, 64), app_bar_separator: Color32::from_rgb(136, 145, 160), toolbar: Color32::from_rgb(246, 247, 249), @@ -117,6 +146,14 @@ impl Tokens { app_bar_hover_button: Color32::from_rgba_unmultiplied_const(255, 255, 255, 30), app_bar_hover_strong: Color32::from_rgba_unmultiplied_const(255, 255, 255, 40), app_bar_tab_active: Color32::from_rgb(53, 59, 71), + dialog_body: Color32::from_rgb(255, 255, 255), + dialog_footer: Color32::from_rgb(246, 248, 250), + dialog_rule: Color32::from_rgb(230, 234, 240), + control_fill: Color32::from_rgb(255, 255, 255), + control_border: Color32::from_rgb(195, 203, 214), + dialog_label: Color32::from_rgb(58, 70, 86), + selected_ink: Color32::from_rgb(26, 80, 127), + selected_bg: Color32::from_rgb(227, 240, 251), timeline_item: Color32::WHITE, timeline_item_error: Color32::from_rgb(250, 220, 220), timeline_marker: Color32::from_rgb(70, 76, 88), @@ -170,6 +207,36 @@ impl Tokens { } pub const fn dark() -> Tokens { let mut t = Tokens::light(); + // Shared typography and dense form dimensions stay identical in both palettes. + t.font_triad = 8.0; + t.font_compass = 13.5; + t.font_subtitle = 15.0; + t.font_intro = 16.0; + t.font_article_heading = 17.0; + t.font_title = 22.0; + t.font_display = 26.0; + t.control_height = 24.0; + t.control_radius = 5; + t.form_label_width = 70.0; + t.form_gap_y = 5.0; + t.form_footer_height = 38.0; + t.form_header_height = 30.0; + t.form_card_width = 264.0; + t.form_icon_size = 18.0; + t.form_close_size = 22.0; + t.form_inset = 7.0; + t.form_icon_inset = 4.5; + t.form_section_icon_size = 11.0; + t.form_checkbox_height = 22.0; + t.form_button_height = 26.0; + t.sketch_palette_width = 232.0; + t.sketch_mode_size = 36.0; + t.sketch_mode_radius = 7; + t.sketch_mode_border = 1.5; + t.sketch_palette_alpha = 0.96; + t.sketch_mode_tint = 0.12; + t.sketch_footer_icon_size = 15.0; + t.font_section_tracking = 0.04; t.app_bar = Color32::from_rgb(28, 31, 38); t.toolbar = Color32::from_rgb(39, 43, 52); t.panel = Color32::from_rgb(39, 43, 52); @@ -184,6 +251,14 @@ impl Tokens { t.mesh_accent = Color32::from_rgb(133, 113, 0); t.form_accent = Color32::from_rgb(123, 99, 166); t.render_accent = Color32::from_rgb(224, 116, 174); + t.dialog_body = Color32::from_rgb(59, 68, 83); + t.dialog_footer = Color32::from_rgb(47, 52, 62); + t.dialog_rule = Color32::from_rgb(53, 59, 71); + t.control_fill = Color32::from_rgb(39, 43, 52); + t.control_border = Color32::from_rgb(74, 82, 98); + t.dialog_label = Color32::from_rgb(180, 187, 200); + t.selected_ink = Color32::from_rgb(156, 199, 245); + t.selected_bg = Color32::from_rgb(42, 70, 110); t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_PRIMARY[1]); t.error = Color32::from_rgb(240, 100, 96); t.warning = Color32::from_rgb(236, 170, 70); @@ -249,6 +324,7 @@ impl Tokens { t.icons.axes[1] = t.triad[1]; t.icons.access = [good, Color32::from_rgb(217, 102, 13)]; t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_RED_GREEN[dark as usize]); + t.glyph_text = icon_primary(crate::icon_svg::CONSTRAINT_RED_GREEN[dark as usize]); t.icons.draft = [good, Color32::from_rgb(242, 230, 153)]; t.surface_accent = if dark { Color32::from_rgb(200, 135, 55) } else { Color32::from_rgb(164, 80, 0) }; t.mesh_accent = if dark { Color32::from_rgb(190, 175, 70) } else { Color32::from_rgb(130, 103, 0) }; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index d1bf0fc4b5..0f25e308ec 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -36,6 +36,10 @@ tiny-skia = { workspace = true } # updated by its installer (MIT; already in the tree as a build dependency). winreg = "0.56" +[dev-dependencies] +# Parse the source guard structurally, including code after inline test modules. +syn = { workspace = true, features = ["visit"] } + # Pictures pasted into the agent chat; already in the tree, with the same features, through # egui-winit's clipboard. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 797da0514e..79c9c0b0f9 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -132,6 +132,7 @@ fn icon_data() -> Result<(), String> { ("CONSTRUCTION_PRIMARY", palette.get("accents").and_then(|v| v.get("construction"))), ("SKETCH_RED_GREEN", palette.get("variants").and_then(|v| v.get("red-green")).and_then(|v| v.get("sketch"))), ("CONSTRUCTION_RED_GREEN", palette.get("variants").and_then(|v| v.get("red-green")).and_then(|v| v.get("construction"))), + ("CONSTRAINT_RED_GREEN", palette.get("variants").and_then(|v| v.get("red-green")).and_then(|v| v.get("constraint"))), ("CONSTRUCTION_BLUE_YELLOW", palette.get("variants").and_then(|v| v.get("blue-yellow")).and_then(|v| v.get("construction"))), ("CONSTRAINT_BLUE_YELLOW", palette.get("variants").and_then(|v| v.get("blue-yellow")).and_then(|v| v.get("constraint"))), ] { diff --git a/crates/ui-egui/src/agent_chat/markdown_view.rs b/crates/ui-egui/src/agent_chat/markdown_view.rs index 2d8d6ebfb8..ba8647329b 100644 --- a/crates/ui-egui/src/agent_chat/markdown_view.rs +++ b/crates/ui-egui/src/agent_chat/markdown_view.rs @@ -5,6 +5,7 @@ //! so the parsed blocks are kept per conversation item and parsed again only when its text //! changes. +use crate::fonts::RichTextExt as _; use std::hash::{Hash, Hasher}; use std::sync::Arc; @@ -41,9 +42,11 @@ fn job(ui: &egui::Ui, spans: &[Span], size: f32, color: Color32) -> LayoutJob { let plain = TextFormat { font_id: base.clone(), color, ..Default::default() }; match s { Span::Text(x) => j.append(x, 0.0, plain), - Span::Bold(x) => j.append(x, 0.0, TextFormat { color: t.text, ..plain }), + Span::Bold(x) => j.append(x, 0.0, TextFormat { font_id: crate::fonts::semibold(size), color: t.text, ..plain }), Span::Italic(x) => j.append(x, 0.0, TextFormat { italics: true, ..plain }), - Span::Code(x) => j.append(x, 0.0, TextFormat { font_id: FontId::monospace(size - 1.0), background: t.field, ..plain }), + Span::Code(x) => { + j.append(x, 0.0, TextFormat { font_id: FontId::monospace(size - (t.font_button - t.font_body)), background: t.field, ..plain }) + } Span::Link { label, .. } => j.append(label, 0.0, TextFormat { color: t.accent, underline: Stroke::new(1.0, t.accent), ..plain }), } } @@ -58,6 +61,7 @@ fn host(url: &str) -> &str { /// A paragraph's text, then its links as buttons (a laid-out text cannot be clicked in parts). fn spans(ui: &mut egui::Ui, spans: &[Span], size: f32, color: Color32) { + let t = Tokens::get(); ui.add(egui::Label::new(job(ui, spans, size, color)).selectable(true).wrap()); let links: Vec<(&str, &str)> = spans.iter().filter_map(|s| if let Span::Link { label, url } = s { Some((label.as_str(), url.as_str())) } else { None }).collect(); @@ -66,7 +70,7 @@ fn spans(ui: &mut egui::Ui, spans: &[Span], size: f32, color: Color32) { for (label, url) in links { // The text above already shows the label; the button says where it goes. let to = format!("↗ {}", host(url)); - ui.hyperlink_to(RichText::new(to).size(size - 1.0), url).on_hover_text(format!("{label}: {url}")); + ui.hyperlink_to(RichText::new(to).size(size - (t.font_button - t.font_body)), url).on_hover_text(format!("{label}: {url}")); } }); } @@ -75,24 +79,28 @@ fn spans(ui: &mut egui::Ui, spans: &[Span], size: f32, color: Color32) { /// Draw `src` as Markdown. `slot`: the conversation item it belongs to. pub fn show(ui: &mut egui::Ui, slot: usize, src: &str) { let t = Tokens::get(); - let size = 13.0; + let size = t.font_button; let parsed = blocks(ui.ctx(), slot, src); for block in parsed.iter() { match block { Block::Paragraph(s) => spans(ui, s, size, t.text), Block::Heading { level, spans: s } => { let size = match level { - 1 => 17.0, - 2 => 15.0, - _ => 14.0, + 1 => t.font_article_heading, + 2 => t.font_subtitle, + _ => t.font_heading, }; ui.add_space(4.0); - ui.add(egui::Label::new(RichText::new(markdown::plain(s)).size(size).strong().color(t.text)).selectable(true).wrap()); + ui.add(egui::Label::new(RichText::new(markdown::plain(s)).size(size).semibold().color(t.text)).selectable(true).wrap()); } Block::Code { text, .. } => { egui::Frame::NONE.fill(t.field).corner_radius(4.0).inner_margin(Margin::same(6)).show(ui, |ui| { ui.set_width(ui.available_width()); - ui.add(egui::Label::new(RichText::new(text).monospace().size(size - 1.0).color(t.text)).selectable(true).wrap()); + ui.add( + egui::Label::new(RichText::new(text).monospace().size(size - (t.font_button - t.font_body)).color(t.text)) + .selectable(true) + .wrap(), + ); if ui.small_button(tr("ui.agent_chat_copy")).clicked() { ui.ctx().copy_text(text.clone()); } @@ -116,7 +124,7 @@ pub fn show(ui: &mut egui::Ui, slot: usize, src: &str) { } Block::Pre(text) => { egui::ScrollArea::horizontal().id_salt(("agent_chat_pre", slot, text.len())).show(ui, |ui| { - ui.add(egui::Label::new(RichText::new(text).monospace().size(size - 2.0).color(t.text)).selectable(true)); + ui.add(egui::Label::new(RichText::new(text).monospace().size(t.font_small).color(t.text)).selectable(true)); }); } Block::Rule => { diff --git a/crates/ui-egui/src/agent_chat/panel.rs b/crates/ui-egui/src/agent_chat/panel.rs index 8f6c792c2d..f4aa88c762 100644 --- a/crates/ui-egui/src/agent_chat/panel.rs +++ b/crates/ui-egui/src/agent_chat/panel.rs @@ -2,6 +2,7 @@ //! or in Preferences), resizable, folded to a thin strip with Minimise, closed with ×. Its side, //! width and fold are preferences, so they come back next time. +use crate::fonts::RichTextExt as _; use egui::{Align, Align2, Color32, Id, Layout, Rect, RichText, Sense, Stroke, pos2, vec2}; use super::*; @@ -134,13 +135,13 @@ fn picture_strip(ui: &mut egui::Ui, n: usize, pictures: &[Picture]) -> Option texture, // Being decoded: a spinner the size of a thumbnail's corner, until it is in. Shown::Loading => { - ui.add(egui::Spinner::new().size(THUMB_MAX.1 / 2.0)); + ui.add(egui::Spinner::new().size(t.space_24)); continue; } // Not a readable picture: its name, or the plain caption. Shown::Failed => { let caption = if picture.note.is_empty() { tr("ui.agent_chat_view_attached").to_string() } else { picture.note.clone() }; - ui.label(RichText::new(caption).color(t.text_dim).size(11.0)); + ui.label(RichText::new(caption).color(t.text_dim).size(t.font_small)); continue; } }; @@ -203,7 +204,7 @@ fn gallery_window(app: &mut SolveApp, ctx: &egui::Context) { crate::scenario::publish_rect("agent_chat:gallery_picture", resp.rect); } Shown::Loading => { - ui.add(egui::Spinner::new().size(32.0)); + ui.add(egui::Spinner::new().size(t.space_24)); } Shown::Failed => { ui.label(RichText::new(tr("ui.agent_chat_view_attached")).color(t.text_dim)); @@ -242,7 +243,7 @@ fn drop_hint(ctx: &egui::Context, area: Rect) { let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("sc_agent_chat_drop_hint"))); let zone = area.shrink(6.0); painter.rect(zone, 10.0, t.accent.gamma_multiply(0.16), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); - let font = egui::FontId::proportional(14.0); + let font = egui::FontId::proportional(Tokens::get().font_heading); let text = painter.layout(tr("ui.agent_chat_drop_hint").to_string(), font, t.text, (zone.width() - 24.0).max(40.0)); let at = zone.center() - text.size() / 2.0; painter.galley(at, text, t.text); @@ -422,7 +423,13 @@ fn strip(app: &mut SolveApp, ui: &mut egui::Ui) { if waiting > 0 { let at = button.right_top() + vec2(-3.0, 3.0); ui.painter().circle_filled(at, 6.5, t.warning); - ui.painter().text(at, Align2::CENTER_CENTER, waiting.min(9).to_string(), egui::FontId::proportional(9.5), t.panel); + ui.painter().text( + at, + Align2::CENTER_CENTER, + waiting.min(9).to_string(), + egui::FontId::proportional(crate::theme::Tokens::get().font_badge), + t.panel, + ); } ui.painter().line_segment( if app.preferences.agent_chat_side == "left" { [r.right_top(), r.right_bottom()] } else { [r.left_top(), r.left_bottom()] }, @@ -451,7 +458,9 @@ fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) { } } } - let l = ui.label(RichText::new(tr("ui.agent_chat")).strong().size(14.0)); + let l = ui.label( + RichText::new(tr("ui.agent_chat")).semibold().color(crate::theme::Tokens::get().text).size(crate::theme::Tokens::get().font_heading), + ); grip.union(l.rect) }; let mut s = state.label().to_string(); @@ -539,7 +548,7 @@ fn dock_drag(app: &mut SolveApp, ui: &egui::Ui, drag: &egui::Response, left: boo zone.center(), Align2::CENTER_CENTER, tr(if to_left { "ui.agent_chat_dock_left" } else { "ui.agent_chat_dock_right" }), - egui::FontId::proportional(14.0), + egui::FontId::proportional(crate::theme::Tokens::get().font_heading), t.text, ); } @@ -693,7 +702,7 @@ pub(super) fn login_chip(ui: &mut egui::Ui, login: &str) { _ => return, }; egui::Frame::NONE.stroke(Stroke::new(1.0, color)).corner_radius(8.0).inner_margin(egui::Margin::symmetric(6, 1)).show(ui, |ui| { - ui.label(RichText::new(text).color(color).size(11.0)); + ui.label(RichText::new(text).color(color).size(crate::theme::Tokens::get().font_small)); }); } @@ -736,29 +745,32 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) { (ui.available_width() - if app.agent_chat.modes.is_empty() { 4.0 } else { 116.0 } - if models { 146.0 } else { 0.0 }).clamp(90.0, 260.0); let mut picked: Option = None; let mut add = false; - let combo = egui::ComboBox::from_id_salt("sc_agent_switch").width(w).selected_text(RichText::new(label).strong()).show_ui(ui, |ui| { - for e in &all { - let sel = current.as_ref().is_some_and(|c| c.id == e.id); - let r = ui.horizontal(|ui| { - kind_icon(ui, e.kind); - let r = ui.selectable_label(sel, &e.name); - ui.label(RichText::new(e.detail()).color(t.text_dim).size(11.0)); - login_chip(ui, e.login); - r - }); - crate::scenario::publish_handle(&format!("agent_chat:agent:{}", e.id), r.inner.rect.center()); - if r.inner.clicked() { - picked = Some(e.id.clone()); + let combo = egui::ComboBox::from_id_salt("sc_agent_switch") + .width(w) + .selected_text(RichText::new(label).semibold().color(crate::theme::Tokens::get().text)) + .show_ui(ui, |ui| { + for e in &all { + let sel = current.as_ref().is_some_and(|c| c.id == e.id); + let r = ui.horizontal(|ui| { + kind_icon(ui, e.kind); + let r = ui.selectable_label(sel, &e.name); + ui.label(RichText::new(e.detail()).color(t.text_dim).size(crate::theme::Tokens::get().font_small)); + login_chip(ui, e.login); + r + }); + crate::scenario::publish_handle(&format!("agent_chat:agent:{}", e.id), r.inner.rect.center()); + if r.inner.clicked() { + picked = Some(e.id.clone()); + } } - } - ui.separator(); - if ui.selectable_label(false, tr("ui.ai_add_agent")).clicked() { - add = true; - } - if ui.selectable_label(false, tr("ui.ai_settings")).clicked() { - crate::prefs::open_at(app, crate::prefs::PAGE_AI); - } - }); + ui.separator(); + if ui.selectable_label(false, tr("ui.ai_add_agent")).clicked() { + add = true; + } + if ui.selectable_label(false, tr("ui.ai_settings")).clicked() { + crate::prefs::open_at(app, crate::prefs::PAGE_AI); + } + }); crate::scenario::publish_handle("agent_chat:switcher", combo.response.rect.center()); combo.response.on_hover_text(tr("ui.ai_switch_tip")); if add { @@ -811,7 +823,7 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) { } }); if let Some(a) = &app.agent_chat.auth_status { - ui.label(RichText::new(a).color(t.text_dim).size(11.0)); + ui.label(RichText::new(a).color(t.text_dim).size(crate::theme::Tokens::get().font_small)); } } @@ -826,7 +838,7 @@ fn picker(app: &mut SolveApp, ui: &mut egui::Ui) { let all = agents::entries(app); let current = agents::current(app); ui.horizontal(|ui| { - ui.label(RichText::new(tr("ui.agent_chat_pick")).strong()); + ui.label(RichText::new(tr("ui.agent_chat_pick")).semibold().color(crate::theme::Tokens::get().text)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let refreshing = app.agent_chat.detecting.is_some(); if ui.add_enabled(!refreshing, egui::Button::new(tr("ui.agent_chat_refresh")).small()).clicked() { @@ -847,8 +859,8 @@ fn picker(app: &mut SolveApp, ui: &mut egui::Ui) { ui.set_width(ui.available_width()); ui.horizontal(|ui| { kind_icon(ui, e.kind); - ui.label(RichText::new(&e.name).strong().color(t.text)); - ui.label(RichText::new(e.detail()).color(t.text_dim).size(11.0)); + ui.label(RichText::new(&e.name).semibold().color(t.text)); + ui.label(RichText::new(e.detail()).color(t.text_dim).size(crate::theme::Tokens::get().font_small)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| login_chip(ui, e.login)); }); }); @@ -866,12 +878,16 @@ fn picker(app: &mut SolveApp, ui: &mut egui::Ui) { |ui| { for m in missing { ui.horizontal(|ui| { - ui.label(RichText::new(m.name).strong()); + ui.label(RichText::new(m.name).semibold().color(crate::theme::Tokens::get().text)); if ui.small_button(tr("ui.agent_chat_copy")).on_hover_text(m.install).clicked() { ui.ctx().copy_text(m.install.to_string()); } }); - ui.add(egui::Label::new(RichText::new(m.install).monospace().size(11.0).color(t.text_dim)).selectable(true).wrap()); + ui.add( + egui::Label::new(RichText::new(m.install).monospace().size(crate::theme::Tokens::get().font_small).color(t.text_dim)) + .selectable(true) + .wrap(), + ); } }, ); @@ -883,7 +899,7 @@ fn auth_box(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); egui::Frame::group(ui.style()).stroke(Stroke::new(1.0, t.warning)).show(ui, |ui| { ui.set_width(ui.available_width()); - ui.label(RichText::new(tr("ui.agent_chat_auth_title")).strong()); + ui.label(RichText::new(tr("ui.agent_chat_auth_title")).semibold().color(crate::theme::Tokens::get().text)); ui.label(RichText::new(tr("ui.agent_chat_auth_needed")).color(t.text_dim)); let methods = app.agent_chat.auth.clone(); let base = app.agent_chat.argv.iter().map(|a| quote(a)).collect::>().join(" "); @@ -893,14 +909,22 @@ fn auth_box(app: &mut SolveApp, ui: &mut egui::Ui) { for m in methods { ui.add_space(4.0); let desc = m.description.trim(); - ui.label(RichText::new(if desc.is_empty() || desc == m.name { m.name.clone() } else { format!("{} — {desc}", m.name) }).strong()); + ui.label( + RichText::new(if desc.is_empty() || desc == m.name { m.name.clone() } else { format!("{} — {desc}", m.name) }) + .semibold() + .color(crate::theme::Tokens::get().text), + ); match &m.kind { acp::AuthKind::Terminal { args, .. } => { ui.label(tr("ui.agent_chat_auth_terminal")); let cmd = format!("{base} {}", args.iter().map(|a| quote(a)).collect::>().join(" ")); ui.horizontal_wrapped(|ui| { egui::Frame::NONE.fill(t.toolbar).corner_radius(4.0).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| { - ui.add(egui::Label::new(RichText::new(&cmd).monospace().size(11.0)).selectable(true).wrap()); + ui.add( + egui::Label::new(RichText::new(&cmd).monospace().size(crate::theme::Tokens::get().font_small)) + .selectable(true) + .wrap(), + ); }); if ui.small_button(tr("ui.agent_chat_copy")).clicked() { ui.ctx().copy_text(cmd.clone()); @@ -950,7 +974,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) { } } else if *image { // Taken when the agent starts on the message: nothing to show yet. - ui.label(RichText::new(tr("ui.agent_chat_view_attached")).color(t.text_dim).size(11.0)); + ui.label(RichText::new(tr("ui.agent_chat_view_attached")).color(t.text_dim).size(t.font_small)); } files_line(ui, files.iter().map(String::as_str)); }); @@ -981,7 +1005,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) { Item::Permission { title, options, answer: given, .. } => { egui::Frame::group(ui.style()).stroke(Stroke::new(1.0, t.accent)).show(ui, |ui| { ui.set_width(ui.available_width()); - ui.label(RichText::new(trf("ui.agent_chat_permission", &[("title", title)])).strong()); + ui.label(RichText::new(trf("ui.agent_chat_permission", &[("title", title)])).semibold().color(crate::theme::Tokens::get().text)); match given { Some(a) => { ui.label(RichText::new(trf("ui.agent_chat_answered", &[("option", a)])).color(t.text_dim)); @@ -1005,8 +1029,8 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) { Item::Approval { id, title, tool, answer: given } => { egui::Frame::group(ui.style()).stroke(Stroke::new(1.5, t.warning)).show(ui, |ui| { ui.set_width(ui.available_width()); - ui.label(RichText::new(tr("ui.agent_chat_approval")).strong()); - ui.add(egui::Label::new(RichText::new(title).monospace().size(11.5)).selectable(true).wrap()); + ui.label(RichText::new(tr("ui.agent_chat_approval")).semibold().color(crate::theme::Tokens::get().text)); + ui.add(egui::Label::new(RichText::new(title).monospace().size(crate::theme::Tokens::get().font_caption)).selectable(true).wrap()); match given { Some(true) => { ui.label(RichText::new(tr("ui.agent_chat_allowed")).color(t.text_dim)); @@ -1039,9 +1063,9 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) { Item::Changes { steps, doc, undone } => { ui.horizontal(|ui| { let text = trf("ui.agent_chat_changes", &[("n", &steps.to_string())]); - ui.label(RichText::new(text).color(t.text_dim).size(11.5)); + ui.label(RichText::new(text).color(t.text_dim).size(crate::theme::Tokens::get().font_caption)); if *undone { - ui.label(RichText::new(tr("ui.agent_chat_undone")).color(t.text_dim).size(11.5)); + ui.label(RichText::new(tr("ui.agent_chat_undone")).color(t.text_dim).size(crate::theme::Tokens::get().font_caption)); } else if Arc::ptr_eq(doc, ¤t_doc) { let b = ui.button(tr("ui.agent_chat_undo_changes")).on_hover_text(tr("ui.ai_undo_tip")); crate::scenario::publish_handle("agent_chat:undo", b.rect.center()); @@ -1071,15 +1095,19 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) { ui.set_width(ui.available_width()); ui.horizontal(|ui| { let tag = if stopped && !busy { "ui.agent_chat_queued_stopped" } else { "ui.agent_chat_queued" }; - ui.label(RichText::new(tr(tag)).color(t.accent).strong().size(11.0)); + ui.label(RichText::new(tr(tag)).color(t.accent).semibold().size(crate::theme::Tokens::get().font_small)); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - let x = ui.add(egui::Button::new(RichText::new("×").size(14.0)).frame(false)).on_hover_text(tr("ui.agent_chat_remove")); + let x = ui + .add(egui::Button::new(RichText::new("×").size(crate::theme::Tokens::get().font_heading)).frame(false)) + .on_hover_text(tr("ui.agent_chat_remove")); crate::scenario::publish_handle(&format!("agent_chat:pending_remove:{n}"), x.rect.center()); if x.clicked() { remove = Some(n); } if editing.as_ref().is_none_or(|(i, _)| *i != n) { - let e = ui.add(egui::Button::new(RichText::new(tr("ui.agent_chat_edit")).size(11.5)).frame(false)); + let e = ui.add( + egui::Button::new(RichText::new(tr("ui.agent_chat_edit")).size(crate::theme::Tokens::get().font_caption)).frame(false), + ); crate::scenario::publish_handle(&format!("agent_chat:pending_edit:{n}"), e.rect.center()); if e.clicked() { start_edit = Some((n, p.text.clone())); @@ -1105,7 +1133,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) { ui.add(egui::Label::new(RichText::new(&p.text).color(t.text)).selectable(true).wrap()); } if p.image { - ui.label(RichText::new(tr("ui.agent_chat_view_attached")).color(t.text_dim).size(11.0)); + ui.label(RichText::new(tr("ui.agent_chat_view_attached")).color(t.text_dim).size(crate::theme::Tokens::get().font_small)); } files_line(ui, p.files.iter().map(|f| f.name.as_str())); } @@ -1134,7 +1162,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) { ui.add_space(12.0); ui.label(RichText::new(tr("ui.agent_chat_intro")).color(t.text_dim)); ui.add_space(6.0); - ui.label(RichText::new(tr("ui.agent_chat_privacy")).color(t.text_dim).size(11.0)); + ui.label(RichText::new(tr("ui.agent_chat_privacy")).color(t.text_dim).size(crate::theme::Tokens::get().font_small)); } if let Some((n, text)) = edit_done { if let (Some(text), Some(p)) = (text.filter(|t| !t.trim().is_empty()), app.agent_chat.pending.get_mut(n)) { @@ -1173,7 +1201,7 @@ fn files_line<'a>(ui: &mut egui::Ui, names: impl Iterator) { let list = names.collect::>().join(", "); if !list.is_empty() { let t = Tokens::get(); - ui.add(egui::Label::new(RichText::new(trf("ui.agent_chat_files", &[("files", &list)])).color(t.text_dim).size(11.0)).wrap()); + ui.add(egui::Label::new(RichText::new(trf("ui.agent_chat_files", &[("files", &list)])).color(t.text_dim).size(t.font_small)).wrap()); } } @@ -1248,11 +1276,11 @@ fn loading_chip(ui: &mut egui::Ui, name: &str, importing: bool, i: usize, w: f32 ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 4.0; let doing = tr(if importing { "ui.agent_chat_file_importing" } else { "ui.agent_chat_file_loading" }); - let mark = ui.add(egui::Spinner::new().size(16.0)).on_hover_text(doing).rect.width(); + let mark = ui.add(egui::Spinner::new().size(t.form_icon_size)).on_hover_text(doing).rect.width(); let name_w = (inner - mark - 18.0 - 8.0).max(8.0); ui.allocate_ui(vec2(name_w, 20.0), |ui| { ui.set_width(name_w); - ui.add(egui::Label::new(RichText::new(name).size(11.5).color(t.text_dim)).truncate()) + ui.add(egui::Label::new(RichText::new(name).size(t.font_small).color(t.text_dim)).truncate()) }) .inner .on_hover_text(name); @@ -1295,14 +1323,20 @@ fn chip(ui: &mut egui::Ui, thumbs: &mut std::collections::HashMap) { let tk = Tokens::get(); match t { Some(Test::Running(_)) => { - ui.label(RichText::new(tr("ui.ai_testing")).color(tk.text_dim).size(11.0)); + ui.label(RichText::new(tr("ui.ai_testing")).color(tk.text_dim).size(crate::theme::Tokens::get().font_small)); } Some(Test::Done(Ok(m))) => { - ui.label(RichText::new(m.clone()).color(tk.accent).size(11.0)); + ui.label(RichText::new(m.clone()).color(tk.accent).size(crate::theme::Tokens::get().font_small)); } Some(Test::Done(Err(e))) => { - ui.add(egui::Label::new(RichText::new(e).color(tk.error).size(11.0)).wrap()); + ui.add(egui::Label::new(RichText::new(e).color(tk.error).size(crate::theme::Tokens::get().font_small)).wrap()); } None => {} } @@ -106,19 +107,20 @@ fn test_line(ui: &mut egui::Ui, t: &Option<&Test>) { fn section(ui: &mut egui::Ui, title: &str) { ui.add_space(10.0); - ui.label(RichText::new(title).strong().size(13.0)); + ui.label(RichText::new(title).semibold().color(crate::theme::Tokens::get().text).size(crate::theme::Tokens::get().font_button)); ui.separator(); } fn note(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(); - ui.add(egui::Label::new(RichText::new(text).size(11.0).color(t.text_dim)).wrap()); + ui.add(egui::Label::new(RichText::new(text).size(crate::theme::Tokens::get().font_small).color(t.text_dim)).wrap()); } /// The AI page of Settings. pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); - let r = ui.checkbox(&mut app.preferences.ai_features, RichText::new(tr("ui.ai_show_features")).strong()); + let r = + ui.checkbox(&mut app.preferences.ai_features, RichText::new(tr("ui.ai_show_features")).semibold().color(crate::theme::Tokens::get().text)); crate::scenario::publish_handle("prefs:ai_features", r.rect.center()); note(ui, tr("ui.ai_show_features_note")); if cfg!(target_arch = "wasm32") { @@ -163,7 +165,7 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { let remembered: Vec<(String, Vec)> = app.preferences.agent_always_allow.iter().filter(|(_, v)| !v.is_empty()).map(|(k, v)| (k.clone(), v.clone())).collect(); ui.add_space(4.0); - ui.label(RichText::new(tr("ui.ai_always_allowed")).strong()); + ui.label(RichText::new(tr("ui.ai_always_allowed")).semibold().color(crate::theme::Tokens::get().text)); if remembered.is_empty() { note(ui, tr("ui.ai_always_allowed_none")); } @@ -172,7 +174,7 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { ui.horizontal_wrapped(|ui| { ui.label(names.get(&agent).cloned().unwrap_or_else(|| agent.clone())); for tool in &tools { - ui.label(RichText::new(tool).monospace().size(11.0).color(t.text_dim)); + ui.label(RichText::new(tool).monospace().size(crate::theme::Tokens::get().font_small).color(t.text_dim)); } if ui.small_button(tr("ui.ai_reset")).clicked() { app.preferences.agent_always_allow.remove(&agent); @@ -230,11 +232,11 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) { ui.set_width(ui.available_width()); ui.horizontal(|ui| { super::panel::kind_icon(ui, e.kind); - ui.label(RichText::new(&e.name).strong()); - ui.label(RichText::new(e.detail()).color(t.text_dim).size(11.0)); + ui.label(RichText::new(&e.name).semibold().color(crate::theme::Tokens::get().text)); + ui.label(RichText::new(e.detail()).color(t.text_dim).size(crate::theme::Tokens::get().font_small)); super::panel::login_chip(ui, e.login); if !e.found { - ui.label(RichText::new(tr("ui.ai_added")).color(t.text_dim).size(11.0)); + ui.label(RichText::new(tr("ui.ai_added")).color(t.text_dim).size(crate::theme::Tokens::get().font_small)); } }); ui.horizontal(|ui| { @@ -388,7 +390,12 @@ pub fn dialog(app: &mut SolveApp, ctx: &egui::Context) { ui.add(egui::TextEdit::singleline(&mut s.command).desired_width(300.0)); ui.end_row(); ui.label(""); - ui.add(egui::Label::new(RichText::new(tr("ui.ai_add_installed_note")).size(11.0).color(t.text_dim)).wrap()); + ui.add( + egui::Label::new( + RichText::new(tr("ui.ai_add_installed_note")).size(crate::theme::Tokens::get().font_small).color(t.text_dim), + ) + .wrap(), + ); ui.end_row(); } AddKind::Custom => { @@ -399,7 +406,12 @@ pub fn dialog(app: &mut SolveApp, ctx: &egui::Context) { ui.add(egui::TextEdit::singleline(&mut s.command).hint_text("my-agent --acp").desired_width(300.0)); ui.end_row(); ui.label(""); - ui.add(egui::Label::new(RichText::new(tr("ui.agent_chat_custom_hint")).size(11.0).color(t.text_dim)).wrap()); + ui.add( + egui::Label::new( + RichText::new(tr("ui.agent_chat_custom_hint")).size(crate::theme::Tokens::get().font_small).color(t.text_dim), + ) + .wrap(), + ); ui.end_row(); } AddKind::Api => { @@ -437,7 +449,7 @@ pub fn dialog(app: &mut SolveApp, ctx: &egui::Context) { ui.end_row(); ui.label(""); let note = if has_keychain { tr("ui.ai_key_note") } else { tr("ui.ai_no_keychain") }; - ui.add(egui::Label::new(RichText::new(note).size(11.0).color(t.text_dim)).wrap()); + ui.add(egui::Label::new(RichText::new(note).size(crate::theme::Tokens::get().font_small).color(t.text_dim)).wrap()); ui.end_row(); ui.label(""); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/agent_chat/tests.rs b/crates/ui-egui/src/agent_chat/tests.rs index 26246fd727..193abff08d 100644 --- a/crates/ui-egui/src/agent_chat/tests.rs +++ b/crates/ui-egui/src/agent_chat/tests.rs @@ -79,7 +79,7 @@ fn ask(tx: &Sender, method: &str, params: Value) -> Receiver (SolveApp, Script, egui::Context) { let script = Script::default(); app.agent_chat.backend = Some(Box::new(script.clone())); app.agent_chat.state = Some(State::Ready); - (app, script, egui::Context::default()) + (app, script, crate::fonts::test_context()) } fn ended(script: &Script, reason: &str) { @@ -631,7 +631,7 @@ done let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); app.agent_chat.backend = Some(Box::new(acp_client::AcpBackend::start(&argv, start).unwrap())); app.agent_chat.state = Some(State::Starting); - let ctx = egui::Context::default(); + let ctx = crate::fonts::test_context(); let until = |app: &mut SolveApp, what: &str, done: &dyn Fn(&SolveApp) -> bool| { let t = std::time::Instant::now(); while !done(app) { @@ -724,7 +724,7 @@ done let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); app.agent_chat.backend = Some(Box::new(acp_client::AcpBackend::start(&argv, start).unwrap())); app.agent_chat.state = Some(State::Starting); - let ctx = egui::Context::default(); + let ctx = crate::fonts::test_context(); let until = |app: &mut SolveApp, done: &dyn Fn(&SolveApp) -> bool| { let t = std::time::Instant::now(); while !done(app) { @@ -842,7 +842,7 @@ done app.agent_chat.state = Some(State::Starting); app.agent_chat.running_id = "fake".into(); app.preferences.agent_models.insert("fake".into(), "smart".into()); - let ctx = egui::Context::default(); + let ctx = crate::fonts::test_context(); let t = std::time::Instant::now(); loop { poll(&mut app, &ctx); diff --git a/crates/ui-egui/src/bom_panel.rs b/crates/ui-egui/src/bom_panel.rs index 5bab059de9..6694eef3aa 100644 --- a/crates/ui-egui/src/bom_panel.rs +++ b/crates/ui-egui/src/bom_panel.rs @@ -89,7 +89,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let bom = d.rows(app); let rows: Vec = bom["rows"].as_array().cloned().unwrap_or_default(); let (mut export, mut apply_row) = (None, None); - let title = RichText::new(crate::i18n::tr_label("Bill of Materials")).strong().size(13.0); + let title = RichText::new(crate::i18n::tr_label("Bill of Materials")).strong().size(t.font_button); crate::frame::window(ctx, title, crate::frame::Width::Wide).id(egui::Id::new("sc_bom")).open(&mut open).resizable(true).show(ctx, |ui| { ui.horizontal(|ui| { let was = (d.top, d.by_definition, d.group_children); diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index d895670271..f083115b9a 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -6,6 +6,7 @@ //! viewport, a double-click on a sketch edits it, and a right-click opens the item's menu //! ([`crate::context_menu`]). +use crate::fonts::RichTextExt as _; #[cfg(test)] #[path = "occurrence_ui_tests.rs"] mod occurrence_ui_tests; @@ -176,14 +177,14 @@ pub fn group_name(app: &SolveApp, id: u64) -> Option { pub fn group_items(app: &SolveApp, id: u64, scope: &str) -> Vec { let Some(g) = find_group(app, id) else { return Vec::new() }; let mut v = vec![ - Item::action("ui.rename", "Rename", "").with(json!({ "group": id })), + Item::action("ui.rename", "Rename", "rename").with(json!({ "group": id })), Item::action("ui.ungroup", "Ungroup", "folder").with(json!({ "group": id })), Item::sep(), // The group itself: its items keep their own state. Item::action("ui.folderVisible", "Show/Hide", "eye").key("V").with(json!({ "key": format!("{scope}/g{id}") })), ]; if g.folder == "bodies" { - v.push(Item::action("ui.isolate", "Isolate", "").with(json!({ "bodies": group_bodies(app, id) }))); + v.push(Item::action("ui.isolate", "Isolate", "body").with(json!({ "bodies": group_bodies(app, id) }))); } v } @@ -426,10 +427,14 @@ pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { icons::paint_icon(ui.painter(), ir, row.icon, if row.dim { DIM_ICON_OPACITY } else { 1.0 }); x += 21.0; } - let col = - if row.selected { crate::theme::TabAccent::current(ui.ctx()).color } else { row.color.unwrap_or(if row.dim { t.text_dim } else { t.text }) }; + let col = if row.selected { + crate::theme::selected_ink(&t, crate::theme::TabAccent::current(ui.ctx()).color) + } else { + row.color.unwrap_or(if row.dim { t.text_dim } else { t.text }) + }; let label_display = if row.name { crate::i18n::tr_name(row.label) } else { crate::i18n::tr_dyn(row.label) }; - let galley = ui.painter().layout_no_wrap(label_display, FontId::proportional(t.font_label), col); + let font = if row.selected { crate::fonts::semibold(t.font_label) } else { FontId::proportional(t.font_label) }; + let galley = ui.painter().layout_no_wrap(label_display, font, col); let lw = galley.size().x; ui.painter().galley(pos2(x, cy - galley.size().y / 2.0), galley, col); let mut bx = (x + lw + 6.0).min(r.right() - 16.0); @@ -673,7 +678,7 @@ fn browser_panel(app: &mut SolveApp, ui: &mut egui::Ui, timeline_above: bool) { DRAWN.with(|d| d.borrow_mut().clear()); OCCURRENCES.with(|d| d.borrow_mut().clear()); let mut acts = Actions::default(); - let width = browser_width(app); + let width = browser_width(app.preferences.browser_width); let panel = egui::Panel::left("sc_browser") .exact_size(width) .resizable(false) @@ -704,8 +709,8 @@ fn browser_panel(app: &mut SolveApp, ui: &mut egui::Ui, timeline_above: bool) { crate::scenario::publish_handle("left-history-divider", rect.center()); } let arrow = if browser_collapsed { "▶" } else { "▼" }; - let response = - ui.add(egui::Button::new(RichText::new(format!("{arrow} BROWSER")).size(t.font_small).strong().color(t.text_dim)).frame(false)); + let response = ui + .add(egui::Button::new(RichText::new(format!("{arrow} BROWSER")).size(t.font_small).semibold().color(t.text_dim)).frame(false)); crate::scenario::publish_handle("left-browser-toggle", response.rect.center()); if response.clicked() { app.preferences.timeline_left_browser_collapsed = !browser_collapsed; @@ -722,8 +727,7 @@ fn browser_panel(app: &mut SolveApp, ui: &mut egui::Ui, timeline_above: bool) { finish_browser(app, ui, acts); } -fn browser_width(app: &SolveApp) -> f32 { - let remembered = app.preferences.browser_width; +fn browser_width(remembered: f32) -> f32 { if remembered.is_finite() { remembered.clamp(MIN_BROWSER_WIDTH, MAX_BROWSER_WIDTH) } else { crate::prefs::DEFAULT_BROWSER_WIDTH } } @@ -749,11 +753,8 @@ pub fn floating(app: &mut SolveApp, ui: &mut egui::Ui, available: Rect) { let mut acts = Actions::default(); let gap = crate::shell::SHELL_GAP; let top_gap = if app.ui.layout == crate::shell::LayoutMode::Docked { gap } else { 0.0 }; - let min = available.min + vec2(gap, top_gap); - let right = (available.right() - gap).max(min.x); - let bottom = app.shell.history.map_or(available.bottom() - gap, |r| r.top() - gap).max(min.y); - let width = browser_width(app); - let bounds = Rect::from_min_max(min, pos2((min.x + width).min(right), bottom)); + let width = browser_width(app.preferences.browser_width); + let bounds = floating_bounds(available, app.shell.history, width, top_gap); app.shell.browser = None; let min_height = HEADER_H + 2.0 * (CARD_INSET + 1.0); if bounds.is_positive() && bounds.height() >= min_height { @@ -780,6 +781,14 @@ pub fn floating(app: &mut SolveApp, ui: &mut egui::Ui, available: Rect) { finish_browser(app, ui, acts); } +fn floating_bounds(available: Rect, history: Option, width: f32, top_gap: f32) -> Rect { + let gap = crate::shell::SHELL_GAP; + let min = available.min + vec2(gap, top_gap); + let right = (available.right() - gap).max(min.x); + let bottom = history.map_or(available.bottom() - gap, |r| r.top() - gap).max(min.y); + Rect::from_min_max(min, pos2((min.x + width).min(right), bottom)) +} + fn browser_contents(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, collapsible: bool) { let t = Tokens::get(); // Only the floating card folds; the docked column keeps its full height. @@ -811,7 +820,7 @@ fn browser_header(app: &mut SolveApp, ui: &mut egui::Ui, collapsible: bool) { let title = crate::i18n::tr("ui.browser_title").to_string(); let kind = if collapsible { egui::WidgetType::Button } else { egui::WidgetType::Label }; response.widget_info(|| egui::WidgetInfo::labeled(kind, true, &title)); - let galley = ui.painter().layout_no_wrap(title, FontId::proportional(t.font_heading), t.text); + let galley = ui.painter().layout_no_wrap(title, crate::fonts::semibold(t.font_heading), t.text); ui.painter().galley(pos2(rect.left() + t.space_6, rect.center().y - galley.size().y / 2.0), galley, t.text); let mut right = rect.right() - t.space_6; if collapsible { @@ -921,8 +930,7 @@ fn finish_browser(app: &mut SolveApp, ui: &mut egui::Ui, acts: Actions) { occurrence_panel(app, ui); capture_prompt(app, ui.ctx()); canvas_panel(app, ui.ctx()); - let repair_anchor = app.viewport.rect.map(|rect| rect.right_top() + vec2(-16.0, 16.0)).unwrap_or_else(|| ui.ctx().content_rect().right_top()); - crate::sketch_attachment_ui::panel(app, ui.ctx(), repair_anchor); + crate::sketch_attachment_ui::panel(app, ui.ctx()); } fn component_name(app: &SolveApp, id: u64) -> String { @@ -1327,7 +1335,7 @@ pub fn view_items(app: &SolveApp, name: &str) -> Vec { Item::action("ui.restoreView", "Restore", "home").with(json!({ "view": name })), Item::action("ui.updateView", "Update to Current View", "perspective").with(json!({ "view": name })).on_if(saved), Item::sep(), - Item::action("ui.rename", "Rename", "").with(json!({ "view": name })).on_if(saved), + Item::action("ui.rename", "Rename", "rename").with(json!({ "view": name })).on_if(saved), Item::action("view.delete", "Delete", "delete").with(json!({ "view": name })).on_if(saved), ] } @@ -1508,7 +1516,7 @@ pub fn canvas_items(app: &SolveApp, id: u64) -> Vec { Item::action("ui.calibrateCanvas", "Calibrate", "measure").with(json!({ "canvas": id })), Item::sep(), Item::action("ui.delete", "Delete", "delete").with(json!({ "canvases": [id] })), - Item::action("ui.rename", "Rename", "").with(json!({ "canvas": id })), + Item::action("ui.rename", "Rename", "rename").with(json!({ "canvas": id })), Item::sep(), Item::action("ui.canvasVisible", "Show/Hide", "eye").key("V").with(json!({ "canvas": id, "visible": !visible })), ] @@ -2340,9 +2348,12 @@ pub fn dims_overlay(app: &SolveApp, ctx: &egui::Context) { } } -/// Where the browser's panels open: the viewport's top left corner. +/// Keep browser panels beside the browser and below the ribbon, including the floating shell. fn panel_pos(app: &SolveApp) -> Pos2 { - app.viewport.rect.map(|r| crate::shell::content_rect(app, r).left_top() + vec2(16.0, 16.0)).unwrap_or(pos2(270.0, 140.0)) + app.viewport + .rect + .map(|r| crate::shell::content_rect(app, r).left_top() + vec2(Tokens::get().space_16, Tokens::get().space_16)) + .unwrap_or(pos2(270.0, 140.0)) } /// The Capture Position question stands for saving. diff --git a/crates/ui-egui/src/browser_card_tests.rs b/crates/ui-egui/src/browser_card_tests.rs index 6f3308687a..2ca4bd7b94 100644 --- a/crates/ui-egui/src/browser_card_tests.rs +++ b/crates/ui-egui/src/browser_card_tests.rs @@ -47,7 +47,7 @@ fn browser_card_shrinks_and_scrolls_without_covering_history() { fn selected_row_paints_tab_soft_fill_and_accent_text_in_both_layouts() { for mode in [LayoutMode::Floating, LayoutMode::Docked] { for tab in ["MODEL", "SURFACE", "SKETCH"] { - let ctx = egui::Context::default(); + let ctx = crate::fonts::test_context(); let mut app = SolveApp::new(Default::default(), Default::default()); app.ui.layout = mode; app.ui.tab = tab.into(); @@ -64,7 +64,7 @@ fn selected_row_paints_tab_soft_fill_and_accent_text_in_both_layouts() { assert!( out.shapes .iter() - .any(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == "Selected body" && t.fallback_color == accent.color)) + .any(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == "Selected body" && t.fallback_color == crate::theme::selected_ink(&Tokens::get(), accent.color))) ); } } @@ -167,3 +167,72 @@ fn one_browser_keeps_its_resized_width_across_layouts_and_overlay_preferences() } } } +#[test] +fn browser_title_uses_a_single_semibold_run() { + let ctx = crate::fonts::test_context(); + let mut app = SolveApp::new(Default::default(), Default::default()); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.set_width(250.0); + browser_header(&mut app, ui, true); + }); + output.textures_delta.clear(); + let title = crate::i18n::tr("ui.browser_title"); + let titles: Vec<_> = output + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(text) if text.galley.text() == title => Some(text), + _ => None, + }) + .collect(); + assert_eq!(titles.len(), 1); + assert_eq!(titles[0].galley.job.sections[0].format.font_id, crate::fonts::semibold(Tokens::get().font_heading)); +} + +#[test] +fn resizing_browser_card_and_column_persists_width_including_floating_preference() { + for (mode, float, left_history) in [ + (LayoutMode::Floating, false, false), + (LayoutMode::Docked, false, false), + (LayoutMode::Docked, true, false), + (LayoutMode::Floating, true, true), + (LayoutMode::Docked, true, true), + ] { + let mut h = Harness::new(); + h.app.ui.layout = mode; + h.app.preferences.browser_float = float; + h.app.preferences.timeline_left = left_history; + h.call("ui.resize", json!({"width":1400,"height":900})); + h.settle(); + let before = h.app.viewport.rect.unwrap(); + let at = handle_at("left-browser-rail").unwrap(); + h.call("ui.drag", json!({"x0":at.x,"y0":at.y,"x1":at.x+80.0,"y1":at.y,"steps":8})); + h.settle(); + let width = h.app.preferences.browser_width; + assert!(width > crate::prefs::DEFAULT_BROWSER_WIDTH + 40.0, "{mode:?}, float={float}, width={width}"); + if mode == LayoutMode::Floating || float { + assert!((rect_of("browser:card").unwrap().width() - width).abs() < 1.0); + assert_eq!(h.app.viewport.rect.unwrap(), before, "the card leaves the projection intact"); + } else { + assert!((h.app.viewport.rect.unwrap().left() - width).abs() < 1.0); + } + let mut restored = SolveApp::new(Default::default(), Default::default()); + restored.load_prefs(&h.app.prefs()); + assert_eq!(restored.preferences.browser_width, width); + assert_eq!(restored.preferences.browser_float, float); + } +} + +#[test] +fn browser_width_rejects_hostile_preferences_and_tiny_windows_still_fit() { + for width in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert_eq!(browser_width(width), crate::prefs::DEFAULT_BROWSER_WIDTH); + } + assert_eq!(browser_width(-100.0), MIN_BROWSER_WIDTH); + assert_eq!(browser_width(f32::MAX), MAX_BROWSER_WIDTH); + let available = Rect::from_min_size(Pos2::ZERO, vec2(100.0, 300.0)); + for top_gap in [0.0, crate::shell::SHELL_GAP] { + let bounds = floating_bounds(available, None, MAX_BROWSER_WIDTH, top_gap); + assert!(available.contains_rect(bounds)); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6a96c74aed..960c6c491c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -571,7 +571,7 @@ fn value_box(ui: &mut egui::Ui, label: &str, value: &mut String, at: Pos2, selec crate::frame::area(egui::Id::new("sc_canvas_area")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| { ui.horizontal(|ui| { - ui.label(egui::RichText::new(crate::i18n::tr_label(label)).color(t.text_dim).size(11.5)); + ui.label(egui::RichText::new(crate::i18n::tr_label(label)).color(t.text_dim).size(crate::theme::Tokens::get().font_caption)); let r = ui.add(egui::TextEdit::singleline(value).id(id).desired_width(72.0)); crate::params_dialog::complete(ui, &r, value); if select_all { @@ -889,7 +889,7 @@ fn measure_line(app: &SolveApp, painter: &egui::Painter, proj: &Proj, r: &serde_ painter.circle(s, 3.5, t.manipulator, Stroke::new(1.0, Color32::WHITE)); } let mid = sa + (sb - sa) * 0.5 + vec2(10.0, -14.0); - let galley = painter.layout_no_wrap(crate::prefs::show_mm(app, d, 1), egui::FontId::proportional(12.5), t.text); + let galley = painter.layout_no_wrap(crate::prefs::show_mm(app, d, 1), egui::FontId::proportional(crate::theme::Tokens::get().font_label), t.text); let rect = egui::Rect::from_min_size(mid, galley.size() + vec2(10.0, 6.0)); crate::scenario::publish_handle("measure:label", rect.center()); crate::scenario::publish_rect("measure:label", rect); @@ -931,7 +931,7 @@ fn measure_components(app: &SolveApp, painter: &egui::Painter, proj: &Proj, a: V (total.center(), vec2(20.0, 20.0)) }; let text = format!("{}: {}", axis.to_uppercase(), crate::prefs::show_mm(app, distance, 1)); - let galley = painter.layout_no_wrap(text, egui::FontId::proportional(12.5), t.text); + let galley = painter.layout_no_wrap(text, egui::FontId::proportional(crate::theme::Tokens::get().font_label), t.text); let size = galley.size() + vec2(10.0, 6.0); let bounds = proj.rect.shrink(4.0); let clamp = |rect: egui::Rect| { diff --git a/crates/ui-egui/src/colour_tokens_tests.rs b/crates/ui-egui/src/colour_tokens_tests.rs index 82a1b7d741..8239e23308 100644 --- a/crates/ui-egui/src/colour_tokens_tests.rs +++ b/crates/ui-egui/src/colour_tokens_tests.rs @@ -121,23 +121,30 @@ fn colours_outside_the_theme_are_only_the_listed_neutral_ones() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); let dirs = [root.join("src"), root.join("../../assets/icons/ui/source")]; let mut found = Vec::new(); - for dir in &dirs { - let entries = std::fs::read_dir(dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display())); - for entry in entries.flatten() { - let path = entry.path(); - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default().to_string(); - if !name.ends_with(".rs") || name.ends_with("_tests.rs") || name == "theme.rs" { + fn scan(dir: &std::path::Path, prefix: &str, found: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + let name = path.file_name().unwrap().to_str().unwrap(); + let relative = format!("{prefix}{name}"); + if path.is_dir() { + scan(&path, &format!("{relative}/"), found); continue; } - let src = std::fs::read_to_string(&path).unwrap_or_default(); + if !name.ends_with(".rs") || name.ends_with("_tests.rs") || matches!(name, "theme.rs" | "gallery.rs" | "test_support.rs") { + continue; + } + let src = std::fs::read_to_string(&path).unwrap(); let code = src.split("#[cfg(test)]").next().unwrap_or(&src); let n: usize = COLOUR_LITERALS.iter().map(|p| code.matches(p).count()).sum(); - let allowed = ALLOWED_LITERALS.iter().find(|(f, _, _)| *f == name).map_or(0, |(_, n, _)| *n); + let allowed = ALLOWED_LITERALS.iter().find(|(f, _, _)| *f == relative).map_or(0, |(_, n, _)| *n); if n > allowed { - found.push(format!("{name}: {n} colour literal(s), at most {allowed} allowed")); + found.push(format!("{relative}: {n} colour literal(s), at most {allowed} allowed")); } } } + for dir in &dirs { + scan(dir, "", &mut found); + } assert!(found.is_empty(), "colour literals outside theme::Tokens: {found:?}"); } diff --git a/crates/ui-egui/src/colour_vision_tests.rs b/crates/ui-egui/src/colour_vision_tests.rs index 4e028c73bb..0a01ec3e22 100644 --- a/crates/ui-egui/src/colour_vision_tests.rs +++ b/crates/ui-egui/src/colour_vision_tests.rs @@ -399,11 +399,48 @@ const NO_MEANING: &[(&str, &str)] = &[ ("font_label", "layout dimension"), ("font_button", "layout dimension"), ("font_heading", "layout dimension"), + ("font_triad", "layout dimension"), + ("font_compass", "layout dimension"), + ("font_subtitle", "layout dimension"), + ("font_intro", "layout dimension"), + ("font_article_heading", "layout dimension"), + ("font_title", "layout dimension"), + ("font_display", "layout dimension"), + ("control_height", "layout dimension"), + ("control_radius", "layout dimension"), + ("form_label_width", "layout dimension"), + ("form_gap_y", "layout dimension"), + ("form_footer_height", "layout dimension"), + ("form_header_height", "layout dimension"), + ("form_card_width", "layout dimension"), + ("form_icon_size", "layout dimension"), + ("form_close_size", "layout dimension"), + ("form_inset", "layout dimension"), + ("form_icon_inset", "layout dimension"), + ("form_section_icon_size", "layout dimension"), + ("form_checkbox_height", "layout dimension"), + ("form_button_height", "layout dimension"), + ("sketch_palette_width", "layout dimension"), + ("sketch_mode_size", "layout dimension"), + ("sketch_mode_radius", "layout dimension"), + ("sketch_mode_border", "layout dimension"), + ("sketch_palette_alpha", "neutral panel opacity"), + ("sketch_mode_tint", "selected background tint of the measured sketch accent"), + ("sketch_footer_icon_size", "layout dimension"), + ("font_section_tracking", "layout dimension"), ("on_accent", "ink on an accent fill"), ("app_bar_hover_subtle", "translucent hover chrome"), ("app_bar_hover_button", "translucent hover chrome"), ("app_bar_hover_strong", "translucent hover chrome"), ("app_bar_tab_active", "the active document tab's surface"), + ("dialog_body", "dense dialog surface"), + ("dialog_footer", "dense dialog footer band"), + ("dialog_rule", "dense dialog divider"), + ("control_fill", "input control fill"), + ("control_border", "input control border"), + ("dialog_label", "dense dialog field label"), + ("selected_ink", "foreground of selected elements"), + ("selected_bg", "background of selected elements"), ("render_accent", "the Render workspace's identity colour"), // The window's chrome: grounds, text and borders (measured as grounds where meaning sits on them). ("panel_header", "chrome"), @@ -678,7 +715,7 @@ fn each_mode_changes_only_its_table() { ]; let mut red_green_light = red_green_both.into_iter().filter(|name| !["surface_accent", "mesh_accent", "form_accent"].contains(name)).collect::>(); - red_green_light.extend(["sketch_projected", "sketch_locked", "timeline_item_error", "construction_geom"]); + red_green_light.extend(["sketch_projected", "sketch_locked", "timeline_item_error", "construction_geom", "glyph_text"]); for (dark, mode, want) in [ (false, ColorVision::Protan, set(&red_green_light)), (true, ColorVision::Protan, set(&red_green_both)), @@ -695,6 +732,7 @@ fn each_mode_changes_only_its_table() { "sketch_locked", "box_crossing", "construction_geom", + "glyph_text", "preview_sketch", "analysis.hatch", ]), diff --git a/crates/ui-egui/src/configuration_rules_ui.rs b/crates/ui-egui/src/configuration_rules_ui.rs index e88ec83790..f3c1361221 100644 --- a/crates/ui-egui/src/configuration_rules_ui.rs +++ b/crates/ui-egui/src/configuration_rules_ui.rs @@ -1,5 +1,6 @@ //! Structured configuration rule editing and non-mutating row preview. use crate::SolveApp; +use crate::fonts::RichTextExt as _; use egui::{RichText, Ui}; use serde_json::{Value, json}; use solidcraft_engine::doc::config::{Column, ConfigRule, RuleCondition, RuleEffect, RuleOperator}; @@ -58,7 +59,7 @@ pub(super) fn show(ui: &mut Ui, app: &mut SolveApp, error: &mut Option, let suppression_features: Vec<(u64, String)> = features.iter().filter(|(id, _)| app.session.doc.configs.columns.contains(&Column::Suppress(*id))).cloned().collect(); ui.separator(); - ui.label(RichText::new(crate::i18n::tr_label("Configuration rules")).strong()); + ui.label(RichText::new(crate::i18n::tr_label("Configuration rules")).semibold().color(crate::theme::Tokens::get().text)); ui.add(egui::Label::new("Rules run from top to bottom. Matched drives change effective values.").wrap()); ui.add(egui::Label::new("Matched restrictions check the final values.").wrap()); for (index, rule) in rules.iter().enumerate() { diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index cd6bcc6fad..702889563b 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -11,6 +11,7 @@ //! state; [`items`] lists them and [`run_item`] runs one, so the control channel (`ui.menu`, //! `ui.menuPick`) and tests reach everything a click does. +use crate::fonts::RichTextExt as _; use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Vec2, pos2, vec2}; use serde::Serialize; use serde_json::{Value, json}; @@ -425,7 +426,7 @@ fn viewport_items(app: &SolveApp) -> Vec { } // Everything under the cursor, for what lies beneath something else. if app.menu.over_geometry { - v.push(act("ui.selectOther", "Select Other…", "").key("Hold click")); + v.push(act("ui.selectOther", "Select Other…", "select").key("Hold click")); v.push(Item::sep()); } if app.session.active_sketch.is_some() { @@ -457,7 +458,7 @@ fn viewport_items(app: &SolveApp) -> Vec { v.push(cmd(app, "solid.extrude", "Extrude")); v.push(cmd(app, "construct.plane.offset", "Offset Plane")); v.push(cmd(app, "solid.shell", "Shell")); - v.push(act("ui.editFeature", "Edit Feature", "").with(json!({ "feature": feature })).on(feature.is_some())); + v.push(act("ui.editFeature", "Edit Feature", "edit").with(json!({ "feature": feature })).on(feature.is_some())); v.push(act("ui.editSketch", "Edit Profile Sketch", "sketch").with(json!({ "sketch": sketch })).on(sketch.is_some())); v.push(Item::sep()); v.push(act("ui.appearance", "Appearance", "appearance").key("A")); @@ -467,8 +468,8 @@ fn viewport_items(app: &SolveApp) -> Vec { v.push(act("ui.delete", "Delete", "delete").key("Del")); v.push(act(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": [face] }))); v.push(Item::sep()); - v.push(act("ui.findBrowser", "Find in Browser", "").with(json!({ "body": face, "feature": feature })).on(feature.is_some())); - v.push(act("ui.findTimeline", "Find in Timeline", "").with(json!({ "feature": feature })).on(feature.is_some())); + v.push(act("ui.findBrowser", "Find in Browser", "list").with(json!({ "body": face, "feature": feature })).on(feature.is_some())); + v.push(act("ui.findTimeline", "Find in Timeline", "rollback").with(json!({ "feature": feature })).on(feature.is_some())); v.push(Item::sep()); v.push(cmd(app, "solid.press_pull", "Press Pull")); v.push(cmd(app, "inspect.measure", "Measure")); @@ -481,10 +482,10 @@ fn viewport_items(app: &SolveApp) -> Vec { v.push(act("ui.delete", "Delete", "delete").key("Del")); v.push(act(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": [body] }))); v.push(Item::sep()); - v.push(act("ui.findBrowser", "Find in Browser", "").with(json!({ "body": body }))); + v.push(act("ui.findBrowser", "Find in Browser", "list").with(json!({ "body": body }))); v.push(act("ui.saveMesh", "Save As Mesh", "export").with(json!({ "bodies": [body] }))); v.push(Item::sep()); - v.push(act("ui.tangentChain", "Select Tangent Chain", "")); + v.push(act("ui.tangentChain", "Select Tangent Chain", "chain")); v.push(cmd(app, "inspect.measure", "Measure")); } else if has(|s| matches!(s, Sel::Body { .. })) { v.extend(body_items(app, &sel_bodies(app), false)); @@ -507,7 +508,7 @@ fn viewport_items(app: &SolveApp) -> Vec { || !app.session.visibility.hidden_sketches.is_empty() || !app.session.visibility.hidden_folders.is_empty(); v.push(act("ui.showAll", "Show All", "eye").on(anything_hidden)); - v.push(act("ui.showAll", "Unisolate", "").on(!app.session.visibility.hidden_bodies.is_empty())); + v.push(act("ui.showAll", "Unisolate", "eye").on(!app.session.visibility.hidden_bodies.is_empty())); v.push(Item::sep()); v.push(cmd(app, "solid.extrude", "Extrude")); v.push(cmd(app, "solid.fillet", "Fillet")); @@ -528,7 +529,7 @@ fn viewport_items(app: &SolveApp) -> Vec { v.push(cmd(app, "parts.resize", "Change Part Size").with(json!({"component":component}))); } v.push(Item::sep()); - v.push(act("ui.clear", "Clear Selection", "").key("Esc")); + v.push(act("ui.clear", "Clear Selection", "close").key("Esc")); } v } @@ -543,7 +544,7 @@ fn body_items(app: &SolveApp, bodies: &[String], browser: bool) -> Vec { act("ui.moveBodies", "Move/Copy", "move").key("M").with(json!({ "bodies": names })).on(!locked), cmd(app, "component.from_bodies", "Create Components from Bodies").with(json!({ "bodies": names })), Item::sep(), - act("ui.material", "Physical Material", "").with(json!({ "bodies": names })), + act("ui.material", "Physical Material", "material").with(json!({ "bodies": names })), act("ui.appearance", "Appearance", "appearance").key("A").with(json!({ "bodies": names })), act("ui.properties", "Properties", "measure").with(json!({ "bodies": names })), Item::sep(), @@ -552,11 +553,11 @@ fn body_items(app: &SolveApp, bodies: &[String], browser: bool) -> Vec { Item::sep(), act("ui.delete", "Delete", "delete").key("Del").with(json!({ "items": items })).on(!locked), cmd(app, "solid.remove", "Remove").with(json!({ "bodies": names })).on(!locked), - act("ui.rename", "Rename", "").with(json!({ "body": bodies.first() })).on(one), + act("ui.rename", "Rename", "rename").with(json!({ "body": bodies.first() })).on(one), Item::sep(), act(if all_hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": names })), - act("ui.lock", if locked { "Unlock" } else { "Lock" }, "").with(json!({ "bodies": names })), - act("ui.isolate", "Isolate", "").with(json!({ "bodies": names })), + act("ui.lock", if locked { "Unlock" } else { "Lock" }, "ground").with(json!({ "bodies": names })), + act("ui.isolate", "Isolate", "body").with(json!({ "bodies": names })), ]; if one { let b = bodies.first().cloned().unwrap_or_default(); @@ -565,9 +566,9 @@ fn body_items(app: &SolveApp, bodies: &[String], browser: bool) -> Vec { } v.push(Item::sep()); if !browser { - v.push(act("ui.findBrowser", "Find in Browser", "").with(json!({ "body": b }))); + v.push(act("ui.findBrowser", "Find in Browser", "list").with(json!({ "body": b }))); } - v.push(act("ui.findTimeline", "Find in Timeline", "").with(json!({ "body": b }))); + v.push(act("ui.findTimeline", "Find in Timeline", "rollback").with(json!({ "body": b }))); } v } @@ -586,14 +587,14 @@ fn sketch_items(app: &SolveApp, id: u64) -> Vec { act("ui.exportDxf", "Export DXF…", "export").with(json!({ "sketch": id })), Item::sep(), act("ui.delete", "Delete", "delete").key("Del").with(json!({ "items": [{"type": "feature", "id": id}] })), - act("ui.rename", "Rename", "").with(json!({ "feature": id })), + act("ui.rename", "Rename", "rename").with(json!({ "feature": id })), Item::sep(), - act("ui.lookAt", "Look At", "").with(json!({ "sketch": id })), - act("ui.sketchProfile", if profiles { "Hide Profile" } else { "Show Profile" }, "").with(json!({ "sketch": id })), + act("ui.lookAt", "Look At", "lookat").with(json!({ "sketch": id })), + act("ui.sketchProfile", if profiles { "Hide Profile" } else { "Show Profile" }, "sketch").with(json!({ "sketch": id })), act("ui.sketchDims", if dims { "Hide Dimension" } else { "Show Dimension" }, "dimension").with(json!({ "sketch": id })), act(if visible { "ui.hideSketch" } else { "ui.showSketch" }, "Show/Hide", "eye").key("V").with(json!({ "sketch": id })), Item::sep(), - act("ui.findTimeline", "Find in Timeline", "").with(json!({ "feature": id })), + act("ui.findTimeline", "Find in Timeline", "rollback").with(json!({ "feature": id })), ] } @@ -607,11 +608,11 @@ fn plane_items(app: &SolveApp, id: u64) -> Vec { act("ui.editFeature", "Edit Feature", "plane").with(json!({ "feature": id })), Item::sep(), act("ui.delete", "Delete", "delete").key("Del").with(json!({ "items": [{"type": "feature", "id": id}] })), - act("ui.rename", "Rename", "").with(json!({ "feature": id })), + act("ui.rename", "Rename", "rename").with(json!({ "feature": id })), Item::sep(), act("ui.originPart", "Show/Hide", "eye").key("V").with(json!({ "keys": [f.name], "show": !visible })), Item::sep(), - act("ui.findTimeline", "Find in Timeline", "").with(json!({ "feature": id })), + act("ui.findTimeline", "Find in Timeline", "rollback").with(json!({ "feature": id })), ] } @@ -637,14 +638,14 @@ fn joint_items(app: &SolveApp, id: u64) -> Vec { let hidden = !joints.iter().any(|j| app.session.joint_visible(*j)); vec![ act("ui.editJoint", "Edit Joint", "joint").with(json!({ "joint": id })), - act("ui.editJoint", "Edit Joint Limits", "").with(json!({ "joint": id })).on(!rigid), - act("ui.driveJoint", "Drive Joint", "").with(json!({ "joint": id })).on(!rigid), - act("ui.animateJoint", "Animate Joint", "").with(json!({ "joint": id })).on(!rigid && !j.suppressed), - act("ui.findJoint", "Find in Window", "").with(json!({ "joint": id })), + act("ui.editJoint", "Edit Joint Limits", "joint").with(json!({ "joint": id })).on(!rigid), + act("ui.driveJoint", "Drive Joint", "drive").with(json!({ "joint": id })).on(!rigid), + act("ui.animateJoint", "Animate Joint", "play").with(json!({ "joint": id })).on(!rigid && !j.suppressed), + act("ui.findJoint", "Find in Window", "fit").with(json!({ "joint": id })), cmd(app, "joint.edit", if j.suppressed { "Unsuppress" } else { "Suppress" }).with(json!({ "joint": id, "suppressed": !j.suppressed })), Item::sep(), act("ui.delete", "Delete", "delete").key("Del").with(json!({ "joints": [id] })), - act("ui.rename", "Rename", "").with(json!({ "joint": id })), + act("ui.rename", "Rename", "rename").with(json!({ "joint": id })), Item::sep(), // Hiding a joint hides only its glyph: it still constrains and moves the parts. act("browser.visibility", "Show/Hide", "eye").key("V").with(json!({ "joints": joints, "visible": hidden })), @@ -733,12 +734,12 @@ fn component_items(app: &SolveApp, id: u64) -> Vec { } v.push(cmd(app, "component.create", "New Component").with(json!({ "parent": id }))); if id == 0 { - v.push(act("ui.copyDesign", "Copy", "").key("Ctrl+C")); - v.push(act("ui.paste", "Paste", "").key("Ctrl+V").on(crate::edit_clipboard::can_paste(app))); + v.push(act("ui.copyDesign", "Copy", "copy").key("Ctrl+C")); + v.push(act("ui.paste", "Paste", "paste").key("Ctrl+V").on(crate::edit_clipboard::can_paste(app))); v.push(cmd(app, "component.from_bodies", "Create Components from Bodies").with(json!({ "bodies": names })).on(!bodies.is_empty())); } v.push(Item::sep()); - v.push(act("ui.material", "Physical Material", "").with(json!({ "bodies": names })).on(!bodies.is_empty())); + v.push(act("ui.material", "Physical Material", "material").with(json!({ "bodies": names })).on(!bodies.is_empty())); v.push( act("ui.appearance", "Appearance", "appearance") .key("A") @@ -761,11 +762,11 @@ fn component_items(app: &SolveApp, id: u64) -> Vec { v.push(cmd(app, "component.paste_new", "Paste New").with(json!({ "component": id, "translate": [10, 10, 0] }))); v.push(act("ui.delete", "Delete", "delete").key("Del").with(json!({ "occurrences": [occ.map(|o| o.id)] })).on(occ.is_some())); } - v.push(act("ui.rename", "Rename", "").with(json!({ "component": id }))); + v.push(act("ui.rename", "Rename", "rename").with(json!({ "component": id }))); v.push(Item::sep()); v.push(act(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": names })).on(!bodies.is_empty())); v.push(act("ui.showAll", "Show All Bodies", "eye")); - v.push(act("ui.isolate", "Isolate", "").with(json!({ "bodies": names })).on(!bodies.is_empty() && id != 0)); + v.push(act("ui.isolate", "Isolate", "body").with(json!({ "bodies": names })).on(!bodies.is_empty() && id != 0)); v.push(act("ui.newGroup", "New Group", "folder").with(json!({ "component": id }))); v } @@ -819,7 +820,7 @@ fn sketch_entity_items(app: &SolveApp) -> Vec { v.push(cmd(app, "sketch.circle.center", "Circle")); v.push(cmd(app, "sketch.dimension", "Sketch Dimension")); v.push(Item::sep()); - v.push(act("ui.paste", "Paste", "").key("Ctrl+V").on(app.session.sketch_clipboard.is_some())); + v.push(act("ui.paste", "Paste", "paste").key("Ctrl+V").on(app.session.sketch_clipboard.is_some())); return v; } let ents: Vec = curves.iter().chain(points.iter()).cloned().collect(); @@ -835,9 +836,9 @@ fn sketch_entity_items(app: &SolveApp) -> Vec { v.extend(explode_text(app)); } v.push(cmd(app, "sketch.move", "Move/Copy").key("M")); - v.push(act("ui.cut", "Cut", "").key("Ctrl+X")); - v.push(act("ui.copy", "Copy", "").key("Ctrl+C")); - v.push(act("ui.paste", "Paste", "").key("Ctrl+V").on(app.session.sketch_clipboard.is_some())); + v.push(act("ui.cut", "Cut", "cut").key("Ctrl+X")); + v.push(act("ui.copy", "Copy", "copy").key("Ctrl+C")); + v.push(act("ui.paste", "Paste", "paste").key("Ctrl+V").on(app.session.sketch_clipboard.is_some())); if !curves.is_empty() { v.push(cmd(app, "sketch.construction", "Normal / Construction").with(json!({ "curves": curves }))); } @@ -1482,12 +1483,23 @@ pub fn describe(app: &SolveApp) -> Value { return json!({ "open": false, "pending": app.menu.pending.is_some() }); }; let radial = if app.menu.radial { radial_items(app) } else { Vec::new() }; + let describe_items = |items: Vec| { + items + .into_iter() + .map(|item| { + let placeholder = !item.icon.is_empty() && crate::icon_svg::resolve(&item.icon).placeholder; + let mut value = json!(item); + value["placeholder"] = json!(placeholder); + value + }) + .collect::>() + }; json!({ "open": true, "at": [at.x, at.y], "target": app.menu.target, - "radial": radial, - "items": items(app, &app.menu.target), + "radial": describe_items(radial), + "items": describe_items(items(app, &app.menu.target)), "rename": app.menu.rename.as_ref().map(|r| r.text.clone()), }) } @@ -1683,7 +1695,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let resp = crate::frame::area(egui::Id::new("sc_marking_menu")).fixed_pos(area.min).order(egui::Order::Foreground).show(ctx, |ui| { let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover()); let painter = ui.painter_at(r.expand(4.0)); - let font = FontId::proportional(13.0); + let font = FontId::proportional(crate::theme::Tokens::get().font_button); // Centre: a ring with a pointer toward the hot direction. let direction = hot.zip(pointer).map(|(k, p)| ((p - c).normalized(), radial.get(k).is_some_and(|i| i.enabled))); crate::icons::glyphs::radial_centre(&painter, c, direction, t); @@ -1782,7 +1794,12 @@ fn workspace_menu(ui: &mut egui::Ui, items: &[Item], key_row: Option, hei ui.set_width(450.0); ui.spacing_mut().item_spacing.y = 0.0; ui.add_space(4.0); - ui.label(egui::RichText::new(crate::i18n::tr("ui.ribbon_workspace").to_uppercase()).size(11.0).strong().color(t.text_dim)); + ui.label( + egui::RichText::new(crate::i18n::tr("ui.ribbon_workspace").to_uppercase()) + .size(crate::theme::Tokens::get().font_small) + .semibold() + .color(t.text_dim), + ); ui.add_space(8.0); egui::ScrollArea::vertical().id_salt("workspace_menu_rows").max_height((height - 116.0).max(60.0)).show(ui, |ui| { for (k, item) in items.iter().enumerate() { @@ -1824,16 +1841,13 @@ fn workspace_menu(ui: &mut egui::Ui, items: &[Item], key_row: Option, hei let name = crate::i18n::tr_label(&item.label); let x = r.left() + 74.0; let y = r.top() + 17.0; - let galley = egui::WidgetText::from(egui::RichText::new(name).strong().size(14.0).color(text)).into_galley( - ui, - Some(egui::TextWrapMode::Extend), - f32::INFINITY, - egui::TextStyle::Button, - ); + let galley = egui::WidgetText::from(egui::RichText::new(name).semibold().size(crate::theme::Tokens::get().font_heading).color(text)) + .into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Button); let badge_x = x + galley.size().x + 9.0; let origin = pos2(x, y - galley.size().y / 2.0); - ui.painter().galley(origin - vec2(0.2, 0.0), galley.clone(), text); - ui.painter().galley(origin + vec2(0.2, 0.0), galley, text); + let ppp = ui.ctx().pixels_per_point().max(0.5); + let origin = pos2((origin.x * ppp).round() / ppp, (origin.y * ppp).round() / ppp); + ui.painter().galley(origin, galley, text); let badge = crate::i18n::tr(if planned { "ui.workspace_planned" } else if current { @@ -1841,24 +1855,28 @@ fn workspace_menu(ui: &mut egui::Ui, items: &[Item], key_row: Option, hei } else { "ui.workspace_available" }); - let badge = ui.painter().layout_no_wrap(badge.to_string(), FontId::proportional(11.0), if current { t.on_accent } else { accent }); + let badge = ui.painter().layout_no_wrap( + badge.to_string(), + FontId::proportional(crate::theme::Tokens::get().font_small), + if current { t.on_accent } else { accent }, + ); let badge_rect = Rect::from_min_size(pos2(badge_x, y - 8.0), badge.size() + vec2(12.0, 3.0)); ui.painter().rect_filled(badge_rect, 8.0, if current { accent } else { soft }); ui.painter().galley(badge_rect.min + vec2(6.0, 1.5), badge, accent); - let line = |label: &str, y: f32| { - let mut job = egui::text::LayoutJob::simple_singleline(label.to_string(), FontId::proportional(12.0), text); + let line = |content: String, y: f32| { + let mut job = + egui::text::LayoutJob::simple_singleline(content, FontId::proportional(crate::theme::Tokens::get().font_body), text); job.wrap.max_width = r.right() - x - 6.0; job.wrap.max_rows = 1; job.wrap.break_anywhere = false; let galley = ui.painter().layout_job(job); ui.painter().galley(pos2(x, y), galley, text); }; - line(crate::i18n::tr(description), r.top() + 29.0); + line(crate::i18n::tr(description).to_string(), r.top() + 29.0); if let Some(tabs) = tabs { - // Reuse existing tab-name translations when a partial catalog falls back - // to the English summary; complete catalogs keep their translated summary. + // Translate each tab even when this composed message falls back to English. let labels = crate::i18n::tr(tabs).split('·').map(|label| crate::i18n::tr_label(label.trim())).collect::>().join(" · "); - line(&labels, r.top() + 47.0); + line(labels, r.top() + 47.0); } resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, item.enabled, name)); if resp.clicked() && item.enabled { @@ -1869,7 +1887,12 @@ fn workspace_menu(ui: &mut egui::Ui, items: &[Item], key_row: Option, hei ui.add_space(6.0); ui.separator(); ui.add_space(6.0); - ui.add(egui::Label::new(egui::RichText::new(crate::i18n::tr("ui.workspace_footer")).size(11.0).color(t.text_dim)).wrap()); + ui.add( + egui::Label::new( + egui::RichText::new(crate::i18n::tr("ui.workspace_footer")).size(crate::theme::Tokens::get().font_small).color(t.text_dim), + ) + .wrap(), + ); ui.add_space(4.0); }); picked @@ -1897,6 +1920,27 @@ pub fn menu_frame(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> .inner } +/// A compact menu button with an explicit semantic icon, for native egui popup menus. +pub fn button(ui: &mut egui::Ui, label: &str, icon: &str) -> egui::Response { + button_enabled(ui, label, icon, true) +} + +/// The disabled form keeps its icon visible and dims it with the menu row. +pub fn button_enabled(ui: &mut egui::Ui, label: &str, icon: &str, enabled: bool) -> egui::Response { + let response = ui + .add_enabled_ui(enabled, |ui| { + let icon_id = ui.id().with(("menu_icon", label)); + let row = egui::Button::new((egui::Atom::custom(icon_id, vec2(16.0, 16.0)), label)).min_size(vec2(180.0, 22.0)).atom_ui(ui); + if let Some(rect) = row.rect(icon_id) { + icons::paint_icon(ui.painter(), rect, icon, if enabled { 1.0 } else { 0.35 }); + } + row.response + }) + .inner; + crate::scenario::publish_handle(&format!("menu:{label}"), response.rect.center()); + response +} + /// One list row; the item when it was clicked. pub fn list_row(ui: &mut egui::Ui, item: &Item) -> Option { list_row_lit(ui, item, false) @@ -1912,7 +1956,12 @@ pub fn list_row_lit(ui: &mut egui::Ui, item: &Item, lit: bool) -> Option { return None; } if item.is_heading() { - let text = ui.painter().layout(crate::i18n::tr_label(&item.label).to_uppercase(), FontId::proportional(10.5), t.text_dim, w - 16.0); + let text = ui.painter().layout( + crate::i18n::tr_label(&item.label).to_uppercase(), + FontId::proportional(crate::theme::Tokens::get().font_panel), + t.text_dim, + w - 16.0, + ); let (r, _) = ui.allocate_exact_size(vec2(w, (text.size().y + 8.0).max(20.0)), Sense::hover()); ui.painter().galley(pos2(r.left() + 8.0, r.top() + 4.0), text, t.text_dim); return None; @@ -1935,10 +1984,23 @@ pub fn list_row_lit(ui: &mut egui::Ui, item: &Item, lit: bool) -> Option { } let text = if item.enabled { t.text } else { t.text_dim.gamma_multiply(0.75) }; let display_label = crate::i18n::tr_label(&item.label); - ui.painter().text(pos2(r.left() + 32.0, r.center().y), Align2::LEFT_CENTER, display_label, FontId::proportional(13.0), text); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, item.enabled, display_label)); + ui.painter().text( + pos2(r.left() + 32.0, r.center().y), + Align2::LEFT_CENTER, + display_label, + FontId::proportional(crate::theme::Tokens::get().font_button), + text, + ); if !item.shortcut.is_empty() { let display_shortcut = crate::i18n::tr_label(&item.shortcut); - ui.painter().text(pos2(r.right() - 10.0, r.center().y), Align2::RIGHT_CENTER, display_shortcut, FontId::proportional(11.5), t.text_dim); + ui.painter().text( + pos2(r.right() - 10.0, r.center().y), + Align2::RIGHT_CENTER, + display_shortcut, + FontId::proportional(crate::theme::Tokens::get().font_caption), + t.text_dim, + ); } (item.enabled && resp.clicked()).then(|| item.clone()) } @@ -1955,7 +2017,7 @@ fn show_rename(app: &mut SolveApp, ctx: &egui::Context) { let resp = crate::frame::area(egui::Id::new("sc_rename")).fixed_pos(r.at).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| { menu_frame(ui, |ui| { ui.add_space(2.0); - ui.label(egui::RichText::new(crate::i18n::tr_label("Rename")).size(11.0).color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr_label("Rename")).size(crate::theme::Tokens::get().font_small).color(t.text_dim)); let te = ui.add(egui::TextEdit::singleline(&mut r.text).desired_width(200.0).id(egui::Id::new("sc_rename_text"))); if !r.focused { // Start with the whole name selected, ready to type over. @@ -2085,7 +2147,7 @@ mod tests { #[test] fn disabled_radial_menu_does_not_capture_right_button_hold() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); - let ctx = egui::Context::default(); + let ctx = crate::fonts::test_context(); let point = pos2(600.0, 400.0); app.viewport.mouse = Some(point); for (time, events) in [ @@ -2165,6 +2227,27 @@ mod workspace_menu_tests { use super::*; use crate::scenario::{Harness, rect_of}; + #[test] + fn workspace_titles_use_one_semibold_text_run() { + let ctx = crate::fonts::test_context(); + let app = SolveApp::new(Default::default(), Default::default()); + let list = items(&app, &Target::Workspace); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + workspace_menu(ui, &list, None, 1000.0); + }); + output.textures_delta.clear(); + let titles: Vec<_> = output + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(text) if text.galley.text() == "Design" => Some(text), + _ => None, + }) + .collect(); + assert_eq!(titles.len(), 1, "workspace titles must never be drawn twice"); + assert_eq!(titles[0].galley.job.sections[0].format.font_id, crate::fonts::semibold(14.0)); + } + #[test] fn workspace_rows_keep_switching_handles_and_disabled_render_behavior() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f33db5648f..fca297c442 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -24,6 +24,7 @@ //! headless CPU render of the model with the current camera, in the window's theme, with the //! section the viewport shows (no window needed); the ground grid and the origin's axes as the //! window shows them, unless `grid` or `axes` says +//! - `ui.icon_audit {reset?, stop?}`: collect/drain requested icon names, resolved masters and placeholder flags; //! - `ui.menu {x?, y?, target?, close?}`: open a context menu at a point (the viewport's marking //! menu, or a browser menu with `target: {type: body|sketch|component, …}`); without a point, //! the open menu and its items. `ui.menuPick {item}` runs an item by id or label; @@ -668,6 +669,16 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string())); ok(Value::Null) } + "ui.icon_audit" => { + if b("reset") { + crate::icon_svg::audit::start(); + } + let requested = crate::icon_svg::audit::take(); + if b("stop") { + crate::icon_svg::audit::stop(); + } + ok(json!({"requested": requested})) + } "ui.menu" => { if b("close") { crate::context_menu::close(app); @@ -957,7 +968,7 @@ mod tests { #[test] fn touchpad_text_prefix_does_not_collapse_queued_pointer_frames() { let mut app = SolveApp::new(Session::default(), Services::default()); - let ctx = egui::Context::default(); + let ctx = crate::fonts::test_context(); let (req, _) = ControlRequest::new("ui.text", json!({"text":"prefix"})); assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(_))); for params in [json!({"x":10,"y":10,"shift":true}), json!({"x":20,"y":20,"alt":true}), json!({"x":30,"y":30})] { @@ -981,7 +992,7 @@ mod tests { #[test] fn touchpad_automation_preserves_each_queued_moves_modifiers() { let mut app = SolveApp::new(Session::default(), Services::default()); - let ctx = egui::Context::default(); + let ctx = crate::fonts::test_context(); for params in [json!({"x":10,"y":10,"shift":true}), json!({"x":20,"y":20,"alt":true}), json!({"x":30,"y":30})] { let (req, _) = ControlRequest::new("ui.move", params); assert!(matches!(handle(&mut app, &ctx, &req), Outcome::Done(_))); @@ -1003,7 +1014,7 @@ mod tests { fn render(app: &mut SolveApp, path: &std::path::Path) -> Vec { let (req, _rx) = ControlRequest::new("ui.render", json!({"path": path.to_string_lossy(), "width": 200, "height": 140})); - match handle(app, &egui::Context::default(), &req) { + match handle(app, &crate::fonts::test_context(), &req) { Outcome::Done(v) => assert_eq!(v["ok"], true, "{v}"), Outcome::Screenshot { .. } => panic!("ui.render took a screenshot"), } @@ -1073,7 +1084,7 @@ mod tests { params[k.as_str()] = v.clone(); } let (req, _rx) = ControlRequest::new("ui.render", params); - match handle(app, &egui::Context::default(), &req) { + match handle(app, &crate::fonts::test_context(), &req) { Outcome::Done(v) => assert_eq!(v["ok"], true, "{v}"), Outcome::Screenshot { .. } => panic!("ui.render took a screenshot"), } @@ -1348,7 +1359,7 @@ mod tests { params["allow_paths"] = json!([root.to_string_lossy()]); } let (req, _rx) = ControlRequest::new(method, params); - match handle(&mut app, &egui::Context::default(), &req) { + match handle(&mut app, &crate::fonts::test_context(), &req) { Outcome::Done(v) => v, Outcome::Screenshot { path } => json!({"ok": true, "screenshot": path}), } diff --git a/crates/ui-egui/src/crash_ui.rs b/crates/ui-egui/src/crash_ui.rs index b1194c35bb..75412fefde 100644 --- a/crates/ui-egui/src/crash_ui.rs +++ b/crates/ui-egui/src/crash_ui.rs @@ -1,6 +1,7 @@ //! The Crash Reports window (Help > Crash Reports…): the reports `solidcraft_engine::crash` //! keeps on this computer, each with a reviewed issue draft. It opens by itself after a crash. +use crate::fonts::RichTextExt as _; use egui::{RichText, vec2}; use solidcraft_engine::crash::{self, Summary}; @@ -88,7 +89,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { if ui.small_button("Copy folder path").clicked() { ctx.copy_text(d.clone()); } - ui.add(egui::Label::new(RichText::new(&d).monospace().size(11.0)).truncate()).on_hover_text(&d); + ui.add(egui::Label::new(RichText::new(&d).monospace().size(crate::theme::Tokens::get().font_small)).truncate()).on_hover_text(&d); }); } ui.separator(); @@ -98,14 +99,14 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { for r in &app.crashes.reports { ui.horizontal(|ui| { - ui.label(RichText::new(report_heading(r)).strong()); + ui.label(RichText::new(report_heading(r)).semibold().color(crate::theme::Tokens::get().text)); if let Some(c) = &r.command { - ui.label(RichText::new(c).monospace().size(11.0).color(t.text_dim)); + ui.label(RichText::new(c).monospace().size(crate::theme::Tokens::get().font_small).color(t.text_dim)); } }); - ui.label(RichText::new(&r.message).size(12.0)); + ui.label(RichText::new(&r.message).size(t.font_body)); if r.kind == "unclean_exit" { - ui.label(RichText::new(crate::i18n::tr("crash.unclean_explanation")).size(12.0).color(t.text_dim)); + ui.label(RichText::new(crate::i18n::tr("crash.unclean_explanation")).size(t.font_body).color(t.text_dim)); } ui.horizontal(|ui| { if ui.small_button("Report…").clicked() { @@ -118,7 +119,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { delete = Some(Some(r.id.clone())); } if r.replayable { - ui.label(RichText::new("replayable").size(11.0).color(t.text_dim)); + ui.label(RichText::new("replayable").size(crate::theme::Tokens::get().font_small).color(t.text_dim)); } }); ui.add_space(6.0); diff --git a/crates/ui-egui/src/design_properties.rs b/crates/ui-egui/src/design_properties.rs index 7a5503205e..1a20dacf6c 100644 --- a/crates/ui-egui/src/design_properties.rs +++ b/crates/ui-egui/src/design_properties.rs @@ -5,6 +5,7 @@ //! Document Settings, the root component's menu and `ui.start` of `document.metadata` or //! `document.metadata.set` open it. +use crate::fonts::RichTextExt as _; use egui::RichText; use serde_json::{Map, Value}; use solidcraft_engine::doc::metadata::{DESCRIPTION, WELL_KNOWN}; @@ -89,7 +90,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let mut open = true; let (mut ok, mut cancel) = (false, false); let metadata = app.session.doc.metadata.clone(); - let title = RichText::new(crate::i18n::tr_label("Design Properties")).strong().size(13.0); + let title = RichText::new(crate::i18n::tr_label("Design Properties")) + .semibold() + .color(crate::theme::Tokens::get().text) + .size(crate::theme::Tokens::get().font_button); crate::frame::window(ctx, title, crate::frame::Width::Normal) .id(egui::Id::new("sc_design_properties")) .open(&mut open) @@ -123,7 +127,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.label(RichText::new(crate::i18n::tr("ui.design_properties.others_note")).small().color(t.text_dim)); egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| { for (k, v) in others { - ui.label(RichText::new(k).monospace().size(12.0).color(t.text)); + ui.label(RichText::new(k).monospace().size(crate::theme::Tokens::get().font_body).color(t.text)); ui.label(RichText::new(preview(v)).monospace().small().color(t.text_dim)); } }); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5a06ffd634..2b5ce075bf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2,6 +2,7 @@ //! inputs (geometry picked in the viewport, never typed), then runs its command; it never //! changes the design itself. Things selected before the command starts become its input. +use crate::fonts::RichTextExt as _; use std::hash::{Hash, Hasher}; use egui::{Color32, RichText, Stroke, vec2}; @@ -18,6 +19,9 @@ use crate::theme::Tokens; use crate::tool_modifiers::{Audit, Scope, ToolModifiers}; use crate::viewport::Hit; +#[path = "dialogs_dense.rs"] +mod dense; + const SELECT_ONLY: &[Scope] = &[Scope::Selection, Scope::FeaturePreview]; const SELECT_MANIP: &[Scope] = &[Scope::Selection, Scope::Manipulator, Scope::FeaturePreview]; @@ -402,6 +406,10 @@ pub struct Dialog { pub focus: bool, /// Folded to its header (the "−" glyph). pub collapsed: bool, + /// Transient display preference; never part of a command or a saved feature. + pub preview_enabled: bool, + /// Preserve Apply intent across asynchronous evaluation; never a command parameter. + repeat_after_apply: bool, /// Parameters the dialog doesn't show but the command needs (kept when editing). pub extra: serde_json::Map, /// What the inputs were when OK was refused: `error` stays only while they still are. @@ -527,6 +535,8 @@ impl Dialog { profile_selection_changed: false, focus: true, collapsed: false, + preview_enabled: true, + repeat_after_apply: false, extra: serde_json::Map::new(), refused: None, then_start: None, @@ -962,7 +972,7 @@ impl Dialog { /// Dialogs holding a table get the wide limit. pub fn is_wide(&self) -> bool { - matches!(self.kind, Kind::Params { .. } | Kind::SketchModify(crate::sketch_modify_ui::Mod::PatternRect { .. })) + matches!(self.kind, Kind::Params { .. }) || (matches!(self.kind, Kind::PatternRect { .. }) && self.inputs.first().is_some_and(|i| i.accept == selection::FEATURES)) || matches!(&self.kind, Kind::Motion(k) if k.wide()) } @@ -1542,6 +1552,9 @@ fn plane_value(s: &Session, sel: Option<&Sel>) -> Option { /// A value field; true when Enter was pressed in it. pub(crate) fn field(ui: &mut egui::Ui, s: &mut String) -> bool { + if crate::ui_kit::contains(ui) { + return crate::ui_kit::field(ui, s); + } let r = ui.text_edit_singleline(s); crate::theme::TabAccent::number_focus(ui, &r); publish_row(&r); @@ -1552,11 +1565,15 @@ pub(crate) fn field(ui: &mut egui::Ui, s: &mut String) -> bool { /// A checkbox row. Its row handle (`dialog:`) lets a scenario click it by the label /// beside it, the way `field` already publishes one for a value field. pub(crate) fn check(ui: &mut egui::Ui, on: &mut bool) { - let r = ui.checkbox(on, ""); - publish_row(&r); + let r = if crate::ui_kit::contains(ui) { crate::ui_kit::checkbox(ui, "", on) } else { ui.checkbox(on, "") }; + publish_check(ui, &r, *on); } pub(crate) fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usize) { + if crate::ui_kit::contains(ui) { + crate::ui_kit::dropdown(ui, id, sel, labels); + return; + } let row = ROW.with(|r| r.borrow().clone()); let selected = labels.get(*sel).copied().map(tr).unwrap_or(""); let r = egui::ComboBox::from_id_salt(id).selected_text(selected).width(FIELD_W).show_ui(ui, |ui| { @@ -1568,39 +1585,6 @@ pub(crate) fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usiz publish_row(&r.response); } -/// A mode choice as a row of icon buttons rather than a drop-down: every mode is on screen at -/// once, each drawn as the shape it makes, with its name as the tooltip. `labels` and `icons` -/// run in step; a mode with no icon gets the fallback square. Publishes the handles a combo -/// does, `dialog::