mirror of
https://github.com/aphrody-labs/cadcraft.git
synced 2026-10-13 19:26:54 +00:00
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:
1 parent
24a3954b39
commit
a349e4925f
8 files changed
+525
-21
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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})));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -23,6 +23,7 @@ pub mod palettes;
|
||||
pub mod parametric;
|
||||
pub mod quick;
|
||||
pub mod theme;
|
||||
pub mod viewcube;
|
||||
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
Reference in new issue
Block a user