diff --git a/src/app/mod.rs b/src/app/mod.rs index fcfd5e723..224b78131 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -708,6 +708,10 @@ pub(super) struct OpenCADStudio { pub(crate) dock_drag_last: Option, /// Live drag target (side + index), shown as a highlight while dragging. pub(crate) dock_drag_target: Option<(crate::app::config::DockSide, usize)>, + /// Reference-table column currently being width-resized (column index), + /// with the last pointer position. Mirrors the Layers Name-column drag. + pub(crate) xref_col_drag: Option, + pub(crate) xref_col_last: Option, /// 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). @@ -2354,8 +2358,17 @@ pub enum Message { XrefManagerRefresh, /// Toggle one palette row (entry index) in the multi-selection set. XrefManagerSelect(usize), + /// Right-click on a palette row: single-select it when outside the + /// selection (the context menu then acts on the selection). + XrefRowRightClick(usize), /// Flip the palette's list/tree presentation. XrefManagerToggleTree, + /// Start a reference-table column divider drag (column index). + XrefColGrab(usize), + /// Pointer moved (table header space) during a column drag. + XrefColMove(iced::Point), + /// Pointer released during a column drag. + XrefColRelease, /// Flip the palette's details/preview lower pane. XrefManagerTogglePreview, /// Toggle the Attach dropdown menu. @@ -2380,11 +2393,9 @@ pub enum Message { XrefManagerPathInput(String), /// Apply the "new path" draft to the anchor entry. XrefManagerPathApply, - /// Find & Replace row drafts. - XrefManagerFindInput(String), - XrefManagerReplaceInput(String), - /// Apply Find & Replace across all direct references. - XrefManagerFindReplaceApply, + /// Prefill the command line for Find & Replace across references + /// (`XREF Path Find `); the CLI parses and runs it. + XrefFindReplacePrompt, LayerToggleVisible(usize), LayerToggleLock(usize), LayerToggleFreeze(usize), @@ -3759,6 +3770,8 @@ impl OpenCADStudio { dock_resizing: None, dock_drag_last: None, dock_drag_target: None, + xref_col_drag: None, + xref_col_last: None, show_file_tabs: true, show_layout_tabs: true, last_point: None, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index bb16348a9..fac41e56d 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -638,6 +638,11 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven iced::Task::none() } DockMsg::DragMove(point) => { + // NOTE: xref column drags intentionally do NOT ride this + // path: its points live in workspace space while the header + // tracker reports header-local points, and mixing the two + // produced a one-time jump plus a stuck drag. Column moves + // arrive via Message::XrefColMove only. if self.dock_dragging.is_some() { let avail = self.tabs[self.active_tab].scene.selection.borrow().vp_size.1; let side = if point.x < self.win_size.0 * 0.5 { @@ -679,6 +684,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.dock_resizing = None; self.dock_drag_last = None; self.dock_drag_target = None; + self.xref_col_drag = None; + self.xref_col_last = None; iced::Task::none() } } @@ -1271,37 +1278,6 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } self.refresh_xref_manager(); } - - /// Apply Find & Replace across all direct references (same engine fn - /// as `XREF Path Find`). - pub(crate) fn xref_manager_find_replace_apply(&mut self) { - if cfg!(target_arch = "wasm32") { - self.command_line.push_error(crate::t!("Reference changes are not available on web — the reference list is read-only.").as_ref()); - return; - } - let i = self.active_tab; - let old = self.xref_manager.find_input.clone(); - let new = self.xref_manager.replace_input.clone(); - if old.is_empty() || new.is_empty() { - self.command_line - .push_error(crate::t!("Usage: XREF Path Find ").as_ref()); - return; - } - self.push_undo_snapshot(i, "XREF-PATH"); - let n = crate::io::xref::replace_path_prefix( - &mut self.tabs[i].scene.document, - &old, - &new, - ); - self.command_line.push_output(crate::tf!( - "XREF: updated {} reference(s).", - n - ).as_ref()); - if n > 0 { - self.post_ref_op(i); - } - self.refresh_xref_manager(); - } } #[cfg(test)] diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 77e2ff78a..9ac964d00 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2148,13 +2148,50 @@ impl OpenCADStudio { Task::none() } Message::XrefManagerSelect(index) => { - self.xref_manager.toggle_select(index); + use crate::ui::window::xref_manager::SelectExtend; + // Normal GUI list behavior off the globally tracked + // modifiers (same source as the layer list): plain click + // selects one row, Ctrl/Cmd toggles, Shift extends a range. + let extend = if self.ctrl_down { + SelectExtend::Toggle + } else if self.shift_down { + SelectExtend::Range + } else { + SelectExtend::Single + }; + self.xref_manager.click_select(index, extend); + Task::none() + } + Message::XrefRowRightClick(index) => { + self.xref_manager.right_click_select(index); Task::none() } Message::XrefManagerToggleTree => { self.xref_manager.toggle_tree(); Task::none() } + Message::XrefColGrab(i) => { + // Start a table-column divider drag; moves arrive via the + // header's mouse_area (XrefColMove), like the Layers + // Name-column divider rides ModalDragMove. + self.xref_col_drag = Some(i); + self.xref_col_last = None; + Task::none() + } + Message::XrefColMove(p) => { + if let Some(i) = self.xref_col_drag { + if let Some(last) = self.xref_col_last { + self.xref_manager.drag_col_by(i, p.x - last.x); + } + self.xref_col_last = Some(p); + } + Task::none() + } + Message::XrefColRelease => { + self.xref_col_drag = None; + self.xref_col_last = None; + Task::none() + } Message::XrefManagerTogglePreview => { self.xref_manager.show_preview ^= true; Task::none() @@ -2255,17 +2292,13 @@ impl OpenCADStudio { self.xref_manager_path_apply(); Task::none() } - Message::XrefManagerFindInput(text) => { - self.xref_manager.find_input = text; - Task::none() - } - Message::XrefManagerReplaceInput(text) => { - self.xref_manager.replace_input = text; - Task::none() - } - Message::XrefManagerFindReplaceApply => { - self.xref_manager_find_replace_apply(); - Task::none() + Message::XrefFindReplacePrompt => { + // No fields in the panel: prefill the command line so the + // existing `XREF Path Find ` parsing runs it. + self.command_line.input = "XREF Path Find ".to_string(); + self.command_line.autocomplete_cursor = None; + self.command_line.cancel_history_navigation(); + return self.focus_cmd_input(); } Message::LayerStateManagerOpen => { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index ba4770bdf..24c7da757 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1875,7 +1875,7 @@ bg={bg_ms:.1}ms n={view_count}" let workspace: Element<'_, Message> = row(parts).width(Fill).height(Fill).into(); let any_dragging = self.dock_dragging.is_some(); - let any_resizing = self.dock_resizing.is_some(); + let any_resizing = self.dock_resizing.is_some() || self.xref_col_drag.is_some(); let workspace = if any_dragging { let id = self.dock_dragging.expect("guarded by any_dragging"); let side = self.dock_drag_target.map(|(s, _)| s).unwrap_or( diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 22772aa47..a0bf67f38 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -59,7 +59,16 @@ impl PanelId { match self { PanelId::Properties => 250.0, PanelId::BlockPalette => 260.0, - PanelId::ExternalReferences => 300.0, + PanelId::ExternalReferences => 360.0, + } + } + + /// Widest a panel may be dragged or sized to. The references table is + /// column-rich, so it allows double the shared maximum. + fn max_width(self) -> f32 { + match self { + PanelId::ExternalReferences => DOCK_MAX_W * 2.0, + _ => DOCK_MAX_W, } } } @@ -160,7 +169,7 @@ impl DockState { pub fn width(&self, id: PanelId, win_w: f32) -> f32 { self.settings(id) .width - .clamp(DOCK_MIN_W, DOCK_MAX_W.min(win_w * 0.45).max(DOCK_MIN_W)) + .clamp(DOCK_MIN_W, id.max_width().min(win_w * 0.45).max(DOCK_MIN_W)) } pub fn auto_collapse(&self, id: PanelId) -> bool { @@ -170,7 +179,7 @@ impl DockState { /// Set the persisted width, clamped. pub fn set_width(&mut self, id: PanelId, width: f32) { let entry = self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id)); - entry.width = width.clamp(DOCK_MIN_W, DOCK_MAX_W); + entry.width = width.clamp(DOCK_MIN_W, id.max_width()); } /// Reset width to the panel's default. @@ -226,6 +235,20 @@ pub const DOCK_MAX_W: f32 = 600.0; mod tests { use super::*; + #[test] + fn external_references_allows_double_max_width() { + let mut state = DockState::default(); + state.ensure_settings(); + // Slightly wider default than the 300-era panels, double the maximum. + // (The 45%-of-window rule still dominates on narrow windows.) + assert_eq!(state.width(PanelId::ExternalReferences, 3000.0), 360.0); + state.set_width(PanelId::ExternalReferences, 5000.0); + assert_eq!(state.width(PanelId::ExternalReferences, 3000.0), DOCK_MAX_W * 2.0); + // Other panels keep the shared maximum. + state.set_width(PanelId::BlockPalette, 5000.0); + assert_eq!(state.width(PanelId::BlockPalette, 3000.0), DOCK_MAX_W); + } + #[test] fn default_docks_each_known_panel_on_an_edge() { let state = DockState::default(); diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 4e598715a..3164c4de8 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -69,6 +69,7 @@ pub static SPLIT_H: &[u8] = include_bytes!("../../assets/icons/ui/split_h.svg"); pub static GRID: &[u8] = include_bytes!("../../assets/icons/ui/grid.svg"); pub static SNAP: &[u8] = include_bytes!("../../assets/icons/ui/snap.svg"); pub static DOC_NEW: &[u8] = include_bytes!("../../assets/icons/ui/doc_new.svg"); +pub static DOC: &[u8] = include_bytes!("../../assets/icons/ui/doc.svg"); pub static FOLDER_OPEN: &[u8] = include_bytes!("../../assets/icons/ui/folder_open.svg"); pub static SAVE: &[u8] = include_bytes!("../../assets/icons/ui/save.svg"); pub static FILE_EXPORT: &[u8] = include_bytes!("../../assets/icons/ui/file_export.svg"); diff --git a/src/ui/window/xref_manager.rs b/src/ui/window/xref_manager.rs index 8d8742611..9a96015c9 100644 --- a/src/ui/window/xref_manager.rs +++ b/src/ui/window/xref_manager.rs @@ -26,9 +26,10 @@ 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. +/// 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; /// Indent per tree depth level. const INDENT_W: f32 = 16.0; /// Longest edge of a preview image, in pixels. @@ -55,7 +56,7 @@ pub const HOST_ROW: usize = usize::MAX; /// 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(Default)] +#[derive(Debug, Clone)] pub struct XrefManagerPanel { /// Cached [`collect_entries_with_prev`] output for the active drawing. pub entries: Vec, @@ -83,9 +84,9 @@ pub struct XrefManagerPanel { 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, + /// Reference-table column widths: Reference, Status, Size, Type, Date, + /// Saved Path. Draggable via the header dividers (clamped). + pub col_widths: [f32; 6], /// Open dropdown menus (one at a time; dismissed together). pub attach_open: bool, pub refresh_open: bool, @@ -98,6 +99,50 @@ pub struct XrefManagerPanel { 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, 88.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, + path_input: String::new(), + col_widths: DEFAULT_COL_WIDTHS, + attach_open: false, + refresh_open: false, + 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 { @@ -237,9 +282,13 @@ impl XrefManagerPanel { .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) { + /// 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; } @@ -253,11 +302,52 @@ impl XrefManagerPanel { self.anchor = Some(index); return; } - 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(); + 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); + } + } + } + } + + /// 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); } } @@ -266,6 +356,20 @@ impl XrefManagerPanel { self.tree = !self.tree; } + /// 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::() + 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); + } + } + /// 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) { @@ -415,7 +519,12 @@ impl XrefManagerPanel { let title_bar = mouse_area( container( row![ - text(crate::t!("External References")).size(12), + text(format!( + "{} ({})", + crate::t!("External References").as_ref(), + self.entries.len() + )) + .size(12), iced::widget::Space::new().width(Fill), pin, close, @@ -432,10 +541,9 @@ impl XrefManagerPanel { ) .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); + // 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 @@ -480,7 +588,8 @@ impl XrefManagerPanel { )], ) }; - // List / Tree are two buttons; the active mode renders inert. + // List / Tree are two buttons on their own left-aligned row; the + // active mode renders inert. let list_btn = if self.tree { toolbar_btn(crate::t!("List").into_owned(), Some(Message::XrefManagerToggleTree)) } else { @@ -492,63 +601,12 @@ impl XrefManagerPanel { 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 mode_btn: Element<'_, Message> = tooltip( - mode_row, - text(crate::t!("Toggle list/tree")).size(11), - 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, - ); + container(row![list_btn, tree_btn].spacing(2)) + .width(Fill) + .padding([2, 8]) + .into(); + // 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. @@ -563,7 +621,6 @@ impl XrefManagerPanel { && !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() }); @@ -595,11 +652,6 @@ impl XrefManagerPanel { } 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(), @@ -636,8 +688,8 @@ impl XrefManagerPanel { ), menu_item( crate::t!("Find and Replace").into_owned(), - Some(Message::XrefManagerFindReplaceApply), - find_gate, + Some(Message::XrefFindReplacePrompt), + None, ), ], ) @@ -651,13 +703,6 @@ impl XrefManagerPanel { row![ attach, refresh, - mode_btn, - detach, - unload, - reload, - bind, - overlay, - attach_type, change_path, help ] @@ -671,23 +716,9 @@ impl XrefManagerPanel { .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 row ───────────────────────────────────────────────── + // (Find & Replace moved to the Change Path menu — it prefills the + // command line instead of living in the panel.) // Path edit applies to the anchor entry; nested anchors stay disabled. let anchor_nested = self .anchor @@ -717,25 +748,6 @@ impl XrefManagerPanel { }; 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) @@ -755,33 +767,56 @@ impl XrefManagerPanel { .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)), - ] - .spacing(4) - .width(Length::Fixed(table_w)) - .align_y(iced::Center), - ) - .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() + // 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); } - }) - .padding([4, 8]) - .width(Fill); + } + 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); @@ -792,6 +827,7 @@ impl XrefManagerPanel { &self.host_name, &self.host_path, table_w, + &self.col_widths, )); continue; } @@ -809,10 +845,14 @@ impl XrefManagerPanel { self.tree && has_children, is_expanded, table_w, + &self.col_widths, )); } - // ── Reference table: header + rows scroll together, horizontally - // (fixed content width overflows narrow docks) and vertically. + // ── 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.")) @@ -829,15 +869,57 @@ impl XrefManagerPanel { .width(Fill) .height(Length::Fixed(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, + )); + } + scrollable(tree_col).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( + column![ + col_header, + scrollable(rows_col).height(Length::Fixed(TABLE_H)), + ] + .width(Length::Fixed(table_w)), + ) + .direction(iced::widget::scrollable::Direction::Horizontal( + iced::widget::scrollable::Scrollbar::new(), + )) + .height(Length::Fixed(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> = if missing > 0 { @@ -941,11 +1023,14 @@ impl XrefManagerPanel { .into() }; - let mut content = column![title_bar, toolbar, edit_row, table_rows]; + let mut content = column![title_bar, toolbar, mode_row, edit_row, table_rows]; if let Some(notice) = notice { content = content.push(notice); } content = content.push(lower); + // 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.spacing(0)) .style(|theme: &Theme| container::Style { background: Some(Background::Color( @@ -953,7 +1038,7 @@ impl XrefManagerPanel { )), ..Default::default() }) - .width(Fill) + .width(Length::Fixed(width)) .height(Fill) .into() } @@ -1219,6 +1304,41 @@ fn menu_item(label: String, msg: Option, gate: Option) -> Eleme } } +/// Per-row right-click menu: the reference operations act on the current +/// selection (right-click selects the row first when outside it), so one +/// static menu serves every row. Nested rows report per-entry errors from +/// the engine, matching the CLI wording. +fn row_menu() -> Element<'static, Message> { + let item = |label: &str, op: XrefPaletteOp| -> Element<'static, Message> { + toolbar_btn(label.to_string(), Some(Message::XrefManagerOp(op))) + }; + container( + column![ + item(&crate::t!("Detach").into_owned(), XrefPaletteOp::Detach), + item(&crate::t!("Unload").into_owned(), XrefPaletteOp::Unload), + item(&crate::t!("Reload").into_owned(), XrefPaletteOp::Reload), + item(&crate::t!("Bind").into_owned(), XrefPaletteOp::Bind), + item(&crate::t!("Overlay").into_owned(), XrefPaletteOp::Overlay), + item(&crate::t!("Attach").into_owned(), XrefPaletteOp::Attach), + ] + .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(200.0)) + .into() +} + fn xref_row<'a>( display: DisplayRow, entry: &'a ReferenceEntry, @@ -1226,6 +1346,7 @@ fn xref_row<'a>( show_expand: bool, is_expanded: bool, table_w: f32, + cw: &[f32; 6], ) -> Element<'a, Message> { let mut name = entry.name.clone(); if display.is_nested { @@ -1254,20 +1375,26 @@ fn xref_row<'a>( } 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 content = row![ - name_cell.width(Length::FillPortion(3)), - text(status_text(entry.status)).size(FONT_SZ).width(Length::Fixed(88.0)), - text(format_size(entry.size_bytes)).size(FONT_SZ).width(Length::Fixed(76.0)), - text(type_text(entry)).size(FONT_SZ).width(Length::Fixed(76.0)), - text(format_date(entry.modified)).size(FONT_SZ).width(Length::Fixed(96.0)), - text(entry.saved_path.clone()).size(FONT_SZ).width(Length::FillPortion(4)), + name_cell.width(Length::Fixed(cw[0])), + gutter(), + text(status_text(entry.status)).size(FONT_SZ).width(Length::Fixed(cw[1])), + 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(4) + .spacing(0) .width(Length::Fixed(table_w)) .align_y(iced::Center); let index = display.index; - mouse_area( + let row = mouse_area( container(content) .style(move |theme: &Theme| { let palette = theme.palette(); @@ -1294,13 +1421,114 @@ fn xref_row<'a>( .width(Fill), ) .on_press(Message::XrefManagerSelect(index)) - .into() + .on_right_press(Message::XrefRowRightClick(index)); + iced_aw::ContextMenu::new(row, row_menu).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> { +/// 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, +) -> Element<'_, Message> { + let mut cells = row![].spacing(2).align_y(iced::Center); + if display.depth > 0 { + cells = cells.push( + iced::widget::Space::new().width(Length::Fixed(INDENT_W * display.depth as f32)), + ); + } + 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(row_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, 12.0)) + .align_y(iced::Center), + ); + cells = cells.push(text(entry.name.clone()).size(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: 0.0, + bottom: 0.0, + left: 8.0, + right: 8.0, + }) + .height(Length::Fixed(ROW_H)) + .width(Fill), + ) + .on_press(Message::XrefManagerSelect(index)) + .on_right_press(Message::XrefRowRightClick(index)); + iced_aw::ContextMenu::new(row, row_menu).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); + if display.depth > 0 { + cells = cells.push( + iced::widget::Space::new().width(Length::Fixed(INDENT_W * display.depth as f32)), + ); + } + cells = cells.push( + container(crate::ui::icons::themed_home(12.0)).align_y(iced::Center), + ); + cells = cells.push(text(name).size(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: 0.0, + bottom: 0.0, + left: 8.0, + right: 8.0, + }) + .height(Length::Fixed(ROW_H)) + .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 { @@ -1317,15 +1545,21 @@ fn host_row<'a>(display: DisplayRow, host_name: &'a str, host_path: &'a str, tab 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::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)), + 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(4) + .spacing(0) .width(Length::Fixed(table_w)) .align_y(iced::Center); container(content) @@ -1608,6 +1842,20 @@ mod tests { 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::() + super::COL_GUTTER * 5.0); + } + #[test] fn image_format_reads_extension() { assert_eq!(image_format("C:/exa/plan.BMP"), "BMP"); @@ -1617,6 +1865,40 @@ mod tests { 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();