Files
OneCAD-Draft/src/ui/window/xref_manager.rs
T
Karim Jerbi 7bbc5b0319 fix(ui): explain silent anno-scale toggles, theme xref empty state
AnnoObjectScaleToggle had three silent no-op paths (no target, locked layer, unknown scale); each now pushes command-line info mirroring the open-path guards. Xref empty-state hardcoded grey now uses muted_style. DD_BOUNDS eviction and attr-clamp changes investigated and deliberately dropped (no reachable defect in either). Tests: anno_scale_toggle_tests cover all three paths (RED: silence). Full suite: 2032 passed / 0 failed / 25 ignored, zero warnings.
2026-10-11 02:17:18 +01:00

2686 lines
102 KiB
Rust

//! Reference Manager palette — list of external references with operations.
//!
//! Task 8b build: the toolbar's mutating affordances are wired to the same
//! engine fns the CLI uses, operating on the multi-selection with per-item
//! report lines. Session state (unloaded set, stat cache) lives per-tab on
//! `DocumentTab`; this panel only caches display rows plus text inputs.
use crate::app::Message;
use crate::io::xref::collect_entries_with_prev;
use crate::io::xref_model::{normalize_lexical, Pathtype, RefKind, RefStatus, RefType, ReferenceEntry};
use crate::ui::ROW_H;
use crate::ui::style::common::muted_style;
use crate::ui::style::row_button::cell_button_style;
use codec::CadDocument;
use iced::widget::{button, column, container, mouse_area, row, scrollable, text, tooltip};
use iced::Padding;
use iced::{Background, Border, Element, Fill, Length, Theme};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
/// `true` on web builds, where the filesystem and native file pickers do not
/// exist. Mutating affordances stay compiled but render disabled; the
/// read-only list renders everywhere.
const IS_WASM: bool = cfg!(target_arch = "wasm32");
/// Font size for table cells (mirrors `layers.rs`).
const FONT_SZ: f32 = ROW_H * 0.42; // ≈11 px at ROW_H=26
/// Fixed table height so the details pane below keeps stable space.
const TABLE_H: f32 = 480.0;
/// Minimum table area height under split dragging.
const TABLE_MIN_H: f32 = 120.0;
/// Minimum table content width so columns stay readable in narrow docks.
const TABLE_MIN_W: f32 = 480.0;
/// Inter-column gutter shared by header, rows, and grab zones so columns align.
const COL_GUTTER: f32 = 6.0;
/// Tree-mode text size: a step above the table cells, like the
/// industry tree.
/// Indent per tree depth level.
const INDENT_W: f32 = 16.0;
/// Dashed vertical tree guide (one per ancestor level), stretched to the row.
const TREE_GUIDE_SVG: &[u8] = b"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 16 100\" preserveAspectRatio=\"none\"><line x1=\"8\" y1=\"0\" x2=\"8\" y2=\"100\" stroke=\"#808080\" stroke-width=\"1.5\" stroke-dasharray=\"4,4\"/></svg>";
/// Shared parsed handle for [`TREE_GUIDE_SVG`] (parsed once, not per row).
fn tree_guide_handle() -> iced::widget::svg::Handle {
static HANDLE: std::sync::OnceLock<iced::widget::svg::Handle> = std::sync::OnceLock::new();
HANDLE
.get_or_init(|| iced::widget::svg::Handle::from_memory(TREE_GUIDE_SVG))
.clone()
}
/// Indent cells for `depth` ancestor levels: dashed guides explaining the
/// hierarchy, same total width as the old blank indent.
fn tree_indent(depth: u32) -> iced::widget::Row<'static, crate::app::Message> {
let mut row = iced::widget::Row::new().spacing(0);
for _ in 0..depth {
row = row.push(
container(
iced::widget::svg(tree_guide_handle())
.width(Length::Fixed(INDENT_W))
.height(Fill),
)
.align_y(iced::Center),
);
}
row
}
const TREE_FONT_SZ: f32 = 13.0;
/// Longest edge of a preview image, in pixels.
const PREVIEW_MAX: u32 = 256;
/// One visible row in [`XrefManagerPanel::display_rows`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DisplayRow {
/// Index into [`XrefManagerPanel::entries`].
pub index: usize,
/// Tree depth (0 in list mode and for roots).
pub depth: u32,
/// True for entries enumerated from a nested file (never rendered).
pub is_nested: bool,
}
/// Display-row index reserved for the host-drawing pseudo-row (list first
/// row, tree root). It addresses no entry: row rendering special-cases it
/// and selection ignores it.
pub const HOST_ROW: usize = usize::MAX;
/// Palette state for the Reference Manager.
///
/// Session state (unloaded set, stat cache) lives per-tab on `DocumentTab`
/// and is passed into [`refresh`](XrefManagerPanel::refresh); this panel
/// only caches display rows, expansion, selection, and text inputs.
#[derive(Debug, Clone)]
pub struct XrefManagerPanel {
/// Cached [`collect_entries_with_prev`] output for the active drawing.
pub entries: Vec<ReferenceEntry>,
/// Display name of the host drawing (first list row / tree root).
pub host_name: String,
/// Absolute path of the host drawing, if saved (host row Saved Path).
pub host_path: String,
/// Multi-selected entry indices — consumed by batch path ops.
pub selected: HashSet<usize>,
/// Anchor entry driving the details pane (last row clicked).
pub anchor: Option<usize>,
/// List (`false`) vs tree (`true`) presentation.
pub tree: bool,
/// Parent keys expanded in tree mode. Empty by default = collapsed.
pub expanded: HashSet<u64>,
/// Parent key → child entry indices, rebuilt on every refresh.
pub children: HashMap<u64, Vec<usize>>,
/// Entry indices enumerated from nested files (not the host drawing).
pub nested: HashSet<usize>,
/// Document tab id that produced `entries` (stale check).
pub source_tab_id: Option<u64>,
/// `edit_revision` that produced `entries` — the palette auto-rescans
/// when the tab's revision moves (reuses the undo-snapshot counter, so
/// CLI and palette mutations both trip it).
pub source_edit_revision: u64,
/// Reference-table column widths: Reference, Status, Size, Type, Date,
/// Saved Path. Draggable via the header dividers (clamped).
pub col_widths: [f32; 6],
/// Reference-table area height. Draggable via the split divider above
/// the Details/Preview pane (clamped).
pub table_h: f32,
/// Open dropdown menus (one at a time; dismissed together).
pub attach_open: bool,
pub refresh_open: bool,
pub path_open: bool,
pub row_change_path_open: bool,
/// Decoded preview images keyed by `(entry key, resolved path)` — rebuilt
/// on every refresh for the anchor entry only, so the per-frame `view`
/// stays pure and file I/O never happens during rendering.
pub previews: HashMap<(u64, String), iced::widget::image::Handle>,
/// Details (`false`) vs Preview (`true`) lower pane.
pub show_preview: bool,
}
/// Default reference-table column widths: Reference, Status, Size, Type,
/// Date, Saved Path.
pub const DEFAULT_COL_WIDTHS: [f32; 6] = [150.0, 104.0, 76.0, 76.0, 96.0, 200.0];
/// Narrowest any reference-table column drags to.
pub const COL_MIN_W: f32 = 48.0;
/// Widest any reference-table column drags to.
pub const COL_MAX_W: f32 = 420.0;
impl Default for XrefManagerPanel {
fn default() -> Self {
Self {
entries: Vec::new(),
host_name: String::new(),
host_path: String::new(),
selected: HashSet::new(),
anchor: None,
tree: false,
expanded: HashSet::new(),
children: HashMap::new(),
nested: HashSet::new(),
source_tab_id: None,
source_edit_revision: 0,
col_widths: DEFAULT_COL_WIDTHS,
table_h: TABLE_H,
attach_open: false,
refresh_open: false,
path_open: false,
row_change_path_open: false,
previews: HashMap::new(),
show_preview: false,
}
}
}
/// Row-pick modifier mode for [`XrefManagerPanel::click_select`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SelectExtend {
/// Plain click: exactly one row selected.
Single,
/// Ctrl/Cmd-click: toggle one row.
Toggle,
/// Shift-click: contiguous range from the anchor.
Range,
}
/// Selection-gated palette operation the toolbar offers.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum XrefPaletteOp {
Open,
Detach,
Unload,
Reload,
Bind,
Overlay,
Attach,
Pathtype(Pathtype),
}
impl XrefManagerPanel {
/// Rebuild `entries` from `doc` via [`collect_entries_with_prev`] and
/// recompute the parent → children linkage for tree mode. Selection/anchor
/// survive by `(key, saved_path)` identity; dead indices are dropped.
///
/// `unloaded`/`prev` are the active tab's session sets, so CLI and
/// palette agree. Returns fresh `(key, mtime)` baselines for every
/// `Loaded` entry — the caller writes them into the tab's stat cache,
/// which makes `Stale` detectable from the second refresh on.
/// Wasm-safe by construction: the only I/O is `collect_entries`' path
/// stat plus `load_file` for nested enumeration, both already wasm-safe.
pub fn refresh(
&mut self,
doc: &CadDocument,
base_dir: &Path,
unloaded: &HashSet<crate::io::xref_model::UnloadKey>,
prev: &HashMap<u64, SystemTime>,
host_name: &str,
host_path: &str,
) -> Vec<(u64, SystemTime)> {
self.host_name = host_name.to_string();
self.host_path = host_path.to_string();
let old = std::mem::take(&mut self.entries);
let sel_ids: HashSet<(u64, String)> = self
.selected
.iter()
.filter_map(|&i| old.get(i))
.map(|e| (e.key, e.saved_path.clone()))
.collect();
let anchor_id: Option<(u64, String)> = self
.anchor
.and_then(|i| old.get(i))
.map(|e| (e.key, e.saved_path.clone()));
let entries = collect_entries_with_prev(doc, base_dir, unloaded, prev);
// Direct references, keyed by (handle, saved path) exactly as
// `collect_entries` emits them. The saved path disambiguates a nested
// entry whose foreign handle happens to collide with a host handle.
let direct_ids = direct_identities(doc);
let mut nested = HashSet::new();
for (i, e) in entries.iter().enumerate() {
if !direct_ids.contains(&(e.key, e.saved_path.clone())) {
nested.insert(i);
}
}
// Parent linkage: each loaded drawing root's file is opened read-only
// and its direct reference paths are matched (by lexical identity)
// against nested entries. First parent wins; unmatchable nested
// entries stay unclaimed and render as roots so nothing vanishes.
let mut children: HashMap<u64, Vec<usize>> = HashMap::new();
let mut claimed: HashSet<usize> = HashSet::new();
let direct_order: Vec<usize> = entries
.iter()
.enumerate()
.filter(|(i, _)| !nested.contains(i))
.map(|(i, _)| i)
.collect();
for &pi in &direct_order {
let parent = &entries[pi];
if parent.kind != RefKind::DwgXref
|| parent.status != RefStatus::Loaded
|| parent.found_at.is_none()
{
continue;
}
let found = parent.found_at.as_deref().unwrap_or("");
let child_paths: HashSet<String> = match crate::io::load_file(Path::new(found)) {
Ok(nested_doc) => nested_doc
.block_records
.iter()
.filter(|br| br.flags.is_xref || br.flags.is_xref_overlay)
.map(|br| normalize_lexical(&br.xref_path))
.collect(),
Err(_) => continue,
};
for (ci, child) in entries.iter().enumerate() {
if !nested.contains(&ci) || claimed.contains(&ci) {
continue;
}
if child_paths.contains(&normalize_lexical(&child.saved_path)) {
children.entry(parent.key).or_default().push(ci);
claimed.insert(ci);
}
}
}
let live_keys: HashSet<u64> = entries.iter().map(|e| e.key).collect();
self.entries = entries;
self.nested = nested;
self.children = children;
self.selected = self
.entries
.iter()
.enumerate()
.filter(|(_, e)| sel_ids.contains(&(e.key, e.saved_path.clone())))
.map(|(i, _)| i)
.collect();
self.anchor = anchor_id.and_then(|(k, p)| {
self.entries
.iter()
.position(|e| e.key == k && e.saved_path == p)
});
// Previews decode here and on selection change (view stays pure):
// the anchor entry only, and only under single selection.
self.previews.clear();
self.ensure_preview_for_anchor();
self.expanded.retain(|k| live_keys.contains(k));
self.entries
.iter()
.filter(|e| e.status == RefStatus::Loaded)
.filter_map(|e| e.modified.map(|m| (e.key, m)))
.collect()
}
/// Click handling for row picks with normal GUI list semantics:
/// - plain click selects exactly one row (previous selection clears);
/// - Ctrl/Cmd-click toggles one row;
/// - Shift-click extends a contiguous range from the anchor (or the
/// clicked row when there is no anchor yet).
/// Tree mode always single-selects per spec, whatever the modifiers.
pub fn click_select(&mut self, index: usize, extend: SelectExtend) {
if index == HOST_ROW || index >= self.entries.len() {
return;
}
if self.tree {
// Tree view selects a single file reference at a time.
if self.selected.contains(&index) {
return;
}
self.selected.clear();
self.selected.insert(index);
self.anchor = Some(index);
self.ensure_preview_for_anchor();
return;
}
match extend {
SelectExtend::Single => {
self.selected.clear();
self.selected.insert(index);
self.anchor = Some(index);
}
SelectExtend::Toggle => {
if !self.selected.remove(&index) {
self.selected.insert(index);
self.anchor = Some(index);
} else if self.anchor == Some(index) {
self.anchor = self.selected.iter().copied().max();
}
}
SelectExtend::Range => {
// The anchor stays fixed across extends: each Shift-click
// re-ranges from the original anchor (plain click moves it).
// Moving it every time collapsed the range and dropped rows.
let from = self.anchor.unwrap_or(index);
let (lo, hi) = if from <= index { (from, index) } else { (index, from) };
self.selected.clear();
self.selected
.extend((lo..=hi).filter(|i| *i < self.entries.len()));
if self.anchor.is_none() {
self.anchor = Some(index);
}
}
}
self.ensure_preview_for_anchor();
}
/// Legacy toggle entry point: Ctrl-style toggle in list mode, single
/// select in tree mode. Kept for tests and non-pointer callers.
pub fn toggle_select(&mut self, index: usize) {
self.click_select(index, SelectExtend::Toggle);
}
/// Right-click selection: select the row alone when it is not already
/// selected (standard right-click behavior — the menu then acts on the
/// selection, which now includes this row). Keeps multi-selections when
/// right-clicking inside them.
pub fn right_click_select(&mut self, index: usize) {
if index == HOST_ROW || index >= self.entries.len() {
return;
}
if !self.selected.contains(&index) {
self.click_select(index, SelectExtend::Single);
} else {
self.ensure_preview_for_anchor();
}
}
/// Flip list/tree presentation.
pub fn toggle_tree(&mut self) {
self.tree = !self.tree;
}
fn ensure_preview_for_anchor(&mut self) {
if self.selected.len() > 1 {
return;
}
let Some(a) = self.anchor.and_then(|i| self.entries.get(i)) else {
return;
};
let Some(found) = a.found_at.clone() else {
return;
};
let key = (a.key, found.clone());
if self.previews.contains_key(&key) {
return;
}
if let Some(img) = reference_preview(a) {
let (w, h) = (img.width(), img.height());
self.previews
.insert(key, iced::widget::image::Handle::from_rgba(w, h, img.into_raw()));
}
}
/// Total table content width: column widths plus inter-column gutters.
/// Header, rows, and grab zones all share this geometry so columns align.
pub fn table_content_width(&self) -> f32 {
(self.col_widths.iter().sum::<f32>() + COL_GUTTER * 5.0).max(TABLE_MIN_W)
}
/// Apply a horizontal divider drag to column `i` (clamped). Called from
/// the dock drag handler with the pointer delta.
pub fn drag_col_by(&mut self, i: usize, dx: f32) {
if let Some(w) = self.col_widths.get_mut(i) {
*w = (*w + dx).clamp(COL_MIN_W, COL_MAX_W);
}
}
/// Apply a vertical split-divider drag to the table area height
/// (clamped). Called with the pointer delta.
pub fn drag_table_by(&mut self, dy: f32) {
self.table_h = (self.table_h + dy).clamp(TABLE_MIN_H, 1200.0);
}
/// Expand/collapse one tree parent (no-op for unknown keys).
pub fn toggle_expand(&mut self, parent_key: u64) {
if !self.expanded.remove(&parent_key) && self.children.contains_key(&parent_key) {
self.expanded.insert(parent_key);
}
}
/// True when the selection includes at least one nested row. Nested rows
/// stay read-only — every mutating button disables with a reason then.
pub fn selection_has_nested(&self) -> bool {
self.selected.iter().any(|i| self.nested.contains(i))
}
/// Selected entry indices that are directly actionable (nested rows
/// excluded — they have no host definition to mutate).
pub fn actionable_selection(&self) -> Vec<usize> {
let mut out: Vec<usize> = self
.selected
.iter()
.copied()
.filter(|i| *i < self.entries.len() && !self.nested.contains(i))
.collect();
out.sort_unstable();
out
}
/// Flat render order for the current mode. Tree mode emits roots
/// (direct entries plus unclaimed nested ones) with expanded children
/// inline, skipping repeats by normalized saved path so a cyclic closure
/// can never recurse on screen.
pub fn display_rows(&self) -> Vec<DisplayRow> {
// The host drawing leads in both modes (list first row, tree root).
let host = DisplayRow {
index: HOST_ROW,
depth: 0,
is_nested: false,
};
if !self.tree {
let mut rows = vec![host];
rows.extend(self.entries.iter().enumerate().map(|(index, _)| {
DisplayRow {
index,
depth: 0,
is_nested: self.nested.contains(&index),
}
}));
return rows;
}
fn append_tree(
panel: &XrefManagerPanel,
index: usize,
depth: u32,
seen: &mut HashSet<(String, String)>,
rows: &mut Vec<DisplayRow>,
) {
let Some(entry) = panel.entries.get(index) else { return; };
if !claim_path(seen, &entry.saved_path, &entry.name) {
return;
}
rows.push(DisplayRow {
index,
depth,
is_nested: panel.nested.contains(&index),
});
if !panel.expanded.contains(&entry.key) {
return;
}
if let Some(children) = panel.children.get(&entry.key) {
for &child in children {
append_tree(panel, child, depth + 1, seen, rows);
}
}
}
let mut rows = Vec::new();
let mut seen: HashSet<(String, String)> = HashSet::new();
// Roots first, then recursively expanded descendants.
for (index, _) in self.entries.iter().enumerate() {
let dominated = self
.children
.values()
.any(|kids| kids.contains(&index));
if self.nested.contains(&index) && dominated {
continue;
}
append_tree(self, index, 0, &mut seen, &mut rows);
}
// The host drawing leads: first list row, tree root. In tree mode
// everything below renders one level deeper as its descendants.
if self.tree {
for row in rows.iter_mut() {
row.depth += 1;
}
}
rows.insert(
0,
DisplayRow {
index: HOST_ROW,
depth: 0,
is_nested: false,
},
);
rows
}
/// Render the palette as a docked side panel (EXTERNALREFERENCES).
///
/// `missing` is the active tab's open-time NotFound count — a neutral
/// notice renders while non-zero. Mirrors the block palette's dock
/// chrome (title, pin, close) and sizing conventions.
pub fn view<'a>(
&'a self,
width: f32,
auto_collapse: bool,
missing: usize,
doc: &'a CadDocument,
) -> Element<'a, Message> {
use crate::ui::dock::PanelId;
let title_bar = crate::ui::dock::title_bar(
PanelId::ExternalReferences,
format!("{} ({})", crate::t!("External References").as_ref(), self.entries.len()),
auto_collapse,
);
// Table content width: column widths plus gutters, stretched to the
// dock when wider so rows fill the panel; narrower docks sidescroll.
let table_w = (width - 16.0).max(self.table_content_width());
// ── Toolbar: split-button groups per spec ───────────────────────
// Attach ▾ (default DWG), Refresh ▾ (default Refresh), Change Path ▾.
// The main button runs the default; the triangle opens the rest in an
// overlay menu. Formats without an attach command in this build
// (DWF/DGN/point clouds/coordination models) are omitted, not dead.
let web_tip = crate::t!("File attach is not available on web — the reference list below is read-only.").into_owned();
let attach = if IS_WASM {
toolbar_tip(crate::t!("Attach DWG").into_owned(), web_tip.clone(), false)
} else {
split_button(
crate::t!("Attach DWG").into_owned(),
Some(Message::XAttachPick),
self.attach_open,
Message::XrefManagerAttachMenu,
vec![
menu_item(
crate::t!("Attach Image").into_owned(),
Some(Message::ImagePick),
None,
),
menu_item(
crate::t!("Embed Image (in drawing)").into_owned(),
Some(Message::ImageEmbedPick),
None,
),
menu_item(
crate::t!("Attach PDF").into_owned(),
Some(Message::PdfAttachPick),
None,
),
],
)
};
let refresh = if IS_WASM {
toolbar_tip(crate::t!("Refresh").into_owned(), web_tip.clone(), false)
} else {
split_button(
crate::t!("Refresh").into_owned(),
Some(Message::XrefManagerRefresh),
self.refresh_open,
Message::XrefManagerRefreshMenu,
vec![menu_item(
crate::t!("Reload All References").into_owned(),
Some(Message::XrefManagerReloadAll),
None,
)],
)
};
// List / Tree view buttons with active-state coloring: the current
// mode renders in the primary accent (blue), the other is clickable.
let mode_btn = |label: String, active: bool| -> Element<'static, Message> {
let msg = if active {
None
} else {
Some(Message::XrefManagerToggleTree)
};
let mut b = button(text(label).size(11))
.style(move |theme: &Theme, status| {
let palette = theme.palette();
if active {
button::Style {
background: Some(Background::Color(palette.primary.base.color)),
border: Border {
radius: 3.0.into(),
color: palette.primary.base.color,
width: 1.0,
},
text_color: palette.primary.base.text,
..Default::default()
}
} else {
let pair = match status {
button::Status::Hovered | button::Status::Pressed => {
palette.background.strong
}
_ => palette.background.weak,
};
button::Style {
background: Some(Background::Color(pair.color)),
border: Border {
radius: 3.0.into(),
color: palette.background.neutral.color,
width: 1.0,
},
text_color: pair.text,
..Default::default()
}
}
})
.padding([4, 10]);
if let Some(m) = msg {
b = b.on_press(m);
}
b.into()
};
let list_btn = mode_btn(crate::t!("List").into_owned(), !self.tree);
let tree_btn = mode_btn(crate::t!("Tree").into_owned(), self.tree);
// Reference operations (Detach/Unload/Reload/Bind/Overlay/Attach)
// live in the per-row right-click menu, not the toolbar.
// Change Path group: greyed until a reference whose path can change
// (a direct, non-nested row) is selected. Each option carries its own
// gate so non-executable choices render greyed with the reason.
let has_direct = self
.entries
.iter()
.enumerate()
.any(|(i, _)| self.selected.contains(&i) && !self.nested.contains(&i));
let single_direct_anchor = self.anchor.is_some_and(|a| {
self.selected.len() == 1
&& self.entries.get(a).is_some()
&& !self.nested.contains(&a)
});
let host_saved = !self.host_path.is_empty();
let web_readonly: Option<String> = IS_WASM.then(|| {
crate::t!("Reference changes are not available on web — the reference list is read-only.").into_owned()
});
let path_group_gate: Option<String> = web_readonly.clone().or_else(|| {
if has_direct {
None
} else if self.selected.is_empty() {
Some(crate::t!("Select a reference first.").into_owned())
} else {
Some(
crate::t!("Nested references are read-only — edit them in their host drawing.")
.into_owned(),
)
}
});
let change_path = match path_group_gate {
Some(reason) => toolbar_tip(crate::t!("Change Path").into_owned(), reason, false),
None => {
let relative_gate = if host_saved {
None
} else {
Some(
crate::t!("XREF Save the drawing first to resolve relative XREF paths.")
.into_owned(),
)
};
let new_path_gate = if single_direct_anchor {
None
} else {
Some(crate::t!("Select a single reference first.").into_owned())
};
split_button(
crate::t!("Change Path").into_owned(),
Some(Message::XrefManagerPathMenu),
self.path_open,
Message::XrefManagerPathMenu,
vec![
menu_item(
crate::t!("Make Absolute").into_owned(),
Some(Message::XrefManagerOp(XrefPaletteOp::Pathtype(
Pathtype::Full,
))),
None,
),
menu_item(
crate::t!("Make Relative").into_owned(),
Some(Message::XrefManagerOp(XrefPaletteOp::Pathtype(
Pathtype::Relative,
))),
relative_gate,
),
menu_item(
crate::t!("Remove Path").into_owned(),
Some(Message::XrefManagerOp(XrefPaletteOp::Pathtype(
Pathtype::None,
))),
None,
),
menu_item(
crate::t!("Select New Path").into_owned(),
Some(Message::XrefPathPick),
new_path_gate,
),
menu_item(
crate::t!("Find and Replace").into_owned(),
Some(Message::XrefFindReplacePrompt),
None,
),
],
)
}
};
// Help opens a modal window explaining the manager (and its web
// shortcomings): the old disabled tip could never be clicked, and
// an anchored dropdown squeezes the text into the toolbar's width.
let help = toolbar_btn(
crate::t!("Help").into_owned(),
Some(Message::XrefHelpOpen),
false,
);
let toolbar = container(
row![
attach,
refresh,
change_path,
help
]
.spacing(4)
.align_y(iced::Center),
)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.weakest.color,
)),
..Default::default()
})
.width(Fill)
.padding([4, 8]);
// ── File References pane title ──────────────────────────────────
// List / Tree live on this row, right-aligned.
let table_title: Element<'_, Message> = container(
row![
text(format!("{} :", crate::t!("File References").as_ref()))
.size(11)
.width(Fill),
list_btn,
tree_btn,
]
.spacing(4)
.align_y(iced::Center),
)
.width(Fill)
.padding(Padding {
top: 4.0,
right: 8.0,
bottom: 0.0,
left: 8.0,
})
.into();
// Each divider between header cells grabs for a width drag
// (`XrefColGrab(i)` resizes column `i`); mirrors the Layers
// Name-column divider.
let mut header_row = row![].spacing(0).align_y(iced::Center);
let headers = [
crate::t!("Reference").into_owned(),
crate::t!("Status").into_owned(),
crate::t!("Size").into_owned(),
crate::t!("Type").into_owned(),
crate::t!("Date").into_owned(),
crate::t!("Saved Path").into_owned(),
];
for (i, label) in headers.into_iter().enumerate() {
header_row = header_row.push(
text(label)
.size(10)
.style(muted_style)
.width(Length::Fixed(self.col_widths[i])),
);
if i + 1 < self.col_widths.len() {
let grab = mouse_area(
container(iced::widget::Space::new().width(Length::Fixed(6.0)))
.width(Length::Fixed(6.0))
.height(Length::Fixed(ROW_H * 0.7)),
)
.on_press(Message::XrefColGrab(i))
.interaction(iced::mouse::Interaction::ResizingHorizontally);
header_row = header_row.push(grab);
}
}
let col_header: Element<'_, Message> = mouse_area(
container(header_row.width(Length::Fixed(table_w)))
.style(|theme: &Theme| {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(palette.background.weak.color)),
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 0.0.into(),
},
..Default::default()
}
})
.padding([4, 8])
.width(Fill),
)
.on_move(|p| Message::XrefColMove(p))
.on_release(Message::XrefColRelease)
.into();
// ── Reference rows ────────────────────────────────────────────────
let mut rows_col = column![].spacing(0);
for display in self.display_rows() {
if display.index == HOST_ROW {
rows_col = rows_col.push(host_row(
display,
&self.host_name,
&self.host_path,
table_w,
&self.col_widths,
));
continue;
}
let Some(entry) = self.entries.get(display.index) else {
continue;
};
let is_sel = self.selected.contains(&display.index);
let kids = self.children.get(&entry.key);
let has_children = kids.is_some_and(|k| !k.is_empty());
let is_expanded = has_children && self.expanded.contains(&entry.key);
rows_col = rows_col.push(xref_row(
display,
entry,
is_sel,
self.tree && has_children,
is_expanded,
table_w,
&self.col_widths,
self.row_change_path_open,
));
}
// ── Reference table: header + rows scroll together. A horizontal
// outer scrollable carries a fixed-width column; the inner vertical
// scrollable pages rows. (A single Both-direction scrollable renders
// blank content in this iced rev — verified by screenshot — so the
// axes stay split across nested scrollables.)
let table_rows: Element<'_, Message> = if self.entries.is_empty() {
container(
text(crate::t!("XREF No external references in this drawing."))
.size(12)
.style(muted_style),
)
.center_x(Fill)
.center_y(Fill)
.width(Fill)
.height(Length::Fixed(self.table_h))
.into()
} else if self.tree {
// Tree view replaces the table: icon + name rows in nesting
// order, no columns. Single-select still applies.
let mut tree_col = column![].spacing(0);
for display in self.display_rows() {
if display.index == HOST_ROW {
tree_col = tree_col.push(tree_host_row(display, &self.host_name));
continue;
}
let Some(entry) = self.entries.get(display.index) else {
continue;
};
let is_sel = self.selected.contains(&display.index);
let kids = self.children.get(&entry.key);
let has_children = kids.is_some_and(|k| !k.is_empty());
let is_expanded = has_children && self.expanded.contains(&entry.key);
tree_col = tree_col.push(tree_row(
display,
entry,
is_sel,
has_children,
is_expanded,
self.row_change_path_open,
));
}
scrollable(tree_col).height(Length::Fixed(self.table_h)).into()
} else {
scrollable(
column![
col_header,
scrollable(rows_col).height(Length::Fixed(self.table_h)),
]
.width(Length::Fixed(table_w)),
)
.direction(iced::widget::scrollable::Direction::Horizontal(
iced::widget::scrollable::Scrollbar::new(),
))
.height(Length::Fixed(self.table_h))
.into()
};
// Thin external padding + darkest surface around the table, like the
// properties panel content.
let table_rows: Element<'_, Message> = container(table_rows)
.padding(4)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.weakest.color,
)),
..Default::default()
})
.width(Fill)
.into();
// ── Missing-on-open notice + details pane ─────────────────────────
let notice: Option<Element<'_, Message>> = if missing > 0 {
Some(
container(text(crate::tf!(
"{} reference(s) not found — open the reference manager with EXTERNALREFERENCES.",
missing
)))
.padding([6, 8])
.width(Fill)
.into(),
)
} else {
None
};
// ── Details / Preview lower pane ────────────────────────────────
// Toggle buttons mirror the spec's Details/Preview switch. Preview is
// a placeholder in v1 (no thumbnail pipeline): a grey field, or
// "Preview not available" — never a broken image control.
let details_tab = toolbar_btn(
crate::t!("Details").into_owned(),
if self.show_preview {
Some(Message::XrefManagerTogglePreview)
} else {
None
},
false,
);
let preview_tab = toolbar_btn(
crate::t!("Preview").into_owned(),
if self.show_preview {
None
} else {
Some(Message::XrefManagerTogglePreview)
},
false,
);
let pane_tabs = row![details_tab, preview_tab]
.spacing(4)
.align_y(iced::Center);
let lower: Element<'_, Message> = if self.show_preview {
// Single selection → the decoded image, or "Preview not
// available" (DXF/PDF/unresolvable). Anything else → the spec's
// solid grey field.
let single = self.anchor.and_then(|i| {
(self.selected.len() <= 1)
.then(|| self.entries.get(i))
.flatten()
});
let preview_body: Element<'_, Message> = match single {
Some(e) => {
let cached = e.found_at.as_deref().and_then(|found| {
self.previews.get(&(e.key, found.to_string()))
});
match cached {
Some(handle) => container(
iced::widget::image(handle.clone())
.width(Length::Fixed(220.0)),
)
.center_x(Fill)
.center_y(Fill)
.width(Fill)
.height(Length::Fixed(120.0))
.into(),
None => container(
column![
text(e.name.as_str()).size(11),
text(crate::t!("Preview not available")).size(11).style(muted_style),
]
.spacing(4)
.align_x(iced::Center),
)
.center_x(Fill)
.center_y(Fill)
.width(Fill)
.height(Length::Fixed(120.0))
.into(),
}
}
None => container(text("—").size(11).style(muted_style))
.center_x(Fill)
.center_y(Fill)
.width(Fill)
.height(Length::Fixed(120.0))
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.weak.color,
)),
..Default::default()
})
.into(),
};
container(column![pane_tabs, preview_body].spacing(2))
.padding([6, 8])
.width(Fill)
.into()
} else {
let details =
details_pane(self.anchor.and_then(|i| self.entries.get(i)), doc);
container(column![pane_tabs, details].spacing(2))
.padding([6, 8])
.width(Fill)
.into()
};
// Horizontal split divider between the table and the lower pane:
// grabs vertically to give the table more (or less) height.
let split = mouse_area(
container(iced::widget::Space::new().height(Length::Fixed(6.0)))
.width(Fill)
.height(Length::Fixed(6.0)),
)
.on_press(Message::XrefSplitGrab)
.interaction(iced::mouse::Interaction::ResizingVertically);
let mut content = column![title_bar, toolbar, table_title, table_rows, split];
if let Some(notice) = notice {
content = content.push(notice);
}
content = content.push(lower);
// Panel-wide move/release tracking for the split drag (mirrors the
// header divider tracking for columns).
let content: Element<'_, Message> = mouse_area(content.spacing(0))
.on_move(|p| Message::XrefSplitMove(p))
.on_release(Message::XrefSplitRelease)
.into();
// Fixed to the dock width like the block palette: the panel never
// sizes itself from its (wider) table content, so the dock width
// setting — and resize — actually takes effect.
container(content)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.base.color,
)),
..Default::default()
})
.width(Length::Fixed(width))
.height(Fill)
.into()
}
}
/// `(key, saved_path)` identities for every reference the host drawing holds
/// directly — mirrors the three [`collect_entries`] scans so nested entries
/// (foreign handles) classify correctly even on handle collisions.
fn direct_identities(doc: &CadDocument) -> HashSet<(u64, String)> {
use codec::objects::ObjectType;
let mut ids: HashSet<(u64, String)> = HashSet::new();
for br in doc.block_records.iter() {
if br.flags.is_xref || br.flags.is_xref_overlay {
ids.insert((br.handle.value(), br.xref_path.clone()));
}
}
for (handle, obj) in doc.objects.iter() {
match obj {
ObjectType::ImageDefinition(def) => {
ids.insert((handle.value(), def.file_name.clone()));
}
ObjectType::UnderlayDefinition(def) => {
ids.insert((handle.value(), def.file_path.clone()));
}
ObjectType::ClassObject(object) => {
if let codec::objects::ClassObjectData::PointCloudDefinitionEx(def) = &object.data {
ids.insert((handle.value(), def.source_filename.clone()));
}
}
_ => {}
}
}
ids
}
/// Claim a `(normalized saved path, entry name)` pair for display; empty
/// paths always pass, exact repeats are skipped (cycle guard). Two entries
/// sharing one file under different names both render.
fn claim_path(seen: &mut HashSet<(String, String)>, saved_path: &str, name: &str) -> bool {
if saved_path.is_empty() {
return true;
}
seen.insert((normalize_lexical(saved_path), name.to_string()))
}
// ── Text helpers ──────────────────────────────────────────────────────────
fn status_text(status: RefStatus) -> std::borrow::Cow<'static, str> {
match status {
RefStatus::Loaded => crate::t!("Loaded"),
RefStatus::Unloaded => crate::t!("Unloaded"),
RefStatus::NotFound => crate::t!("Not found"),
// Unreadable file: the industry palette term is Unresolved.
RefStatus::Failed => crate::t!("Unresolved"),
RefStatus::Stale => crate::t!("Stale"),
RefStatus::Orphaned => crate::t!("Orphaned"),
RefStatus::Unreferenced => crate::t!("Unreferenced"),
}
}
/// Status icon for the list view: one distinct glyph + theme color per
/// lifecycle state, drawn from the shared monochrome chrome set so the
/// artwork stays font-independent on the web build.
fn status_icon(status: RefStatus) -> Element<'static, Message> {
use crate::ui::icons;
let (bytes, tone) = status_icon_key(status);
match tone {
StatusTone::Success => icons::themed_success(bytes, 12.0),
StatusTone::Secondary => icons::themed_secondary(bytes, 12.0),
StatusTone::Warning => icons::themed_warning(bytes, 12.0),
StatusTone::Danger => icons::themed_danger(bytes, 12.0),
}
}
/// Icon tone: which theme color a status glyph renders in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum StatusTone {
Success,
Secondary,
Warning,
Danger,
}
/// Pure status → (glyph bytes, tone) mapping behind [`status_icon`].
/// Every lifecycle state resolves to a distinct pair.
fn status_icon_key(status: RefStatus) -> (&'static [u8], StatusTone) {
use crate::ui::icons;
use StatusTone as T;
match status {
RefStatus::Loaded => (icons::CHECK, T::Success),
RefStatus::Unloaded => (icons::MINUS, T::Secondary),
RefStatus::Stale => (icons::DIRTY_DOT, T::Warning),
RefStatus::NotFound => (icons::CLOSE, T::Danger),
RefStatus::Failed => (icons::DOT, T::Danger),
RefStatus::Orphaned => (icons::DOT, T::Warning),
RefStatus::Unreferenced => (icons::DOC, T::Secondary),
}
}
fn type_text(entry: &ReferenceEntry) -> std::borrow::Cow<'static, str> {
match entry.kind {
RefKind::DwgXref => match entry.ref_type {
RefType::Attach => crate::t!("Attach"),
RefType::Overlay => crate::t!("Overlay"),
},
// Raster images display their file format (spec: type column shows
// the image format); unknown extensions fall back to Image.
RefKind::Image => image_format(&entry.saved_path),
RefKind::Underlay => {
let ext = std::path::Path::new(&entry.saved_path.replace('\\', "/"))
.extension()
.map(|e| e.to_string_lossy().to_ascii_uppercase())
.unwrap_or_default();
match ext.as_str() {
"DWF" | "DWFX" => crate::t!("DWF"),
"DGN" => crate::t!("DGN"),
_ => crate::t!("PDF"),
}
}
// The file's own kind: RCP (project) or RCS (scan).
RefKind::PointCloud => {
let ext = std::path::Path::new(&entry.saved_path.replace('\\', "/"))
.extension()
.map(|e| e.to_string_lossy().to_ascii_uppercase())
.unwrap_or_default();
if ext == "RCS" { "RCS".into() } else { "RCP".into() }
}
}
}
/// Uppercase file extension of an image path (`plan.BMP` → `BMP`).
/// Extensionless or overlong suffixes fall back to `Image`.
fn image_format(saved_path: &str) -> std::borrow::Cow<'static, str> {
let file = saved_path.rsplit(['/', '\\']).next().unwrap_or("");
let ext = file
.rsplit('.')
.next()
.filter(|_| file.contains('.'))
.unwrap_or("");
if ext.is_empty() || ext.len() > 5 {
crate::t!("Image")
} else {
std::borrow::Cow::Owned(ext.to_ascii_uppercase())
}
}
fn format_size(size_bytes: Option<u64>) -> String {
match size_bytes {
None => "—".to_string(),
Some(b) if b < 1024 => format!("{b} B"),
Some(b) if b < 1024 * 1024 => format!("{:.1} KB", b as f64 / 1024.0),
Some(b) if b < 1024 * 1024 * 1024 => format!("{:.1} MB", b as f64 / 1_048_576.0),
Some(b) => format!("{:.1} GB", b as f64 / 1_073_741_824.0),
}
}
/// Calendar date (`YYYY-MM-DD`) for a file modification time. Hand-rolled
/// civil-from-days so no date crate joins the dependency tree.
fn format_date(modified: Option<SystemTime>) -> String {
let Some(t) = modified else {
return "—".to_string();
};
let secs = t
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let z = secs.div_euclid(86_400) + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy as i64 - (153 * mp as u64 + 2) as i64 / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as i64;
format!("{:04}-{:02}-{:02}", if m <= 2 { y + 1 } else { y }, m, d)
}
// ── Widget helpers (layers.rs conventions) ────────────────────────────────
fn toolbar_btn(label: String, msg: Option<Message>, fill: bool) -> Element<'static, Message> {
let mut b = button(text(label).size(11))
.style(|theme: &Theme, status| {
let palette = theme.palette();
let pair = match status {
button::Status::Hovered | button::Status::Pressed => {
palette.background.strong
}
_ => palette.background.weak,
};
button::Style {
background: Some(Background::Color(pair.color)),
border: Border {
radius: 3.0.into(),
color: palette.background.neutral.color,
width: 1.0,
},
text_color: pair.text,
..Default::default()
}
})
.padding([4, 10]);
if fill {
b = b.width(Fill);
}
if let Some(m) = msg {
b = b.on_press(m);
}
b.into()
}
/// Disabled toolbar affordance with an explanatory tooltip (gated
/// operations, nested-selection blocks, or web-gated mutations).
fn toolbar_tip(label: String, tip: String, fill: bool) -> Element<'static, Message> {
let b = button(text(label).size(11).style(|theme: &Theme| {
iced::widget::text::Style {
color: Some(
theme
.palette()
.background
.base
.text
.scale_alpha(0.42),
),
}
}))
.style(|theme: &Theme, _| {
let palette = theme.palette();
button::Style {
background: Some(Background::Color(palette.background.weak.color)),
border: Border {
radius: 3.0.into(),
color: palette.background.neutral.color,
width: 1.0,
},
text_color: palette.background.weak.text.scale_alpha(0.42),
..Default::default()
}
})
.padding([4, 10]);
let b = if fill { b.width(Fill) } else { b };
tooltip(b, text(tip).size(11), tooltip::Position::Bottom).into()
}
/// Split button: a default action plus a triangle that opens the rest in an
/// overlay menu (spec toolbar: Attach ▾, Refresh ▾, Change Path ▾).
///
/// Both halves share one bordered group look: the main half keeps only the
/// left corners rounded (no right border), the caret keeps only the right
/// corners (no left border), so the pair reads as a single control. Hover
/// and press states highlight each half independently.
/// `main_msg` runs the default action; `toggle` flips this menu (closing the
/// others is handled at the message site); `items` are the menu rows, each
/// full-width. Dismissal (Escape / outside click) funnels to
/// [`Message::XrefManagerDismissMenus`].
fn split_button(
main_label: String,
main_msg: Option<Message>,
menu_open: bool,
toggle: Message,
items: Vec<Element<'static, Message>>,
) -> Element<'static, Message> {
use iced::border::Radius;
// Triangle matches the main button's height and bordered style so the
// group reads as one split control: same padding, icon box fixed to the
// 11px text line height.
let half_style = |left: bool| {
move |theme: &Theme, status| {
let palette = theme.palette();
let pair = match status {
button::Status::Hovered | button::Status::Pressed => {
palette.background.strong
}
_ => palette.background.weak,
};
let radius = if left {
Radius {
top_left: 3.0,
top_right: 0.0,
bottom_right: 0.0,
bottom_left: 3.0,
}
} else {
Radius {
top_left: 0.0,
top_right: 3.0,
bottom_right: 3.0,
bottom_left: 0.0,
}
};
button::Style {
background: Some(Background::Color(pair.color)),
border: Border {
radius: radius.into(),
color: palette.background.neutral.color,
width: 1.0,
},
text_color: pair.text,
..Default::default()
}
}
};
let mut main = button(text(main_label).size(11))
.style(half_style(true))
.padding([4, 10]);
if let Some(m) = main_msg {
main = main.on_press(m);
}
let caret = button(
container(crate::ui::icons::themed_arrow_down(9.0))
.align_y(iced::Center)
.height(Length::Fixed(15.0)),
)
.on_press(toggle)
.style(half_style(false))
.padding([4, 10]);
// Overlap the halves by the 1px shared border so no double line shows
// where they meet.
let head = row![main, caret].spacing(-1.0).align_y(iced::Center);
let popup: Element<'static, Message> = container(
column(items.into_iter().map(|item| {
container(item).width(Fill).into()
}))
.spacing(2)
.padding(4),
)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.base.color,
)),
border: Border {
color: theme.palette().background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
})
.width(Length::Fixed(220.0))
.into();
iced_aw::DropDown::new(head, popup, menu_open)
.alignment(iced_aw::drop_down::Alignment::Bottom)
.offset(2.0)
.on_dismiss(Message::XrefManagerDismissMenus)
.into()
}
/// One dropdown-menu row: a full-width button when the option can execute,
/// otherwise greyed text carrying the reason.
fn menu_item(label: String, msg: Option<Message>, gate: Option<String>) -> Element<'static, Message> {
match (msg, gate) {
(Some(m), None) => toolbar_btn(label, Some(m), true),
(_, reason) => toolbar_tip(label, reason.unwrap_or_default(), true),
}
}
/// Wrapper that publishes a right-click message before delegating to child
/// (which may capture right-clicks, e.g. `ContextMenu`).
struct RightClickArea<'a> {
child: Element<'a, Message>,
on_right_press: Message,
}
impl<'a> RightClickArea<'a> {
pub fn new(child: impl Into<Element<'a, Message>>, on_right_press: Message) -> Self {
Self {
child: child.into(),
on_right_press,
}
}
}
impl<'a> iced_core::Widget<Message, Theme, iced::Renderer> for RightClickArea<'a> {
fn diff(&mut self, tree: &mut iced_core::widget::Tree) {
tree.diff_children(std::slice::from_mut(&mut self.child));
}
fn size(&self) -> iced::Size<Length> {
self.child.as_widget().size()
}
fn layout(
&mut self,
tree: &mut iced_core::widget::Tree,
renderer: &iced::Renderer,
limits: &iced_core::layout::Limits,
) -> iced_core::layout::Node {
self.child
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn update(
&mut self,
tree: &mut iced_core::widget::Tree,
event: &iced_core::Event,
layout: iced_core::Layout<'_>,
cursor: iced_core::mouse::Cursor,
renderer: &iced::Renderer,
shell: &mut iced_core::Shell<'_, Message>,
viewport: &iced::Rectangle,
) {
if let iced_core::Event::Mouse(iced_core::mouse::Event::ButtonPressed(
iced_core::mouse::Button::Right,
)) = event
{
if cursor.is_over(layout.bounds()) {
shell.publish(self.on_right_press.clone());
}
}
self.child.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
shell,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &iced_core::widget::Tree,
layout: iced_core::Layout<'_>,
cursor: iced_core::mouse::Cursor,
viewport: &iced::Rectangle,
renderer: &iced::Renderer,
) -> iced_core::mouse::Interaction {
self.child
.as_widget()
.mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer)
}
fn operate(
&mut self,
tree: &mut iced_core::widget::Tree,
layout: iced_core::Layout<'_>,
renderer: &iced::Renderer,
operation: &mut dyn iced_core::widget::Operation,
) {
self.child
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn draw(
&self,
tree: &iced_core::widget::Tree,
renderer: &mut iced::Renderer,
theme: &Theme,
style: &iced_core::renderer::Style,
layout: iced_core::Layout<'_>,
cursor: iced_core::mouse::Cursor,
viewport: &iced::Rectangle,
) {
self.child.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut iced_core::widget::Tree,
layout: iced_core::Layout<'b>,
renderer: &iced::Renderer,
viewport: &iced::Rectangle,
translation: iced::Vector,
) -> Option<iced_core::overlay::Element<'b, Message, Theme, iced::Renderer>> {
self.child.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a> From<RightClickArea<'a>> for Element<'a, Message> {
fn from(widget: RightClickArea<'a>) -> Self {
Element::new(widget)
}
}
const MENU_ROW_H: f32 = 24.0;
const MENU_SEP_H: f32 = 5.0;
const MENU_PADDING: f32 = 4.0;
const MENU_SPACING: f32 = 1.0;
/// One right-click menu row, styled like the model-space context menu:
/// borderless subtle text rows. Hovering a regular row dismisses any open flyout.
///
/// NOTE: this must stay a SINGLE interactive widget (`mouse_area` alone).
/// Wrapping a `button` in a `mouse_area` here breaks click delivery inside
/// the `ContextMenu` overlay: the overlay subtree state (including a button's
/// press state) is wiped on every view rebuild, so a press → release button
/// never publishes. `on_press` fires in the press batch itself and needs no
/// saved state.
fn menu_row(label: String, msg: Message) -> Element<'static, Message> {
mouse_area(
container(text(label).size(12))
.padding([4, 12])
.height(Length::Fixed(MENU_ROW_H))
.width(Fill),
)
.on_press(msg)
.on_enter(Message::XrefRowChangePathLeave)
.interaction(iced::mouse::Interaction::Pointer)
.into()
}
/// One flyout submenu row: does not close the flyout on hover. Same
/// single-`mouse_area` rule as [`menu_row`] — no nested `button`.
fn submenu_row(label: String, msg: Message) -> Element<'static, Message> {
mouse_area(
container(text(label).size(12))
.padding([4, 12])
.height(Length::Fixed(MENU_ROW_H))
.width(Fill),
)
.on_press(msg)
.on_enter(Message::XrefRowChangePathEnter)
.on_exit(Message::XrefRowChangePathLeave)
.interaction(iced::mouse::Interaction::Pointer)
.into()
}
fn menu_separator() -> Element<'static, Message> {
container(
container(iced::widget::Space::new().height(Length::Fixed(1.0)))
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.neutral.color)),
..Default::default()
})
.width(Fill),
)
.padding([2, 8])
.height(Length::Fixed(MENU_SEP_H))
.width(Fill)
.into()
}
/// Per-row right-click menu. Eight items exactly: universal tools +
/// path tools. No Bind, no Attach/Overlay — those are DWG-only and
/// hidden entirely. Change Path Type shows a right triangle and a
/// second list adjacent to "Change Path Type" on hover.
fn row_menu_for(index: usize, change_path_open: bool) -> Element<'static, Message> {
let item = |label: &str, op: XrefPaletteOp| -> Element<'static, Message> {
menu_row(label.to_string(), Message::XrefRowOp(index, op))
};
let pathtype_item = |label: &str, pt: Pathtype| -> Element<'static, Message> {
submenu_row(
label.to_string(),
Message::XrefRowOp(index, XrefPaletteOp::Pathtype(pt)),
)
};
let change_path_row: Element<'static, Message> = mouse_area(
container(
row![
text(crate::t!("Change Path Type").into_owned()).size(12).width(Fill),
crate::ui::icons::themed_arrow_right(10.0),
]
.align_y(iced::Center)
.spacing(8),
)
.style(move |theme: &Theme| {
let palette = theme.palette();
let bg = if change_path_open {
Some(Background::Color(palette.background.weak.color))
} else {
None
};
container::Style {
background: bg,
border: Border {
radius: 2.0.into(),
..Default::default()
},
..Default::default()
}
})
.padding([4, 12])
.height(Length::Fixed(MENU_ROW_H))
.width(Fill),
)
.on_enter(Message::XrefRowChangePathEnter)
.into();
let main: Element<'static, Message> = container(
column![
item(&crate::t!("Open").into_owned(), XrefPaletteOp::Open),
item(&crate::t!("Attach...").into_owned(), XrefPaletteOp::Attach),
item(&crate::t!("Unload").into_owned(), XrefPaletteOp::Unload),
item(&crate::t!("Reload").into_owned(), XrefPaletteOp::Reload),
item(&crate::t!("Detach").into_owned(), XrefPaletteOp::Detach),
menu_separator(),
change_path_row,
menu_separator(),
menu_row(
crate::t!("Select New Path").into_owned(),
Message::XrefRowPathPick(index),
),
menu_row(
crate::t!("Find and Replace...").into_owned(),
Message::XrefRowFindReplacePrompt(index),
),
]
.spacing(MENU_SPACING)
.padding(MENU_PADDING),
)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.base.color,
)),
border: Border {
color: theme.palette().background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
})
.width(Length::Fixed(220.0))
.into();
if !change_path_open {
return main;
}
// Top offset so the flyout top border aligns directly with the "Change Path Type" row.
// 5 items * 24.0 + 5 * 1.0 (spacing) + 5.0 (separator) + 1.0 (spacing) + 4.0 (container padding) = 135.0
let flyout_offset = MENU_PADDING + 5.0 * MENU_ROW_H + 5.0 * MENU_SPACING + MENU_SEP_H + MENU_SPACING;
// Plain container on purpose: each submenu row tracks hover itself
// (`on_enter` keeps the flyout open, `on_exit` closes it). Wrapping the
// clickable rows in an outer `mouse_area` would nest interactives and
// break click delivery inside the `ContextMenu` overlay — see `menu_row`.
let flyout: Element<'static, Message> = container(
column![
pathtype_item(
&crate::t!("Make Absolute").into_owned(),
Pathtype::Full
),
pathtype_item(
&crate::t!("Make Relative").into_owned(),
Pathtype::Relative
),
pathtype_item(&crate::t!("Remove Path").into_owned(), Pathtype::None),
]
.spacing(MENU_SPACING)
.padding(MENU_PADDING),
)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.base.color,
)),
border: Border {
color: theme.palette().background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
})
.width(Length::Fixed(180.0))
.into();
let flyout_column = column![
iced::widget::Space::new().height(Length::Fixed(flyout_offset)),
flyout,
];
row![main, flyout_column]
.spacing(0)
.align_y(iced::alignment::Vertical::Top)
.into()
}
fn xref_row<'a>(
display: DisplayRow,
entry: &'a ReferenceEntry,
is_selected: bool,
show_expand: bool,
is_expanded: bool,
table_w: f32,
cw: &[f32; 6],
change_path_open: bool,
) -> Element<'a, Message> {
let mut name = entry.name.clone();
if display.is_nested {
name.push_str(&crate::t!(" (nested — not rendered)"));
}
let mut name_cell = row![].spacing(2).align_y(iced::Center);
if display.depth > 0 {
name_cell = name_cell.push(
iced::widget::Space::new().width(Length::Fixed(INDENT_W * display.depth as f32)),
);
}
// Tree parents own the only interactive cell besides selection: the
// expand/collapse arrow. Nested rows never show one.
if show_expand {
let key = entry.key;
let arrow = button(crate::ui::icons::themed_arrow_toggle(is_expanded, 10.0))
.on_press(Message::XrefManagerToggleExpand(key))
.style(cell_button_style(is_selected, display.index))
.padding(Padding {
top: 6.0,
bottom: 6.0,
left: 2.0,
right: 2.0,
});
name_cell = name_cell.push(arrow);
}
let name_text: Element<'_, Message> = text(name).size(FONT_SZ).into();
name_cell = name_cell.push(name_text);
let gutter = || iced::widget::Space::new().width(Length::Fixed(COL_GUTTER));
let status_cell: Element<'_, Message> = row![
container(status_icon(entry.status)).align_y(iced::Center),
text(status_text(entry.status)).size(FONT_SZ).width(Fill),
]
.spacing(4)
.align_y(iced::Center)
.width(Length::Fixed(cw[1]))
.into();
let content = row![
name_cell.width(Length::Fixed(cw[0])),
gutter(),
status_cell,
gutter(),
text(format_size(entry.size_bytes)).size(FONT_SZ).width(Length::Fixed(cw[2])),
gutter(),
text(type_text(entry)).size(FONT_SZ).width(Length::Fixed(cw[3])),
gutter(),
text(format_date(entry.modified)).size(FONT_SZ).width(Length::Fixed(cw[4])),
gutter(),
text(entry.saved_path.clone()).size(FONT_SZ).width(Length::Fixed(cw[5])),
]
.spacing(0)
.width(Length::Fixed(table_w))
.align_y(iced::Center);
let index = display.index;
let row = mouse_area(
container(content)
.style(move |theme: &Theme| {
let palette = theme.palette();
let pair = if is_selected {
palette.primary.weak
} else if index % 2 == 0 {
palette.background.base
} else {
palette.background.weak
};
container::Style {
background: Some(Background::Color(pair.color)),
text_color: Some(pair.text),
..Default::default()
}
})
.padding(Padding {
top: 0.0,
bottom: 0.0,
left: 8.0,
right: 8.0,
})
.height(Length::Fixed(ROW_H))
.width(Fill),
)
.on_press(Message::XrefManagerSelect(index));
let cm = iced_aw::ContextMenu::new(row, move || row_menu_for(index, change_path_open));
RightClickArea::new(cm, Message::XrefRowRightClick(index)).into()
}
/// One tree-mode row: indent + optional expand arrow + file icon + name.
/// Same selection highlight and right-click menu as table rows.
fn tree_row(
display: DisplayRow,
entry: &ReferenceEntry,
is_selected: bool,
show_expand: bool,
is_expanded: bool,
change_path_open: bool,
) -> Element<'_, Message> {
let mut cells = row![].spacing(2).align_y(iced::Center);
cells = cells.push(tree_indent(display.depth));
if show_expand {
let key = entry.key;
let arrow = button(crate::ui::icons::themed_arrow_toggle(is_expanded, 10.0))
.on_press(Message::XrefManagerToggleExpand(key))
.style(cell_button_style(is_selected, display.index))
.padding(Padding {
top: 6.0,
bottom: 6.0,
left: 2.0,
right: 2.0,
});
cells = cells.push(arrow);
}
cells = cells.push(
container(crate::ui::icons::themed_secondary(crate::ui::icons::DOC, 14.0))
.align_y(iced::Center),
);
cells = cells.push(text(entry.name.clone()).size(TREE_FONT_SZ));
let index = display.index;
let row = mouse_area(
container(cells.spacing(4).width(Fill))
.style(move |theme: &Theme| {
let palette = theme.palette();
let pair = if is_selected {
palette.primary.weak
} else if index % 2 == 0 {
palette.background.base
} else {
palette.background.weak
};
container::Style {
background: Some(Background::Color(pair.color)),
text_color: Some(pair.text),
..Default::default()
}
})
.padding(Padding {
top: 5.0,
bottom: 5.0,
left: 8.0,
right: 8.0,
})
.width(Fill),
)
.on_press(Message::XrefManagerSelect(index));
let cm = iced_aw::ContextMenu::new(row, move || row_menu_for(index, change_path_open));
RightClickArea::new(cm, Message::XrefRowRightClick(index)).into()
}
/// Tree-mode host root: home icon + name with the current-drawing marker.
/// Never selectable, like its table counterpart.
fn tree_host_row(display: DisplayRow, host_name: &str) -> Element<'_, Message> {
let mut name = if host_name.is_empty() {
crate::t!("Untitled").into_owned()
} else {
host_name.to_string()
};
name.push('*');
let mut cells = row![].spacing(2).align_y(iced::Center);
cells = cells.push(tree_indent(display.depth));
cells = cells.push(
container(crate::ui::icons::themed_home(14.0)).align_y(iced::Center),
);
cells = cells.push(text(name).size(TREE_FONT_SZ));
container(cells.spacing(4).width(Fill))
.style(|theme: &Theme| {
let pair = theme.palette().background.base;
container::Style {
background: Some(Background::Color(pair.color)),
text_color: Some(pair.text),
..Default::default()
}
})
.padding(Padding {
top: 5.0,
bottom: 5.0,
left: 8.0,
right: 8.0,
})
.width(Fill)
.into()
}
fn host_row<'a>(display: DisplayRow, host_name: &'a str, host_path: &'a str, table_w: f32, cw: &[f32; 6]) -> Element<'a, Message> {
let name = if host_name.is_empty() {
crate::t!("Untitled").into_owned()
} else {
host_name.to_string()
};
let saved = if host_path.is_empty() { "—" } else { host_path };
let mut name_cell = row![].spacing(2).align_y(iced::Center);
if display.depth > 0 {
name_cell = name_cell.push(
iced::widget::Space::new().width(Length::Fixed(INDENT_W * display.depth as f32)),
);
}
name_cell = name_cell.push({
let name_text: Element<'_, Message> = text(name).size(FONT_SZ).into();
name_text
});
let gutter = || iced::widget::Space::new().width(Length::Fixed(COL_GUTTER));
let content = row![
name_cell.width(Length::Fixed(cw[0])),
gutter(),
text(crate::t!("Opened")).size(FONT_SZ).width(Length::Fixed(cw[1])),
gutter(),
text("—").size(FONT_SZ).width(Length::Fixed(cw[2])),
gutter(),
text(crate::t!("Current")).size(FONT_SZ).width(Length::Fixed(cw[3])),
gutter(),
text("—").size(FONT_SZ).width(Length::Fixed(cw[4])),
gutter(),
text(saved).size(FONT_SZ).width(Length::Fixed(cw[5])),
]
.spacing(0)
.width(Length::Fixed(table_w))
.align_y(iced::Center);
container(content)
.style(|theme: &Theme| {
let pair = theme.palette().background.base;
container::Style {
background: Some(Background::Color(pair.color)),
text_color: Some(pair.text),
..Default::default()
}
})
.padding(Padding {
top: 0.0,
bottom: 0.0,
left: 8.0,
right: 8.0,
})
.height(Length::Fixed(ROW_H))
.width(Fill)
.into()
}
fn details_pane<'a>(entry: Option<&'a ReferenceEntry>, doc: &'a CadDocument) -> Element<'a, Message> {
let inner: Element<'_, Message> = match entry {
None => text(crate::t!("Select a reference to inspect its details"))
.size(11)
.style(muted_style)
.into(),
Some(e) => {
let found = e.found_at.as_deref().unwrap_or("—");
let saved = if e.saved_path.is_empty() {
"—"
} else {
e.saved_path.as_str()
};
let size = format_size(e.size_bytes);
let date = format_date(e.modified);
let mut rows = column![
detail_row(crate::t!("Reference"), e.name.as_str()),
detail_row(crate::t!("Status"), status_text(e.status)),
detail_row(crate::t!("Size"), size),
detail_row(crate::t!("Type"), type_text(e)),
detail_row(crate::t!("Date"), date),
detail_row(crate::t!("Found At"), found),
detail_row(crate::t!("Saved Path"), saved),
]
.spacing(1);
// Image-specific properties (read-only). The file format exposes
// pixel dimensions and resolution units; color system / color
// depth are not stored by the format and are omitted deliberately.
if e.kind == RefKind::Image {
if let Some(def) = find_image_def(doc, e.key) {
rows = rows
.push(detail_row(
crate::t!("Pixel Width"),
format!("{}", def.size_in_pixels.0),
))
.push(detail_row(
crate::t!("Pixel Height"),
format!("{}", def.size_in_pixels.1),
))
.push(detail_row(
crate::t!("Resolution Unit"),
format!("{:?}", def.resolution_unit),
));
}
if let Some((w, h)) = find_image_display_size(doc, e.key) {
rows = rows
.push(detail_row(
crate::t!("Display Width"),
format!("{:.2}", w),
))
.push(detail_row(
crate::t!("Display Height"),
format!("{:.2}", h),
));
}
}
rows.into()
}
};
container(
column![
text(crate::t!("Details")).size(10).style(muted_style),
inner,
]
.spacing(2),
)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.weak.color)),
border: Border {
color: theme.palette().background.neutral.color,
width: 1.0,
radius: 0.0.into(),
},
..Default::default()
})
.padding([6, 8])
.width(Fill)
.into()
}
/// Decode one entry's preview image (`None` = placeholder territory).
///
/// Sources mirror what the formats actually carry: DWG files embed a preview
/// (read header-only via `dwg_thumbnailer`, no full parse — DXF has none, so
/// it resolves to no preview); raster images decode through the `image`
/// crate and downscale; PDFs have no rasterizer in this build. Only
/// Loaded/Stale rows resolve — Unloaded/NotFound show the placeholder by
/// design. Web builds skip filesystem decoding entirely.
fn reference_preview(entry: &ReferenceEntry) -> Option<image::RgbaImage> {
if IS_WASM {
return None;
}
// Any entry whose file resolves decodes — including Unloaded and
// Unreferenced rows — so previews don't depend on load state. Missing
// files fail the decode below and fall back to the placeholder.
let found = entry.found_at.as_deref().filter(|s| !s.is_empty())?;
match entry.kind {
RefKind::DwgXref => {
let ext = found
.rsplit(['/', '\\'])
.next()
.and_then(|f| f.rsplit('.').next())
.unwrap_or("")
.to_ascii_lowercase();
if ext != "dwg" {
return None;
}
dwg_thumbnailer::extract(std::path::Path::new(found), PREVIEW_MAX)
}
RefKind::Image => {
let img = image::open(found).ok()?;
Some(img.thumbnail(PREVIEW_MAX, PREVIEW_MAX).to_rgba8())
}
RefKind::Underlay | RefKind::PointCloud => None,
}
}
/// Image definition backing a [`RefKind::Image`] entry, looked up by the
/// entry key (definition-object handle) for the Details pane extras.
fn find_image_def(
doc: &CadDocument,
key: u64,
) -> Option<&codec::objects::ImageDefinition> {
doc.objects.get(&codec::types::Handle::from(key)).and_then(|o| match o {
codec::objects::ObjectType::ImageDefinition(def) => Some(def),
_ => None,
})
}
fn find_image_display_size(doc: &CadDocument, key: u64) -> Option<(f64, f64)> {
let handle = codec::types::Handle::from(key);
for entity in doc.entities() {
if let codec::EntityType::RasterImage(img) = entity {
if img.definition_handle == Some(handle) {
let w = (img.u_vector.x.powi(2) + img.u_vector.y.powi(2) + img.u_vector.z.powi(2)).sqrt();
let h = (img.v_vector.x.powi(2) + img.v_vector.y.powi(2) + img.v_vector.z.powi(2)).sqrt();
return Some((w, h));
}
}
}
None
}
fn detail_row<'a>(
label: impl Into<std::borrow::Cow<'a, str>>,
value: impl Into<std::borrow::Cow<'a, str>>,
) -> Element<'a, Message> {
let label: std::borrow::Cow<'a, str> = label.into();
let value: std::borrow::Cow<'a, str> = value.into();
row![
text(label).size(11).style(muted_style).width(Length::Fixed(84.0)),
text(value).size(11),
]
.spacing(6)
.align_y(iced::Center)
.into()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::io::xref_model::RefKind;
#[test]
fn size_formats_without_deps() {
assert_eq!(format_size(None), "—");
assert_eq!(format_size(Some(0)), "0 B");
assert_eq!(format_size(Some(900)), "900 B");
assert_eq!(format_size(Some(2048)), "2.0 KB");
assert_eq!(format_size(Some(5 * 1_048_576)), "5.0 MB");
}
#[test]
fn date_formats_known_days() {
assert_eq!(format_date(None), "—");
let day = |days: u64| {
format_date(Some(UNIX_EPOCH + std::time::Duration::from_secs(days * 86_400)))
};
assert_eq!(day(0), "1970-01-01");
assert_eq!(day(10957), "2000-01-01");
assert_eq!(day(19723), "2024-01-01");
}
fn entry(key: u64, name: &str, saved: &str) -> ReferenceEntry {
let mut e = ReferenceEntry::new(key, name, RefKind::DwgXref);
e.saved_path = saved.to_string();
e
}
#[test]
fn select_toggles_anchor_and_set() {
let mut panel = XrefManagerPanel::default();
panel.entries = vec![entry(1, "A", "a.dwg"), entry(2, "B", "b.dwg")];
panel.toggle_select(0);
panel.toggle_select(1);
assert!(panel.selected.contains(&0) && panel.selected.contains(&1));
assert_eq!(panel.anchor, Some(1));
panel.toggle_select(1);
assert!(!panel.selected.contains(&1));
assert_eq!(panel.anchor, Some(0));
panel.toggle_select(9); // out of range: no-op
assert_eq!(panel.selected.len(), 1);
}
#[test]
fn tree_skips_repeat_paths() {
let mut panel = XrefManagerPanel::default();
panel.entries = vec![
entry(1, "A", "a.dwg"),
entry(2, "B", "b.dwg"),
entry(3, "B2", "b.dwg"),
];
panel.tree = true;
// Host row leads; same file under two names renders twice; only
// exact (path, name) repeats collapse.
let rows = panel.display_rows();
assert_eq!(rows.len(), 4);
assert_eq!(rows[0].index, HOST_ROW);
assert!(rows[1..].iter().all(|r| r.depth >= 1));
panel.entries.push(entry(4, "B", "b.dwg"));
assert_eq!(panel.display_rows().len(), 4);
// Mark B2 nested under A: A claims (a.dwg, A), B2-as-child claims
// (b.dwg, B2); the B root still renders as (b.dwg, B).
panel.nested.insert(2);
panel.children.insert(1, vec![2]);
panel.expanded.insert(1);
let rows = panel.display_rows();
assert_eq!(rows.len(), 4); // host, A, B2-as-child-of-A, B
assert!(rows.iter().any(|r| r.index == 2 && r.is_nested));
// A second parent claiming the same child shows nothing twice.
panel.entries.push(entry(5, "C", "c.dwg"));
panel.children.insert(5, vec![2]);
panel.expanded.insert(5);
let count = panel
.display_rows()
.iter()
.filter(|r| r.index == 2)
.count();
assert_eq!(count, 1);
}
#[test]
fn host_row_leads_and_ignores_selection() {
let mut panel = XrefManagerPanel::default();
panel.entries = vec![entry(1, "A", "a.dwg")];
panel.host_name = "host".to_string();
for tree in [false, true] {
panel.tree = tree;
let rows = panel.display_rows();
assert_eq!(rows[0].index, HOST_ROW);
assert_eq!(rows[0].depth, 0);
}
panel.toggle_select(HOST_ROW); // no-op, never selected
assert!(panel.selected.is_empty());
assert_eq!(panel.anchor, None);
}
#[test]
fn tree_selects_single_reference() {
let mut panel = XrefManagerPanel::default();
panel.entries = vec![entry(1, "A", "a.dwg"), entry(2, "B", "b.dwg")];
panel.tree = true;
panel.toggle_select(0);
panel.toggle_select(1);
assert_eq!(panel.selected, HashSet::from([1]));
assert_eq!(panel.anchor, Some(1));
panel.tree = false;
panel.toggle_select(0);
assert_eq!(panel.selected, HashSet::from([0, 1]));
}
#[test]
fn actionable_selection_skips_nested() {
let mut panel = XrefManagerPanel::default();
panel.entries = vec![entry(1, "A", "a.dwg"), entry(2, "B", "b.dwg")];
panel.nested.insert(1);
panel.toggle_select(0);
panel.toggle_select(1);
assert!(panel.selection_has_nested());
// Only the direct row is actionable; the nested row stays read-only.
assert_eq!(panel.actionable_selection(), vec![0]);
panel.toggle_select(1);
assert!(!panel.selection_has_nested());
}
#[test]
fn column_drag_clamps_and_ignores_unknown() {
let mut panel = XrefManagerPanel::default();
assert_eq!(panel.col_widths, super::DEFAULT_COL_WIDTHS);
panel.drag_col_by(0, 40.0);
assert_eq!(panel.col_widths[0], super::DEFAULT_COL_WIDTHS[0] + 40.0);
panel.drag_col_by(1, -1000.0);
assert_eq!(panel.col_widths[1], super::COL_MIN_W);
panel.drag_col_by(2, 10000.0);
assert_eq!(panel.col_widths[2], super::COL_MAX_W);
panel.drag_col_by(99, 10.0); // no-op, no panic
assert_eq!(panel.table_content_width(), panel.col_widths.iter().sum::<f32>() + super::COL_GUTTER * 5.0);
}
#[test]
fn table_split_drag_clamps() {
let mut panel = XrefManagerPanel::default();
assert_eq!(panel.table_h, super::TABLE_H);
panel.drag_table_by(60.0);
assert_eq!(panel.table_h, super::TABLE_H + 60.0);
panel.drag_table_by(-10000.0);
assert_eq!(panel.table_h, super::TABLE_MIN_H);
}
#[test]
fn status_icons_are_unique_per_status() {
use super::{status_icon_key, RefStatus};
let all = [
RefStatus::Loaded,
RefStatus::Unloaded,
RefStatus::NotFound,
RefStatus::Failed,
RefStatus::Stale,
RefStatus::Orphaned,
RefStatus::Unreferenced,
];
let mut seen = std::collections::HashSet::new();
for status in all {
let (bytes, tone) = status_icon_key(status);
assert!(
seen.insert((bytes.as_ptr(), tone)),
"status {status:?} shares its icon with another state"
);
}
assert_eq!(seen.len(), all.len());
}
#[test]
fn image_format_reads_extension() {
assert_eq!(image_format("C:/exa/plan.BMP"), "BMP");
assert_eq!(image_format("a/b/c.png"), "PNG");
assert_eq!(image_format("noext"), "Image");
assert_eq!(image_format("toolong.abcdef"), "Image");
assert_eq!(image_format(""), "Image");
}
#[test]
fn click_select_single_then_range() {
use super::SelectExtend;
let mut panel = XrefManagerPanel::default();
panel.entries = vec![
entry(1, "A", "a.dwg"),
entry(2, "B", "b.dwg"),
entry(3, "C", "c.dwg"),
];
// Plain click selects exactly one row.
panel.click_select(0, SelectExtend::Single);
panel.click_select(2, SelectExtend::Single);
assert_eq!(panel.selected, HashSet::from([2]));
assert_eq!(panel.anchor, Some(2));
// Shift-click extends a contiguous range from the anchor.
panel.click_select(2, SelectExtend::Single);
panel.click_select(0, SelectExtend::Range);
assert_eq!(panel.selected, HashSet::from([0, 1, 2]));
// Repeated extends keep the original anchor — rows are never lost.
panel.entries.push(entry(4, "D", "d.dwg"));
panel.entries.push(entry(5, "E", "e.dwg"));
panel.click_select(4, SelectExtend::Range);
assert_eq!(panel.selected, HashSet::from([2, 3, 4]));
assert_eq!(panel.anchor, Some(2));
// Ctrl-click toggles one row.
panel.click_select(3, SelectExtend::Toggle);
assert_eq!(panel.selected, HashSet::from([2, 4]));
// Range with no anchor starts at the clicked row.
let mut fresh = XrefManagerPanel::default();
fresh.entries = vec![entry(1, "A", "a.dwg"), entry(2, "B", "b.dwg")];
fresh.click_select(1, SelectExtend::Range);
assert_eq!(fresh.selected, HashSet::from([1]));
}
#[test]
fn expand_collapses_by_default() {
let mut panel = XrefManagerPanel::default();
panel.entries = vec![entry(1, "A", "a.dwg"), entry(2, "B", "b.dwg")];
panel.nested.insert(1);
panel.children.insert(1, vec![1]);
panel.tree = true;
assert!(!panel.display_rows().iter().any(|r| r.is_nested));
panel.toggle_expand(1);
assert!(panel.display_rows().iter().any(|r| r.is_nested));
panel.toggle_expand(1);
assert!(!panel.display_rows().iter().any(|r| r.is_nested));
}
fn preview_doc2(dir: &std::path::Path) -> codec::CadDocument {
// Two referenced images → genuine multi-select.
use codec::objects::{ImageDefinition, ObjectType};
let mut doc = codec::CadDocument::new();
for (i, name) in ["a.png", "b.png"].iter().enumerate() {
let img = image::RgbaImage::from_pixel(8, 8, image::Rgba([9, 9, 9, 255]));
img.save(dir.join(name)).unwrap();
let path = dir.join(name).to_string_lossy().into_owned();
let h = doc.allocate_handle();
let mut def = ImageDefinition::with_dimensions(path.clone(), 8, 8);
def.handle = h;
doc.objects.insert(h, ObjectType::ImageDefinition(def));
let mut ent = codec::entities::RasterImage::new(
&path,
codec::types::Vector3::ZERO,
8.0 + i as f64,
8.0,
);
ent.definition_handle = Some(h);
doc.add_entity(codec::EntityType::RasterImage(ent)).unwrap();
}
doc
}
fn preview_doc(dir: &std::path::Path, name: &str) -> codec::CadDocument {
// Referenced 8x8 PNG under `dir`; the entry resolves Loaded.
use codec::objects::{ImageDefinition, ObjectType};
let img = image::RgbaImage::from_pixel(8, 8, image::Rgba([9, 9, 9, 255]));
img.save(dir.join(name)).unwrap();
let img_path = dir.join(name).to_string_lossy().into_owned();
let mut doc = codec::CadDocument::new();
let h = doc.allocate_handle();
let mut def = ImageDefinition::with_dimensions(img_path.clone(), 8, 8);
def.handle = h;
doc.objects.insert(h, ObjectType::ImageDefinition(def));
let mut ent = codec::entities::RasterImage::new(
&img_path,
codec::types::Vector3::ZERO,
8.0,
8.0,
);
ent.definition_handle = Some(h);
doc.add_entity(codec::EntityType::RasterImage(ent)).unwrap();
doc
}
#[test]
fn preview_decodes_for_single_selection() {
let dir = std::env::temp_dir().join(format!(
"ocs_xref_preview_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let doc = preview_doc(&dir, "img.png");
let mut panel = XrefManagerPanel::default();
let empty = std::collections::HashSet::new();
let no_prev = std::collections::HashMap::new();
panel.refresh(&doc, &dir, &empty, &no_prev, "host", "");
assert!(panel.previews.is_empty(), "nothing selected → no decode");
panel.toggle_select(0);
panel.refresh(&doc, &dir, &empty, &no_prev, "host", "");
assert_eq!(panel.previews.len(), 1, "single anchor decodes once");
let (handle, _) = panel.previews.iter().next().unwrap();
assert!(panel.entries.iter().any(|e| e.key == handle.0));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn preview_skipped_for_multi_selection() {
// Spec: the preview pane shows an image for a single selected
// reference only — multi-select decodes nothing (grey field).
let dir = std::env::temp_dir().join(format!(
"ocs_xref_preview_multi_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let doc = preview_doc2(&dir);
let mut panel = XrefManagerPanel::default();
let empty = std::collections::HashSet::new();
let no_prev = std::collections::HashMap::new();
panel.refresh(&doc, &dir, &empty, &no_prev, "host", "");
// One image row exists; duplicate it as a second row to multi-select.
panel.entries.push(panel.entries[0].clone());
panel.toggle_select(0);
panel.toggle_select(1);
assert_eq!(panel.selected.len(), 2);
panel.refresh(&doc, &dir, &empty, &no_prev, "host", "");
assert!(
panel.previews.is_empty(),
"multi-select → grey field, no decode"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn preview_missing_without_embedded_data() {
// A DWG reference whose file has no embedded preview resolves to no
// preview (placeholder path), never an error.
let dir = std::env::temp_dir().join(format!(
"ocs_xref_preview_dwg_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("plan.dwg"), b"not-a-real-dwg").unwrap();
let mut doc = codec::CadDocument::new();
let mut br = codec::tables::BlockRecord::new("PLAN");
br.flags.is_xref = true;
br.xref_path = dir.join("plan.dwg").to_string_lossy().into_owned();
br.handle = doc.allocate_handle();
doc.block_records.add(br).unwrap();
let mut panel = XrefManagerPanel::default();
let empty = std::collections::HashSet::new();
let no_prev = std::collections::HashMap::new();
panel.refresh(&doc, &dir, &empty, &no_prev, "host", "");
panel.toggle_select(0);
panel.refresh(&doc, &dir, &empty, &no_prev, "host", "");
assert!(
panel.previews.is_empty(),
"undecodable file → placeholder, no cache entry"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn preview_decodes_unloaded_entry_with_file() {
// Previews key off file resolvability, not load state: an Unloaded
// image whose file exists still decodes.
use crate::io::xref_model::UnloadKey;
let dir = std::env::temp_dir().join(format!(
"ocs_xref_preview_unloaded_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let doc = preview_doc(&dir, "img.png");
let key = doc
.objects
.iter()
.find_map(|(h, o)| match o {
codec::objects::ObjectType::ImageDefinition(_) => Some(h.value()),
_ => None,
})
.unwrap();
let mut unloaded = std::collections::HashSet::new();
unloaded.insert(UnloadKey::Direct(key));
let no_prev = std::collections::HashMap::new();
let mut panel = XrefManagerPanel::default();
panel.refresh(&doc, &dir, &unloaded, &no_prev, "host", "");
panel.toggle_select(0);
panel.refresh(&doc, &dir, &unloaded, &no_prev, "host", "");
assert_eq!(panel.previews.len(), 1);
std::fs::remove_dir_all(&dir).ok();
}
// ── Headless click-delivery tests for the row context menu ──────────
//
// These drive the REAL iced runtime through the `iced_test` headless
// simulator — including the `iced_aw::ContextMenu` overlay — and prove
// that clicks on menu rows publish their messages.
type MenuSimulator = iced_test::simulator::Simulator<'static, Message>;
/// Builds the shipped row menu inside a real `ContextMenu` overlay and
/// opens it with a synthetic right-click on the underlay, mirroring a
/// user right-clicking a reference row.
fn open_row_menu(change_path_open: bool) -> MenuSimulator {
let mut ui = iced_test::simulator(iced_aw::ContextMenu::new(
text("anchor"),
move || row_menu_for(0, change_path_open),
));
let anchor = ui.find("anchor").expect("anchor underlay present");
let center = anchor
.visible_bounds()
.expect("anchor visible")
.center();
ui.point_at(center);
ui.simulate([iced_core::Event::Mouse(iced::mouse::Event::ButtonPressed(
iced::mouse::Button::Right,
))]);
ui
}
fn published_ops(ui: MenuSimulator) -> Vec<(usize, XrefPaletteOp)> {
let mut ops = Vec::new();
for message in ui.into_messages() {
if let Message::XrefRowOp(index, op) = message {
ops.push((index, op));
}
}
ops
}
#[test]
fn context_menu_row_click_delivers_op() {
let unload = crate::t!("Unload").into_owned();
let mut ui = open_row_menu(false);
ui.click(unload.as_str())
.expect("Unload row is clickable inside the overlay");
assert!(
published_ops(ui).contains(&(0, XrefPaletteOp::Unload)),
"clicking Unload must publish XrefRowOp(0, Unload)"
);
}
#[test]
fn hover_change_path_row_requests_flyout() {
// The "Change Path Type >" row reveals the second menu on hover.
let label = crate::t!("Change Path Type").into_owned();
let mut ui = open_row_menu(false);
let target = ui
.find(label.as_str())
.expect("Change Path Type row present in overlay");
let center = target
.visible_bounds()
.expect("row visible")
.center();
ui.point_at(center);
ui.simulate([iced_core::Event::Mouse(iced::mouse::Event::CursorMoved {
position: center,
})]);
let mut saw_enter = false;
for message in ui.into_messages() {
if matches!(message, Message::XrefRowChangePathEnter) {
saw_enter = true;
}
}
assert!(
saw_enter,
"hovering Change Path Type must publish XrefRowChangePathEnter (opens the second menu)"
);
}
#[test]
fn context_menu_flyout_click_delivers_pathtype_op() {
let make_absolute = crate::t!("Make Absolute").into_owned();
let mut ui = open_row_menu(true);
ui.click(make_absolute.as_str())
.expect("flyout row is clickable inside the overlay");
assert!(
published_ops(ui).contains(&(0, XrefPaletteOp::Pathtype(Pathtype::Full))),
"clicking Make Absolute must publish the Full-pathtype op"
);
}
#[test]
fn context_menu_overlay_subtree_state_is_reset_every_view() {
// Root-cause pin. `iced_aw::ContextMenu` keeps its overlay `Element`
// in the WIDGET STRUCT (`overlay_instance`), which is rebuilt on every
// `view()` — not in persistent widget `Tree` state. So `diff` takes
// the `None` arm below on every app update cycle and wipes the whole
// overlay subtree state:
//
// match self.overlay_instance.as_mut() {
// Some(overlay) => tree.children[1].diff(overlay),
// None => tree.children[1] = Tree::empty(), // <-- always hit
// }
//
// Consequence: a `button` inside the menu sets `is_pressed` on press
// and only publishes on RELEASE — but any message in between (ticks,
// hover, clock) rebuilds the view, wipes `is_pressed`, and the
// release publishes nothing ("buttons do nothing"). Menu rows must
// therefore publish on PRESS via a single `mouse_area` (see
// `menu_row`), which needs no transient state. If this test ever
// fails, iced_aw learned to persist overlay state and rows may use
// buttons again.
use iced_core::Widget;
let mut menu = iced_aw::ContextMenu::new(text("anchor"), || row_menu_for(0, false));
let mut tree = iced_core::widget::Tree::new(
&menu as &dyn Widget<Message, iced::Theme, iced::Renderer>,
);
menu.diff(&mut tree);
assert_eq!(tree.children.len(), 2);
assert!(
tree.children[1].children.is_empty(),
"fresh ContextMenu widget must start with an empty overlay subtree"
);
}
}