diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 93107c07..d4f9bcb6 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -820,8 +820,9 @@ fn canvas_panel(app: &mut SolveApp, ctx: &egui::Context) { let mut edit: Option = None; let mut done = false; let title = if m.calibrate { format!("Calibrate: {}", c.name) } else { format!("Edit Canvas: {}", c.name) }; - egui::Window::new(title) + crate::frame::window(ctx, title, crate::frame::Width::Normal) .id(egui::Id::new("sc_canvas_panel")) + .pivot(egui::Align2::LEFT_TOP) .default_pos(panel_pos(app)) .open(&mut open) .resizable(false) @@ -1318,8 +1319,9 @@ fn occurrence_panel(app: &mut SolveApp, ctx: &egui::Context) { let mut open = true; let mut action: Option<&str> = None; let before = (m.translate, m.angle_deg); - egui::Window::new(format!("Move: {name}")) + crate::frame::window(ctx, format!("Move: {name}"), crate::frame::Width::Normal) .id(egui::Id::new("sc_occ_move")) + .pivot(egui::Align2::LEFT_TOP) .default_pos(panel_pos(app)) .open(&mut open) .resizable(false) @@ -1476,7 +1478,7 @@ fn capture_prompt(app: &mut SolveApp, ctx: &egui::Context) { } let n = app.session.pending_moves.len(); let mut answer: Option<&str> = None; - egui::Window::new("Capture Position") + crate::frame::window(ctx, "Capture Position", crate::frame::Width::Normal) .id(egui::Id::new("sc_capture_prompt")) .collapsible(false) .resizable(false) @@ -1585,8 +1587,9 @@ fn redefine_panel(app: &mut SolveApp, ctx: &egui::Context) { let face = app.session.selection.iter().find_map(|s| if let Sel::Face { point, .. } = s { Some(*point) } else { None }); let mut open = true; let mut pick: Option = None; - egui::Window::new(format!("Redefine Sketch Plane: {name}")) + crate::frame::window(ctx, format!("Redefine Sketch Plane: {name}"), crate::frame::Width::Normal) .id(egui::Id::new("sc_redefine")) + .pivot(egui::Align2::LEFT_TOP) .default_pos(panel_pos(app)) .open(&mut open) .resizable(false) diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index dc243127..b37145e7 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -1453,9 +1453,8 @@ fn show_props(app: &mut SolveApp, ctx: &egui::Context) { let Some((title, v)) = app.menu.props.clone() else { return }; let mut open = true; let f = |x: &Value, k: &str| x.get(k).and_then(Value::as_f64).unwrap_or(f64::NAN); - egui::Window::new(format!("Properties: {title}")) + crate::frame::window(ctx, format!("Properties: {title}"), crate::frame::Width::Normal) .id(egui::Id::new("sc_props")) - .default_pos(ctx.content_rect().center() - vec2(160.0, 140.0)) .open(&mut open) .resizable(false) .collapsible(false) diff --git a/crates/ui-egui/src/delete.rs b/crates/ui-egui/src/delete.rs index 392c5f00..cd4115ea 100644 --- a/crates/ui-egui/src/delete.rs +++ b/crates/ui-egui/src/delete.rs @@ -94,7 +94,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { }; let (deleted, fails) = (list("deleted"), list("would_fail")); let mut answer: Option = None; - egui::Window::new("Delete") + crate::frame::window(ctx, "Delete", crate::frame::Width::Normal) .id(egui::Id::new("sc_delete_confirm")) .collapsible(false) .resizable(false) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d986273d..d0005805 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -578,6 +578,11 @@ impl Dialog { }) } + /// Dialogs holding a table get the wide limit. + pub fn is_wide(&self) -> bool { + matches!(self.kind, Kind::Params { .. }) || matches!(&self.kind, Kind::Motion(k) if k.wide()) + } + pub fn wants_picks(&self) -> bool { !self.inputs.is_empty() } @@ -1009,8 +1014,7 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) { } /// Width limits of a docked dialog: shrink-wrapped to its content within these. -const DIALOG_MIN_W: f32 = 252.0; -const DIALOG_MAX_W: f32 = 380.0; +use crate::frame::{DIALOG_MAX_W, DIALOG_MIN_W, DIALOG_WIDE_MAX_W}; /// Width of value fields and choice boxes in a dialog. const FIELD_W: f32 = 124.0; @@ -1131,7 +1135,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let mut applied = false; let mut cancel = false; let mut enter = false; - let wide = matches!(d.kind, Kind::Params { .. }) || matches!(&d.kind, Kind::Motion(k) if k.wide()); + let wide = d.is_wide(); let heading = if d.editing.is_some() { format!("EDIT {}", title(&d.kind)) } else { title(&d.kind).to_string() }; let frame = egui::Frame::window(&ctx.global_style()) .fill(t.dialog_bg) @@ -1141,415 +1145,420 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { // OK is offered once the inputs make a feature. let has_ok = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences); let valid = !has_ok || !d.previews() || (apply_commands(app, &d).is_ok() && app.preview.error.is_none()); - egui::Window::new(RichText::new(heading.clone()).strong().size(13.0)) - .id(egui::Id::new("sc_dialog")) - .title_bar(false) - .frame(frame) - .pivot(egui::Align2::RIGHT_TOP) - .fixed_pos(anchor) - .constrain(false) - .auto_sized() - .scroll([false, true]) - .collapsible(false) - .max_height((vp.bottom() - anchor.y - 8.0).max(120.0)) - .open(&mut keep) - .show(ctx, |ui| { - ui.spacing_mut().text_edit_width = FIELD_W; - let margin = 2.0 * ui.style().spacing.window_margin.leftf(); - ui.set_min_width(if wide { 480.0 } else { DIALOG_MIN_W } - margin); - ui.set_max_width(if wide { 560.0 } else { DIALOG_MAX_W } - margin); - // Header: "−" folds the dialog, the command name, "»" at the right. - ui.horizontal(|ui| { - let fold = if d.collapsed { "+" } else { "−" }; - if ui - .add(egui::Button::new(RichText::new(fold).size(14.0)).frame(false)) - .on_hover_text(if d.collapsed { "Expand" } else { "Collapse" }) - .clicked() - { - d.collapsed = !d.collapsed; - } - ui.label(RichText::new(heading.as_str()).strong().size(12.5)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(RichText::new("»").color(t.text_dim)); - }); - }); - if d.collapsed { - return; + let width = if wide { crate::frame::Width::Wide } else { crate::frame::Width::Normal }; + let wkey = egui::Id::new(("sc_dialog_w", wide)); + crate::frame::docked( + RichText::new(heading.clone()).strong().size(13.0), + egui::Id::new(("sc_dialog", title(&d.kind))), + anchor, + vp.bottom() - anchor.y - 8.0, + width, + ) + .frame(frame) + .scroll([false, true]) + .open(&mut keep) + .show(ctx, |ui| { + ui.spacing_mut().text_edit_width = FIELD_W; + // Long texts (errors, notes) wrap instead of widening the dialog. + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); + let margin = 2.0 * ui.style().spacing.window_margin.leftf(); + let min_w = if wide { 480.0 } else { DIALOG_MIN_W } - margin; + ui.set_min_width(min_w); + 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. + ui.horizontal(|ui| { + let fold = if d.collapsed { "+" } else { "−" }; + if ui + .add(egui::Button::new(RichText::new(fold).size(14.0)).frame(false)) + .on_hover_text(if d.collapsed { "Expand" } else { "Collapse" }) + .clicked() + { + d.collapsed = !d.collapsed; } - ui.add_space(2.0); - egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { - placement_row(&mut d, ui); - input_rows(&mut d, ui); - match &mut d.kind { - Kind::Sketch => {} - Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, start, all } => { - row_label(ui, "Start"); - let mut st = usize::from(!start.is_empty()); - combo(ui, "ex_start", &["Profile Plane", "Offset"], &mut st); - if st == 1 && start.is_empty() { - *start = "5 mm".into(); - } else if st == 0 { - start.clear(); - } - ui.end_row(); - if !start.is_empty() { - row_label(ui, "Offset"); - enter |= field(ui, start); - ui.end_row(); - } - row_label(ui, "Direction"); - // One Side, Two Sides, Symmetric (a flip is a negative distance). - let order = [0usize, TWO_SIDES, 2]; - let mut pos = order.iter().position(|o| o == direction).unwrap_or(0); - combo(ui, "ex_dir", &["One Side", "Two Sides", "Symmetric"], &mut pos); - *direction = order.get(pos).copied().unwrap_or(0); - ui.end_row(); - row_label(ui, "Extent Type"); - let mut ext = usize::from(*all); - combo(ui, "ex_ext", &["Distance", "All"], &mut ext); - *all = ext == 1; - ui.end_row(); - if !*all { - row_label(ui, "Distance"); - enter |= field(ui, distance); - ui.end_row(); - if *direction == TWO_SIDES { - row_label(ui, "Distance 2"); - enter |= field(ui, distance2); - ui.end_row(); - } - } - if *direction < 2 { - row_label(ui, "Taper Angle"); - enter |= field(ui, taper); - ui.end_row(); - } - row_label(ui, "Operation"); - let before = *operation; - combo(ui, "ex_op", &OP_LABELS, operation); - // A choice made by hand sticks. - if *operation != before { - *auto_op = false; - } + ui.label(RichText::new(heading.as_str()).strong().size(12.5)); + crate::frame::right_aligned(ui, total, 8.0, |ui| ui.label(RichText::new("»").color(t.text_dim))); + }); + if d.collapsed { + return; + } + ui.add_space(2.0); + egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { + placement_row(&mut d, ui); + input_rows(&mut d, ui); + match &mut d.kind { + Kind::Sketch => {} + Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, start, all } => { + row_label(ui, "Start"); + let mut st = usize::from(!start.is_empty()); + combo(ui, "ex_start", &["Profile Plane", "Offset"], &mut st); + if st == 1 && start.is_empty() { + *start = "5 mm".into(); + } else if st == 0 { + start.clear(); + } + ui.end_row(); + if !start.is_empty() { + row_label(ui, "Offset"); + enter |= field(ui, start); ui.end_row(); } - Kind::Revolve { angle, operation } => { - row_label(ui, "Angle"); - enter |= field(ui, angle); - ui.end_row(); - row_label(ui, "Operation"); - combo(ui, "rv_op", &OP_LABELS, operation); - ui.end_row(); - } - Kind::Fillet { radius, chamfer, chain } => { - row_label(ui, if *chamfer { "Distance" } else { "Radius" }); - enter |= field(ui, radius); - ui.end_row(); - row_label(ui, "Tangent Chain"); - ui.checkbox(chain, ""); - ui.end_row(); - } - Kind::Shell { thickness } => { - row_label(ui, "Inside Thickness"); - enter |= field(ui, thickness); - ui.end_row(); - } - Kind::Draft { angle } => { - row_label(ui, "Angle"); - enter |= field(ui, angle); - ui.end_row(); - } - Kind::Mirror => { - row_label(ui, "Operation"); - let mut op = usize::from(d.extra.get("combine").and_then(Value::as_bool).unwrap_or(false)); - combo(ui, "mi_op", &["New Body", "Join"], &mut op); - d.extra.insert("combine".into(), json!(op == 1)); - ui.end_row(); - } - Kind::Measure { result, .. } => measure_rows(ui, result.as_ref()), - Kind::Preferences => preferences_rows(app, ui), - Kind::OffsetPlane { offset } => { - row_label(ui, "Distance"); - enter |= field(ui, offset); - ui.end_row(); - } - Kind::AnglePlane { angle } => { - row_label(ui, "Angle"); - enter |= field(ui, angle); - ui.end_row(); - } - Kind::Split | Kind::ReplaceFace | Kind::Remove => {} - Kind::Rib { thickness, depth, flip, .. } => { - row_label(ui, "Thickness"); - enter |= field(ui, thickness); - ui.end_row(); - row_label(ui, "Depth"); - let r = ui.add(egui::TextEdit::singleline(depth).hint_text("to the next face")); - crate::params_dialog::complete(ui, &r, depth); - enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - ui.end_row(); - row_label(ui, "Flip"); - ui.checkbox(flip, ""); - ui.end_row(); - } - Kind::Emboss { depth, deboss } => { - row_label(ui, "Depth"); - enter |= field(ui, depth); - ui.end_row(); - row_label(ui, "Type"); - let mut i = usize::from(*deboss); - combo(ui, "emb_mode", &["Emboss", "Deboss"], &mut i); - *deboss = i == 1; - ui.end_row(); - } - Kind::Align { flip } => { - row_label(ui, "Flip"); - ui.checkbox(flip, ""); - ui.end_row(); - } - Kind::PathPattern { count, spacing } => { - for (l, v) in [("Quantity", count), ("Spacing", spacing)] { - row_label(ui, l); - enter |= field(ui, v); - ui.end_row(); - } - } - Kind::Pipe { diameter, wall } => { - row_label(ui, "Diameter"); - enter |= field(ui, diameter); - ui.end_row(); - row_label(ui, "Wall"); - let r = ui.add(egui::TextEdit::singleline(wall).hint_text("solid")); - crate::params_dialog::complete(ui, &r, wall); - enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - ui.end_row(); - } - Kind::Stock { margin } => { - row_label(ui, "Margin"); - enter |= field(ui, margin); - ui.end_row(); - } - Kind::Scale { factor } => { - row_label(ui, "Scale factor"); - enter |= field(ui, factor); - ui.end_row(); - } - Kind::OffsetFaces { distance } => { + row_label(ui, "Direction"); + // One Side, Two Sides, Symmetric (a flip is a negative distance). + let order = [0usize, TWO_SIDES, 2]; + let mut pos = order.iter().position(|o| o == direction).unwrap_or(0); + combo(ui, "ex_dir", &["One Side", "Two Sides", "Symmetric"], &mut pos); + *direction = order.get(pos).copied().unwrap_or(0); + ui.end_row(); + row_label(ui, "Extent Type"); + let mut ext = usize::from(*all); + combo(ui, "ex_ext", &["Distance", "All"], &mut ext); + *all = ext == 1; + ui.end_row(); + if !*all { row_label(ui, "Distance"); enter |= field(ui, distance); ui.end_row(); - } - Kind::Thread { designation, length } => { - // Fusion's order: Full Length, then the size; a length only when not full. - row_label(ui, "Full Length"); - let mut full = length.trim().is_empty(); - if ui.checkbox(&mut full, "").changed() { - *length = if full { String::new() } else { "10 mm".into() }; - } - ui.end_row(); - row_label(ui, "Size"); - let r = ui.add(egui::TextEdit::singleline(designation).hint_text("fits the face, e.g. M8")); - enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - ui.end_row(); - if !full { - row_label(ui, "Length"); - enter |= field(ui, length); + if *direction == TWO_SIDES { + row_label(ui, "Distance 2"); + enter |= field(ui, distance2); ui.end_row(); } } - Kind::Material { index } => { - let names: Vec<&str> = solvecraft_engine::doc::MATERIALS.iter().map(|(n, _)| *n).collect(); - row_label(ui, "Material"); - combo(ui, "mat", &names, index); + if *direction < 2 { + row_label(ui, "Taper Angle"); + enter |= field(ui, taper); ui.end_row(); } - Kind::Section { offset, flip } => { - row_label(ui, "Distance"); - enter |= field(ui, offset); - ui.end_row(); - row_label(ui, "Flip"); - ui.checkbox(flip, ""); - ui.end_row(); + row_label(ui, "Operation"); + let before = *operation; + combo(ui, "ex_op", &OP_LABELS, operation); + // A choice made by hand sticks. + if *operation != before { + *auto_op = false; } - Kind::PatternRect { count, spacing, count2, spacing2 } => { - for (l, v) in [("Quantity", count), ("Spacing", spacing), ("Quantity 2", count2), ("Spacing 2", spacing2)] { - row_label(ui, l); - enter |= field(ui, v); - ui.end_row(); - } - } - Kind::PatternCirc { count, angle } => { - for (l, v) in [("Quantity", count), ("Total angle", angle)] { - row_label(ui, l); - enter |= field(ui, v); - ui.end_row(); - } - } - Kind::Loft { operation } => { - row_label(ui, "Operation"); - combo(ui, "lf_op", &OP_LABELS, operation); - ui.end_row(); - } - Kind::Sweep { operation } => { - row_label(ui, "Operation"); - combo(ui, "sw_op", &OP_LABELS, operation); - ui.end_row(); - } - k @ Kind::Move { .. } => enter |= crate::dialogs_move::rows(ui, k), - Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } => { - row_label(ui, "Extents"); - let mut ext = usize::from(opts.all); - combo(ui, "hole_ext", &["Distance", "All"], &mut ext); - opts.all = ext == 1; - ui.end_row(); - row_label(ui, "Hole Type"); - combo(ui, "hole_kind", &HOLE_LABELS, kind); - ui.end_row(); - row_label(ui, "Hole Tap Type"); - let mut tap = usize::from(opts.tapped); - combo(ui, "hole_tap", &["Simple", "Tapped"], &mut tap); - opts.tapped = tap == 1; - ui.end_row(); - if opts.tapped { - row_label(ui, "Size"); - let r = ui.add(egui::TextEdit::singleline(&mut opts.thread).hint_text("M5")); - enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - ui.end_row(); - } - if *kind == 0 { - row_label(ui, "Drill Point"); - let mut tip = usize::from(opts.angled); - combo(ui, "hole_tip", &["Flat", "Angle"], &mut tip); - opts.angled = tip == 1; - ui.end_row(); - } - if !opts.all { - row_label(ui, "Depth"); - enter |= field(ui, depth); - ui.end_row(); - } - if *kind == 0 && opts.angled { - row_label(ui, "Tip Angle"); - enter |= field(ui, &mut opts.tip_angle); - ui.end_row(); - } - row_label(ui, "Diameter"); - enter |= field(ui, diameter); - ui.end_row(); - if *kind == 1 { - row_label(ui, "Counterbore Ø"); - enter |= field(ui, cb_diameter); - ui.end_row(); - row_label(ui, "Counterbore depth"); - enter |= field(ui, cb_depth); - ui.end_row(); - } - if *kind == 2 { - row_label(ui, "Countersink Ø"); - enter |= field(ui, cs_diameter); - ui.end_row(); - row_label(ui, "Countersink angle"); - enter |= field(ui, cs_angle); - ui.end_row(); - } - } - Kind::Primitive { fields, operation, .. } => { - for (k, v) in fields.iter_mut() { - row_label(ui, k); - enter |= field(ui, v); - ui.end_row(); - } - row_label(ui, "Operation"); - combo(ui, "pr_op", &OP_LABELS, operation); - ui.end_row(); - } - Kind::Combine { operation, keep_tools } => { - row_label(ui, "Operation"); - let mut op = operation.saturating_sub(1); - combo(ui, "cb_op", &OP_LABELS[1..], &mut op); - *operation = op + 1; - ui.end_row(); - row_label(ui, "Keep Tools"); - ui.checkbox(keep_tools, ""); - ui.end_row(); - } - Kind::Params { new_name, new_expr } => params_table(app, ui, new_name, new_expr), - Kind::EditParam { name, expr } => { - ui.label(name.as_str()); - let r = ui.text_edit_singleline(expr); - crate::params_dialog::complete(ui, &r, expr); - if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - ok = true; - } - r.request_focus(); - ui.end_row(); - } - Kind::Rename { name, .. } => { - row_label(ui, "Name"); - let r = ui.text_edit_singleline(name); - if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - ok = true; - } - r.request_focus(); - ui.end_row(); - } - Kind::Assembly(k) => enter |= crate::dialogs_assembly::rows(app, ui, k, &mut d.inputs, &mut d.active), - Kind::Sheet(k) => enter |= crate::dialogs_sheet::rows(app, ui, k, &mut d.inputs), - Kind::Plastic(k) => enter |= crate::dialogs_plastic::rows(app, ui, k, &d.inputs), - Kind::Appearance(k) => enter |= crate::dialogs_appearance::rows(app, ui, k, &mut d.inputs), - Kind::Motion(k) => enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs), - Kind::Part(k) => enter |= crate::dialogs_parts::rows(app, ui, k, &d.inputs), - Kind::ConfirmDelete { with, fail, .. } => { - if !with.is_empty() { - row_label(ui, "Also deletes"); - ui.label(with.join(", ")); - ui.end_row(); - } - if !fail.is_empty() { - row_label(ui, "Will fail"); - ui.label(RichText::new(fail.join(", ")).color(t.warning)); - ui.end_row(); - } - } - } - if let Some(e) = d.error.as_ref().or(app.preview.error.as_ref()) { - row_label(ui, ""); - ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap()); ui.end_row(); } - }); - ui.add_space(6.0); - // Footer: an info glyph at the left, OK and Cancel at the right. - ui.horizontal(|ui| { - let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), egui::Sense::hover()); - ui.painter().circle_stroke(r.center(), 7.0, Stroke::new(1.2, t.text_dim)); - ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "i", egui::FontId::proportional(11.0), t.text_dim); - resp.on_hover_text(heading.as_str()); - // The spinner's place is always taken: a widget that comes and goes would shift - // the automatic ids of OK and Cancel, and a click pressed in one frame and - // released in the next would be lost (an animating preview toggles it often). - let (sr, sresp) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover()); - if app.preview.busy { - egui::Spinner::new().size(14.0).paint_at(ui, sr); - sresp.on_hover_text("Updating the preview"); + Kind::Revolve { angle, operation } => { + row_label(ui, "Angle"); + enter |= field(ui, angle); + ui.end_row(); + row_label(ui, "Operation"); + combo(ui, "rv_op", &OP_LABELS, operation); + ui.end_row(); } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences) { "Close" } else { "Cancel" }; - if ui.add(egui::Button::new(close).min_size(vec2(64.0, 24.0))).clicked() { - cancel = true; + Kind::Fillet { radius, chamfer, chain } => { + row_label(ui, if *chamfer { "Distance" } else { "Radius" }); + enter |= field(ui, radius); + ui.end_row(); + row_label(ui, "Tangent Chain"); + ui.checkbox(chain, ""); + ui.end_row(); + } + Kind::Shell { thickness } => { + row_label(ui, "Inside Thickness"); + enter |= field(ui, thickness); + ui.end_row(); + } + Kind::Draft { angle } => { + row_label(ui, "Angle"); + enter |= field(ui, angle); + ui.end_row(); + } + Kind::Mirror => { + row_label(ui, "Operation"); + let mut op = usize::from(d.extra.get("combine").and_then(Value::as_bool).unwrap_or(false)); + combo(ui, "mi_op", &["New Body", "Join"], &mut op); + d.extra.insert("combine".into(), json!(op == 1)); + ui.end_row(); + } + Kind::Measure { result, .. } => measure_rows(ui, result.as_ref()), + Kind::Preferences => preferences_rows(app, ui), + Kind::OffsetPlane { offset } => { + row_label(ui, "Distance"); + enter |= field(ui, offset); + ui.end_row(); + } + Kind::AnglePlane { angle } => { + row_label(ui, "Angle"); + enter |= field(ui, angle); + ui.end_row(); + } + Kind::Split | Kind::ReplaceFace | Kind::Remove => {} + Kind::Rib { thickness, depth, flip, .. } => { + row_label(ui, "Thickness"); + enter |= field(ui, thickness); + ui.end_row(); + row_label(ui, "Depth"); + let r = ui.add(egui::TextEdit::singleline(depth).hint_text("to the next face")); + crate::params_dialog::complete(ui, &r, depth); + enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.end_row(); + row_label(ui, "Flip"); + ui.checkbox(flip, ""); + ui.end_row(); + } + Kind::Emboss { depth, deboss } => { + row_label(ui, "Depth"); + enter |= field(ui, depth); + ui.end_row(); + row_label(ui, "Type"); + let mut i = usize::from(*deboss); + combo(ui, "emb_mode", &["Emboss", "Deboss"], &mut i); + *deboss = i == 1; + ui.end_row(); + } + Kind::Align { flip } => { + row_label(ui, "Flip"); + ui.checkbox(flip, ""); + ui.end_row(); + } + Kind::PathPattern { count, spacing } => { + for (l, v) in [("Quantity", count), ("Spacing", spacing)] { + row_label(ui, l); + enter |= field(ui, v); + ui.end_row(); } - if has_ok { - let label = if matches!(d.kind, Kind::ConfirmDelete { .. }) { "Delete" } else { "OK" }; - let b = egui::Button::new(RichText::new(label).color(if valid { Color32::WHITE } else { t.text_dim })) - .fill(if valid { t.accent } else { Color32::TRANSPARENT }) - .min_size(vec2(64.0, 24.0)); - if ui.add_enabled(valid, b).clicked() { - ok = true; - } + } + Kind::Pipe { diameter, wall } => { + row_label(ui, "Diameter"); + enter |= field(ui, diameter); + ui.end_row(); + row_label(ui, "Wall"); + let r = ui.add(egui::TextEdit::singleline(wall).hint_text("solid")); + crate::params_dialog::complete(ui, &r, wall); + enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.end_row(); + } + Kind::Stock { margin } => { + row_label(ui, "Margin"); + enter |= field(ui, margin); + ui.end_row(); + } + Kind::Scale { factor } => { + row_label(ui, "Scale factor"); + enter |= field(ui, factor); + ui.end_row(); + } + Kind::OffsetFaces { distance } => { + row_label(ui, "Distance"); + enter |= field(ui, distance); + ui.end_row(); + } + Kind::Thread { designation, length } => { + // Fusion's order: Full Length, then the size; a length only when not full. + row_label(ui, "Full Length"); + let mut full = length.trim().is_empty(); + if ui.checkbox(&mut full, "").changed() { + *length = if full { String::new() } else { "10 mm".into() }; } - }); + ui.end_row(); + row_label(ui, "Size"); + let r = ui.add(egui::TextEdit::singleline(designation).hint_text("fits the face, e.g. M8")); + enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.end_row(); + if !full { + row_label(ui, "Length"); + enter |= field(ui, length); + ui.end_row(); + } + } + Kind::Material { index } => { + let names: Vec<&str> = solvecraft_engine::doc::MATERIALS.iter().map(|(n, _)| *n).collect(); + row_label(ui, "Material"); + combo(ui, "mat", &names, index); + ui.end_row(); + } + Kind::Section { offset, flip } => { + row_label(ui, "Distance"); + enter |= field(ui, offset); + ui.end_row(); + row_label(ui, "Flip"); + ui.checkbox(flip, ""); + ui.end_row(); + } + Kind::PatternRect { count, spacing, count2, spacing2 } => { + for (l, v) in [("Quantity", count), ("Spacing", spacing), ("Quantity 2", count2), ("Spacing 2", spacing2)] { + row_label(ui, l); + enter |= field(ui, v); + ui.end_row(); + } + } + Kind::PatternCirc { count, angle } => { + for (l, v) in [("Quantity", count), ("Total angle", angle)] { + row_label(ui, l); + enter |= field(ui, v); + ui.end_row(); + } + } + Kind::Loft { operation } => { + row_label(ui, "Operation"); + combo(ui, "lf_op", &OP_LABELS, operation); + ui.end_row(); + } + Kind::Sweep { operation } => { + row_label(ui, "Operation"); + combo(ui, "sw_op", &OP_LABELS, operation); + ui.end_row(); + } + k @ Kind::Move { .. } => enter |= crate::dialogs_move::rows(ui, k), + Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } => { + row_label(ui, "Extents"); + let mut ext = usize::from(opts.all); + combo(ui, "hole_ext", &["Distance", "All"], &mut ext); + opts.all = ext == 1; + ui.end_row(); + row_label(ui, "Hole Type"); + combo(ui, "hole_kind", &HOLE_LABELS, kind); + ui.end_row(); + row_label(ui, "Hole Tap Type"); + let mut tap = usize::from(opts.tapped); + combo(ui, "hole_tap", &["Simple", "Tapped"], &mut tap); + opts.tapped = tap == 1; + ui.end_row(); + if opts.tapped { + row_label(ui, "Size"); + let r = ui.add(egui::TextEdit::singleline(&mut opts.thread).hint_text("M5")); + enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.end_row(); + } + if *kind == 0 { + row_label(ui, "Drill Point"); + let mut tip = usize::from(opts.angled); + combo(ui, "hole_tip", &["Flat", "Angle"], &mut tip); + opts.angled = tip == 1; + ui.end_row(); + } + if !opts.all { + row_label(ui, "Depth"); + enter |= field(ui, depth); + ui.end_row(); + } + if *kind == 0 && opts.angled { + row_label(ui, "Tip Angle"); + enter |= field(ui, &mut opts.tip_angle); + ui.end_row(); + } + row_label(ui, "Diameter"); + enter |= field(ui, diameter); + ui.end_row(); + if *kind == 1 { + row_label(ui, "Counterbore Ø"); + enter |= field(ui, cb_diameter); + ui.end_row(); + row_label(ui, "Counterbore depth"); + enter |= field(ui, cb_depth); + ui.end_row(); + } + if *kind == 2 { + row_label(ui, "Countersink Ø"); + enter |= field(ui, cs_diameter); + ui.end_row(); + row_label(ui, "Countersink angle"); + enter |= field(ui, cs_angle); + ui.end_row(); + } + } + Kind::Primitive { fields, operation, .. } => { + for (k, v) in fields.iter_mut() { + row_label(ui, k); + enter |= field(ui, v); + ui.end_row(); + } + row_label(ui, "Operation"); + combo(ui, "pr_op", &OP_LABELS, operation); + ui.end_row(); + } + Kind::Combine { operation, keep_tools } => { + row_label(ui, "Operation"); + let mut op = operation.saturating_sub(1); + combo(ui, "cb_op", &OP_LABELS[1..], &mut op); + *operation = op + 1; + ui.end_row(); + row_label(ui, "Keep Tools"); + ui.checkbox(keep_tools, ""); + ui.end_row(); + } + Kind::Params { new_name, new_expr } => params_table(app, ui, new_name, new_expr), + Kind::EditParam { name, expr } => { + ui.label(name.as_str()); + let r = ui.text_edit_singleline(expr); + crate::params_dialog::complete(ui, &r, expr); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + ok = true; + } + r.request_focus(); + ui.end_row(); + } + Kind::Rename { name, .. } => { + row_label(ui, "Name"); + let r = ui.text_edit_singleline(name); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + ok = true; + } + r.request_focus(); + ui.end_row(); + } + Kind::Assembly(k) => enter |= crate::dialogs_assembly::rows(app, ui, k, &mut d.inputs, &mut d.active), + Kind::Sheet(k) => enter |= crate::dialogs_sheet::rows(app, ui, k, &mut d.inputs), + Kind::Plastic(k) => enter |= crate::dialogs_plastic::rows(app, ui, k, &d.inputs), + Kind::Appearance(k) => enter |= crate::dialogs_appearance::rows(app, ui, k, &mut d.inputs), + Kind::Motion(k) => enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs), + Kind::Part(k) => enter |= crate::dialogs_parts::rows(app, ui, k, &d.inputs), + Kind::ConfirmDelete { with, fail, .. } => { + if !with.is_empty() { + row_label(ui, "Also deletes"); + ui.label(with.join(", ")); + ui.end_row(); + } + if !fail.is_empty() { + row_label(ui, "Will fail"); + ui.label(RichText::new(fail.join(", ")).color(t.warning)); + ui.end_row(); + } + } + } + if let Some(e) = d.error.as_ref().or(app.preview.error.as_ref()) { + row_label(ui, ""); + ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap()); + ui.end_row(); + } + }); + ui.add_space(6.0); + // Footer: an info glyph at the left, OK and Cancel at the right. + ui.horizontal(|ui| { + let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), egui::Sense::hover()); + ui.painter().circle_stroke(r.center(), 7.0, Stroke::new(1.2, t.text_dim)); + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "i", egui::FontId::proportional(11.0), t.text_dim); + resp.on_hover_text(heading.as_str()); + // The spinner's place is always taken: a widget that comes and goes would shift + // the automatic ids of OK and Cancel, and a click pressed in one frame and + // released in the next would be lost (an animating preview toggles it often). + let (sr, sresp) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover()); + if app.preview.busy { + egui::Spinner::new().size(14.0).paint_at(ui, sr); + sresp.on_hover_text("Updating the preview"); + } + let buttons = if has_ok { 64.0 * 2.0 + ui.spacing().item_spacing.x } else { 64.0 }; + crate::frame::right_aligned(ui, total, buttons, |ui| { + if has_ok { + let label = if matches!(d.kind, Kind::ConfirmDelete { .. }) { "Delete" } else { "OK" }; + let b = egui::Button::new(RichText::new(label).color(if valid { Color32::WHITE } else { t.text_dim })) + .fill(if valid { t.accent } else { Color32::TRANSPARENT }) + .min_size(vec2(64.0, 24.0)); + if ui.add_enabled(valid, b).clicked() { + ok = true; + } + } + let close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. } | Kind::Preferences) { "Close" } else { "Cancel" }; + if ui.add(egui::Button::new(close).min_size(vec2(64.0, 24.0))).clicked() { + cancel = true; + } }); }); + crate::frame::remember_width(ui, wkey); + }); // 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/documents.rs b/crates/ui-egui/src/documents.rs index 35a3286f..a9cc0dd5 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -380,7 +380,7 @@ pub fn prompt(app: &mut SolveApp, ctx: &egui::Context) { let Some(i) = app.docs.closing else { return }; let name = tabs_info(app).get(i).map(|x| x.0.clone()).unwrap_or_default(); let mut answer: Option = None; - egui::Window::new("Save changes?") + crate::frame::window(ctx, "Save changes?", crate::frame::Width::Normal) .id(egui::Id::new("sc_close_prompt")) .collapsible(false) .resizable(false) diff --git a/crates/ui-egui/src/frame.rs b/crates/ui-egui/src/frame.rs new file mode 100644 index 00000000..ca4ce833 --- /dev/null +++ b/crates/ui-egui/src/frame.rs @@ -0,0 +1,97 @@ +//! The one place windows are made. Every command dialog, panel and pop-up window comes from +//! here, so they all shrink to their content within the same width limits (a test checks that no +//! 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. +//! - [`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). + +use egui::{Align2, Pos2, Ui, WidgetText, vec2}; + +/// Width limits of a docked command dialog. +pub const DIALOG_MIN_W: f32 = 260.0; +pub const DIALOG_MAX_W: f32 = 380.0; +/// Docked dialogs holding a table (motion study, configurations, exploded views). +pub const DIALOG_WIDE_MAX_W: f32 = 560.0; +/// Floating windows: prompts and panels, and those holding a table (Parameters, Keyboard +/// Shortcuts). +pub const WINDOW_MAX_W: f32 = 460.0; +pub const WINDOW_WIDE_MAX_W: f32 = 760.0; + +/// How wide a window may get. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Width { + Normal, + Wide, +} + +/// 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> { + let max = match width { + Width::Normal => DIALOG_MAX_W, + Width::Wide => DIALOG_WIDE_MAX_W, + }; + egui::Window::new(title) + .id(id) + .title_bar(false) + .pivot(Align2::RIGHT_TOP) + .fixed_pos(anchor) + .constrain(false) + .auto_sized() + .collapsible(false) + .max_width(max) + .max_height(max_height.max(120.0)) +} + +/// 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> { + let max = match width { + Width::Normal => WINDOW_MAX_W, + Width::Wide => WINDOW_WIDE_MAX_W, + }; + let c = ctx.content_rect().center(); + egui::Window::new(title).collapsible(false).pivot(Align2::CENTER_CENTER).default_pos(c - vec2(0.0, 60.0)).max_width(max) +} + +/// The width a window's content had last frame (at least `min`), for laying out rows that end at +/// the right edge. +pub fn content_width(ui: &Ui, key: egui::Id, min: f32) -> f32 { + ui.ctx().data(|d| d.get_temp::(key)).unwrap_or(min).max(min) +} + +/// Remember the content width for next frame (call at the end of the window's content). +pub fn remember_width(ui: &Ui, key: egui::Id) { + let w = ui.min_rect().width(); + ui.ctx().data_mut(|d| d.insert_temp(key, w)); +} + +/// In a horizontal row: push what `add` draws (`width` wide) to the right edge of a content +/// `total` wide, without making the row any wider than that. +pub fn right_aligned(ui: &mut Ui, total: f32, width: f32, add: impl FnOnce(&mut Ui) -> R) -> R { + let used = ui.min_rect().width(); + let gap = (total - used - width - ui.spacing().item_spacing.x).max(0.0); + ui.add_space(gap); + add(ui) +} + +#[cfg(test)] +mod tests { + /// Windows are made in this file only, so none can skip the width limits. + #[test] + fn windows_come_from_the_frame_helper() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut bad = Vec::new(); + for e in std::fs::read_dir(&dir).unwrap() { + let p = e.unwrap().path(); + if p.extension().is_some_and(|x| x == "rs") && p.file_name().is_some_and(|n| n != "frame.rs") { + let text = std::fs::read_to_string(&p).unwrap(); + if text.contains("Window::new(") { + bad.push(p.display().to_string()); + } + } + } + assert!(bad.is_empty(), "use crate::frame::{{docked, window}} instead of egui::Window::new in {bad:?}"); + } +} diff --git a/crates/ui-egui/src/help.rs b/crates/ui-egui/src/help.rs index cf28803b..cba2ce03 100644 --- a/crates/ui-egui/src/help.rs +++ b/crates/ui-egui/src/help.rs @@ -2,7 +2,7 @@ //! date, licences, attributions, fonts), the keyboard shortcuts (read only), and Report an Issue, //! which copies diagnostic information to the clipboard. -use egui::{RichText, pos2, vec2}; +use egui::{RichText, vec2}; use crate::SolveApp; use crate::theme::Tokens; @@ -91,12 +91,10 @@ fn about(app: &mut SolveApp, ctx: &egui::Context) { } let t = Tokens::get(); let mut open = true; - let screen = ctx.content_rect(); - egui::Window::new("About SolveCraft") + crate::frame::window(ctx, "About SolveCraft", crate::frame::Width::Normal) .id(egui::Id::new("sc_about")) .open(&mut open) - .default_pos(pos2(screen.center().x - 260.0, screen.top() + 90.0)) - .default_size(vec2(540.0, 560.0)) + .default_size(vec2(440.0, 560.0)) .show(ctx, |ui| { ui.label(RichText::new("SolveCraft").size(22.0).strong()); ui.label(format!("Version {VERSION} · commit {COMMIT} · built {BUILD_DATE}")); diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 798ea2b8..b3520484 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -139,9 +139,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let t = Tokens::get(); let mut open = true; let mut action: Option<(String, &str)> = None; - egui::Window::new("Keyboard Shortcuts") + crate::frame::window(ctx, "Keyboard Shortcuts", crate::frame::Width::Wide) .id(egui::Id::new("sc_keymap")) - .default_pos(egui::pos2(270.0, 140.0)) .open(&mut open) .default_size([520.0, 480.0]) .show(ctx, |ui| { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 28457c1f..65fcd427 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -25,6 +25,7 @@ pub mod dialogs_sheet; pub mod dim_view; pub mod documents; pub mod drag_snap; +pub mod frame; pub mod gizmo; pub mod gpu; pub mod help; diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index 681063ef..8ddadb24 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -95,8 +95,9 @@ pub fn popup(app: &mut SolveApp, ctx: &egui::Context) { let mut picked: Option<&'static str> = None; let t = Tokens::get(); let center = ctx.content_rect().center(); - egui::Window::new("Search commands") + crate::frame::window(ctx, "Search commands", crate::frame::Width::Normal) .id(egui::Id::new("sc_search")) + .pivot(egui::Align2::LEFT_TOP) .fixed_pos(egui::pos2(center.x - 200.0, 150.0)) .fixed_size(vec2(400.0, 340.0)) .collapsible(false) diff --git a/crates/ui-egui/src/params_dialog.rs b/crates/ui-egui/src/params_dialog.rs index 86475cf8..5a7bb76c 100644 --- a/crates/ui-egui/src/params_dialog.rs +++ b/crates/ui-egui/src/params_dialog.rs @@ -125,10 +125,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let t = Tokens::get(); let mut open = true; let all = rows(app); - egui::Window::new(RichText::new("PARAMETERS").strong().size(13.0)) + crate::frame::window(ctx, RichText::new("PARAMETERS").strong().size(13.0), crate::frame::Width::Wide) .id(egui::Id::new("sc_params_dialog")) .open(&mut open) - .default_width(820.0) + .default_width(740.0) .default_height(520.0) .resizable(true) .collapsible(false) diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index fe6d2cf9..4c66e60c 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -205,10 +205,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let mut open = true; let sections = ["General", "Navigation", "Display", "Sketch", "Selection"]; let before = app.preferences.clone(); - egui::Window::new("Preferences") + crate::frame::window(ctx, "Preferences", crate::frame::Width::Wide) .id(egui::Id::new("sc_prefs")) .open(&mut open) - .default_pos(egui::pos2(300.0, 120.0)) .default_size([560.0, 360.0]) .show(ctx, |ui| { ui.horizontal_top(|ui| { diff --git a/crates/ui-egui/src/recovery_ui.rs b/crates/ui-egui/src/recovery_ui.rs index 42a0b82b..bd3791a8 100644 --- a/crates/ui-egui/src/recovery_ui.rs +++ b/crates/ui-egui/src/recovery_ui.rs @@ -57,7 +57,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let dir = app.autosave.as_ref().map(|a| a.dir.clone()).or_else(recovery::default_dir); let mut act: Option<(Entry, bool)> = None; let mut later = false; - egui::Window::new(RichText::new("Recover unsaved design?").strong().size(13.0)) + crate::frame::window(ctx, RichText::new("Recover unsaved design?").strong().size(13.0), crate::frame::Width::Normal) .collapsible(false) .resizable(false) .min_width(420.0) diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index 1f7a2fb7..aacf69fe 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -200,71 +200,70 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let mut look = false; let mut finish = false; let has_curves = !selected_curves(app).is_empty(); - egui::Window::new("SKETCH PALETTE") - .id(egui::Id::new("sc_sketch_palette")) - .title_bar(false) - .frame(frame) - .pivot(egui::Align2::RIGHT_TOP) - .fixed_pos(anchor) - .constrain(false) - .auto_sized() - .collapsible(false) - .show(ctx, |ui| { - ui.set_width(WIDTH); - ui.horizontal(|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("SKETCH PALETTE").strong().size(12.5)); - }); - if FOLDED.with(Cell::get) { - return; + crate::frame::docked( + "SKETCH PALETTE", + egui::Id::new("sc_sketch_palette"), + anchor, + ctx.content_rect().bottom() - anchor.y - 8.0, + crate::frame::Width::Normal, + ) + .frame(frame) + .show(ctx, |ui| { + ui.set_width(WIDTH); + ui.horizontal(|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)); } - let ofold = OPTIONS_FOLDED.with(Cell::get); - if ui.add(egui::Button::new(RichText::new(format!("{} Options", if ofold { "+" } else { "−" })).size(12.0)).frame(false)).clicked() { - OPTIONS_FOLDED.with(|f| f.set(!ofold)); - } - if !ofold { - egui::Grid::new("sc_palette_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label("Linetype"); - ui.horizontal(|ui| { - // With a selection: convert it; without: the mode new curves are drawn in. - let current = mode(app); - for (i, (label, tip)) in [("—", "Normal"), ("- -", "Construction"), ("-·-", "Centerline")].iter().enumerate() { - let on = !has_curves && current == i as u8; - let tip = if has_curves { format!("Make the selection {tip}") } else { format!("Draw {tip} curves") }; - if ui.add(egui::Button::new(*label).selected(on).min_size(vec2(30.0, 20.0))).on_hover_text(tip).clicked() { - linetype = Some(i as u8); - } + ui.label(RichText::new("SKETCH PALETTE").strong().size(12.5)); + }); + if FOLDED.with(Cell::get) { + return; + } + let ofold = OPTIONS_FOLDED.with(Cell::get); + if ui.add(egui::Button::new(RichText::new(format!("{} Options", if ofold { "+" } else { "−" })).size(12.0)).frame(false)).clicked() { + OPTIONS_FOLDED.with(|f| f.set(!ofold)); + } + if !ofold { + egui::Grid::new("sc_palette_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label("Linetype"); + ui.horizontal(|ui| { + // With a selection: convert it; without: the mode new curves are drawn in. + let current = mode(app); + for (i, (label, tip)) in [("—", "Normal"), ("- -", "Construction"), ("-·-", "Centerline")].iter().enumerate() { + let on = !has_curves && current == i as u8; + let tip = if has_curves { format!("Make the selection {tip}") } else { format!("Draw {tip} curves") }; + if ui.add(egui::Button::new(*label).selected(on).min_size(vec2(30.0, 20.0))).on_hover_text(tip).clicked() { + linetype = Some(i as u8); } - }); - ui.end_row(); - ui.label("Look At"); - if ui.button("Look At").clicked() { - look = true; - } - ui.end_row(); - for (k, label) in TOGGLES { - // The label is part of the checkbox: clicking it toggles too. - let mut on = shown(k); - if ui.checkbox(&mut on, label).changed() { - set = Some((k.to_string(), on)); - } - ui.end_row(); } }); - } - ui.add_space(4.0); - ui.label(RichText::new(dof_line.as_str()).color(if ok { t.sketch_accent } else { t.text_dim })); - ui.add_space(4.0); - ui.horizontal(|ui| { - ui.add_space(WIDTH - 100.0); - if ui.add(egui::Button::new(RichText::new("Finish Sketch").color(Color32::WHITE)).fill(t.accent)).clicked() { - finish = true; + ui.end_row(); + ui.label("Look At"); + if ui.button("Look At").clicked() { + look = true; + } + ui.end_row(); + for (k, label) in TOGGLES { + // The label is part of the checkbox: clicking it toggles too. + let mut on = shown(k); + if ui.checkbox(&mut on, label).changed() { + set = Some((k.to_string(), on)); + } + ui.end_row(); } }); + } + ui.add_space(4.0); + ui.label(RichText::new(dof_line.as_str()).color(if ok { t.sketch_accent } else { t.text_dim })); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.add_space(WIDTH - 100.0); + if ui.add(egui::Button::new(RichText::new("Finish Sketch").color(Color32::WHITE)).fill(t.accent)).clicked() { + finish = true; + } }); + }); if let Some((k, on)) = set { let mut p = serde_json::Map::new(); p.insert(k, Value::Bool(on)); diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 0d1cd0b1..3d512444 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -1022,18 +1022,22 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P fn text_entry(app: &mut SolveApp, ctx: &egui::Context) { let Some((at, mut text)) = TEXT.with(|t| t.borrow_mut().take()) else { return }; let mut done = None; - egui::Window::new("Text").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)).show(ctx, |ui| { - let r = ui.text_edit_singleline(&mut text); - r.request_focus(); - ui.horizontal(|ui| { - if ui.button("OK").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { - done = Some(true); - } - if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { - done = Some(false); - } + crate::frame::window(ctx, "Text", crate::frame::Width::Normal) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .show(ctx, |ui| { + let r = ui.text_edit_singleline(&mut text); + r.request_focus(); + ui.horizontal(|ui| { + if ui.button("OK").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + done = Some(true); + } + if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + done = Some(false); + } + }); }); - }); match done { Some(true) => { if !text.trim().is_empty() { diff --git a/crates/ui-egui/tests/window_widths.rs b/crates/ui-egui/tests/window_widths.rs new file mode 100644 index 00000000..21dabe63 --- /dev/null +++ b/crates/ui-egui/tests/window_widths.rs @@ -0,0 +1,92 @@ +//! Every command dialog and every window stays within the width limits of `frame`: started +//! through the control channel (the toolbar's way) and measured as drawn. + +use serde_json::json; +use solvecraft_ui_egui::frame::{DIALOG_MAX_W, DIALOG_MIN_W, DIALOG_WIDE_MAX_W, WINDOW_WIDE_MAX_W}; +use solvecraft_ui_egui::scenario::Harness; + +/// Widths of the windows on screen (their areas in the middle layer). +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_map(|l| m.area_rect(l.id).map(|r| (format!("{:?}", l.id), r.width()))) + .collect() + }) +} + +fn fresh() -> Harness { + let mut h = Harness::new(); + h.call("engine.execute", json!({"command": "PrimitiveBox", "params": {"length": 40, "width": 30, "height": 20}})); + h.frames(3); + h +} + +#[test] +fn command_dialogs_shrink_to_their_content_within_limits() { + let mut h = fresh(); + let mut bad = Vec::new(); + // The frame border and shadow add a little to the content limits. + let slack = 4.0; + for spec in solvecraft_engine::command_specs() { + h.call("ui.start", json!({"command": spec.id})); + h.frames(4); + let wide = h.app.dialog.as_ref().is_some_and(|d| d.is_wide()); + let max = if wide { DIALOG_WIDE_MAX_W } else { DIALOG_MAX_W }; + for (layer, w) in window_widths(&h) { + // Floating windows a command opened (Parameters…) have their own limit. + let limit = if h.app.dialog.is_some() { max } else { WINDOW_WIDE_MAX_W }; + if w > limit + slack { + bad.push(format!("{} ({layer}): {w} px", spec.id)); + } + } + // The docked dialog shrinks to its content: most dialogs stay well under the maximum. + if h.app.dialog.is_some() && !wide { + for (_, w) in window_widths(&h) { + if w < DIALOG_MIN_W - slack { + bad.push(format!("{}: {w} px is narrower than {DIALOG_MIN_W}", spec.id)); + } + } + } + h.app.dialog = None; + h.app.params = None; + h.app.tool = None; + if h.app.session.active_sketch.is_some() { + h.app.finish_sketch(); + } + h.frames(2); + } + assert!(bad.is_empty(), "windows too wide or too narrow:\n{}", bad.join("\n")); +} + +#[test] +fn common_dialogs_are_not_stretched() { + // Simple dialogs have room to spare: they must not be pushed out to the maximum. + let mut h = fresh(); + for id in ["Extrude", "FusionFilletEdgesCommand", "FusionShellBodyCommand", "FusionMoveCommand", "FusionHoleCommand"] { + h.call("ui.start", json!({"command": id})); + h.frames(6); + let w: f32 = window_widths(&h).iter().map(|x| x.1).fold(0.0, f32::max); + assert!(w < DIALOG_MAX_W - 20.0, "{id} is stretched to {w} px"); + h.app.dialog = None; + h.frames(2); + } +} + +#[test] +fn floating_windows_stay_within_limits() { + let mut h = fresh(); + h.app.prefs_window.open = true; + h.app.keymap.open = true; + h.app.help.about = true; + h.app.ui.palette_open = true; + solvecraft_ui_egui::params_dialog::open(&mut h.app); + h.frames(6); + let ws = window_widths(&h); + assert!(ws.len() >= 4, "windows on screen: {ws:?}"); + for (layer, w) in ws { + assert!(w <= WINDOW_WIDE_MAX_W + 4.0, "{layer} is {w} px wide"); + } +}