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 <noreply@anthropic.com>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-10 12:16:27 -04:00
1 parent 24a3954b39
commit a349e4925f
8 files changed
+525 -21

No files matched your search

+4 -18
View File
@@ -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));
+181 -3
View File
@@ -12,9 +12,16 @@ pub struct CmdLine {
pub buffer: String,
pub history: Vec<String>,
pub history_pos: Option<usize>,
/// 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<String> = 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<Rect>,
/// The expanded history window.
window: Option<Rect>,
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<Event>) {
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})));
}
}
+24
View File
@@ -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::<Vec<_>>(),
"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);
+2
View File
@@ -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 => {
+1
View File
@@ -23,6 +23,7 @@ pub mod palettes;
pub mod parametric;
pub mod quick;
pub mod theme;
pub mod viewcube;
use std::sync::mpsc::Receiver;
+16
View File
@@ -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<Resu
app.ui.start_tab = true;
Ok(Value::Null)
}
// The expanded command history (AutoCAD's text window; TEXTSCR opens it).
"ui.toggle.history" | "textscr" => {
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"),
+296
View File
@@ -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<Part> {
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<egui::Event>) {
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());
}
}
+1
View File
@@ -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. |