feat(xref): row selection, resizable columns, tree view, and find-replace

Single, multi, and range row selection, resizable table columns, hierarchical tree presentation, and find-and-replace through the command line.
This commit is contained in:
Karim Jerbi committed 2026-09-15 23:56:31 +01:00
1 parent 019422d220
commit 494a872e8d
7 files changed
+565 -237

No files matched your search

+18 -5
View File
@@ -708,6 +708,10 @@ pub(super) struct OpenCADStudio {
pub(crate) dock_drag_last: Option<iced::Point>,
/// 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<usize>,
pub(crate) xref_col_last: Option<iced::Point>,
/// 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 <old> <new>`); 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,
+7 -31
View File
@@ -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 <old> <new>").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)]
+45 -12
View File
@@ -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 <old> <new>` 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 => {
+1 -1
View File
@@ -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(
+26 -3
View File
@@ -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();
+1
View File
@@ -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");
+467 -185
View File
@@ -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<ReferenceEntry>,
@@ -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::<f32>() + COL_GUTTER * 5.0).max(TABLE_MIN_W)
}
/// Apply a horizontal divider drag to column `i` (clamped). Called from
/// the dock drag handler with the pointer delta.
pub fn drag_col_by(&mut self, i: usize, dx: f32) {
if let Some(w) = self.col_widths.get_mut(i) {
*w = (*w + dx).clamp(COL_MIN_W, COL_MAX_W);
}
}
/// 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<String> = 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<String>| 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<String> = 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<Element<'_, Message>> = 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<Message>, gate: Option<String>) -> 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::<f32>() + 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();