diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 99305b8df..5b865745f 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -578,8 +578,13 @@ impl OpenCADStudio { } } - cmd if cmd.eq_ignore_ascii_case("XREF") || cmd.to_ascii_uppercase().starts_with("XREF ") => { - // XREF sub-option dispatcher (Task 5). The verb is + // XREFMAN — open/focus the Reference Manager palette (display-only, + // Task 7). The XREF command below is untouched. + "XREFMAN" => { + return Some(Task::done(Message::ToggleXrefManager)); + } + + cmd if cmd.eq_ignore_ascii_case("XREF") || cmd.to_ascii_uppercase().starts_with("XREF ") => { // XREF sub-option dispatcher (Task 5). The verb is // case-insensitive; arguments split on whitespace. let rest = cmd .splitn(2, char::is_whitespace) diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 3e749827f..f3ad6dfc8 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -713,6 +713,7 @@ inventory::submit!(crate::command::CommandRegistration { "XDATA", "XR", "XREF", + "XREFMAN", "XRELOAD", "ZOOM", "ZS", diff --git a/src/app/mod.rs b/src/app/mod.rs index dddbad85e..6ee956ed7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -708,6 +708,8 @@ pub(super) struct OpenCADStudio { pub(crate) dock_drag_target: Option<(crate::app::config::DockSide, usize)>, /// Docked Insert Block panel state (search, preview size, cached thumbnails). pub(crate) block_palette: crate::ui::window::block_palette::BlockPalette, + /// Reference Manager palette state (display-only in Task 7). + pub(crate) xref_manager: crate::ui::window::xref_manager::XrefManagerPanel, /// Whether the document file tabs are shown at the top (FILETAB). show_file_tabs: bool, /// Whether the layout/paper-space tabs are shown at the bottom (LAYOUTTAB). @@ -1728,6 +1730,7 @@ pub enum ModalKind { UpdateNotice, DonationPrompt, Layers, + XrefManager, LayerStateManager, LayerTranslator, DrawingUnits, @@ -2344,6 +2347,16 @@ pub enum Message { /// active command as if typed (empty keyword = Enter). (#304) CommandOptionPick(String), ToggleLayers, + /// Open/focus (or close) the Reference Manager palette. + ToggleXrefManager, + /// Rescan the active drawing's references into the palette. + XrefManagerRefresh, + /// Toggle one palette row (entry index) in the multi-selection set. + XrefManagerSelect(usize), + /// Flip the palette's list/tree presentation. + XrefManagerToggleTree, + /// Expand/collapse one tree parent (block-record handle key). + XrefManagerToggleExpand(u64), LayerToggleVisible(usize), LayerToggleLock(usize), LayerToggleFreeze(usize), @@ -3710,6 +3723,7 @@ impl OpenCADStudio { show_properties: true, show_block_palette: false, block_palette: Default::default(), + xref_manager: Default::default(), dock: Default::default(), dock_expanded: None, dock_dragging: None, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index fa0134635..2f5aecbb7 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -757,6 +757,32 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.refresh_block_palette(); } } + + /// Rebuild the Reference Manager's entry list from the active drawing. + /// Display-only: `collect_entries` stats files but mutates nothing. + pub(crate) fn refresh_xref_manager(&mut self) { + let i = self.active_tab; + let base_dir: std::path::PathBuf = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let tab_id = self.tabs[i].id; + self.xref_manager + .refresh(&self.tabs[i].scene.document, &base_dir); + self.xref_manager.source_tab_id = Some(tab_id); + } + + /// Re-scan when the palette is open but showing another tab's drawing. + pub(crate) fn refresh_xref_manager_if_stale(&mut self) { + if self.active_modal != Some(crate::app::ModalKind::XrefManager) { + return; + } + let i = self.active_tab; + if self.xref_manager.source_tab_id != Some(self.tabs[i].id) { + self.refresh_xref_manager(); + } + } } #[cfg(test)] diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 1f999afd0..9d5e17ded 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -314,6 +314,9 @@ impl OpenCADStudio { // The block panel watches the drawing's block list and rebuilds its // thumbnails whenever the names change (BLOCK define, file open, …). self.refresh_block_palette_if_stale(); + // The Reference Manager watches the active drawing and re-scans when + // the palette is open on another tab's entries. + self.refresh_xref_manager_if_stale(); // Let V4 plugins observe selection changes that happened while handling // this message (picking, window select, QSELECT, SELECTALL, grip edits, // and plugin request draining). @@ -2127,6 +2130,33 @@ impl OpenCADStudio { Task::none() } + Message::ToggleXrefManager => { + if self.active_modal == Some(super::ModalKind::XrefManager) { + self.active_modal = None; + self.reset_modal_geometry(); + } else { + self.refresh_xref_manager(); + self.active_modal = Some(super::ModalKind::XrefManager); + } + Task::none() + } + Message::XrefManagerRefresh => { + self.refresh_xref_manager(); + Task::none() + } + Message::XrefManagerSelect(index) => { + self.xref_manager.toggle_select(index); + Task::none() + } + Message::XrefManagerToggleTree => { + self.xref_manager.toggle_tree(); + Task::none() + } + Message::XrefManagerToggleExpand(key) => { + self.xref_manager.toggle_expand(key); + Task::none() + } + Message::LayerStateManagerOpen => { let i = self.active_tab; self.ribbon.close_dropdown(); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 8df1e0324..43a9e4fa5 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -20,6 +20,8 @@ impl OpenCADStudio { Some(K::UpdateNotice) => crate::tr!("modal", "update-available"), Some(K::DonationPrompt) => crate::tr!("donation", "title"), Some(K::Layers) => crate::tr!("modal", "layer-manager"), + // XREF-Task8: locale (hardcoded until Task 8 batches locale strings). + Some(K::XrefManager) => "Reference Manager".to_string(), Some(K::LayerStateManager) => crate::tr!("modal", "layer-state-manager"), Some(K::LayerTranslator) => crate::t!("Layer Translator").into_owned(), Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(), @@ -394,6 +396,11 @@ impl OpenCADStudio { |flow| tab.layers.view_window(self.layer_name_col_w, flow), ) } + super::super::ModalKind::XrefManager => { + sized_flow(ex, 960, 520, |flow| { + self.xref_manager.view_window(flow) + }) + } super::super::ModalKind::LayerTranslator => { use crate::modules::draw::layers::laytrans; let i = self.active_tab; diff --git a/src/io/xref_model.rs b/src/io/xref_model.rs index d483dc105..ee7526fbe 100644 --- a/src/io/xref_model.rs +++ b/src/io/xref_model.rs @@ -312,9 +312,17 @@ pub enum RefType { /// One external reference in the XREF manager's unified list. /// /// `key` is the block-record handle (DWG xref) or definition-object handle -/// (image / PDF) — stable across renames, so [`renamed`](Self::renamed) keeps -/// it. `saved_path` is the raw stored string verbatim, never synthesized; -/// `found_at` is where it actually resolved, if anywhere. +/// (image / PDF) for top-level entries — stable across renames, so +/// [`renamed`](Self::renamed) keeps it. Nested child entries (enumerated from +/// a host file, never merged) use [`child_key`] — a stable hash of +/// `(parent_key, name, saved_path)` — so a foreign handle that collides with +/// a host handle can never alias a host row. `saved_path` is the raw stored +/// string verbatim, never synthesized; `found_at` is where it actually +/// resolved, if anywhere. +/// +/// `parent_key` is `None` for roots and `Some(host_key)` for nested children. +/// The palette's refresh path keys rows off `(key, saved_path)` — both sides +/// go through `collect_entries`, so the same derivation applies everywhere. #[derive(Debug, Clone, PartialEq)] pub struct ReferenceEntry { pub key: u64, @@ -327,6 +335,7 @@ pub struct ReferenceEntry { pub saved_path: String, pub found_at: Option, pub loaded: bool, + pub parent_key: Option, } impl ReferenceEntry { @@ -342,6 +351,7 @@ impl ReferenceEntry { saved_path: String::new(), found_at: None, loaded: true, + parent_key: None, } } @@ -360,7 +370,6 @@ impl ReferenceEntry { pub fn bind_symbol(parent: &str, child: &str, sym: &str) -> String { format!("{parent}$0${child}$0${sym}") } - /// [`bind_symbol`] for the two-level case, bumping the `$N$` counter past /// every collision in `taken` (`PLAN$0$WALLS` taken → `PLAN$1$WALLS`). pub fn bind_symbol_taken(parent: &str, sym: &str, taken: &[impl AsRef]) -> String { @@ -374,10 +383,183 @@ pub fn bind_symbol_taken(parent: &str, sym: &str, taken: &[impl AsRef]) -> } } +/// Stable key for a nested child entry. +/// +/// Top-level entries keep the host handle value unchanged. Nested children +/// come from foreign files whose handle values can collide with host handles, +/// so they are namespaced via `DefaultHasher(parent_key, name, saved_path)`. +/// Deterministic within a run; same derivation is used everywhere +/// `collect_entries` output is consumed, so palette `(key, saved_path)` rows +/// keep resolving. +pub fn child_key(parent_key: u64, name: &str, saved_path: &str) -> u64 { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut h = DefaultHasher::new(); + parent_key.hash(&mut h); + name.hash(&mut h); + saved_path.hash(&mut h); + h.finish() +} + +/// Pure status decision for one entry (no filesystem). +/// +/// - `resolved` is the stat outcome (`Loaded`/`NotFound`/`Failed`; `Unloaded` +/// never reaches here — it is decided before stat). +/// - `live` is the current mtime, `cached` the load-time mtime from the +/// previous refresh (`None` on first call → no `Stale` on bootstrap). +/// - `parent_failed` is true for nested children whose host entry is +/// `NotFound`/`Failed` → `Loaded` is overridden to `Orphaned`. +/// +/// The 1s slack absorbs filesystem timestamp granularity so a file that did +/// not actually change is not flagged `Stale`. +pub fn decide_status( + resolved: RefStatus, + live: Option, + cached: Option, + parent_failed: bool, +) -> RefStatus { + if parent_failed && resolved == RefStatus::Loaded { + return RefStatus::Orphaned; + } + if resolved == RefStatus::Loaded { + if let (Some(l), Some(c)) = (live, cached) { + if let Ok(delta) = l.duration_since(c) { + if delta > std::time::Duration::from_secs(1) { + return RefStatus::Stale; + } + } + } + } + resolved +} + +/// Session set of unloaded reference keys. +/// +/// Owns the `HashSet` that `collect_entries` takes as `unloaded`. +/// Task 8b wires session state; the set itself lives here so both sides +/// share one owner. Keys are top-level handle values (nested children are +/// never unloadable on their own). +#[derive(Debug, Clone, Default)] +pub struct UnloadSet(pub std::collections::HashSet); + +impl UnloadSet { + pub fn add(&mut self, key: u64) { + self.0.insert(key); + } + pub fn remove(&mut self, key: &u64) { + self.0.remove(key); + } + pub fn contains(&self, key: &u64) -> bool { + self.0.contains(key) + } + pub fn is_unloaded(&self, key: u64) -> bool { + self.0.contains(&key) + } + pub fn as_set(&self) -> &std::collections::HashSet { + &self.0 + } +} + +/// Load-time mtimes per reference key, populated on first refresh. +/// +/// `Stale` is detectable only thereafter: an empty cache means bootstrap, so +/// `decide_status` never reports `Stale` without a cached baseline. Reload +/// clears the key (fresh baseline on next refresh). Task 8b owns session +/// wiring; the map type lives here. +#[derive(Debug, Clone, Default)] +pub struct RefStatCache(pub std::collections::HashMap); + +impl RefStatCache { + pub fn insert(&mut self, key: u64, mtime: std::time::SystemTime) { + self.0.insert(key, mtime); + } + pub fn get(&self, key: &u64) -> Option { + self.0.get(key).copied() + } + pub fn remove(&mut self, key: &u64) { + self.0.remove(key); + } +} + #[cfg(test)] mod tests { use super::{normalize_lexical, to_pathtype, to_pathtype_result, wildcard_match, Pathtype, PathtypeError}; use super::{bind_symbol, bind_symbol_taken, RefKind, ReferenceEntry}; + use super::{child_key, decide_status, RefStatCache, RefStatus, UnloadSet}; + + #[test] + fn child_key_differs_from_colliding_host_key() { + // Crafted collision: nested foreign handle 42 equals a host handle 42. + // Namespaced derivation must not alias the host row. + let host_key = 42u64; + let nested = child_key(100, "DETAIL", "refs/detail.dwg"); + assert_ne!(nested, host_key); + // Stable: same inputs → same key; different parent → different key. + assert_eq!(nested, child_key(100, "DETAIL", "refs/detail.dwg")); + assert_ne!(nested, child_key(101, "DETAIL", "refs/detail.dwg")); + } + + #[test] + fn decide_status_stale_needs_slack() { + use std::time::{Duration, UNIX_EPOCH}; + let cached = UNIX_EPOCH + Duration::from_secs(1_000); + // +0.5s: within 1s slack → stays Loaded. + let live = cached + Duration::from_millis(500); + assert_eq!( + decide_status(RefStatus::Loaded, Some(live), Some(cached), false), + RefStatus::Loaded + ); + // +2s: beyond slack → Stale. + let live2 = cached + Duration::from_secs(2); + assert_eq!( + decide_status(RefStatus::Loaded, Some(live2), Some(cached), false), + RefStatus::Stale + ); + // No cached baseline (first refresh) → never Stale on bootstrap. + assert_eq!( + decide_status(RefStatus::Loaded, Some(live2), None, false), + RefStatus::Loaded + ); + } + + #[test] + fn decide_status_orphaned_overrides_loaded_only() { + use std::time::{Duration, UNIX_EPOCH}; + let t = Some(UNIX_EPOCH + Duration::from_secs(1_000)); + assert_eq!( + decide_status(RefStatus::Loaded, t, t, true), + RefStatus::Orphaned + ); + // Non-Loaded nested states survive the parent override. + assert_eq!( + decide_status(RefStatus::NotFound, t, t, true), + RefStatus::NotFound + ); + assert_eq!( + decide_status(RefStatus::Failed, t, t, true), + RefStatus::Failed + ); + } + + #[test] + fn unload_set_add_remove_contains() { + let mut s = UnloadSet::default(); + assert!(!s.is_unloaded(7)); + s.add(7); + assert!(s.is_unloaded(7)); + assert!(s.contains(&7)); + s.remove(&7); + assert!(!s.is_unloaded(7)); + } + + #[test] + fn ref_stat_cache_roundtrip() { + use std::time::{Duration, UNIX_EPOCH}; + let mut c = RefStatCache::default(); + let t = UNIX_EPOCH + Duration::from_secs(5); + c.insert(9, t); + assert_eq!(c.get(&9), Some(t)); + } #[test] fn lexical_normalize_missing_file_no_fs_touch() { diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 09b3b2314..7a29b89a3 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -21,3 +21,4 @@ pub mod annotation_data; pub mod alias_editor; pub mod find_replace; pub mod named_parameters; +pub mod xref_manager; diff --git a/src/ui/window/xref_manager.rs b/src/ui/window/xref_manager.rs index 8d8742611..a0351081e 100644 --- a/src/ui/window/xref_manager.rs +++ b/src/ui/window/xref_manager.rs @@ -1,16 +1,18 @@ -//! Reference Manager palette — list of external references with operations. +//! Reference Manager palette — display-only list of external references. //! -//! 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. +//! Task 7 build: a read-only table (Reference / Status / Size / Type / Date / +//! Saved Path) fed by [`collect_entries`], a List/Tree toggle over the nested +//! closure, a details pane for the selected entry, and a toolbar whose +//! mutating affordances are present but disabled. Mutations (attach result +//! handling, path edits, unload/reload, missing-on-open prompt) arrive in +//! Tasks 8–9 — this file performs no document mutation. 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::io::xref::collect_entries; +use crate::io::xref_model::{normalize_lexical, RefKind, RefStatus, RefType, ReferenceEntry}; use crate::ui::ROW_H; use acadrust::CadDocument; -use iced::widget::{button, column, container, mouse_area, row, scrollable, text, text_input, tooltip}; +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}; @@ -26,13 +28,8 @@ const IS_WASM: bool = cfg!(target_arch = "wasm32"); 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 = 240.0; -/// Minimum table content width: the six columns stay readable and the list -/// sidescrolls in narrower docks instead of squeezing. -const TABLE_MIN_W: f32 = 480.0; /// Indent per tree depth level. const INDENT_W: f32 = 16.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)] @@ -45,25 +42,17 @@ pub struct DisplayRow { 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. +/// Display-only state for the Reference Manager palette. /// -/// 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. +/// `unloaded_keys` is the session unloaded set — empty for now; Task 8 wires +/// it to unload/reload without restructuring this state. #[derive(Default)] pub struct XrefManagerPanel { - /// Cached [`collect_entries_with_prev`] output for the active drawing. + /// Cached [`collect_entries`] output for the active drawing. pub entries: Vec, - /// 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. + /// Block-record / definition handles treated as unloaded (Task 8). + pub unloaded_keys: HashSet, + /// Multi-selected entry indices — consumed by batch path ops in Task 8. pub selected: HashSet, /// Anchor entry driving the details pane (last row clicked). pub anchor: Option, @@ -77,61 +66,13 @@ pub struct XrefManagerPanel { pub nested: HashSet, /// Document tab id that produced `entries` (stale check). pub source_tab_id: Option, - /// `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, - /// Draft for the details-pane "new path" edit. - pub path_input: String, - /// Drafts for the Find & Replace row. - pub find_input: String, - pub replace_input: String, - /// Open dropdown menus (one at a time; dismissed together). - pub attach_open: bool, - pub refresh_open: bool, - pub 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, -} - -/// Selection-gated palette operation the toolbar offers. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum XrefPaletteOp { - 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, - prev: &HashMap, - host_name: &str, - host_path: &str, - ) -> Vec<(u64, SystemTime)> { - self.host_name = host_name.to_string(); - self.host_path = host_path.to_string(); + /// Rebuild `entries` from `doc` via [`collect_entries`] and recompute the + /// parent → children linkage for tree mode. Selection/anchor survive by + /// `(key, saved_path)` identity; dead indices are dropped. + pub fn refresh(&mut self, doc: &CadDocument, base_dir: &Path) { let old = std::mem::take(&mut self.entries); let sel_ids: HashSet<(u64, String)> = self .selected @@ -144,7 +85,7 @@ impl XrefManagerPanel { .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); + let entries = collect_entries(doc, base_dir, &self.unloaded_keys); // 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. @@ -212,45 +153,13 @@ impl XrefManagerPanel { .iter() .position(|e| e.key == k && e.saved_path == p) }); - // Previews decode here (not in `view`, which must stay pure): the - // anchor entry only, and only under single selection — the spec shows - // a preview solely for one selected reference. - self.previews.clear(); - if self.selected.len() <= 1 { - if let Some(a) = self.anchor.and_then(|i| self.entries.get(i)) { - if let (Some(found), Some(img)) = - (a.found_at.clone(), reference_preview(a)) - { - let (w, h) = (img.width(), img.height()); - self.previews.insert( - (a.key, found), - iced::widget::image::Handle::from_rgba(w, h, img.into_raw()), - ); - } - } - } 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 toggles membership in the multi-selection set; selecting sets the /// details anchor, deselecting the anchor falls back to a survivor. pub fn toggle_select(&mut self, index: usize) { - 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); + if index >= self.entries.len() { return; } if !self.selected.remove(&index) { @@ -273,77 +182,27 @@ impl XrefManagerPanel { } } - /// 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 { - let mut out: Vec = 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 { - // 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 { + return self + .entries + .iter() + .enumerate() + .map(|(index, _)| DisplayRow { index, depth: 0, is_nested: self.nested.contains(&index), - } - })); - return rows; + }) + .collect(); } - fn append_tree( - panel: &XrefManagerPanel, - index: usize, - depth: u32, - seen: &mut HashSet<(String, String)>, - rows: &mut Vec, - ) { - 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 mut seen: HashSet = HashSet::new(); + // Roots first, in collect order; children follow an expanded parent. + for (index, entry) in self.entries.iter().enumerate() { let dominated = self .children .values() @@ -351,421 +210,102 @@ impl XrefManagerPanel { 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; + if !claim_path(&mut seen, &entry.saved_path) { + continue; + } + rows.push(DisplayRow { + index, + depth: 0, + is_nested: self.nested.contains(&index), + }); + if self.expanded.contains(&entry.key) { + if let Some(kids) = self.children.get(&entry.key) { + for &ci in kids { + let Some(child) = self.entries.get(ci) else { + continue; + }; + if !claim_path(&mut seen, &child.saved_path) { + continue; + } + rows.push(DisplayRow { + index: ci, + depth: 1, + is_nested: true, + }); + } + } } } - 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::{DockMsg, PanelId}; - // ── Dock chrome (title, pin, close) — matches the block palette ── - let pin_icon = if auto_collapse { - crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0) + /// Render the palette as the full content of its modal dialog. + pub fn view_window(&self, sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> { + // ── Toolbar ─────────────────────────────────────────────────────── + let can_attach = !IS_WASM; + let attach = if can_attach { + toolbar_btn("Attach", Some(Message::XAttachPick)) // XREF-Task8: locale } else { - crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0) - }; - let pin = button(pin_icon) - .on_press(Message::Dock(DockMsg::AutoCollapseToggle( - PanelId::ExternalReferences, - ))) - .style(move |theme: &Theme, status| { - let mut style = button::subtle(theme, status); - if auto_collapse { - let palette = theme.palette(); - style.background = Some(Background::Color(palette.primary.weak.color)); - style.text_color = palette.primary.weak.text; - style.border.color = palette.primary.base.color; - style.border.width = 1.0; - } - style - }) - .padding([3, 5]); - let pin = tooltip(pin, text("Auto").size(10), tooltip::Position::Bottom).gap(4); - let close = button(crate::ui::icons::themed_secondary(crate::ui::icons::CLOSE, 12.0)) - .on_press(Message::Dock(DockMsg::Close(PanelId::ExternalReferences))) - .style(button::subtle) - .padding([3, 5]); - let close = tooltip(close, text("Close").size(10), tooltip::Position::Bottom).gap(4); - let title_bar = mouse_area( - container( - row![ - text(crate::t!("External References")).size(12), - iced::widget::Space::new().width(Fill), - pin, - close, - ] - .spacing(3) - .align_y(iced::Center), - ) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.weak.color)), - ..Default::default() - }) - .width(Fill) - .padding([3, 6]), - ) - .on_press(Message::Dock(DockMsg::DockGrab(PanelId::ExternalReferences))) - .interaction(iced::mouse::Interaction::Grab); - // Table content follows the dock width (minus chrome padding) with a - // floor so columns stay readable: widening the dock widens the table, - // narrowing it sidescrolls instead of squeezing. - let table_w = (width - 16.0).max(TABLE_MIN_W); - // ── 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").into_owned(), web_tip.clone()) - } else { - split_button( - toolbar_btn(crate::t!("Attach").into_owned(), Some(Message::XAttachPick)), - self.attach_open, - Message::XrefManagerAttachMenu, - vec![ - menu_item( - crate::t!("Image").into_owned(), - Some(Message::ImagePick), - None, - ), - menu_item( - crate::t!("PDF").into_owned(), - Some(Message::PdfAttachPick), - None, - ), - ], + toolbar_tip( + "Attach", // XREF-Task8: locale + "File attach is not available on web — the reference list below is read-only.", // XREF-Task8: locale ) }; - let refresh = if IS_WASM { - toolbar_tip(crate::t!("Refresh").into_owned(), web_tip.clone()) - } else { - split_button( - toolbar_btn( - 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 are two buttons; the active mode renders inert. - let list_btn = if self.tree { - toolbar_btn(crate::t!("List").into_owned(), Some(Message::XrefManagerToggleTree)) - } else { - toolbar_btn(crate::t!("List").into_owned(), None) - }; - let tree_btn = if self.tree { - toolbar_btn(crate::t!("Tree").into_owned(), None) - } else { - toolbar_btn(crate::t!("Tree").into_owned(), Some(Message::XrefManagerToggleTree)) - }; - let mode_row: Element<'static, Message> = - row![list_btn, tree_btn].spacing(2).into(); + let image_btn = toolbar_tip( + "Image…", // XREF-Task8: locale + "images/PDF attach — Task 8", // XREF-Task8: locale + ); + let pdf_btn = toolbar_tip( + "PDF…", // XREF-Task8: locale + "images/PDF attach — Task 8", // XREF-Task8: locale + ); + let refresh = toolbar_btn("Refresh", Some(Message::XrefManagerRefresh)); // XREF-Task8: locale + let mode_label = if self.tree { "List" } else { "Tree" }; // XREF-Task8: locale + let mode_btn = toolbar_btn(mode_label, Some(Message::XrefManagerToggleTree)); let mode_btn: Element<'_, Message> = tooltip( - mode_row, - text(crate::t!("Toggle list/tree")).size(11), + mode_btn, + text("Toggle list/tree").size(11), // XREF-Task8: locale tooltip::Position::Bottom, ) .into(); - // Selection-gated ops: disabled with a reason when nothing actionable - // is selected (empty selection, or nested rows which stay read-only). - let gate: Option = if IS_WASM { - Some(crate::t!("Reference changes are not available on web — the reference list is read-only.").into_owned()) - } else if self.selected.is_empty() { - Some(crate::t!("Select a reference first.").into_owned()) - } else if self.selection_has_nested() { - Some( - crate::t!("Nested references are read-only — edit them in their host drawing.") - .into_owned(), - ) - } else { - None - }; - let op_btn = |label: String, op: XrefPaletteOp, gate: &Option| match gate { - Some(reason) => toolbar_tip(label, reason.clone()), - None => toolbar_btn(label, Some(Message::XrefManagerOp(op))), - }; - let detach = op_btn( - crate::t!("Detach").into_owned(), - XrefPaletteOp::Detach, - &gate, - ); - let unload = op_btn( - crate::t!("Unload").into_owned(), - XrefPaletteOp::Unload, - &gate, - ); - let reload = op_btn( - crate::t!("Reload").into_owned(), - XrefPaletteOp::Reload, - &gate, - ); - let bind = op_btn( - crate::t!("Bind").into_owned(), - XrefPaletteOp::Bind, - &gate, - ); - let overlay = op_btn( - crate::t!("Overlay").into_owned(), - XrefPaletteOp::Overlay, - &gate, - ); - // Type control, both directions: Attach ↔ Overlay (same engine fn the - // CLI Overlay arm uses; images/PDFs report the drawing-only error). - let attach_type = op_btn( - crate::t!("Attach").into_owned(), - XrefPaletteOp::Attach, - &gate, - ); - // 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 find_ready = !self.find_input.is_empty() && !self.replace_input.is_empty(); - let web_readonly: Option = 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 = 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), - 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()) - }; - let find_gate = if find_ready { - None - } else { - Some(crate::t!("Type the path prefix to find first.").into_owned()) - }; - split_button( - toolbar_btn( - 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::XrefManagerFindReplaceApply), - find_gate, - ), - ], - ) - } - }; + // Change Path submenu items exist but stay disabled until Task 8. + let change_path = row![ + text("Change Path:").size(11), // XREF-Task8: locale + toolbar_tip("Full", "path operations — Task 8"), // XREF-Task8: locale + toolbar_tip("Relative", "path operations — Task 8"), // XREF-Task8: locale + toolbar_tip("File name", "path operations — Task 8"), // XREF-Task8: locale + ] + .spacing(2) + .align_y(iced::Center); + // No docs hook exists yet — tooltip only, no docs files (Task 7). let help = toolbar_tip( - crate::t!("Help").into_owned(), - crate::t!("Reference Manager — select rows, then Detach, Unload, Reload, Bind, Overlay, or a Change Path mode.").into_owned(), + "Help", // XREF-Task8: locale + "Reference Manager — path operations ship with a later task.", // XREF-Task8: locale ); let toolbar = container( - row![ - attach, - refresh, - mode_btn, - detach, - unload, - reload, - bind, - overlay, - attach_type, - change_path, - help - ] - .spacing(4) - .align_y(iced::Center), + row![attach, image_btn, pdf_btn, refresh, mode_btn, change_path, help] + .spacing(4) + .align_y(iced::Center), ) .style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.weak.color)), ..Default::default() }) - .width(Fill) - .padding([4, 8]); - - // ── Find & Replace / path-edit row ──────────────────────────────── - let find_apply = if IS_WASM { - toolbar_tip( - crate::t!("Apply").into_owned(), - crate::t!("Reference changes are not available on web — the reference list is read-only.").into_owned(), - ) - } else if self.find_input.is_empty() { - toolbar_tip( - crate::t!("Apply").into_owned(), - crate::t!("Type the path prefix to find first.").into_owned(), - ) - } else { - toolbar_btn( - crate::t!("Apply").into_owned(), - Some(Message::XrefManagerFindReplaceApply), - ) - }; - // Path edit applies to the anchor entry; nested anchors stay disabled. - let anchor_nested = self - .anchor - .is_some_and(|a| self.nested.contains(&a)); - let path_apply = match self.anchor.and_then(|i| self.entries.get(i)) { - Some(_) if IS_WASM => toolbar_tip( - crate::t!("Set Path").into_owned(), - crate::t!("Reference changes are not available on web — the reference list is read-only.").into_owned(), - ), - Some(_) if anchor_nested => toolbar_tip( - crate::t!("Set Path").into_owned(), - crate::t!("Nested references are read-only — edit them in their host drawing.") - .into_owned(), - ), - Some(_) if !self.path_input.is_empty() => toolbar_btn( - crate::t!("Set Path").into_owned(), - Some(Message::XrefManagerPathApply), - ), - Some(_) => toolbar_tip( - crate::t!("Set Path").into_owned(), - crate::t!("Type a new path first.").into_owned(), - ), - None => toolbar_tip( - crate::t!("Set Path").into_owned(), - crate::t!("Select a reference first.").into_owned(), - ), - }; - let edit_row = container( - row![ - text(crate::t!("Find:")).size(11), - text_input( - crate::t!("old path prefix").as_ref(), - &self.find_input - ) - .on_input(Message::XrefManagerFindInput) - .size(11) - .padding(4) - .width(Length::Fixed(150.0)), - text(crate::t!("Replace:")).size(11), - text_input( - crate::t!("new path prefix").as_ref(), - &self.replace_input - ) - .on_input(Message::XrefManagerReplaceInput) - .size(11) - .padding(4) - .width(Length::Fixed(150.0)), - find_apply, - text(crate::t!("Path:")).size(11), - text_input(crate::t!("new path for selection").as_ref(), &self.path_input) - .on_input(Message::XrefManagerPathInput) - .size(11) - .padding(4) - .width(Length::Fill), - path_apply, - ] - .spacing(4) - .align_y(iced::Center), - ) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.weak.color)), - ..Default::default() - }) - .width(Fill) + .width(sizing.width) .padding([4, 8]); // ── Column header ───────────────────────────────────────────────── let col_header = container( row![ - text(crate::t!("Reference")).size(10).style(muted_style).width(Length::FillPortion(3)), - text(crate::t!("Status")).size(10).style(muted_style).width(Length::Fixed(88.0)), - text(crate::t!("Size")).size(10).style(muted_style).width(Length::Fixed(76.0)), - text(crate::t!("Type")).size(10).style(muted_style).width(Length::Fixed(76.0)), - text(crate::t!("Date")).size(10).style(muted_style).width(Length::Fixed(96.0)), - text(crate::t!("Saved Path")).size(10).style(muted_style).width(Length::FillPortion(4)), + text("Reference").size(10).style(muted_style).width(Length::FillPortion(3)), // XREF-Task8: locale + text("Status").size(10).style(muted_style).width(Length::Fixed(88.0)), // XREF-Task8: locale + text("Size").size(10).style(muted_style).width(Length::Fixed(76.0)), // XREF-Task8: locale + text("Type").size(10).style(muted_style).width(Length::Fixed(76.0)), // XREF-Task8: locale + text("Date").size(10).style(muted_style).width(Length::Fixed(96.0)), // XREF-Task8: locale + text("Saved Path").size(10).style(muted_style).width(Length::FillPortion(4)), // XREF-Task8: locale ] .spacing(4) - .width(Length::Fixed(table_w)) + .width(sizing.width) .align_y(iced::Center), ) .style(|theme: &Theme| { @@ -781,20 +321,11 @@ impl XrefManagerPanel { } }) .padding([4, 8]) - .width(Fill); + .width(sizing.width); // ── 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, - )); - continue; - } let Some(entry) = self.entries.get(display.index) else { continue; }; @@ -808,14 +339,11 @@ impl XrefManagerPanel { is_sel, self.tree && has_children, is_expanded, - table_w, )); } - // ── Reference table: header + rows scroll together, horizontally - // (fixed content width overflows narrow docks) and vertically. - let table_rows: Element<'_, Message> = if self.entries.is_empty() { + let body: Element<'_, Message> = if self.entries.is_empty() { container( - text(crate::t!("XREF No external references in this drawing.")) + text("No external references in this drawing") // XREF-Task8: locale .size(12) .color(iced::Color { r: 0.55, @@ -830,131 +358,21 @@ impl XrefManagerPanel { .height(Length::Fixed(TABLE_H)) .into() } else { - scrollable(column![col_header, rows_col]) - .direction(iced::widget::scrollable::Direction::Both { - vertical: iced::widget::scrollable::Scrollbar::new(), - horizontal: iced::widget::scrollable::Scrollbar::new(), - }) - .height(Length::Fixed(TABLE_H)) - .into() + scrollable(rows_col).height(Length::Fixed(TABLE_H)).into() }; - // ── Missing-on-open notice + details pane ───────────────────────── - let notice: Option> = 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 - }, - ); - let preview_tab = toolbar_btn( - crate::t!("Preview").into_owned(), - if self.show_preview { - None - } else { - Some(Message::XrefManagerTogglePreview) - }, - ); - 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() - }; + // ── Details pane ────────────────────────────────────────────────── + let details = details_pane(self.anchor.and_then(|i| self.entries.get(i))); - let mut content = column![title_bar, toolbar, edit_row, table_rows]; - if let Some(notice) = notice { - content = content.push(notice); - } - content = content.push(lower); - container(content.spacing(0)) + container(column![toolbar, col_header, body, details].spacing(0)) .style(|theme: &Theme| container::Style { background: Some(Background::Color( theme.palette().background.base.color, )), ..Default::default() }) - .width(Fill) - .height(Fill) + .width(sizing.width) + .height(sizing.height) .into() } } @@ -965,6 +383,9 @@ impl XrefManagerPanel { fn direct_identities(doc: &CadDocument) -> HashSet<(u64, String)> { use acadrust::entities::UnderlayType; use acadrust::objects::ObjectType; + use acadrust::types::Handle; + use acadrust::EntityType; + use rustc_hash::FxHashSet; let mut ids: HashSet<(u64, String)> = HashSet::new(); for br in doc.block_records.iter() { @@ -972,10 +393,20 @@ fn direct_identities(doc: &CadDocument) -> HashSet<(u64, String)> { ids.insert((br.handle.value(), br.xref_path.clone())); } } + let mut referenced_images: FxHashSet = FxHashSet::default(); + for e in doc.entities() { + if let EntityType::RasterImage(img) = e { + if let Some(h) = img.definition_handle { + referenced_images.insert(h); + } + } + } for (handle, obj) in doc.objects.iter() { match obj { ObjectType::ImageDefinition(def) => { - ids.insert((handle.value(), def.file_name.clone())); + if referenced_images.contains(handle) { + ids.insert((handle.value(), def.file_name.clone())); + } } ObjectType::UnderlayDefinition(def) => { if def.underlay_type == UnderlayType::Pdf { @@ -988,57 +419,36 @@ fn direct_identities(doc: &CadDocument) -> HashSet<(u64, String)> { 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 { +/// Claim a normalized saved path for display; empty paths always pass, repeats +/// are skipped (cycle guard). +fn claim_path(seen: &mut HashSet, saved_path: &str) -> bool { if saved_path.is_empty() { return true; } - seen.insert((normalize_lexical(saved_path), name.to_string())) + seen.insert(normalize_lexical(saved_path)) } // ── Text helpers ────────────────────────────────────────────────────────── -fn status_text(status: RefStatus) -> std::borrow::Cow<'static, str> { +fn status_text(status: RefStatus) -> &'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"), + RefStatus::Loaded => "Loaded", // XREF-Task8: locale + RefStatus::Unloaded => "Unloaded", // XREF-Task8: locale + RefStatus::NotFound => "Not found", // XREF-Task8: locale + RefStatus::Failed => "Failed", // XREF-Task8: locale + RefStatus::Stale => "Stale", // XREF-Task8: locale + RefStatus::Orphaned => "Orphaned", // XREF-Task8: locale } } -fn type_text(entry: &ReferenceEntry) -> std::borrow::Cow<'static, str> { +fn type_text(entry: &ReferenceEntry) -> &'static str { match entry.kind { RefKind::DwgXref => match entry.ref_type { - RefType::Attach => crate::t!("Attach"), - RefType::Overlay => crate::t!("Overlay"), + RefType::Attach => "Attach", // XREF-Task8: locale + RefType::Overlay => "Overlay", // XREF-Task8: locale }, - // 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::Pdf => crate::t!("PDF"), - } -} - -/// 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()) + RefKind::Image => "Image", // XREF-Task8: locale + RefKind::Pdf => "PDF", // XREF-Task8: locale } } @@ -1103,7 +513,7 @@ fn row_button_style(selected: bool, index: usize) -> impl Fn(&Theme, button::Sta } } -fn toolbar_btn(label: String, msg: Option) -> Element<'static, Message> { +fn toolbar_btn<'a>(label: &'static str, msg: Option) -> Element<'a, Message> { let mut b = button(text(label).size(11)) .style(|theme: &Theme, status| { let palette = theme.palette(); @@ -1131,9 +541,9 @@ fn toolbar_btn(label: String, msg: Option) -> Element<'static, Message> 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) -> Element<'static, Message> { +/// Disabled toolbar affordance with an explanatory tooltip (Task 8 work or +/// web-gated mutations). +fn toolbar_tip<'a>(label: &'static str, tip: &'static str) -> Element<'a, Message> { let b = button(text(label).size(11).style(|theme: &Theme| { iced::widget::text::Style { color: Some( @@ -1163,73 +573,16 @@ fn toolbar_tip(label: String, tip: String) -> Element<'static, Message> { 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 ▾). -/// -/// `main` is the already-gated default-action element; `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: Element<'static, Message>, - menu_open: bool, - toggle: Message, - items: Vec>, -) -> Element<'static, Message> { - let caret = button( - container(crate::ui::icons::themed_arrow_down(9.0)).align_y(iced::Center), - ) - .on_press(toggle) - .style(button::subtle) - .padding([4, 6]); - let head = row![main, caret].spacing(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, gate: Option) -> Element<'static, Message> { - match (msg, gate) { - (Some(m), None) => toolbar_btn(label, Some(m)), - (_, reason) => toolbar_tip(label, reason.unwrap_or_default()), - } -} - fn xref_row<'a>( display: DisplayRow, entry: &'a ReferenceEntry, is_selected: bool, show_expand: bool, is_expanded: bool, - table_w: f32, ) -> Element<'a, Message> { let mut name = entry.name.clone(); if display.is_nested { - name.push_str(&crate::t!(" (nested — not rendered)")); + name.push_str(" (nested — not rendered)"); // XREF-Task8: locale } let mut name_cell = row![].spacing(2).align_y(iced::Center); if display.depth > 0 { @@ -1263,7 +616,7 @@ fn xref_row<'a>( text(entry.saved_path.clone()).size(FONT_SZ).width(Length::FillPortion(4)), ] .spacing(4) - .width(Length::Fixed(table_w)) + .width(Fill) .align_y(iced::Center); let index = display.index; @@ -1297,60 +650,9 @@ fn xref_row<'a>( .into() } -/// The host-drawing pseudo-row: always first, never selectable, never -/// actionable. Mirrors the industry layout (current drawing leads the list -/// and roots the tree) with an `Opened` / `Current` status/type pair. -fn host_row<'a>(display: DisplayRow, host_name: &'a str, host_path: &'a str, table_w: f32) -> 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 content = row![ - name_cell.width(Length::FillPortion(3)), - text(crate::t!("Opened")).size(FONT_SZ).width(Length::Fixed(88.0)), - text("—").size(FONT_SZ).width(Length::Fixed(76.0)), - text(crate::t!("Current")).size(FONT_SZ).width(Length::Fixed(76.0)), - text("—").size(FONT_SZ).width(Length::Fixed(96.0)), - text(saved).size(FONT_SZ).width(Length::FillPortion(4)), - ] - .spacing(4) - .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> { +fn details_pane(entry: Option<&ReferenceEntry>) -> Element<'_, Message> { let inner: Element<'_, Message> = match entry { - None => text(crate::t!("Select a reference to inspect its details")) + None => text("Select a reference to inspect its details") // XREF-Task8: locale .size(11) .style(muted_style) .into(), @@ -1363,42 +665,22 @@ fn details_pane<'a>(entry: Option<&'a ReferenceEntry>, doc: &'a CadDocument) -> }; 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), + column![ + detail_row("Reference", e.name.as_str()), // XREF-Task8: locale + detail_row("Status", status_text(e.status)), // XREF-Task8: locale + detail_row("Size", size), // XREF-Task8: locale + detail_row("Type", type_text(e)), // XREF-Task8: locale + detail_row("Date", date), // XREF-Task8: locale + detail_row("Found At", found), // XREF-Task8: locale + detail_row("Saved Path", saved), // XREF-Task8: locale ] - .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), - )); - } - } - rows.into() + .spacing(1) + .into() } }; container( column![ - text(crate::t!("Details")).size(10).style(muted_style), + text("Details").size(10).style(muted_style), // XREF-Task8: locale inner, ] .spacing(2), @@ -1417,60 +699,7 @@ fn details_pane<'a>(entry: Option<&'a ReferenceEntry>, doc: &'a CadDocument) -> .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 { - if IS_WASM { - return None; - } - if !matches!(entry.status, RefStatus::Loaded | RefStatus::Stale) { - return None; - } - 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::Pdf => 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<&acadrust::objects::ImageDefinition> { - doc.objects.get(&acadrust::types::Handle::from(key)).and_then(|o| match o { - acadrust::objects::ObjectType::ImageDefinition(def) => Some(def), - _ => None, - }) -} - -fn detail_row<'a>( - label: impl Into>, - value: impl Into>, -) -> Element<'a, Message> { - let label: std::borrow::Cow<'a, str> = label.into(); +fn detail_row<'a>(label: &'static str, value: impl Into>) -> Element<'a, Message> { let value: std::borrow::Cow<'a, str> = value.into(); row![ text(label).size(11).style(muted_style).width(Length::Fixed(84.0)), @@ -1536,26 +765,20 @@ mod tests { 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). + // Repeats collapse even among roots: B2 shares B's path. + assert_eq!(panel.display_rows().len(), 2); + // Mark B2 nested under A: the repeat path now collapses away — + // A claims a.dwg, B2-as-child claims b.dwg, the B root is a repeat. 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_eq!(rows.len(), 2); // A, B-as-child-of-A 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); + // A second parent claiming the same path shows nothing twice. + panel.entries.push(entry(4, "C", "c.dwg")); + panel.children.insert(4, vec![2]); + panel.expanded.insert(4); let count = panel .display_rows() .iter() @@ -1564,59 +787,6 @@ mod tests { 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 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 expand_collapses_by_default() { let mut panel = XrefManagerPanel::default(); @@ -1630,139 +800,4 @@ mod tests { panel.toggle_expand(1); assert!(!panel.display_rows().iter().any(|r| r.is_nested)); } - - fn preview_doc2(dir: &std::path::Path) -> acadrust::CadDocument { - // Two referenced images → genuine multi-select. - use acadrust::objects::{ImageDefinition, ObjectType}; - let mut doc = acadrust::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 = acadrust::entities::RasterImage::new( - &path, - acadrust::types::Vector3::ZERO, - 8.0 + i as f64, - 8.0, - ); - ent.definition_handle = Some(h); - doc.add_entity(acadrust::EntityType::RasterImage(ent)).unwrap(); - } - doc - } - - fn preview_doc(dir: &std::path::Path, name: &str) -> acadrust::CadDocument { - // Referenced 8x8 PNG under `dir`; the entry resolves Loaded. - use acadrust::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 = acadrust::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 = acadrust::entities::RasterImage::new( - &img_path, - acadrust::types::Vector3::ZERO, - 8.0, - 8.0, - ); - ent.definition_handle = Some(h); - doc.add_entity(acadrust::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 = acadrust::CadDocument::new(); - let mut br = acadrust::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(); - } }