From a349e4925fed2c0c6b8ab0e000862ed75fd77a24 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 18:16:27 +0200 Subject: [PATCH] Command line, ViewCube and UCS icon take the pointer; history window (F2) (#181) Clicks on the command line's history, the UCS icon and the ViewCube ring fell through to the drawing (picking objects or starting a selection window, or entering a point during a command), the chevron did nothing, and the wheel over the overlays zoomed the drawing. The ViewCube's Top face was a click-only widget, so the canvas still counted as hovered underneath it (crosshair, wheel). - Every overlay now registers a widget that senses clicks and drags, so egui hands the pointer to the overlay instead of the canvas. - Command line: a click on the history lines or the chevron (now pointing up) opens a scrollable history window with the whole log; F2 and TEXTSCR open it too (`ui.toggle.history`, Window > Command History). Dragging the history's top edge sets how many lines show (`ui.cmdline.lines`, 0-12). - ViewCube (new viewcube.rs): Top face and a Home button run `zoom.extents`; hover highlights; right-click menu (Home, Hide ViewCube). The compass ring says that the 2D plan view can't rotate (no view twist yet) instead of doing nothing. - UCS icon and the WCS pill open the coordinate-system menu (World, Hide UCS Icon). - Control channel: `ui.drag` (real press, move, release) and `cmdline.state` -> `historyExpanded`. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 22 +-- crates/ui-egui/src/cmdline.rs | 184 +++++++++++++++++++- crates/ui-egui/src/control.rs | 24 +++ crates/ui-egui/src/icons.rs | 2 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 16 ++ crates/ui-egui/src/viewcube.rs | 296 +++++++++++++++++++++++++++++++++ docs/control-protocol.md | 1 + 8 files changed, 525 insertions(+), 21 deletions(-) create mode 100644 crates/ui-egui/src/viewcube.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a8015bd..aac8de2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -782,6 +782,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { // UCS icon, ViewCube and viewport label. if app.ui.show_ucs_icon { draw_ucs_icon(&painter, rect); + crate::viewcube::ucs_icon(app, ui, rect); } if app.ui.show_viewcube && sheet.is_none() { draw_viewcube(app, ui, rect); @@ -878,25 +879,10 @@ fn viewport_label(p: &egui::Painter, rect: Rect) { fn draw_viewcube(app: &mut CadApp, ui: &mut egui::Ui, rect: Rect) { let t = Tokens::get(); - let c = pos2(rect.right() - 92.0, rect.top() + 82.0); + let l = crate::viewcube::Layout::new(rect); + let (c, ring) = (l.center, l.ring); let p = ui.painter_at(rect); - let ring = 58.0; - p.circle_stroke(c, ring, Stroke::new(9.0, Color32::from_rgb(0x48, 0x50, 0x5c))); - p.circle_stroke(c, ring + 4.5, Stroke::new(1.0, Color32::from_rgb(0x5c, 0x65, 0x72))); - let f = egui::FontId::proportional(17.0); - let lc = Color32::from_rgb(0xc8, 0xcc, 0xd2); - p.text(c + vec2(0.0, -ring - 1.0), egui::Align2::CENTER_CENTER, "N", f.clone(), lc); - p.text(c + vec2(0.0, ring + 1.0), egui::Align2::CENTER_CENTER, "S", f.clone(), lc); - p.text(c + vec2(ring + 1.0, 0.0), egui::Align2::CENTER_CENTER, "E", f.clone(), lc); - p.text(c + vec2(-ring - 1.0, 0.0), egui::Align2::CENTER_CENTER, "W", f, lc); - let face = Rect::from_center_size(c, vec2(44.0, 44.0)); - let resp = ui.interact(face, ui.id().with("viewcube"), Sense::click()); - p.rect_filled(face, 2.0, if resp.hovered() { Color32::from_rgb(0xb8, 0xbc, 0xc2) } else { Color32::from_rgb(0x9a, 0x9e, 0xa4) }); - p.rect_stroke(face, 2.0, Stroke::new(1.0, Color32::from_rgb(0x6c, 0x70, 0x76)), egui::StrokeKind::Inside); - p.text(c, egui::Align2::CENTER_CENTER, "TOP", egui::FontId::proportional(13.0), Color32::from_rgb(0x50, 0x54, 0x5a)); - if resp.clicked() { - let _ = app.session.zoom_extents(); - } + crate::viewcube::show(app, ui, &l); // WCS pill. let pill = Rect::from_center_size(c + vec2(0.0, ring + 26.0), vec2(56.0, 16.0)); p.rect_filled(pill, 8.0, Color32::from_rgb(0x48, 0x50, 0x5c)); diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs index 1c7f215..9b7a620 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -12,9 +12,16 @@ pub struct CmdLine { pub buffer: String, pub history: Vec, pub history_pos: Option, + /// The expanded history window is open (F2, the chevron, or a click on the history lines). pub expanded: bool, + /// Dragging the history's top edge: (pointer y, line count) when the drag started. + resize_from: Option<(f32, usize)>, } +/// The most history lines the command line shows above its bar. +pub const MAX_LINES: usize = 12; +const LINE_H: f32 = 16.0; + /// Matching command names/aliases for AutoComplete. fn suggestions(prefix: &str) -> Vec<(String, &'static str)> { if prefix.is_empty() || prefix.contains(' ') { @@ -150,8 +157,9 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { let h = 24.0; let bar = Rect::from_min_size(pos2(canvas.center().x - w / 2.0, canvas.bottom() - h - 10.0), vec2(w, h)); let p = ui.painter_at(canvas); - // History lines above the bar. - let n = app.ui.history_lines.min(12); + history(app, ui, canvas, bar); + // History lines above the bar (the expanded window replaces them). + let n = if app.cmd.expanded { 0 } else { app.ui.history_lines.min(MAX_LINES) }; let lines: Vec = app.session.log.iter().rev().take(n).rev().cloned().collect(); if !lines.is_empty() { let lh = 16.0; @@ -246,7 +254,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { crate::icons::paint( &p, Rect::from_center_size(pos2(bar.right() - 14.0, bar.center().y), vec2(14.0, 14.0)), - crate::icons::Icon::ChevronDown, + if app.cmd.expanded { crate::icons::Icon::ChevronDown } else { crate::icons::Icon::ChevronUp }, false, ); // AutoComplete list. @@ -279,3 +287,173 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { } } } + +/// The command line's areas, laid out like [`show`] draws them. +struct Zones { + /// The inline history lines (when there are any and the window is closed). + lines: Option, + /// The expanded history window. + window: Option, + chevron: Rect, + /// The top edge that resizes the inline history. + grip: Rect, + /// Everything, AutoComplete list included: kept away from the drawing underneath. + all: Rect, +} + +fn zones(app: &CadApp, canvas: Rect, bar: Rect) -> Zones { + let n = if app.cmd.expanded { 0 } else { app.ui.history_lines.min(MAX_LINES).min(app.session.log.len()) }; + let lines = + (n > 0).then(|| Rect::from_min_size(pos2(bar.left(), bar.top() - LINE_H * n as f32 - 4.0), vec2(bar.width(), LINE_H * n as f32 + 2.0))); + let window = app.cmd.expanded.then(|| { + // As tall as the log (at least three lines), at most half the drawing area. + let full = LINE_H * app.session.log.len().max(3) as f32 + 4.0; + let h = full.min((canvas.height() * 0.5).max(3.0 * LINE_H)); + Rect::from_min_size(pos2(bar.left(), bar.top() - h - 4.0), vec2(bar.width(), h + 2.0)) + }); + let top = window.or(lines).map_or(bar.top(), |r| r.top()); + let grip = Rect::from_min_max(pos2(bar.left(), top - 3.0), pos2(bar.right(), top + 3.0)); + let mut all = bar.union(grip); + if app.session.running.is_none() { + let k = suggestions(&app.cmd.buffer).len(); + if k > 0 { + let above = if n == 0 { 0.0 } else { LINE_H * n as f32 + 6.0 }; + all = all.union(Rect::from_min_size(pos2(bar.left(), bar.top() - 6.0 - 20.0 * k as f32 - above), vec2(bar.width(), 1.0))); + } + } + Zones { lines, window, chevron: Rect::from_center_size(pos2(bar.right() - 14.0, bar.center().y), vec2(24.0, 22.0)), grip, all } +} + +/// The pointer on the command line: one widget under all of it keeps clicks, drags and the wheel +/// off the drawing (the keywords and AutoComplete rows sit on top of it). Clicking the history +/// lines or the chevron opens/closes the history window (`ui.toggle.history`, also F2); dragging +/// the top edge sets how many lines show (`ui.cmdline.lines`). +fn history(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect, bar: Rect) { + let z = zones(app, canvas, bar); + let resp = ui.interact(z.all, ui.id().with("cmdline"), Sense::click_and_drag()); + let at = resp.hover_pos().or(resp.interact_pointer_pos()); + let on_grip = |p: Pos2| z.grip.contains(p) && z.window.is_none(); + let on_toggle = |p: Pos2| z.chevron.contains(p) || z.lines.is_some_and(|r| r.contains(p)); + if app.cmd.resize_from.is_some() || at.is_some_and(on_grip) { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } else if at.is_some_and(on_toggle) { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + if resp.drag_started() + && let Some(o) = ui.input(|i| i.pointer.press_origin()) + && on_grip(o) + { + app.cmd.resize_from = Some((o.y, app.ui.history_lines.min(MAX_LINES))); + } + if let Some((y0, n0)) = app.cmd.resize_from { + if let (true, Some(p)) = (resp.dragged(), resp.interact_pointer_pos()) { + let n = (n0 as f32 + (y0 - p.y) / LINE_H).round().clamp(0.0, MAX_LINES as f32) as usize; + if n != app.ui.history_lines { + let _ = app.run("ui.cmdline.lines", serde_json::json!({ "lines": n })); + } + } else { + app.cmd.resize_from = None; + } + } + if resp.clicked() && resp.interact_pointer_pos().is_some_and(on_toggle) { + let _ = app.run("ui.toggle.history", serde_json::json!({})); + } + if let Some(r) = zones(app, canvas, bar).window { + history_window(app, ui, r); + } +} + +/// The expanded history: the whole command log, scrollable, newest at the bottom. +fn history_window(app: &CadApp, ui: &mut egui::Ui, r: Rect) { + let t = Tokens::get(); + ui.painter().rect_filled(r, 3.0, t.cmd_bg); + ui.painter().rect_stroke(r, 3.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + let inner = r.shrink2(vec2(8.0, 3.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(egui::Align::Min))); + child.spacing_mut().item_spacing.y = 0.0; + let log = &app.session.log; + egui::ScrollArea::vertical().id_salt("cmd_history").stick_to_bottom(true).auto_shrink([false, false]).show_rows( + &mut child, + LINE_H, + log.len(), + |ui, rows| { + for l in log.get(rows).into_iter().flatten() { + let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), LINE_H), Sense::hover()); + let p = ui.painter().with_clip_rect(row.intersect(ui.clip_rect())); + p.text(pos2(row.left(), row.center().y), egui::Align2::LEFT_CENTER, l, crate::theme::small(), t.text_dim); + } + }, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use cadcraft_engine::Session; + use egui::{Event, PointerButton}; + + fn frame(app: &mut CadApp, ctx: &egui::Context, events: Vec) { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + + fn press(app: &mut CadApp, ctx: &egui::Context, at: Pos2, pressed: bool) { + frame(app, ctx, vec![Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: egui::Modifiers::default() }]); + } + + #[test] + fn history_expands_by_click_chevron_f2_and_resizes_by_drag_without_touching_the_drawing() { + let mut app = CadApp::new(Session::empty(), crate::Services::default()); + app.start("ui.sample"); + for i in 0..20 { + app.session.echo(format!("line {i}")); + } + let ctx = egui::Context::default(); + frame(&mut app, &ctx, Vec::new()); + frame(&mut app, &ctx, Vec::new()); + let canvas = app.canvas.rect.unwrap_or(Rect::NOTHING); + let w = (canvas.width() * 0.48).clamp(360.0, 760.0); + let bar = Rect::from_min_size(pos2(canvas.center().x - w / 2.0, canvas.bottom() - 34.0), vec2(w, 24.0)); + let z = zones(&app, canvas, bar); + let Some(lines) = z.lines else { panic!("three history lines show by default") }; + // A click on the history lines opens the history window, and doesn't start a selection. + for (at, pressed) in [(lines.center(), true), (lines.center(), false)] { + frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); + press(&mut app, &ctx, at, pressed); + } + frame(&mut app, &ctx, Vec::new()); + assert!(app.cmd.expanded); + assert!(app.session.pending_window.is_none(), "the click stayed on the command line"); + // The chevron closes it; F2 and TEXTSCR open it. + let chevron = z.chevron.center(); + press(&mut app, &ctx, chevron, true); + press(&mut app, &ctx, chevron, false); + frame(&mut app, &ctx, Vec::new()); + assert!(!app.cmd.expanded); + let f2 = |pressed| Event::Key { key: Key::F2, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE }; + frame(&mut app, &ctx, vec![f2(true), f2(false)]); + assert!(app.cmd.expanded); + assert_eq!(app.run("ui.toggle.history", serde_json::json!({"on": false})), Ok(serde_json::json!({"on": false}))); + app.cmdline("textscr"); + assert!(app.cmd.expanded); + let _ = app.run("ui.toggle.history", serde_json::json!({"on": false})); + frame(&mut app, &ctx, Vec::new()); + // Dragging the top edge up by two lines shows two more lines (capped). + let top = pos2(lines.center().x, lines.top() - 1.0); + frame(&mut app, &ctx, vec![Event::PointerMoved(top)]); + press(&mut app, &ctx, top, true); + for dy in [8.0, 20.0, 2.0 * LINE_H] { + frame(&mut app, &ctx, vec![Event::PointerMoved(top - vec2(0.0, dy))]); + } + press(&mut app, &ctx, top - vec2(0.0, 2.0 * LINE_H), false); + frame(&mut app, &ctx, Vec::new()); + assert_eq!(app.ui.history_lines, 5); + assert!(!app.cmd.expanded, "a drag isn't a click"); + assert!(app.session.pending_window.is_none()); + assert_eq!(app.run("ui.cmdline.lines", serde_json::json!({"lines": 99})), Ok(serde_json::json!({"lines": MAX_LINES}))); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 06e93fc..c787c87 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -85,6 +85,7 @@ pub fn cmdline_state(app: &CadApp) -> Value { "accept": p.as_ref().map(|p| p.accept), "buffer": app.cmd.buffer, "history": app.session.log.iter().rev().take(20).rev().cloned().collect::>(), + "historyExpanded": app.cmd.expanded, }) } @@ -209,6 +210,29 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou } ok(Value::Null) } + // Press at (x, y), move in steps to `to`, release there: a real drag through egui. + "ui.drag" => { + let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") }; + let to = p.get("to").and_then(Value::as_array); + let (Some(tx), Some(ty)) = (to.and_then(|a| a.first()).and_then(Value::as_f64), to.and_then(|a| a.get(1)).and_then(Value::as_f64)) else { + return err("missing to: [x, y]"); + }; + let button = match s("button") { + Some("right") => egui::PointerButton::Secondary, + Some("middle") => egui::PointerButton::Middle, + _ => egui::PointerButton::Primary, + }; + let modifiers = egui::Modifiers::default(); + let from = egui::pos2(x as f32, y as f32); + let to = egui::pos2(tx as f32, ty as f32); + app.synthetic.push(egui::Event::PointerMoved(from)); + app.synthetic.push(egui::Event::PointerButton { pos: from, button, pressed: true, modifiers }); + for i in 1..=8 { + app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + } + app.synthetic.push(egui::Event::PointerButton { pos: to, button, pressed: false, modifiers }); + ok(Value::Null) + } "ui.key" => { let Some(key) = s("key").and_then(key_from) else { return err("unknown key") }; let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 4e04315..1367c32 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -118,6 +118,7 @@ pub enum Icon { Annotation, Workspace, ChevronDown, + ChevronUp, ChevronLeft, ChevronRight, Plus, @@ -793,6 +794,7 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { pen.l(9.0, 9.0, 21.0, 9.0); } ChevronDown => pen.poly(&[(6.0, 9.0), (12.0, 15.0), (18.0, 9.0)], false, b), + ChevronUp => pen.poly(&[(6.0, 15.0), (12.0, 9.0), (18.0, 15.0)], false, b), ChevronLeft => pen.poly(&[(15.0, 6.0), (9.0, 12.0), (15.0, 18.0)], false, b), ChevronRight => pen.poly(&[(9.0, 6.0), (15.0, 12.0), (9.0, 18.0)], false, b), Plus => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 64f4831..d64cb7c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -23,6 +23,7 @@ pub mod palettes; pub mod parametric; pub mod quick; pub mod theme; +pub mod viewcube; use std::sync::mpsc::Receiver; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fa130e7..23a2713 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -36,6 +36,8 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("ui.dialog.dsettings", "Drafting Settings...", &[], None), ("ui.dialog.about", "About CADCraft", &["Help", "About CADCraft"], None), ("ui.dialog.commands", "Command Reference", &["Help", "CADCraft Help"], Some("F1")), + ("ui.toggle.history", "Command History", &["Window", "Command History"], Some("F2")), + ("ui.cmdline.lines", "Command Line History Lines", &[], None), ("ui.noop", "", &[], None), ("ui.quit", "Quit CADCraft", &[], Some("Cmd+Q")), ]; @@ -139,6 +141,19 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option { + let open = json!({"on": true}); + toggle(&mut app.cmd.expanded, if id == "textscr" { &open } else { params }); + Ok(json!({ "on": app.cmd.expanded })) + } + // `{"lines": n}`: how many history lines show above the command line (0–12); without it, reports it. + "ui.cmdline.lines" => { + if let Some(n) = params.get("lines").and_then(Value::as_u64) { + app.ui.history_lines = n.min(crate::cmdline::MAX_LINES as u64) as usize; + } + Ok(json!({ "lines": app.ui.history_lines })) + } "ui.dialog.layers" | "ui.dialog.blocks" | "ui.dialog.dsettings" @@ -321,6 +336,7 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) { (sc(cmd, Key::Num3), "ui.toggle.toolsets"), (sc(cmd, Key::Num9), "ui.toggle.cmdline"), (sc(Modifiers::NONE, Key::F1), "ui.dialog.commands"), + (sc(Modifiers::NONE, Key::F2), "ui.toggle.history"), (sc(Modifiers::NONE, Key::F3), "osnap"), (sc(Modifiers::NONE, Key::F7), "grid"), (sc(Modifiers::NONE, Key::F8), "ortho"), diff --git a/crates/ui-egui/src/viewcube.rs b/crates/ui-egui/src/viewcube.rs new file mode 100644 index 0000000..0cefd0a --- /dev/null +++ b/crates/ui-egui/src/viewcube.rs @@ -0,0 +1,296 @@ +//! The navigation overlays on the drawing area: the ViewCube (Top face, Home, compass ring and +//! the WCS menu) and the UCS icon. +//! +//! Every overlay registers a widget that senses clicks *and* drags, so egui hands the pointer to +//! the overlay instead of the canvas underneath (a click on the ViewCube must never also pick or +//! start a selection window in the drawing). What the overlays do goes through commands: +//! `zoom.extents` (Top face, Home), `ui.toggle.viewcube`, `ui.toggle.ucsicon`. +//! +//! CADCraft's model view is a 2D plan view of the World Coordinate System: there is no view +//! twist, no 3D view direction and no user coordinate system yet, so the compass ring explains +//! that instead of rotating, and the WCS menu lists the only coordinate system there is. + +use egui::{Color32, CursorIcon, Pos2, Rect, Sense, Stroke, pos2, vec2}; +use serde_json::json; + +use crate::CadApp; +use crate::theme::Tokens; + +/// Shown when the compass ring is clicked or dragged. +pub const NO_TWIST: &str = "ViewCube: rotating the view needs a view twist, which the 2D plan view doesn't support yet."; + +/// Where the ViewCube's parts are on the canvas. +#[derive(Clone, Copy, Debug)] +pub struct Layout { + pub center: Pos2, + /// Radius of the compass ring's centre line. + pub ring: f32, + pub face: Rect, + pub home: Rect, + /// The "WCS" coordinate-system menu under the cube. + pub pill: Rect, + /// Everything that belongs to the cube (the area kept away from the canvas). + pub bounds: Rect, +} + +impl Layout { + pub fn new(canvas: Rect) -> Self { + let center = pos2(canvas.right() - 92.0, canvas.top() + 82.0); + let ring = 58.0; + Layout { + center, + ring, + face: Rect::from_center_size(center, vec2(44.0, 44.0)), + home: Rect::from_center_size(center + vec2(-ring + 2.0, -ring + 2.0), vec2(20.0, 20.0)), + pill: Rect::from_center_size(center + vec2(0.0, ring + 26.0), vec2(56.0, 16.0)), + bounds: Rect::from_center_size(center, vec2(2.0 * (ring + 10.0), 2.0 * (ring + 10.0))), + } + } + + /// The part of the cube at `p` (not the pill, which is its own widget). + pub fn part_at(&self, p: Pos2) -> Option { + if self.home.contains(p) { + return Some(Part::Home); + } + if self.face.contains(p) { + return Some(Part::Face); + } + let d = p.distance(self.center); + ((self.ring - 10.0..=self.ring + 10.0).contains(&d)).then_some(Part::Ring) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Part { + Face, + Home, + Ring, +} + +impl Part { + fn tip(self) -> &'static str { + match self { + Part::Face => "Top: plan view of the drawing (zoom extents)", + Part::Home => "Home: zoom to the drawing extents", + Part::Ring => "Compass: view rotation isn't available in the 2D plan view yet", + } + } +} + +/// Paint the ViewCube's ring, compass letters, Top face and Home button, and handle the pointer. +/// The canvas paints the WCS pill itself; this returns the pointer's part for its highlight. +pub fn show(app: &mut CadApp, ui: &mut egui::Ui, l: &Layout) { + let resp = ui.interact(l.bounds, ui.id().with("viewcube"), Sense::click_and_drag()); + let part = resp.hover_pos().and_then(|p| l.part_at(p)); + let p = ui.painter_at(l.bounds.union(l.pill)); + let ring_col = if part == Some(Part::Ring) { Color32::from_rgb(0x5c, 0x67, 0x7c) } else { Color32::from_rgb(0x48, 0x50, 0x5c) }; + p.circle_stroke(l.center, l.ring, Stroke::new(9.0, ring_col)); + p.circle_stroke(l.center, l.ring + 4.5, Stroke::new(1.0, Color32::from_rgb(0x5c, 0x65, 0x72))); + let f = egui::FontId::proportional(17.0); + let lc = Color32::from_rgb(0xc8, 0xcc, 0xd2); + let c = l.center; + let ring = l.ring; + p.text(c + vec2(0.0, -ring - 1.0), egui::Align2::CENTER_CENTER, "N", f.clone(), lc); + p.text(c + vec2(0.0, ring + 1.0), egui::Align2::CENTER_CENTER, "S", f.clone(), lc); + p.text(c + vec2(ring + 1.0, 0.0), egui::Align2::CENTER_CENTER, "E", f.clone(), lc); + p.text(c + vec2(-ring - 1.0, 0.0), egui::Align2::CENTER_CENTER, "W", f, lc); + let face_col = if part == Some(Part::Face) { Color32::from_rgb(0xb8, 0xbc, 0xc2) } else { Color32::from_rgb(0x9a, 0x9e, 0xa4) }; + p.rect_filled(l.face, 2.0, face_col); + p.rect_stroke(l.face, 2.0, Stroke::new(1.0, Color32::from_rgb(0x6c, 0x70, 0x76)), egui::StrokeKind::Inside); + p.text(c, egui::Align2::CENTER_CENTER, "TOP", egui::FontId::proportional(13.0), Color32::from_rgb(0x50, 0x54, 0x5a)); + paint_home(&p, l.home, part == Some(Part::Home)); + + let resp = match part { + Some(part) => resp.on_hover_cursor(CursorIcon::PointingHand).on_hover_text(part.tip()), + None => resp, + }; + let pressed_on = resp.interact_pointer_pos().and_then(|o| l.part_at(o)); + if resp.clicked() { + match pressed_on { + Some(Part::Face | Part::Home) => { + let _ = app.run("zoom.extents", json!({})); + } + Some(Part::Ring) => app.session.echo(NO_TWIST), + None => {} + } + } + if resp.drag_started() && pressed_on == Some(Part::Ring) { + app.session.echo(NO_TWIST); + } + let mut action = None; + resp.context_menu(|ui| { + if ui.button("Home").clicked() { + action = Some(("zoom.extents", json!({}))); + ui.close(); + } + if ui.button("Hide ViewCube").clicked() { + action = Some(("ui.toggle.viewcube", json!({"on": false}))); + ui.close(); + } + }); + if let Some((id, params)) = action { + let _ = app.run(id, params); + } + // The WCS pill (painted by the canvas) opens the coordinate-system menu. + let pill = ui.interact(l.pill, ui.id().with("viewcube_wcs"), Sense::click_and_drag()).on_hover_cursor(CursorIcon::PointingHand); + let pill = pill.on_hover_text("Coordinate system"); + ucs_menu(app, egui::Popup::menu(&pill)); +} + +/// A small house, drawn in code. +fn paint_home(p: &egui::Painter, r: Rect, hot: bool) { + let t = Tokens::get(); + if hot { + p.rect_filled(r, 3.0, t.control_hover); + } + let st = Stroke::new(1.3, if hot { t.text } else { t.text_dim }); + let c = r.center(); + let roof = [c + vec2(-6.5, 0.5), c + vec2(0.0, -6.0), c + vec2(6.5, 0.5)]; + p.line(roof.to_vec(), st); + p.line(vec![c + vec2(-4.5, -1.0), c + vec2(-4.5, 6.0), c + vec2(4.5, 6.0), c + vec2(4.5, -1.0)], st); + p.line_segment([c + vec2(-1.2, 6.0), c + vec2(-1.2, 2.5)], st); + p.line_segment([c + vec2(1.2, 6.0), c + vec2(1.2, 2.5)], st); + p.line_segment([c + vec2(-1.2, 2.5), c + vec2(1.2, 2.5)], st); +} + +/// The UCS icon's arms (as drawn by the canvas at the lower-left corner): the X arm and the Y arm. +pub fn ucs_arms(canvas: Rect) -> [Rect; 2] { + let o = pos2(canvas.left() + 36.0, canvas.bottom() - 34.0); + [Rect::from_min_max(o + vec2(-8.0, -8.0), o + vec2(80.0, 8.0)), Rect::from_min_max(o + vec2(-8.0, -82.0), o + vec2(8.0, 8.0))] +} + +/// Make the UCS icon clickable: hovering highlights it, a click (either button) opens the +/// coordinate-system menu. +pub fn ucs_icon(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { + let t = Tokens::get(); + let [x, y] = ucs_arms(canvas); + let rx = ui.interact(x, ui.id().with("ucs_x"), Sense::click_and_drag()); + let ry = ui.interact(y, ui.id().with("ucs_y"), Sense::click_and_drag()); + let resp = (rx | ry).on_hover_cursor(CursorIcon::PointingHand).on_hover_text("UCS icon: World Coordinate System"); + if resp.hovered() { + let o = pos2(x.left() + 8.0, x.center().y); + let st = Stroke::new(2.0, t.hover); + let p = ui.painter_at(canvas); + p.line_segment([o, o + vec2(60.0, 0.0)], st); + p.line_segment([o, o + vec2(0.0, -60.0)], st); + } + // Opened with `Bool(true)`: a pointer-anchored popup records its position only then. + let open = (resp.clicked() || resp.secondary_clicked()).then_some(egui::SetOpenCommand::Bool(true)); + ucs_menu(app, egui::Popup::menu(&resp).open_memory(open).at_pointer_fixed()); +} + +/// The coordinate-system menu: the WCS (the only coordinate system so far) and the icon switch. +fn ucs_menu(app: &mut CadApp, popup: egui::Popup<'_>) { + let mut hide = false; + popup.show(|ui| { + ui.radio(true, "World (WCS)").on_hover_text("CADCraft draws in the World Coordinate System; user coordinate systems aren't available yet."); + ui.separator(); + if ui.button("Hide UCS Icon").clicked() { + hide = true; + ui.close(); + } + }); + if hide { + let _ = app.run("ui.toggle.ucsicon", json!({"on": false})); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cadcraft_engine::Session; + + /// The app with the sample drawing, laid out once in a 1400×900 window. + fn app_and_ctx() -> (CadApp, egui::Context) { + let mut app = CadApp::new(Session::empty(), crate::Services::default()); + app.ui.in_window_menu = true; + app.start("ui.sample"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, Vec::new()); + frame(&mut app, &ctx, Vec::new()); + (app, ctx) + } + + fn frame(app: &mut CadApp, ctx: &egui::Context, events: Vec) { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + + /// A real pointer click (move, press, release in separate frames, like the control channel). + pub(crate) fn click(app: &mut CadApp, ctx: &egui::Context, at: Pos2, button: egui::PointerButton) { + let m = egui::Modifiers::default(); + frame(app, ctx, vec![egui::Event::PointerMoved(at)]); + frame(app, ctx, vec![egui::Event::PointerButton { pos: at, button, pressed: true, modifiers: m }]); + frame(app, ctx, vec![egui::Event::PointerButton { pos: at, button, pressed: false, modifiers: m }]); + frame(app, ctx, Vec::new()); + } + + /// A menu popup was shown in the last frame. + fn menu_shown(ctx: &egui::Context) -> bool { + ctx.memory(|m| m.areas().visible_layer_ids().iter().any(|l| l.order == egui::Order::Foreground)) + } + + fn canvas(app: &CadApp) -> Rect { + app.canvas.rect.unwrap_or(Rect::NOTHING) + } + + fn view(app: &CadApp) -> (f64, f64, f64) { + let v = app.session.state().map(|s| s.view()).unwrap_or_default(); + (v.center.x, v.center.y, v.height) + } + + #[test] + fn parts_are_found_where_they_are_drawn() { + let l = Layout::new(Rect::from_min_size(pos2(200.0, 80.0), vec2(1000.0, 800.0))); + assert_eq!(l.part_at(l.center), Some(Part::Face)); + assert_eq!(l.part_at(l.home.center()), Some(Part::Home)); + assert_eq!(l.part_at(l.center + vec2(0.0, -l.ring)), Some(Part::Ring), "N"); + assert_eq!(l.part_at(l.center + vec2(l.ring + 3.0, 0.0)), Some(Part::Ring), "E"); + assert_eq!(l.part_at(l.center + vec2(30.0, 30.0)), None, "between the face and the ring"); + assert!(l.bounds.contains_rect(l.home) && l.bounds.contains_rect(l.face)); + } + + #[test] + fn viewcube_clicks_reach_the_cube_not_the_drawing() { + let (mut app, ctx) = app_and_ctx(); + let l = Layout::new(canvas(&app)); + let fitted = view(&app); + // Zoomed in, the Top face fits the drawing again (zoom extents) and nothing is picked. + let _ = app.run("zoom.in", json!({})); + assert_ne!(view(&app), fitted); + click(&mut app, &ctx, l.center, egui::PointerButton::Primary); + assert_eq!(view(&app), fitted, "Top face zooms to extents"); + assert!(app.session.pending_window.is_none(), "the click didn't start a selection window"); + assert!(app.session.selection().is_empty()); + // Home does the same. + let _ = app.run("zoom.in", json!({})); + click(&mut app, &ctx, l.home.center(), egui::PointerButton::Primary); + assert_eq!(view(&app), fitted, "Home zooms to extents"); + // The ring explains that the 2D view can't rotate, and doesn't touch the drawing. + click(&mut app, &ctx, l.center + vec2(0.0, -l.ring), egui::PointerButton::Primary); + assert_eq!(app.session.log.last().map(String::as_str), Some(NO_TWIST)); + assert!(app.session.pending_window.is_none()); + assert_eq!(view(&app), fitted); + // A click in the drawing still reaches the drawing. + let empty = canvas(&app).center() + vec2(0.0, -300.0); + click(&mut app, &ctx, empty, egui::PointerButton::Primary); + assert!(app.session.pending_window.is_some(), "an empty-space click starts a selection window"); + } + + #[test] + fn ucs_icon_and_wcs_pill_open_the_coordinate_system_menu() { + let (mut app, ctx) = app_and_ctx(); + let [x, _] = ucs_arms(canvas(&app)); + click(&mut app, &ctx, x.center(), egui::PointerButton::Primary); + assert!(menu_shown(&ctx), "UCS icon menu"); + assert!(app.session.pending_window.is_none(), "the click stayed on the icon"); + let l = Layout::new(canvas(&app)); + click(&mut app, &ctx, l.pill.center(), egui::PointerButton::Primary); + assert!(menu_shown(&ctx), "WCS menu"); + assert!(app.session.pending_window.is_none()); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a8e9bca..30376a6 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -23,6 +23,7 @@ Errors come back as `{"id": …, "ok": false, "error": "message"}`. | `ui.menu.list` / `ui.menu.invoke` | `{command}` | The menu tree; invoke an item like a click (interactive). | | `ui.pointer` | `{x, y, space?: "world"\|"screen", button?, action?: "click"\|"move"}` | Click in the drawing area (world coordinates by default). Right button = Enter. | | `ui.click` / `ui.move` | `{x, y, button?, shift?}` | Real egui pointer events in screen points (reach every widget). | +| `ui.drag` | `{x, y, to: [x, y], button?}` | Real press, move and release (drag the command line's top edge, the ViewCube ring…). | | `ui.key` / `ui.text` | `{key, cmd?, shift?, alt?}` / `{text}` | Synthetic keyboard input. | | `ui.set` | UiState fields | Show/hide palettes, toolbars, command line, ViewCube…; `theme: "system"\|"light"\|"dark"` (also `engine.execute` with `ui.theme {theme}`); `ui.inspect` reports the theme shown (`theme`: light/dark). | | `ui.resize` | `{width, height}` | Resize the window. |