diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dd8f83fb..47305032 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1935,7 +1935,10 @@ fn picked_side(app: &SolveApp, d: &Dialog, first: &Sel) -> Option { /// 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 }; + let Some(mut d) = app.dialog.take() else { + crate::frame::reset_dock_offset(ctx); + return; + }; // Edit Text lasts as long as its text: other design changes leave it open. if let Kind::Text(k) = &mut d.kind && !crate::sketch_text::refresh(app, k) @@ -1979,7 +1982,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } else { crate::frame::Side::Right }; - let anchor = egui::pos2(side.anchor_x(vp), top); + // 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)); + let docked = egui::pos2(side.anchor_x(vp), top); + let anchor = crate::frame::offset_anchor(docked, moved, vp, side); let mut keep = true; let mut ok = false; let mut applied = false; @@ -2020,7 +2027,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.set_max_width(if wide { DIALOG_WIDE_MAX_W } else { DIALOG_MAX_W } - margin); // Rows that end at the right edge line up with last frame's content width. let total = crate::frame::content_width(ui, wkey, min_w); - // Header: "−" folds the dialog, the command name, "»" at the right, on the title band. + // Header: "−" folds the dialog, the command name, "»" at the right, on the title band, + // which also moves the dialog. + let band_top = ui.cursor().min; + let band = egui::Rect::from_min_size(band_top, vec2(total, ui.spacing().interact_size.y + 2.0)); + crate::frame::drag_dock(ui, band, title(&d.kind), moved, anchor - docked); crate::frame::heading(ui, crate::frame::Level::Title, total, |ui| { let total = total - 2.0 * crate::frame::HEADING_MARGIN; let fold = if d.collapsed { "+" } else { "−" }; @@ -2031,7 +2042,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { { d.collapsed = !d.collapsed; } - ui.label(RichText::new(heading.as_str()).strong().size(12.5)); + // Not selectable: a drag on the title moves the dialog. + ui.add(egui::Label::new(RichText::new(heading.as_str()).strong().size(12.5)).selectable(false)); let chevron_width = ui.spacing().interact_size.x; crate::frame::right_aligned(ui, total, chevron_width, |ui| { let chevron = if d.collapsed { "«" } else { "»" }; diff --git a/crates/ui-egui/src/frame.rs b/crates/ui-egui/src/frame.rs index 6e7dcff2..87f4d838 100644 --- a/crates/ui-egui/src/frame.rs +++ b/crates/ui-egui/src/frame.rs @@ -92,6 +92,63 @@ pub fn docked_on<'a>(title: impl Into, id: egui::Id, anchor: Pos2, m .max_height(max_height.max(120.0)) } +/// How far the open docked dialog titled `title` was dragged from its docked place (#1335); +/// zero for a dialog that was not dragged, or once another dialog opens. +pub fn dock_offset(ctx: &egui::Context, title: &str) -> egui::Vec2 { + ctx.data(|d| d.get_temp::<(String, egui::Vec2)>(egui::Id::new("sc_dock_offset"))) + .filter(|(t, _)| t == title) + .map(|(_, v)| v) + .unwrap_or(egui::Vec2::ZERO) +} + +/// Forget a dragged dialog's place (it closed): the next one opens docked. +pub fn reset_dock_offset(ctx: &egui::Context) { + ctx.data_mut(|d| d.remove::<(String, egui::Vec2)>(egui::Id::new("sc_dock_offset"))); +} + +/// A docked dialog's anchor moved by `offset`, kept so its docked corner stays in `vp` with +/// room for its title band. +pub fn offset_anchor(docked: Pos2, offset: egui::Vec2, vp: egui::Rect, side: Side) -> Pos2 { + let p = docked + offset; + let (lo, hi) = match side { + Side::Right => (vp.left() + DIALOG_MIN_W, vp.right()), + Side::Left => (vp.left(), vp.right() - DIALOG_MIN_W), + }; + let x = if lo <= hi { p.x.clamp(lo, hi) } else { docked.x }; + let y = if vp.top() <= vp.bottom() - 40.0 { p.y.clamp(vp.top(), vp.bottom() - 40.0) } else { docked.y }; + egui::pos2(x, y) +} + +/// Let `band` (a docked dialog's title band) drag the dialog: the drag adds to its offset +/// `moved` (as last frame left it; `applied` of it is where the dialog is now, after keeping +/// it on screen). Called before the band's buttons are added, so they keep their clicks. +pub fn drag_dock(ui: &Ui, band: egui::Rect, title: &str, moved: egui::Vec2, applied: egui::Vec2) { + let response = ui.interact(band, ui.id().with("sc_dock_drag"), egui::Sense::drag()); + crate::scenario::publish_handle("dialog:title", band.center()); + // The drag runs from where the press started, so no movement is lost while egui decides + // it is a drag, nor on the frame it ends. + let base = egui::Id::new("sc_dock_drag_base"); + if response.drag_started() + && let Some(origin) = ui.input(|i| i.pointer.press_origin()) + { + ui.ctx().data_mut(|d| d.insert_temp(base, (applied, origin))); + } + if response.dragged() || response.drag_stopped() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + let start = ui.ctx().data(|d| d.get_temp::<(egui::Vec2, Pos2)>(base)); + let to = match (start, ui.input(|i| i.pointer.latest_pos())) { + (Some((start, from)), Some(now)) => start + (now - from), + _ => applied + response.drag_delta(), + }; + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_dock_offset"), (title.to_string(), to))); + } else if response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } else if moved != applied { + // Kept on screen: remember where it is, not where it was dragged off to. + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("sc_dock_offset"), (title.to_string(), applied))); + } +} + /// A floating window, centred on the screen unless the caller places it, shrink-wrapped to its /// content up to its width limit. pub fn window<'a>(ctx: &egui::Context, title: impl Into, width: Width) -> egui::Window<'a> { @@ -229,6 +286,43 @@ mod tests { 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:?}"); + // Dragged by its title text. + let title = h.widget("CREATE SKETCH").map(|[x, y]| egui::pos2(x as f32, y as f32)).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. #[test] fn windows_come_from_the_frame_helper() {