Xrefs: External References palette and XREF (fixes #525) (#533)

Squash of PR #533 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 12:11:52 -04:00
1 parent 0230250bf5
commit bc7744b27b
10 files changed
+395 -4

No files matched your search

+15
View File
@@ -27,6 +27,11 @@ pub(super) fn specs() -> Vec<CommandSpec> {
.alias(&["xa"])
.params("{path: DWG/DXF file (saved as given; a relative path is relative to this drawing's folder), name? (default: the file name), overlay?: bool, at?: [x,y] (default 0,0), scale?: number | [x, y], rotation? (degrees)} → {name, handle, file}")
.interactive(|_| Ok(Box::new(XrefM::new("XATTACH", XStage::File { overlay: false })))),
CommandSpec::new("xref", "Reference Manager", run_xref)
.menu(&["Window", "Reference Manager"])
.alias(&["xr", "externalreferences"])
.params("{} → {xrefs: [{name, path, foundAt, type: Attach|Overlay, status: Loaded|Unloaded|Not Found|Unresolved|Unreferenced|Not loaded, references, objects}]} (the app opens the External References palette instead)")
.noundo(),
CommandSpec::new("-xref", "External References", run_dash_xref)
.alias(&["-xr"])
.params("{option: \"?\"|bind|detach|path|unload|reload|overlay|attach|type, names?: \"A,B*\" (default *), path? (path, attach, overlay), insert?: bool (bind: Insert type), overlay?: bool (type), at?, scale?, rotation? (attach)}")
@@ -199,6 +204,16 @@ fn named(s: &Session, cmd: &str, p: &Value) -> Result<Vec<String>> {
Ok(names)
}
/// XREF's JSON form: the xrefs as the External References palette lists them (the palette is
/// the app's; typed without a UI, the list is echoed).
fn run_xref(s: &mut Session, _p: &Value) -> Result<Value> {
let r = run_dash_xref(s, &json!({ "option": "?" }))?;
if r["xrefs"].as_array().is_some_and(|a| a.is_empty()) {
s.echo("No xrefs are attached.");
}
Ok(r)
}
fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
let option = str_param(p, "option").unwrap_or("?").trim().to_ascii_lowercase();
let host = host(s);
+10 -2
View File
@@ -13,16 +13,19 @@ pub enum Panel {
ToolSets,
Layers,
Properties,
#[serde(rename = "externalReferences")]
ExternalRefs,
}
impl Panel {
pub const ALL: &[Self] = &[Self::Canvas, Self::ToolSets, Self::Layers, Self::Properties];
pub const ALL: &[Self] = &[Self::Canvas, Self::ToolSets, Self::Layers, Self::Properties, Self::ExternalRefs];
pub fn id(self) -> &'static str {
match self {
Self::Canvas => "canvas",
Self::ToolSets => "toolSets",
Self::Layers => "layers",
Self::Properties => "properties",
Self::ExternalRefs => "externalReferences",
}
}
fn label(self) -> &'static str {
@@ -31,6 +34,7 @@ impl Panel {
Self::ToolSets => "Tool Sets",
Self::Layers => "Layers",
Self::Properties => "Properties",
Self::ExternalRefs => "External References",
}
}
fn default_side(self) -> Zone {
@@ -39,6 +43,7 @@ impl Panel {
Self::ToolSets => Zone::Left,
Self::Layers => Zone::Right,
Self::Properties => Zone::Right,
Self::ExternalRefs => Zone::Right,
}
}
fn parse(id: &str) -> Result<Self, String> {
@@ -349,6 +354,9 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
crate::shortcut_menus::palette(app, ui, crate::palettes::properties_section, crate::shortcut_menus::properties_entries)
});
}
Panel::ExternalRefs => {
egui::ScrollArea::both().id_salt("dock-xrefs").auto_shrink([false, false]).show(ui, |ui| crate::xrefs::section(app, ui));
}
},
);
// Existing panel close buttons and commands still own their visibility switches.
@@ -377,7 +385,7 @@ fn panel_limits(panel: &Panel) -> PanelLimits {
let (min, max_width) = match panel {
Panel::Canvas => (egui::vec2(300.0, 180.0), 1_000_000.0),
Panel::ToolSets => (egui::vec2(220.0, 150.0), 480.0),
Panel::Layers | Panel::Properties => (egui::vec2(220.0, 100.0), 720.0),
Panel::Layers | Panel::Properties | Panel::ExternalRefs => (egui::vec2(220.0, 100.0), 720.0),
};
PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) }
}
+1
View File
@@ -63,6 +63,7 @@ fn ukrainian_covers_commands_menus_tools_and_snap_labels() {
fn every_marked_ui_literal_has_a_translation() {
let catalog = catalog("uk").unwrap();
let sources = [
include_str!("../xrefs.rs"),
include_str!("../canvas.rs"),
include_str!("../chrome.rs"),
include_str!("../cmdline.rs"),
+23
View File
@@ -4,6 +4,29 @@
# UTF-8: context<TAB>English source<TAB>Ukrainian translation.
# Empty context = ordinary label; @plural = one|few|many forms with {n}.
# Keep command IDs, keywords, technology/product names and user data unchanged.
# External References palette (#525).
Apply Застосувати
Attach DWG... Приєднати DWG...
Bind Вбудувати
Detach Від'єднати
Loaded Завантажено
Make it a block of this drawing, keeping its layer names Зробити блоком цього креслення зі збереженням назв шарів
Make it a block of this drawing; its layers become NAME$0$LAYER Зробити блоком цього креслення; його шари стануть NAME$0$LAYER
No external references are attached. Зовнішні посилання не приєднано.
Not Found Не знайдено
Not loaded Не завантажено
Open Відкрити
Overlay Накладення
Reference Manager Диспетчер посилань
Reference Name Ім'я посилання
References: {n} Посилань: {n}
Reload Оновити
Reload All Оновити всі
Saved Path Збережений шлях
Unload Вивантажити
Unloaded Вивантажено
Unreferenced Без посилань
Unresolved Не розв'язано
Cancel Скасувати
OK Гаразд
or [ або [
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
4 # UTF-8: context<TAB>English source<TAB>Ukrainian translation.
5 # Empty context = ordinary label; @plural = one|few|many forms with {n}.
6 # Keep command IDs, keywords, technology/product names and user data unchanged.
7 # External References palette (#525).
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
+2
View File
@@ -19,6 +19,8 @@ pub mod control;
pub mod credits;
pub mod dialogs;
pub mod docking;
// The External References palette.
pub mod xrefs;
pub mod dyninput;
pub mod gpu;
+3
View File
@@ -69,6 +69,9 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option<Resu
}
fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
if let Some(r) = crate::xrefs::route(app, id, params) {
return Some(r);
}
if let Some(r) = crate::managers::route(app, id, params) {
return Some(r);
}
+257
View File
@@ -0,0 +1,257 @@
//! The External References palette: the drawing's xrefs with their status, type and path, and
//! Attach, Reload, Unload, Detach, Bind, Path and Open. A thin view over the engine's XREF
//! commands (`xref`, `-xref`, `xattach`, `xopen`): it reads the drawing and acts through
//! [`CadApp::run`] / [`CadApp::start`].
use egui::RichText;
use serde_json::{Value, json};
use crate::CadApp;
use crate::docking::Panel;
/// XREF typed or chosen from a menu opens this palette; XATTACH there asks for the file first
/// (with the host's file picker, where there is one). JSON calls (with parameters) run the
/// engine's commands as they are.
pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
if !params.is_null() {
return None;
}
match cadcraft_engine::cmd::resolve_alias(id).as_str() {
"xref" => Some(open_palette(app)),
"xattach" => {
let picked = app.services.pick_open.as_ref().and_then(|f| f())?;
let path = relative_to_host(app, &picked);
if let Err(e) = app.session.start("xattach") {
return Some(Err(e.to_string()));
}
app.cmdline(&path);
Some(Ok(Value::Null))
}
_ => None,
}
}
/// Show the palette. The first time it docks below the Layers palette (where the palettes column
/// is); after that it comes back where it was.
fn open_palette(app: &mut CadApp) -> Result<Value, String> {
let panel = Panel::ExternalRefs;
let first = !app.ui.docking.layout.contains(&panel) && !app.ui.docking.hidden.iter().any(|(p, _)| *p == panel);
let r = crate::docking::command(app, false, &json!({ "operation": "open", "panel": panel.id() }))?;
if first && app.ui.docking.layout.contains(&Panel::Layers) {
let moved = json!({ "operation": "move", "panel": panel.id(), "target": Panel::Layers.id(), "zone": "bottom" });
if let Ok(r) = crate::docking::command(app, false, &moved) {
return Ok(r);
}
}
Ok(r)
}
/// A picked file inside the host drawing's folder is attached by its relative path, so the
/// project folder can move.
fn relative_to_host(app: &CadApp, picked: &str) -> String {
let host = app.session.state().ok().and_then(|st| st.path.clone());
let dir = host.as_deref().and_then(|h| std::path::Path::new(h).parent());
match dir.and_then(|d| std::path::Path::new(picked).strip_prefix(d).ok()) {
Some(rel) if !rel.as_os_str().is_empty() => rel.to_string_lossy().into_owned(),
_ => picked.to_string(),
}
}
/// One xref as the palette lists it.
#[derive(Clone, Debug, PartialEq)]
struct Row {
name: String,
status: String,
overlay: bool,
path: String,
references: usize,
}
/// The xrefs of the active drawing (cached per drawing revision: counting references walks it).
fn rows(app: &CadApp, ui: &egui::Ui) -> Vec<Row> {
let Ok(st) = app.session.state() else { return Vec::new() };
let key = (st.uid, st.revision);
let id = egui::Id::new("xref-rows");
if let Some((k, rows)) = ui.data(|d| d.get_temp::<((u64, u64), Vec<Row>)>(id))
&& k == key
{
return rows;
}
let d = &st.doc;
let xrefs: Vec<_> = d.blocks.values().filter(|b| b.xref_path.is_some() && !b.name.contains('|')).collect();
let mut counts = vec![0usize; xrefs.len()];
if !xrefs.is_empty() {
let mut count = |store: &cadcraft_engine::doc::EntityStore| {
for e in store.iter() {
if let cadcraft_engine::doc::EntityKind::Insert(i) = &e.kind
&& let Some(k) = xrefs.iter().position(|b| b.name.eq_ignore_ascii_case(&i.block))
&& let Some(c) = counts.get_mut(k)
{
*c += 1;
}
}
};
count(&d.model);
d.layouts.iter().for_each(|l| count(&l.entities));
d.blocks.values().filter(|b| !b.name.contains('|')).for_each(|b| count(&b.entities));
}
let rows: Vec<Row> = xrefs
.iter()
.zip(counts)
.map(|(b, references)| {
let loaded = b.xref_status == cadcraft_engine::doc::XrefStatus::Loaded;
Row {
name: b.name.clone(),
status: if loaded && references == 0 { "Unreferenced".into() } else { b.xref_status.label().into() },
overlay: b.xref_overlay,
path: b.xref_path.clone().unwrap_or_default(),
references,
}
})
.collect();
ui.data_mut(|d| d.insert_temp(id, (key, rows.clone())));
rows
}
/// What a click in the palette asks for, run once the palette is drawn.
enum Act {
Start(&'static str),
Run(&'static str, Value),
}
/// The palette body.
pub fn section(app: &mut CadApp, ui: &mut egui::Ui) {
let t = crate::theme::Tokens::get();
let rows = rows(app, ui);
let sel_id = egui::Id::new("xref-selected");
let path_id = egui::Id::new("xref-path-edit");
let mut selected: Option<String> = ui.data(|d| d.get_temp(sel_id));
if !selected.as_ref().is_some_and(|s| rows.iter().any(|r| &r.name == s)) {
selected = rows.first().map(|r| r.name.clone());
}
let mut act = None;
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.add_space(8.0);
if ui.button(crate::tl!("Attach DWG...")).clicked() {
act = Some(Act::Start("xattach"));
}
if ui.add_enabled(!rows.is_empty(), egui::Button::new(crate::tl!("Reload All"))).clicked() {
act = Some(Act::Run("-xref", json!({ "option": "reload", "names": "*" })));
}
});
ui.add_space(6.0);
if rows.is_empty() {
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.label(RichText::new(crate::tl!("No external references are attached.")).color(t.text_dim));
});
} else {
ui.horizontal(|ui| {
ui.add_space(8.0);
egui::Grid::new("xref-grid").num_columns(4).striped(true).spacing([10.0, 4.0]).show(ui, |ui| {
for h in [crate::tl!("Reference Name"), crate::tl!("Status"), crate::tl!("Type"), crate::tl!("Saved Path")] {
ui.label(RichText::new(h).color(t.text_dim));
}
ui.end_row();
for r in &rows {
let on = selected.as_deref() == Some(r.name.as_str());
if ui.selectable_label(on, &r.name).clicked() {
selected = Some(r.name.clone());
ui.data_mut(|d| d.remove::<String>(path_id));
}
let bad = matches!(r.status.as_str(), "Not Found" | "Unresolved");
ui.label(RichText::new(crate::i18n::t(&r.status)).color(if bad { t.warn } else { t.text }));
ui.label(crate::i18n::t(if r.overlay { "Overlay" } else { "Attach" }));
ui.label(&r.path).on_hover_text(crate::tf!("References: {n}", n = r.references));
ui.end_row();
}
});
});
}
ui.data_mut(|d| match &selected {
Some(s) => {
d.insert_temp(sel_id, s.clone());
}
None => d.remove::<String>(sel_id),
});
if let Some(r) = selected.as_ref().and_then(|s| rows.iter().find(|r| &r.name == s)) {
ui.add_space(8.0);
ui.separator();
details(ui, r, path_id, &mut act);
}
match act {
Some(Act::Start(id)) => app.start(id),
Some(Act::Run(id, p)) => {
if let Err(e) = app.run(id, p) {
app.set_status(e);
}
}
None => {}
}
}
/// The selected xref: its actions, type and path.
fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>) {
let name = r.name.clone();
let loaded = r.status == "Loaded" || r.status == "Unreferenced";
let op = |option: &str| json!({ "option": option, "names": name });
ui.horizontal_wrapped(|ui| {
ui.add_space(8.0);
if ui.button(crate::tl!("Reload")).clicked() {
*act = Some(Act::Run("-xref", op("reload")));
}
if ui.add_enabled(r.status != "Unloaded", egui::Button::new(crate::tl!("Unload"))).clicked() {
*act = Some(Act::Run("-xref", op("unload")));
}
if ui.button(crate::tl!("Detach")).clicked() {
*act = Some(Act::Run("-xref", op("detach")));
}
if ui
.add_enabled(loaded, egui::Button::new(crate::tl!("Bind")))
.on_hover_text(crate::tl!("Make it a block of this drawing; its layers become NAME$0$LAYER"))
.clicked()
{
*act = Some(Act::Run("-xref", op("bind")));
}
if ui
.add_enabled(loaded, egui::Button::new(crate::tl!("Insert")))
.on_hover_text(crate::tl!("Make it a block of this drawing, keeping its layer names"))
.clicked()
{
*act = Some(Act::Run("-xref", json!({ "option": "bind", "names": name, "insert": true })));
}
if ui.add_enabled(r.status != "Not Found", egui::Button::new(crate::tl!("Open"))).clicked() {
*act = Some(Act::Run("xopen", json!({ "name": name })));
}
});
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.label(crate::tl!("Type"));
let mut overlay = r.overlay;
let a = ui.radio_value(&mut overlay, false, crate::tl!("Attach")).clicked();
let o = ui.radio_value(&mut overlay, true, crate::tl!("Overlay")).clicked();
if (a || o) && overlay != r.overlay {
*act = Some(Act::Run("-xref", json!({ "option": "type", "names": name, "overlay": overlay })));
}
});
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.label(crate::tl!("Saved Path"));
let mut text: String = ui.data(|d| d.get_temp(path_id)).unwrap_or_else(|| r.path.clone());
let w = (ui.available_width() - 70.0).max(60.0);
let edit = ui.add(egui::TextEdit::singleline(&mut text).desired_width(w));
let apply = ui.add_enabled(!text.trim().is_empty() && text != r.path, egui::Button::new(crate::tl!("Apply"))).clicked()
|| (edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && text != r.path);
if apply {
*act = Some(Act::Run("-xref", json!({ "option": "path", "names": name, "path": text.trim() })));
ui.data_mut(|d| d.remove::<String>(path_id));
} else if text != r.path {
ui.data_mut(|d| d.insert_temp(path_id, text));
} else {
ui.data_mut(|d| d.remove::<String>(path_id));
}
});
}
+82
View File
@@ -0,0 +1,82 @@
//! The External References palette (#525): XREF opens it; its rows and buttons act through the
//! engine's -XREF commands.
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{IoHooks, set_io};
use cadcraft_engine::doc::geom::Vec3;
use cadcraft_engine::doc::{Drawing, Entity, EntityKind, Handle, Layer, Line, XrefStatus};
use cadcraft_ui_egui::{CadApp, Services, docking::Panel};
use egui::vec2;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
/// Test drawings: any file holds one line on layer Walls.
fn read(_bytes: &[u8], _name: &str) -> Result<Drawing, String> {
let mut d = Drawing::new_metric();
d.layers.push(Layer { name: "Walls".into(), ..Layer::default() });
let mut e = Entity::new(Handle(0x60), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(50.0, 20.0, 0.0) }));
e.common.layer = "Walls".into();
d.model.push(e);
Ok(d)
}
fn site_file() -> String {
let dir = std::env::temp_dir().join(format!("cadcraft-xref-palette-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("site.dxf");
std::fs::write(&p, "site").unwrap();
p.to_string_lossy().into_owned()
}
fn status(app: &CadApp) -> Option<XrefStatus> {
app.session.doc().ok()?.block("site").map(|b| b.xref_status)
}
#[test]
fn xref_opens_the_palette_and_its_buttons_manage_the_xref() {
set_io(IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None });
let mut app = CadApp::new(Session::default(), Services::default());
app.run("xattach", json!({ "path": site_file(), "at": [10, 10] })).unwrap();
assert_eq!(status(&app), Some(XrefStatus::Loaded));
// Typed (by its alias) it opens the palette instead of listing.
assert!(app.cmdline("XR").is_none());
assert!(app.ui.docking.layout.contains(&Panel::ExternalRefs));
app.ui.in_window_menu = false;
let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_ui_state(
|ui, app: &mut CadApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
app,
);
h.run_steps(4);
h.get_by_label("site");
h.get_by_label("Loaded");
h.get_by_label("Unload").click();
h.run_steps(3);
assert_eq!(status(h.state()), Some(XrefStatus::Unloaded));
h.get_by_label("Unloaded");
h.get_by_label("Reload").click();
h.run_steps(3);
assert_eq!(status(h.state()), Some(XrefStatus::Loaded));
h.get_by_label("Overlay").click();
h.run_steps(3);
assert!(h.state().session.doc().unwrap().block("site").unwrap().xref_overlay);
h.get_by_label("Detach").click();
h.run_steps(3);
assert_eq!(status(h.state()), None);
h.get_by_label("No external references are attached.");
// Undo brings it back, and the palette lists it again.
h.state_mut().session.undo().unwrap();
h.run_steps(3);
h.get_by_label("site");
}
#[test]
fn json_xref_lists_and_never_opens_the_palette() {
let mut app = CadApp::new(Session::default(), Services::default());
let r = app.run("xref", json!({})).unwrap();
assert_eq!(r["xrefs"], json!([]));
assert!(!app.ui.docking.layout.contains(&Panel::ExternalRefs));
}
+1 -1
View File
@@ -65,7 +65,7 @@ file before restarting to resume saving workspace choices.
Tool Sets, Layers, and Properties can be regrouped, split, floated within the app window, closed, and reopened. The drawing canvas stays visible and cannot be closed or turned into a tab. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents.
Panel IDs: `toolSets`, `layers`, `properties`. The protected center is `canvas`.
Panel IDs: `toolSets`, `layers`, `properties`, `externalReferences`. The protected center is `canvas`.
`ui.dock` accepts `operation`; basic per-panel operations also require `panel`. The advanced `resizeSplit` operation below uses a split `path` instead of a panel. Basic operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout.
+1 -1
View File
@@ -29,7 +29,7 @@ do them first.
| # | Gap | Evidence | User impact | Est. |
|---|---|---|---|---|
| F1 | **[alpha blocker]** Prompt options print "not available yet": PEDIT Edit vertex Break/Regen/Tangent, some SPLINEDIT options; MTEXT Dynamic/Static columns (no column model). SPLINE, MTEXT and MLEADER options done (#408): closed splines keep but ignore a fit tolerance, SPLINE Object fits the polyline's vertices (no spline frame is kept) (TRIM/EXTEND, PLINE arc mode and Length, XLINE done (#397); OFFSET, ROTATE and FILLET options done earlier) | `modify2.rs:594,783,806`, `cmd/mtext_opts.rs` | Medium: the most-used commands are done | 3–6 |
| F2 | **[alpha blocker]** No xrefs (XATTACH, XREF palette, REFEDIT, BIND, XCLIP, overlay, paths); DXF xref blocks read with an empty path | `dxf_read.rs:923` | High: multi-file projects | 30–45 |
| F2 | XREF editing in place and clipping: REFEDIT and XCLIP are missing. XATTACH, the External References palette, -XREF (Bind/Detach/Path/Unload/Reload/Overlay), XBIND, XOPEN, overlays, relative paths and loading at open are done (#525). Unloaded state and xref-layer overrides last only for the session | `cmd/blocks/xref.rs` | Medium: xrefs can't be edited in place or clipped | 15–25 |
| F3 | **[alpha blocker]** (block editor and REFEDIT; dynamic-block authoring is beta) No block editor (BEDIT) or dynamic blocks (parameters, actions, visibility, lookup); dynamic blocks from other files lose their behaviour | issue #185 offers help | High: standard libraries are dynamic | 50–75 |
| F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no AUDITCTL `.adt` log and no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 3–5 |
| F5 | Arrays are not associative: no ARRAYEDIT; ARRAY/ARRAYRECT/ARRAYPOLAR edit the array in an option loop, then create plain copies (ASsociative, Levels, polar ROWs and grip editing print "not available yet") | `cmd/array.rs` | Medium-high | 8–12 |