diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b5dee163..3f43409d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1422,8 +1422,9 @@ fn auto_operation(app: &SolveApp, d: &mut Dialog) { } } -/// Show the active dialog (if any), docked flush to the right edge of the viewport just below -/// the axis view, sized to its content. +/// Show the active dialog (if any), docked flush to an edge of the viewport just below the axis +/// view, sized to its content. The right edge is its place, except while a sketch is open: the +/// Sketch Palette keeps the right there, and the dialog takes the left so both stay on screen. pub fn show(app: &mut SolveApp, ctx: &egui::Context) { app.viewport.dialog_rect = None; let Some(mut d) = app.dialog.take() else { return }; @@ -1462,7 +1463,14 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { 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 anchor = egui::pos2(vp.right(), top); + // 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) { + crate::frame::Side::Left + } else { + crate::frame::Side::Right + }; + let anchor = egui::pos2(side.anchor_x(vp), top); let mut keep = true; let mut ok = false; let mut applied = false; @@ -1471,11 +1479,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let wide = d.is_wide(); let raw_title = title(&d.kind); let heading = if d.editing.is_some() { crate::i18n::format("dialog.edit_title", &[("title", tr(raw_title))]) } else { tr(raw_title).to_string() }; - let frame = egui::Frame::window(&ctx.global_style()) - .fill(t.dialog_bg) - .stroke(Stroke::new(1.0, t.dialog_border)) - .corner_radius(egui::CornerRadius { nw: 4, sw: 4, ne: 0, se: 0 }) - .shadow(egui::Shadow { offset: [-2, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(40) }); + let frame = crate::frame::dock_frame(ctx, side); // The area includes the border as well as the inner margins; reserve all of it so a // horizontally scrolling table cannot push a wide dialog two pixels past its limit. let horizontal_frame = frame.inner_margin.leftf() + frame.inner_margin.rightf() + 2.0 * frame.stroke.width; @@ -1484,12 +1488,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let valid = !has_ok || !d.previews() || (built.unwrap_or_else(|| apply_commands(app, &d).is_ok()) && app.preview.error.is_none()); let width = if wide { crate::frame::Width::Wide } else { crate::frame::Width::Normal }; let wkey = egui::Id::new(("sc_dialog_w", wide)); - let response = crate::frame::docked( + let response = crate::frame::docked_on( RichText::new(heading.clone()).strong().size(13.0), egui::Id::new(("sc_dialog", title(&d.kind))), anchor, vp.bottom() - anchor.y - 8.0, width, + side, ) .frame(frame) .open(&mut keep) @@ -2242,6 +2247,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { crate::frame::remember_width(ui, wkey); }); app.viewport.dialog_rect = response.map(|r| r.response.rect); + if let Some(r) = app.viewport.dialog_rect { + crate::scenario::publish_rect("dialog:outer", r); + } // Keyboard: Enter applies (from a value field, or with nothing focused), Esc cancels. let (enter_free, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); let nothing_focused = ctx.memory(|m| m.focused().is_none()); diff --git a/crates/ui-egui/src/frame.rs b/crates/ui-egui/src/frame.rs index c8d65cae..3e323a2c 100644 --- a/crates/ui-egui/src/frame.rs +++ b/crates/ui-egui/src/frame.rs @@ -3,6 +3,8 @@ //! other file creates an `egui::Window`, and that every dialog stays within the limits). //! //! - [`docked`]: command dialogs and command panels, flush to the right edge of the viewport. +//! - [`docked_on`]: the same, against either edge ([`Side`]), with [`dock_frame`] for its +//! chrome. A dialog opened while the Sketch Palette holds the right edge takes the left. //! - [`window`]: floating windows (Preferences, Parameters, prompts…), centred by default. //! - [`right_aligned`]: right-align a row's last items without widening the window (a //! right-to-left layout would stretch it to its maximum width). @@ -28,17 +30,60 @@ pub enum Width { Wide, } +/// Which edge of the viewport a docked panel sits against. The Sketch Palette holds the right +/// edge while a sketch is open, so a dialog started there takes the left. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Side { + Right, + Left, +} + +impl Side { + /// The x of the viewport edge this side docks against. + pub fn anchor_x(self, viewport: egui::Rect) -> f32 { + match self { + Side::Right => viewport.right(), + Side::Left => viewport.left(), + } + } +} + +/// The window frame of a docked panel: the two corners away from its edge are rounded, and the +/// shadow falls the same way, so a left dock mirrors a right one. +pub fn dock_frame(ctx: &egui::Context, side: Side) -> egui::Frame { + let t = crate::theme::Tokens::get(); + let (corner_radius, shadow_x) = match side { + Side::Right => (egui::CornerRadius { nw: 4, sw: 4, ne: 0, se: 0 }, -2), + Side::Left => (egui::CornerRadius { nw: 0, sw: 0, ne: 4, se: 4 }, 2), + }; + egui::Frame::window(&ctx.global_style()) + .fill(t.dialog_bg) + .stroke(egui::Stroke::new(1.0, t.dialog_border)) + .corner_radius(corner_radius) + .shadow(egui::Shadow { offset: [shadow_x, 2], blur: 8, spread: 0, color: egui::Color32::from_black_alpha(40) }) +} + /// A command dialog docked at `anchor` (its top right corner), shrink-wrapped to its content. pub fn docked<'a>(title: impl Into, id: egui::Id, anchor: Pos2, max_height: f32, width: Width) -> egui::Window<'a> { + docked_on(title, id, anchor, max_height, width, Side::Right) +} + +/// A command dialog docked against `side` at `anchor` (its top corner on that side), +/// shrink-wrapped to its content. +pub fn docked_on<'a>(title: impl Into, id: egui::Id, anchor: Pos2, max_height: f32, width: Width, side: Side) -> egui::Window<'a> { let max = match width { Width::Normal => DIALOG_MAX_W, Width::Wide => DIALOG_WIDE_MAX_W, }; + let pivot = match side { + Side::Right => Align2::RIGHT_TOP, + Side::Left => Align2::LEFT_TOP, + }; egui::Window::new(title) .enabled(!crate::issue_report::cad_input_blocked()) .id(id) .title_bar(false) - .pivot(Align2::RIGHT_TOP) + .pivot(pivot) .fixed_pos(anchor) .constrain(false) .auto_sized() diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 06287899..f8162f5a 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -716,6 +716,19 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> { return Err(format!("{a} {lo:?} does not span {b} {hi:?} (±{tol})")); } } + "disjoint" => { + // {"disjoint": ["a", "b"]}: both published rects are on screen and neither + // covers any part of the other (a dialog beside the Sketch Palette). + let names: Vec<&str> = v.as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + let (Some(a), Some(b)) = (names.first(), names.get(1)) else { + return Err("disjoint takes two published rect names".to_string()); + }; + let ra = rect_of(a).ok_or(format!("no rect {a}"))?; + let rb = rect_of(b).ok_or(format!("no rect {b}"))?; + if ra.intersect(rb).is_positive() { + return Err(format!("{a} {ra:?} overlaps {b} {rb:?}")); + } + } "dialog_docked" => { // The command dialog: docked at the viewport's right, its width in [min, max]. let r = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_dialog"))).ok_or("no command dialog")?; diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index b28aefcc..1f485988 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -218,6 +218,34 @@ pub fn set_linetype(app: &mut SolveApp, kind: u8) { } } +/// The palette's content width in `vp`; it gives way to the viewport on narrow views. +fn width(vp: egui::Rect) -> f32 { + WIDTH.min((vp.width() * 0.46 - 16.0).max(120.0)) +} + +/// The viewport the palette docks into. +fn viewport(app: &SolveApp, ctx: &egui::Context) -> egui::Rect { + app.viewport.rect.unwrap_or_else(|| ctx.content_rect()) +} + +/// Is a sketch being edited? That is the palette's own condition. +pub fn editing(app: &SolveApp) -> bool { + app.session.active_sketch.and_then(|s| app.session.world_state().sketch(s).map(|_| ())).is_some() +} + +/// Has the viewport room for the palette and a command dialog side by side? A narrow view has +/// not, and there the dialog keeps the right edge on its own. +pub fn fits_beside_a_dialog(app: &SolveApp, ctx: &egui::Context) -> bool { + let vp = viewport(app, ctx); + vp.width() >= width(vp) + crate::frame::DIALOG_MIN_W + 40.0 +} + +/// Is the palette on screen? It stays for as long as a sketch is being edited, a command dialog +/// open or not: the dialog takes the left edge of the viewport and the palette keeps the right. +pub fn visible(app: &SolveApp, ctx: &egui::Context) -> bool { + editing(app) && (app.dialog.is_none() || fits_beside_a_dialog(app, ctx)) +} + /// A small disclosure triangle for the Options section: pointing right when collapsed, down /// when open. Kept separate from the palette's own "−"/"+" fold, which stays a plain glyph. fn disclosure_triangle(p: &egui::Painter, c: egui::Pos2, open: bool, col: Color32) { @@ -291,11 +319,11 @@ fn feature_row(app: &SolveApp, ui: &mut egui::Ui, width: f32, family: &crate::sk start } -/// Draw the palette (while sketching and no command dialog is open). +/// Draw the palette (while a sketch is being edited). pub fn show(app: &mut SolveApp, ctx: &egui::Context) { app.viewport.palette_rect = None; let _language = crate::i18n::scope(&app.preferences.language); - if app.dialog.is_some() { + if !visible(app, ctx) { return; } let (opts, dof_line, ok) = { @@ -304,16 +332,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { (ss.sketch.view.clone(), dof_text(ss.report.dof, ss.report.ok()), ss.report.ok() && ss.report.dof == 0) }; let t = Tokens::get(); - let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect()); + 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 frame = egui::Frame::window(&ctx.global_style()) - .fill(t.dialog_bg) - .stroke(Stroke::new(1.0, t.dialog_border)) - .corner_radius(egui::CornerRadius { nw: 4, sw: 4, ne: 0, se: 0 }) - .shadow(egui::Shadow { offset: [-2, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(40) }); + let frame = crate::frame::dock_frame(ctx, crate::frame::Side::Right); let shown = |k: &str| match k { "grid" => !opts.hide_grid, "snap" => !opts.no_snap, @@ -344,7 +368,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ) .frame(frame) .show(ctx, |ui| { - let width = WIDTH.min((vp.width() * 0.46 - 16.0).max(120.0)); + 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); @@ -616,6 +640,39 @@ mod tests { } } + /// A command dialog opened while sketching used to hide the palette outright. The two now + /// share the viewport, the dialog at the left edge and the palette at the right, and a view + /// too narrow for both keeps the dialog alone. + #[test] + fn a_dialog_opened_while_sketching_keeps_the_palette_beside_it() { + FOLDED.with(|v| v.set(false)); + OPTIONS_FOLDED.with(|v| v.set(false)); + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]})).unwrap(); + h.call("ui.resize", json!({"width":1400,"height":900})); + h.settle(); + assert!(h.app.viewport.palette_rect.is_some(), "the palette is there while sketching"); + h.app.start("inspect.measure"); + h.settle(); + assert!(h.app.dialog.is_some(), "the command opens a dialog"); + let palette = h.app.viewport.palette_rect.expect("the palette stays on screen while a dialog is open"); + let dialog = h.app.viewport.dialog_rect.unwrap(); + assert!(!palette.intersect(dialog).is_positive(), "palette {palette:?} overlaps dialog {dialog:?}"); + assert!(dialog.left() < palette.left(), "the dialog takes the left edge: {dialog:?}, {palette:?}"); + let finish = crate::scenario::rect_of("sketch_palette:finish").expect("Finish Sketch stays reachable"); + assert!(palette.contains_rect(finish)); + // Neither panel ever covers the other, however narrow the view gets. + for width in [1200.0, 1000.0, 860.0, 720.0, 600.0] { + h.call("ui.resize", json!({"width":width,"height":800})); + h.settle(); + if let (Some(p), Some(d)) = (h.app.viewport.palette_rect, h.app.viewport.dialog_rect) { + assert!(!p.intersect(d).is_positive(), "width {width}: palette {p:?} overlaps dialog {d:?}"); + } + assert!(h.app.dialog.is_some(), "width {width}: the dialog stays open"); + } + } + #[test] fn linetype_buttons_follow_the_selection_and_fall_back_to_the_mode() { let mut h = crate::scenario::Harness::new(); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 529cfc7e..87dbcf3e 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -2741,13 +2741,11 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, 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.dialog.is_none()), - app.viewport.dialog_rect.filter(|_| app.dialog.is_some()), - ] - .into_iter() - .flatten() - .collect(); + 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)); } } diff --git a/crates/ui-egui/tests/scenarios/sketch_dialog_keeps_the_palette.json b/crates/ui-egui/tests/scenarios/sketch_dialog_keeps_the_palette.json new file mode 100644 index 00000000..f47a3179 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/sketch_dialog_keeps_the_palette.json @@ -0,0 +1,21 @@ +[ + {"note": "A command dialog opened while a sketch is being edited no longer hides the Sketch Palette. The dialog docks at the left edge of the viewport, the palette keeps the right edge, and neither covers the other. Finish Sketch and the Sketch Status readout stay reachable the whole time."}, + {"start": "sketch.create"}, + {"click": {"plane": "XY"}}, + {"start": "sketch.rectangle.two_point"}, + {"click": {"sketch": [0, 0]}}, + {"click": {"sketch": [40, 30]}}, + {"key": "Escape"}, + {"expect": {"sketching": true}}, + {"expect": {"dialog": null}}, + {"expect": {"shown_handles": ["sketch_palette:finish"]}}, + {"start": "inspect.measure"}, + {"expect": {"dialog": "Measure"}}, + {"expect": {"shown_handles": ["sketch_palette:finish"]}}, + {"expect": {"disjoint": ["sketch_palette:outer", "dialog:outer"]}}, + {"shot": "sketch_dialog_keeps_the_palette"}, + {"widget": "Close"}, + {"expect": {"dialog": null}}, + {"expect": {"sketching": true}}, + {"expect": {"shown_handles": ["sketch_palette:finish"]}} +]