A sketch dialog and the Sketch Palette on screen at the same time (#791)
A command dialog opened while sketching no longer hides the Sketch Palette: the dialog docks at the left of the viewport and the palette keeps the right. A view too narrow for both keeps the dialog alone.
This commit is contained in:
1 parent
16766bdbc7
commit
de1479029d
6 files changed
+168
-26
No files matched your search
@@ -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());
|
||||
|
||||
@@ -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<WidgetText>, 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<WidgetText>, 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()
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user