One frame for every window (frame.rs): command dialogs dock right and shrink to their content (260 to 380 px, 560 for table dialogs), floating windows are centred and capped (460 px, 760 for tables); header and footer rows right-align without stretching the dialog, long texts wrap; all windows migrated; tests: no egui::Window outside frame.rs, every command's dialog and the floating windows measured within limits
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
74c7259825
commit
3f44110b59
16 files changed
+684
-483
No files matched your search
@@ -820,8 +820,9 @@ fn canvas_panel(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let mut edit: Option<Value> = 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<Value> = 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<bool> = 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)
|
||||
|
||||
+402
-393
@@ -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());
|
||||
|
||||
@@ -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<u8> = 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)
|
||||
|
||||
@@ -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<WidgetText>, 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<WidgetText>, 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::<f32>(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<R>(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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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}"));
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user