diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e4f1736c4..756259551 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -35,6 +35,46 @@ impl Xf { } } +#[derive(Clone, Copy, Debug)] +pub struct HotGrip { + pub handle: Handle, + pub index: usize, + pub base: Vec2, + pub mode: cadcraft_engine::grips::GripMode, +} + +impl HotGrip { + pub fn label(&self) -> &'static str { + use cadcraft_engine::grips::GripMode::*; + match self.mode { + Stretch => "** STRETCH ** Specify stretch point or [Base point/Copy/Undo/eXit]:", + Move => "** MOVE ** Specify move point or [Base point/Copy/Undo/eXit]:", + Rotate => "** ROTATE ** Specify rotation angle or [Base point/Copy/Undo/Reference/eXit]:", + Scale => "** SCALE ** Specify scale factor or [Base point/Copy/Undo/Reference/eXit]:", + Mirror => "** MIRROR ** Specify second point or [Base point/Copy/Undo/eXit]:", + } + } + pub fn next_mode(&mut self) { + use cadcraft_engine::grips::GripMode::*; + self.mode = match self.mode { + Stretch => Move, + Move => Rotate, + Rotate => Scale, + Scale => Mirror, + Mirror => Stretch, + }; + } +} + +/// Apply a hot grip at `to` (one undo step) and clear it. +pub fn apply_hot_grip(app: &mut CadApp, to: Vec2) { + if let Some(g) = app.canvas.hot_grip.take() + && let Err(e) = app.session.grip_edit(g.handle, g.index, to, g.mode) + { + app.session.echo(e.to_string()); + } +} + #[derive(Default)] pub struct CanvasState { pub list: Option, @@ -57,6 +97,8 @@ pub struct CanvasState { pub cursor: Option, pub polar_angle: Option, pan_last: Option, + /// A hot (clicked) grip being dragged: entity, grip index, grip position, mode. + pub hot_grip: Option, /// Zoom to extents once the canvas size is known (after opening a drawing). pub zoom_pending: bool, pub build_ms: f64, @@ -371,8 +413,9 @@ fn draw_snap_marker(p: &egui::Painter, at: Pos2, hit: &SnapHit) { /// The rubber-band and snap-adjusted cursor point for the current prompt. fn effective_point(app: &mut CadApp, raw: Vec2, xf: &Xf) -> Vec2 { let prompt = app.session.current_prompt(); - let wants_point = prompt.as_ref().is_some_and(|p| p.accept.point && !p.accept.select); - let base = prompt.as_ref().and_then(|p| p.base); + let hot = app.canvas.hot_grip; + let wants_point = hot.is_some() || prompt.as_ref().is_some_and(|p| p.accept.point && !p.accept.select); + let base = hot.map(|g| g.base).or_else(|| prompt.as_ref().and_then(|p| p.base)); let s = app.session.settings.clone(); app.canvas.snap = None; app.canvas.polar_angle = None; @@ -491,9 +534,15 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { && let Some(p) = app.canvas.cursor { if app.session.running.is_some() { + app.canvas.hot_grip = None; if let Err(e) = app.session.input(Input::Point(p)) { app.session.echo(e.to_string()); } + } else if app.canvas.hot_grip.is_some() { + apply_hot_grip(app, p); + } else if let Some(g) = grip_at(app, &xf, hover_pos) { + app.canvas.hot_grip = Some(g); + app.session.echo(g.label()); } else if let Err(e) = app.session.idle_click(raw_world.unwrap_or(p), mods.shift) { app.session.echo(e.to_string()); } @@ -526,10 +575,31 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { // ---------- paint ---------- painter.rect_filled(rect, 0.0, t.canvas); - draw_grid(app, &painter, &xf); let t0 = crate::now_ms(); ensure_list(app, 1.0 / scale); - let bg = Rgb(t.canvas.r(), t.canvas.g(), t.canvas.b()); + let sheet = app.canvas.list.as_ref().and_then(|l| l.sheet); + let bg = match sheet { + Some(sh) => { + // Paper space: grey surround, the sheet with a shadow, the printable area dashed. + painter.rect_filled(rect, 0.0, Color32::from_rgb(0x50, 0x57, 0x63)); + let a = xf.to_screen(Vec2::new(0.0, sh.size.y)); + let b = xf.to_screen(Vec2::new(sh.size.x, 0.0)); + let paper = Rect::from_two_pos(a, b); + painter.rect_filled(paper.translate(vec2(5.0, 5.0)), 0.0, Color32::from_black_alpha(110)); + painter.rect_filled(paper, 0.0, Color32::WHITE); + let pa = Rect::from_two_pos( + xf.to_screen(Vec2::new(sh.printable.min.x, sh.printable.max.y)), + xf.to_screen(Vec2::new(sh.printable.max.x, sh.printable.min.y)), + ); + let pts = [pa.left_top(), pa.right_top(), pa.right_bottom(), pa.left_bottom(), pa.left_top()]; + painter.extend(Shape::dashed_line(&pts, Stroke::new(1.0, Color32::from_gray(150)), 4.0, 4.0)); + Rgb(255, 255, 255) + } + None => { + draw_grid(app, &painter, &xf); + Rgb(t.canvas.r(), t.canvas.g(), t.canvas.b()) + } + }; let sel = app.session.selection(); if app.canvas.gpu.is_some() { draw_list_gpu(&mut app.canvas, &painter, &xf, bg, app.session.settings.lwdisplay); @@ -552,17 +622,53 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let gs = app.session.settings.gripsize as f32; for h in sel.iter().take(200) { if let Some(e) = st.doc.entity(*h) { - for g in e.kind.grips() { + for (gi, g) in e.kind.grips().into_iter().enumerate() { let sp = xf.to_screen(g); if rect.contains(sp) { let r = Rect::from_center_size(sp, vec2(gs * 2.0, gs * 2.0)); - painter.rect_filled(r, 0.0, t.grip); + let hot = app.canvas.hot_grip.is_some_and(|hg| hg.handle == *h && hg.index == gi); + let warm = !hot && hover_pos.is_some_and(|hp| r.expand(2.0).contains(hp)); + painter.rect_filled( + r, + 0.0, + if hot { + t.grip_hot + } else if warm { + Color32::from_rgb(0xff, 0x7f, 0x9f) + } else { + t.grip + }, + ); painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_rgb(0x10, 0x30, 0x80)), egui::StrokeKind::Middle); } } } } } + // Hot grip preview. + if let (Some(g), Some(c), Ok(st)) = (app.canvas.hot_grip, app.canvas.cursor, app.session.state()) + && let Some(e) = st.doc.entity(g.handle) + { + let k = match cadcraft_engine::grips::mode_matrix(g.mode, g.base, c) { + Some(m) => { + let mut k = e.kind.clone(); + k.transform(&m); + Some(k) + } + None if g.mode == cadcraft_engine::grips::GripMode::Stretch => cadcraft_engine::grips::stretch_grip(&e.kind, g.index, c), + None => None, + }; + if let Some(k) = k { + let ent = cadcraft_doc::Entity { handle: Handle(0), common: e.common.clone(), kind: k }; + let list = cadcraft_render::build_entities( + &st.doc, + std::iter::once(&ent), + &cadcraft_render::Options { tolerance: 0.5 / scale, min_dash: 2.0 / scale, ..Default::default() }, + ); + draw_list(&painter, &xf, &list, bg, false); + } + painter.extend(Shape::dashed_line(&[xf.to_screen(g.base), xf.to_screen(c)], Stroke::new(1.0, t.text_dim), 4.0, 3.0)); + } // Rubber band preview of the active command. if let (Some(c), true) = (app.canvas.cursor, app.session.running.is_some()) { let ents = app.session.preview(c); @@ -607,7 +713,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { if app.ui.show_ucs_icon { draw_ucs_icon(&painter, rect); } - if app.ui.show_viewcube { + if app.ui.show_viewcube && sheet.is_none() { draw_viewcube(app, ui, rect); } viewport_label(&painter, rect); @@ -726,3 +832,23 @@ fn draw_viewcube(app: &mut CadApp, ui: &mut egui::Ui, rect: Rect) { p.rect_filled(pill, 8.0, Color32::from_rgb(0x48, 0x50, 0x5c)); p.text(pill.center(), egui::Align2::CENTER_CENTER, "WCS ⌄", crate::theme::small(), t.text_dim); } + +/// The grip of a selected object under the cursor, if any. +fn grip_at(app: &CadApp, xf: &Xf, hover: Option) -> Option { + let hp = hover?; + let st = app.session.state().ok()?; + let gs = app.session.settings.gripsize as f32 + 3.0; + for h in app.session.selection().iter().take(200) { + let e = st.doc.entity(*h)?; + if st.doc.layer(&e.common.layer).is_some_and(|l| l.locked) { + continue; + } + for (i, g) in e.kind.grips().into_iter().enumerate() { + let sp = xf.to_screen(g); + if (sp.x - hp.x).abs() <= gs && (sp.y - hp.y).abs() <= gs { + return Some(HotGrip { handle: *h, index: i, base: g, mode: cadcraft_engine::grips::GripMode::Stretch }); + } + } + } + None +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 48cac28b2..80bb36428 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -183,7 +183,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let resp = ui.interact(br, ui.id().with(("sb", tip)), Sense::click()); icons::paint(&p, br.shrink(2.0), icon, false); if resp.on_hover_text(tip).clicked() && icon == Icon::Plus { - app.set_status("New layout: coming soon"); + let _ = app.run("layout.new", json!({})); } x += 22.0; } @@ -218,7 +218,14 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { x += w + 2.0; if i == 0 { let pr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(16.0, 16.0)); + let presp = ui.interact(pr, ui.id().with("layout_plus"), Sense::click()); icons::paint(&p, pr.shrink(2.0), Icon::Plus, false); + if presp.on_hover_text("New layout").clicked() + && let Ok(v) = app.run("layout.new", json!({})) + && let Some(n) = v.get("name").and_then(serde_json::Value::as_str) + { + switch = Some(n.to_string()); + } x += 22.0; } } diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs index a8ecd1301..452111551 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -37,6 +37,40 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) { return; } let events = ctx.input(|i| i.events.clone()); + // A hot grip takes Space/Enter (cycle mode, or apply a typed point) and Escape. + if app.canvas.hot_grip.is_some() { + for ev in &events { + match ev { + egui::Event::Text(t) if t != " " => app.cmd.buffer.push_str(t), + egui::Event::Key { key: Key::Backspace, pressed: true, .. } => { + app.cmd.buffer.pop(); + } + egui::Event::Key { key: Key::Escape, pressed: true, .. } => { + app.canvas.hot_grip = None; + app.cmd.buffer.clear(); + app.session.echo("*Cancel*"); + } + egui::Event::Key { key: Key::Enter | Key::Space, pressed: true, .. } => { + let typed = std::mem::take(&mut app.cmd.buffer); + if typed.trim().is_empty() { + if let Some(g) = app.canvas.hot_grip.as_mut() { + g.next_mode(); + let l = g.label(); + app.session.echo(l); + } + } else { + let base = app.canvas.hot_grip.map(|g| g.base).unwrap_or_default(); + match cadcraft_engine::prompt::parse_point(&typed, base) { + Some(p) => crate::canvas::apply_hot_grip(app, p), + None => app.session.echo("Requires a point (x,y, @dx,dy or @d {} + } + } + return; + } let text_prompt = app.session.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point && !p.accept.number); for ev in events { match ev { @@ -134,62 +168,69 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { let mut x = bar.left() + 30.0; let prompt = app.session.current_prompt(); let font = crate::theme::body(); - match &prompt { - Some(pr) => { - let name = app.session.running.as_ref().map(|r| r.id.to_ascii_uppercase()).unwrap_or_default(); - let g = p.layout_no_wrap(format!("{name} "), font.clone(), t.text_faint); - let gw = g.size().x; - p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text_faint); - x += gw; - let g = p.layout_no_wrap(pr.message.clone(), font.clone(), t.text); - let gw = g.size().x; - p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); - x += gw; - if !pr.keywords.is_empty() { - let g = p.layout_no_wrap(if pr.message.is_empty() { " [".into() } else { " or [".into() }, font.clone(), t.text); + if let Some(g) = app.canvas.hot_grip { + let gl = p.layout_no_wrap(g.label().to_string(), font.clone(), t.text); + let gw = gl.size().x; + p.galley(pos2(x, bar.center().y - gl.size().y / 2.0), gl, t.text); + x += gw + 6.0; + } else { + match &prompt { + Some(pr) => { + let name = app.session.running.as_ref().map(|r| r.id.to_ascii_uppercase()).unwrap_or_default(); + let g = p.layout_no_wrap(format!("{name} "), font.clone(), t.text_faint); + let gw = g.size().x; + p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text_faint); + x += gw; + let g = p.layout_no_wrap(pr.message.clone(), font.clone(), t.text); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); x += gw; - let kws = pr.keywords.clone(); - for (i, k) in kws.iter().enumerate() { - let g = p.layout_no_wrap(k.clone(), font.clone(), Color32::from_rgb(0x8f, 0xc1, 0xff)); - let r = Rect::from_min_size(pos2(x, bar.top() + 3.0), vec2(g.size().x, h - 6.0)); - let resp = ui.interact(r, ui.id().with(("kw", i)), Sense::click()); - if resp.hovered() { - p.rect_filled(r, 2.0, Color32::from_rgb(0x2f, 0x5e, 0xa8)); - } + if !pr.keywords.is_empty() { + let g = p.layout_no_wrap(if pr.message.is_empty() { " [".into() } else { " or [".into() }, font.clone(), t.text); let gw = g.size().x; - p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, Color32::from_rgb(0x8f, 0xc1, 0xff)); + p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); x += gw; - if resp.clicked() { - let _ = app.session.input(Input::Keyword(k.clone())); - } - if i + 1 < kws.len() { - let g = p.layout_no_wrap("/".into(), font.clone(), t.text); + let kws = pr.keywords.clone(); + for (i, k) in kws.iter().enumerate() { + let g = p.layout_no_wrap(k.clone(), font.clone(), Color32::from_rgb(0x8f, 0xc1, 0xff)); + let r = Rect::from_min_size(pos2(x, bar.top() + 3.0), vec2(g.size().x, h - 6.0)); + let resp = ui.interact(r, ui.id().with(("kw", i)), Sense::click()); + if resp.hovered() { + p.rect_filled(r, 2.0, Color32::from_rgb(0x2f, 0x5e, 0xa8)); + } let gw = g.size().x; - p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); + p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, Color32::from_rgb(0x8f, 0xc1, 0xff)); x += gw; + if resp.clicked() { + let _ = app.session.input(Input::Keyword(k.clone())); + } + if i + 1 < kws.len() { + let g = p.layout_no_wrap("/".into(), font.clone(), t.text); + let gw = g.size().x; + p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); + x += gw; + } } + let g = p.layout_no_wrap("]".into(), font.clone(), t.text); + let gw = g.size().x; + p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); + x += gw; } - let g = p.layout_no_wrap("]".into(), font.clone(), t.text); + if let Some(d) = &pr.default { + let g = p.layout_no_wrap(format!(" <{d}>"), font.clone(), t.text); + let gw = g.size().x; + p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); + x += gw; + } + let g = p.layout_no_wrap(": ".into(), font.clone(), t.text); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); x += gw; } - if let Some(d) = &pr.default { - let g = p.layout_no_wrap(format!(" <{d}>"), font.clone(), t.text); - let gw = g.size().x; - p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); - x += gw; - } - let g = p.layout_no_wrap(": ".into(), font.clone(), t.text); - let gw = g.size().x; - p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); - x += gw; - } - None => { - if app.cmd.buffer.is_empty() { - p.text(pos2(x, bar.center().y), egui::Align2::LEFT_CENTER, "Type a command", egui::FontId::proportional(12.5), t.text_faint); + None => { + if app.cmd.buffer.is_empty() { + p.text(pos2(x, bar.center().y), egui::Align2::LEFT_CENTER, "Type a command", egui::FontId::proportional(12.5), t.text_faint); + } } } }