From 22a9bd602c18175503106895da2bac4185d59cd5 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Sat, 12 Sep 2026 21:18:40 +0100 Subject: [PATCH] feat(xref): row-level context menu actions and unified split-button toolbar Right-click actions bound to the row under the cursor, split-button toolbar groups with shared borders and hover states, and a notice when an operation needs a selection first. --- locales/ar-SA/opencadstudio.ftl | 1 + locales/bg-BG/opencadstudio.ftl | 1 + locales/cs-CZ/opencadstudio.ftl | 1 + locales/de-DE/opencadstudio.ftl | 1 + locales/el-GR/opencadstudio.ftl | 1 + locales/en-US/opencadstudio.ftl | 1 + locales/es-ES/opencadstudio.ftl | 1 + locales/fi-FI/opencadstudio.ftl | 1 + locales/fr-FR/opencadstudio.ftl | 1 + locales/hi-IN/opencadstudio.ftl | 1 + locales/hu-HU/opencadstudio.ftl | 1 + locales/it-IT/opencadstudio.ftl | 1 + locales/ja-JP/opencadstudio.ftl | 1 + locales/ko-KR/opencadstudio.ftl | 1 + locales/nl-NL/opencadstudio.ftl | 1 + locales/pl-PL/opencadstudio.ftl | 1 + locales/pt-BR/opencadstudio.ftl | 1 + locales/ru-RU/opencadstudio.ftl | 1 + locales/tr-TR/opencadstudio.ftl | 1 + locales/zh-CN/opencadstudio.ftl | 1 + locales/zh-TW/opencadstudio.ftl | 1 + src/app/mod.rs | 16 +- src/app/update/dialog.rs | 40 +--- src/app/update/mod.rs | 29 ++- src/app/view/mod.rs | 3 +- src/io/xref.rs | 15 ++ src/locale_catalog.rs | 1 + src/ui/window/xref_manager.rs | 395 ++++++++++++++++++++++---------- 28 files changed, 352 insertions(+), 168 deletions(-) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 7bb530079..a8c6e216d 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -3317,6 +3317,7 @@ insert = .revolve-select-objects-or-mode = REVOLVE حدد عناصر التدوير أو [الوضع] (Enter للإنهاء): .path = Path: + .xref-file-references = File References model = .create = إنشاء .boolean = عمليات منطقية diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index fb31b680d..0b258d743 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -3304,6 +3304,7 @@ insert = .revolve-select-objects-or-mode = REVOLVE Изберете обекти за завъртане или [Режим] (Enter за край): .path = Път: + .xref-file-references = File References model = .create = Създаване .boolean = Булеви операции diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 4edfea292..04dbf62a3 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -3302,6 +3302,7 @@ insert = .revolve-select-objects-or-mode = REVOLVE Vyberte objekty k rotaci nebo [Režim] (Enter ukončí): .path = Cesta: + .xref-file-references = File References model = .create = Vytvořit .boolean = Booleovské operace diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index b54489ca1..9c5b8664d 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -3303,6 +3303,7 @@ insert = .revolve-select-objects-or-mode = REVOLVE Objekte zum Rotieren wählen oder [Modus] (Enter zum Beenden): .path = Path: + .xref-file-references = File References model = .create = Erstellen .boolean = Boolesche Operationen diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 85633b204..25682258e 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -3176,6 +3176,7 @@ insert = .not-found = Not found .orphaned = Orphaned .unreferenced = Unreferenced + .xref-file-references = File References .xref-change-path = Change Path .xref-make-absolute = Make Absolute .xref-make-relative = Make Relative diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 729bd8711..3e1b88a89 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -3177,6 +3177,7 @@ insert = .not-found = Not found .orphaned = Orphaned .unreferenced = Unreferenced + .xref-file-references = File References .xref-change-path = Change Path .xref-make-absolute = Make Absolute .xref-make-relative = Make Relative diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index c5ccf4e59..eef59816e 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -3305,6 +3305,7 @@ insert = .revolve-select-objects-or-mode = REVOLVE Seleccione objetos para revolucionar o [Modo] (Intro para terminar): .path = Ruta: + .xref-file-references = File References model = .create = Crear .boolean = Booleanas diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 29ba7d47a..f87e281ea 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -3304,6 +3304,7 @@ insert = .revolve-select-objects-or-mode = REVOLVE Valitse pyöräytettävät objektit tai [Tila] (Enter lopettaa): .path = Path: + .xref-file-references = File References model = .create = Luo .boolean = Boolen operaatiot diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 6e42bc415..8a310c1a4 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -3305,6 +3305,7 @@ insert = .revolve-select-objects-or-mode = REVOLVE Sélectionnez les objets de révolution ou [Mode] (Entrée pour terminer) : .path = Chemin : + .xref-file-references = File References model = .create = Créer .boolean = Opérations booléennes diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index e6c18acf1..97bec1c6e 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -3293,6 +3293,7 @@ insert = .revolve-select-objects-or-mode = REVOLVE घुमाने के लिए ऑब्जेक्ट चुनें या [मोड] (समाप्त करने के लिए Enter): .path = Path: + .xref-file-references = File References model = .create = बनाएँ .boolean = बूलियन संक्रियाएँ diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index f2b81d643..135319ef3 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -3304,6 +3304,7 @@ insert = .revolve-select-objects-or-mode = REVOLVE Válasszon megforgatandó objektumokat vagy [Mód] (Enter a befejezéshez): .path = Path: + .xref-file-references = File References model = .create = Létrehozás .boolean = Logikai műveletek diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index eff522794..c178da5a6 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -3304,6 +3304,7 @@ insert = .revolve-select-objects-or-mode = REVOLVE Seleziona oggetti da rivoluzionare o [Modalità] (Invio per terminare): .path = Path: + .xref-file-references = File References model = .create = Crea .boolean = Operazioni booleane diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 8de10394f..10d6d99c5 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -3293,6 +3293,7 @@ insert = .revolve-select-objects-or-mode = REVOLVE 回転させるオブジェクトを選択、または[モード](Enterで終了): .path = Path: + .xref-file-references = File References model = .create = 作成 .boolean = ブール演算 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index c9e5a5345..60d6e8576 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -3304,6 +3304,7 @@ insert = .revolve-select-objects-or-mode = REVOLVE 회전할 객체 선택 또는 [모드] (Enter로 완료): .path = Path: + .xref-file-references = File References model = .create = 생성 .boolean = 불리언 연산 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 49eb43ac1..dfdc94ec6 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -3303,6 +3303,7 @@ insert = .revolve-select-objects-or-mode = REVOLVE Selecteer objecten om te wentelen of [Modus] (Enter om te voltooien): .path = Path: + .xref-file-references = File References model = .create = Maken .boolean = Booleaanse bewerkingen diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 05900caf3..29abc4f1d 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -3304,6 +3304,7 @@ insert = .revolve-select-objects-or-mode = REVOLVE Wybierz obiekty do obrotu lub [Tryb] (Enter kończy): .path = Path: + .xref-file-references = File References model = .create = Utwórz .boolean = Operacje boolowskie diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 32a0196bd..63644359b 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -3304,6 +3304,7 @@ insert = .revolve-specify-start-angle = REVOLVE { common.specify }: { common.start-angle } .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Selecione uma linha, semirreta ou linha de construção como eixo: .revolve-select-objects-or-mode = REVOLVE Selecione objetos para revolução ou [Modo] (Enter para concluir): + .xref-file-references = File References model = .create = Criar diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index aefefa284..2d87598e9 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -3306,6 +3306,7 @@ insert = .revolve-select-objects-or-mode = REVOLVE Выберите объекты для вращения или [Режим] (Enter — завершить): .path = Путь: + .xref-file-references = File References model = .create = Создание .boolean = Булевы операции diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 53a6d0022..9bf56ec2b 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -3265,6 +3265,7 @@ insert = .specify-the-requested-path-or-point-or-use-undo = { common.specify }: { common.path } / { common.point } [{ common.undo }]. .path = Path: + .xref-file-references = File References model = .create = Oluştur .boolean = Boole işlemleri diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index b4915c7da..7904af03f 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -3288,6 +3288,7 @@ insert = .revolve-select-objects-or-mode = REVOLVE 选择要旋转的对象或 [模式](按 Enter 完成): .path = Path: + .xref-file-references = File References model = .create = 创建 .boolean = 布尔运算 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index de8fc834f..79a82c39d 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -3300,6 +3300,7 @@ insert = .revolve-select-objects-or-mode = REVOLVE 選取要旋轉的物件或 [模式](按 Enter 完成): .path = Path: + .xref-file-references = File References model = .create = 建立 .boolean = 布林運算 diff --git a/src/app/mod.rs b/src/app/mod.rs index 224b78131..9129a89e7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -712,6 +712,8 @@ pub(super) struct OpenCADStudio { /// with the last pointer position. Mirrors the Layers Name-column drag. pub(crate) xref_col_drag: Option, pub(crate) xref_col_last: Option, + /// Table/lower-pane split divider drag in progress. + pub(crate) xref_split_drag: bool, /// 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). @@ -2369,6 +2371,12 @@ pub enum Message { XrefColMove(iced::Point), /// Pointer released during a column drag. XrefColRelease, + /// Start a table/lower-pane split divider drag. + XrefSplitGrab, + /// Pointer moved (panel space) during a split drag. + XrefSplitMove(iced::Point), + /// Pointer released during a split drag. + XrefSplitRelease, /// Flip the palette's details/preview lower pane. XrefManagerTogglePreview, /// Toggle the Attach dropdown menu. @@ -2389,10 +2397,9 @@ pub enum Message { XrefManagerToggleExpand(u64), /// Selection-scoped palette operation (detach/unload/reload/overlay/pathtype). XrefManagerOp(crate::ui::window::xref_manager::XrefPaletteOp), - /// Details-pane "new path" draft text. - XrefManagerPathInput(String), - /// Apply the "new path" draft to the anchor entry. - XrefManagerPathApply, + /// Row-scoped palette operation: selects row `index`, then applies the + /// operation to it. + XrefRowOp(usize, crate::ui::window::xref_manager::XrefPaletteOp), /// Prefill the command line for Find & Replace across references /// (`XREF Path Find `); the CLI parses and runs it. XrefFindReplacePrompt, @@ -3772,6 +3779,7 @@ impl OpenCADStudio { dock_drag_target: None, xref_col_drag: None, xref_col_last: None, + xref_split_drag: false, 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 fac41e56d..577d94834 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -686,6 +686,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.dock_drag_target = None; self.xref_col_drag = None; self.xref_col_last = None; + self.xref_split_drag = false; iced::Task::none() } } @@ -908,6 +909,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven .collect() }; if picked.is_empty() { + self.command_line + .push_info(crate::t!("Select a reference first.").as_ref()); return; } let needs_host = matches!( @@ -1241,43 +1244,6 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.post_ref_op(i); self.refresh_xref_manager(); } - - /// Apply the details-pane path draft to the anchor entry (verbatim - /// store, same engine fn as `XREF Path`). - pub(crate) fn xref_manager_path_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 anchor = self.xref_manager.anchor.and_then(|a| { - self.xref_manager - .entries - .get(a) - .filter(|_| !self.xref_manager.nested.contains(&a)) - .map(|e| (e.key, e.name.clone())) - }); - let Some((key, _)) = anchor else { - return; - }; - let new_raw = self.xref_manager.path_input.clone(); - if new_raw.is_empty() { - return; - } - self.push_undo_snapshot(i, "XREF-PATH"); - match crate::io::xref::set_ref_path(&mut self.tabs[i].scene.document, key, &new_raw) - { - Ok(name) => { - self.command_line.push_output(crate::tf!( - "XREF: Path set for \"{}\" — Reload to apply.", - name - ).as_ref()); - self.post_ref_op(i); - } - Err(msg) => self.command_line.push_error(msg.as_str()), - } - self.refresh_xref_manager(); - } } #[cfg(test)] diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9ac964d00..b2d8ca915 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2192,6 +2192,25 @@ impl OpenCADStudio { self.xref_col_last = None; Task::none() } + Message::XrefSplitGrab => { + self.xref_split_drag = true; + self.xref_col_last = None; + Task::none() + } + Message::XrefSplitMove(p) => { + if self.xref_split_drag { + if let Some(last) = self.xref_col_last { + self.xref_manager.drag_table_by(p.y - last.y); + } + self.xref_col_last = Some(p); + } + Task::none() + } + Message::XrefSplitRelease => { + self.xref_split_drag = false; + self.xref_col_last = None; + Task::none() + } Message::XrefManagerTogglePreview => { self.xref_manager.show_preview ^= true; Task::none() @@ -2284,12 +2303,10 @@ impl OpenCADStudio { self.xref_manager_op(op); Task::none() } - Message::XrefManagerPathInput(text) => { - self.xref_manager.path_input = text; - Task::none() - } - Message::XrefManagerPathApply => { - self.xref_manager_path_apply(); + Message::XrefRowOp(index, op) => { + // Row-scoped op: select the row first, then run the op. + self.xref_manager.right_click_select(index); + self.xref_manager_op(op); Task::none() } Message::XrefFindReplacePrompt => { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 24c7da757..72d0e2dd6 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1875,7 +1875,8 @@ 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() || self.xref_col_drag.is_some(); + let any_resizing = + self.dock_resizing.is_some() || self.xref_col_drag.is_some() || self.xref_split_drag; 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/io/xref.rs b/src/io/xref.rs index 28d1f54a4..948965cb6 100644 --- a/src/io/xref.rs +++ b/src/io/xref.rs @@ -978,6 +978,18 @@ pub fn collect_entries_with_prev( } } + /// Resolve `raw` into `found_at` without statting: unloaded rows keep no + /// size/date (spec) but still know where their file lives, so previews + /// and path operations work without loading. + fn resolve_only(entry: &mut ReferenceEntry, raw: &str, base_dir: &Path) { + if raw.is_empty() { + return; + } + if let Some(found) = resolve_path(raw, base_dir) { + entry.found_at = Some(found.to_string_lossy().into_owned()); + } + } + fn file_name_only(raw: &str) -> &str { raw.rsplit(['/', '\\']).next().unwrap_or(raw) } @@ -999,6 +1011,7 @@ pub fn collect_entries_with_prev( entry.saved_path = br.xref_path.clone(); if unloaded.contains(&UnloadKey::Direct(key)) { entry.status = RefStatus::Unloaded; + resolve_only(&mut entry, &br.xref_path, base_dir); } else { stat_into(&mut entry, &br.xref_path, base_dir); entry.status = decide_status( @@ -1033,6 +1046,7 @@ pub fn collect_entries_with_prev( entry.saved_path = def.file_name.clone(); if unloaded.contains(&UnloadKey::Direct(key)) { entry.status = RefStatus::Unloaded; + resolve_only(&mut entry, &def.file_name, base_dir); } else { stat_into(&mut entry, &def.file_name, base_dir); entry.status = decide_status( @@ -1077,6 +1091,7 @@ pub fn collect_entries_with_prev( entry.saved_path = def.file_path.clone(); if unloaded.contains(&UnloadKey::Direct(key)) { entry.status = RefStatus::Unloaded; + resolve_only(&mut entry, &def.file_path, base_dir); } else { stat_into(&mut entry, &def.file_path, base_dir); entry.status = decide_status( diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 20eaf2a7d..927480dcf 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3157,6 +3157,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "XREF: bound \"{}\" with {} unremapped style handles (see bind limitations)." => Some(("insert", "xref-bound-with-unremapped-handles")), "XREF: cannot bind nested reference '{}'. Bind it in its host drawing." => Some(("insert", "xref-cannot-bind-nested-reference")), "Unreferenced" => Some(("insert", "unreferenced")), + "File References" => Some(("insert", "xref-file-references")), "Change Path" => Some(("insert", "xref-change-path")), "Make Absolute" => Some(("insert", "xref-make-absolute")), "Make Relative" => Some(("insert", "xref-make-relative")), diff --git a/src/ui/window/xref_manager.rs b/src/ui/window/xref_manager.rs index 9a96015c9..1e865d641 100644 --- a/src/ui/window/xref_manager.rs +++ b/src/ui/window/xref_manager.rs @@ -10,7 +10,7 @@ use crate::io::xref::collect_entries_with_prev; use crate::io::xref_model::{normalize_lexical, Pathtype, RefKind, RefStatus, RefType, ReferenceEntry}; use crate::ui::ROW_H; use 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}; @@ -25,13 +25,46 @@ const IS_WASM: bool = cfg!(target_arch = "wasm32"); /// Font size for table cells (mirrors `layers.rs`). const FONT_SZ: f32 = ROW_H * 0.42; // ≈11 px at ROW_H=26 /// Fixed table height so the details pane below keeps stable space. -const TABLE_H: f32 = 240.0; +const TABLE_H: f32 = 480.0; +/// Minimum table area height under split dragging. +const TABLE_MIN_H: f32 = 120.0; /// Minimum table content width so columns stay readable in narrow docks. const TABLE_MIN_W: f32 = 480.0; /// Inter-column gutter shared by header, rows, and grab zones so columns align. const COL_GUTTER: f32 = 6.0; +/// Tree-mode text size: a step above the table cells, like the +/// industry tree. /// Indent per tree depth level. const INDENT_W: f32 = 16.0; + +/// Dashed vertical tree guide (one per ancestor level), stretched to the row. +const TREE_GUIDE_SVG: &[u8] = b""; + +/// Shared parsed handle for [`TREE_GUIDE_SVG`] (parsed once, not per row). +fn tree_guide_handle() -> iced::widget::svg::Handle { + static HANDLE: std::sync::OnceLock = std::sync::OnceLock::new(); + HANDLE + .get_or_init(|| iced::widget::svg::Handle::from_memory(TREE_GUIDE_SVG)) + .clone() +} + +/// Indent cells for `depth` ancestor levels: dashed guides explaining the +/// hierarchy, same total width as the old blank indent. +fn tree_indent(depth: u32) -> iced::widget::Row<'static, crate::app::Message> { + let mut row = iced::widget::Row::new().spacing(0); + for _ in 0..depth { + row = row.push( + container( + iced::widget::svg(tree_guide_handle()) + .width(Length::Fixed(INDENT_W)) + .height(Fill), + ) + .align_y(iced::Center), + ); + } + row +} +const TREE_FONT_SZ: f32 = 13.0; /// Longest edge of a preview image, in pixels. const PREVIEW_MAX: u32 = 256; @@ -82,11 +115,12 @@ pub struct XrefManagerPanel { /// 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, /// Reference-table column widths: Reference, Status, Size, Type, Date, /// Saved Path. Draggable via the header dividers (clamped). pub col_widths: [f32; 6], + /// Reference-table area height. Draggable via the split divider above + /// the Details/Preview pane (clamped). + pub table_h: f32, /// Open dropdown menus (one at a time; dismissed together). pub attach_open: bool, pub refresh_open: bool, @@ -121,8 +155,8 @@ impl Default for XrefManagerPanel { nested: HashSet::new(), source_tab_id: None, source_edit_revision: 0, - path_input: String::new(), col_widths: DEFAULT_COL_WIDTHS, + table_h: TABLE_H, attach_open: false, refresh_open: false, path_open: false, @@ -370,6 +404,12 @@ impl XrefManagerPanel { } } + /// Apply a vertical split-divider drag to the table area height + /// (clamped). Called with the pointer delta. + pub fn drag_table_by(&mut self, dy: f32) { + self.table_h = (self.table_h + dy).clamp(TABLE_MIN_H, 1200.0); + } + /// Expand/collapse one tree parent (no-op for unknown keys). pub fn toggle_expand(&mut self, parent_key: u64) { if !self.expanded.remove(&parent_key) && self.children.contains_key(&parent_key) { @@ -554,7 +594,8 @@ impl XrefManagerPanel { toolbar_tip(crate::t!("Attach").into_owned(), web_tip.clone()) } else { split_button( - toolbar_btn(crate::t!("Attach").into_owned(), Some(Message::XAttachPick)), + crate::t!("Attach").into_owned(), + Some(Message::XAttachPick), self.attach_open, Message::XrefManagerAttachMenu, vec![ @@ -575,10 +616,8 @@ impl XrefManagerPanel { toolbar_tip(crate::t!("Refresh").into_owned(), web_tip.clone()) } else { split_button( - toolbar_btn( - crate::t!("Refresh").into_owned(), - Some(Message::XrefManagerRefresh), - ), + crate::t!("Refresh").into_owned(), + Some(Message::XrefManagerRefresh), self.refresh_open, Message::XrefManagerRefreshMenu, vec![menu_item( @@ -588,23 +627,55 @@ impl XrefManagerPanel { )], ) }; - // 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 { - toolbar_btn(crate::t!("List").into_owned(), None) + // List / Tree view buttons with active-state coloring: the current + // mode renders in the primary accent (blue), the other is clickable. + let mode_btn = |label: String, active: bool| -> Element<'static, Message> { + let msg = if active { + None + } else { + Some(Message::XrefManagerToggleTree) + }; + let mut b = button(text(label).size(11)) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + if active { + button::Style { + background: Some(Background::Color(palette.primary.base.color)), + border: Border { + radius: 3.0.into(), + color: palette.primary.base.color, + width: 1.0, + }, + text_color: palette.primary.base.text, + ..Default::default() + } + } else { + let pair = match status { + button::Status::Hovered | button::Status::Pressed => { + palette.background.strong + } + _ => palette.background.weak, + }; + button::Style { + background: Some(Background::Color(pair.color)), + border: Border { + radius: 3.0.into(), + color: palette.background.neutral.color, + width: 1.0, + }, + text_color: pair.text, + ..Default::default() + } + } + }) + .padding([4, 10]); + if let Some(m) = msg { + b = b.on_press(m); + } + b.into() }; - let 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> = - container(row![list_btn, tree_btn].spacing(2)) - .width(Fill) - .padding([2, 8]) - .into(); + let list_btn = mode_btn(crate::t!("List").into_owned(), !self.tree); + let tree_btn = mode_btn(crate::t!("Tree").into_owned(), self.tree); // Reference operations (Detach/Unload/Reload/Bind/Overlay/Attach) // live in the per-row right-click menu, not the toolbar. // Change Path group: greyed until a reference whose path can change @@ -653,10 +724,8 @@ impl XrefManagerPanel { Some(crate::t!("Select a single reference first.").into_owned()) }; split_button( - toolbar_btn( - crate::t!("Change Path").into_owned(), - Some(Message::XrefManagerPathMenu), - ), + crate::t!("Change Path").into_owned(), + Some(Message::XrefManagerPathMenu), self.path_open, Message::XrefManagerPathMenu, vec![ @@ -710,63 +779,35 @@ impl XrefManagerPanel { .align_y(iced::Center), ) .style(|theme: &Theme| container::Style { - background: Some(Background::Color(theme.palette().background.weak.color)), + background: Some(Background::Color( + theme.palette().background.weakest.color, + )), ..Default::default() }) .width(Fill) .padding([4, 8]); - // ── 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 - .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( + // ── File References pane title ────────────────────────────────── + // List / Tree live on this row, right-aligned. + let table_title: Element<'_, Message> = container( row![ - text(crate::t!("Path:")).size(11), - text_input(crate::t!("new path for selection").as_ref(), &self.path_input) - .on_input(Message::XrefManagerPathInput) + text(format!("{} :", crate::t!("File References").as_ref())) .size(11) - .padding(4) - .width(Length::Fill), - path_apply, + .width(Fill), + list_btn, + tree_btn, ] .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]); - - // ── Column header ───────────────────────────────────────────────── + .padding(Padding { + top: 4.0, + right: 8.0, + bottom: 0.0, + left: 8.0, + }) + .into(); // Each divider between header cells grabs for a width drag // (`XrefColGrab(i)` resizes column `i`); mirrors the Layers // Name-column divider. @@ -867,7 +908,7 @@ impl XrefManagerPanel { .center_x(Fill) .center_y(Fill) .width(Fill) - .height(Length::Fixed(TABLE_H)) + .height(Length::Fixed(self.table_h)) .into() } else if self.tree { // Tree view replaces the table: icon + name rows in nesting @@ -893,19 +934,19 @@ impl XrefManagerPanel { is_expanded, )); } - scrollable(tree_col).height(Length::Fixed(TABLE_H)).into() + scrollable(tree_col).height(Length::Fixed(self.table_h)).into() } else { scrollable( column![ col_header, - scrollable(rows_col).height(Length::Fixed(TABLE_H)), + scrollable(rows_col).height(Length::Fixed(self.table_h)), ] .width(Length::Fixed(table_w)), ) .direction(iced::widget::scrollable::Direction::Horizontal( iced::widget::scrollable::Scrollbar::new(), )) - .height(Length::Fixed(TABLE_H)) + .height(Length::Fixed(self.table_h)) .into() }; // Thin external padding + darkest surface around the table, like the @@ -1023,15 +1064,30 @@ impl XrefManagerPanel { .into() }; - let mut content = column![title_bar, toolbar, mode_row, edit_row, table_rows]; + // Horizontal split divider between the table and the lower pane: + // grabs vertically to give the table more (or less) height. + let split = mouse_area( + container(iced::widget::Space::new().height(Length::Fixed(6.0))) + .width(Fill) + .height(Length::Fixed(6.0)), + ) + .on_press(Message::XrefSplitGrab) + .interaction(iced::mouse::Interaction::ResizingVertically); + let mut content = column![title_bar, toolbar, table_title, table_rows, split]; if let Some(notice) = notice { content = content.push(notice); } content = content.push(lower); + // Panel-wide move/release tracking for the split drag (mirrors the + // header divider tracking for columns). + let content: Element<'_, Message> = mouse_area(content.spacing(0)) + .on_move(|p| Message::XrefSplitMove(p)) + .on_release(Message::XrefSplitRelease) + .into(); // Fixed to the dock width like the block palette: the panel never // sizes itself from its (wider) table content, so the dock width // setting — and resize — actually takes effect. - container(content.spacing(0)) + container(content) .style(|theme: &Theme| container::Style { background: Some(Background::Color( theme.palette().background.base.color, @@ -1251,23 +1307,78 @@ fn toolbar_tip(label: String, tip: String) -> Element<'static, Message> { /// 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 +/// Both halves share one bordered group look: the main half keeps only the +/// left corners rounded (no right border), the caret keeps only the right +/// corners (no left border), so the pair reads as a single control. Hover +/// and press states highlight each half independently. +/// `main_msg` runs the default action; `toggle` flips this menu (closing the +/// others is handled at the message site); `items` are the menu rows, each +/// full-width. Dismissal (Escape / outside click) funnels to /// [`Message::XrefManagerDismissMenus`]. fn split_button( - main: Element<'static, Message>, + main_label: String, + main_msg: Option, menu_open: bool, toggle: Message, items: Vec>, ) -> Element<'static, Message> { + use iced::border::Radius; + // Triangle matches the main button's height and bordered style so the + // group reads as one split control: same padding, icon box fixed to the + // 11px text line height. + let half_style = |left: bool| { + move |theme: &Theme, status| { + let palette = theme.palette(); + let pair = match status { + button::Status::Hovered | button::Status::Pressed => { + palette.background.strong + } + _ => palette.background.weak, + }; + let radius = if left { + Radius { + top_left: 3.0, + top_right: 0.0, + bottom_right: 0.0, + bottom_left: 3.0, + } + } else { + Radius { + top_left: 0.0, + top_right: 3.0, + bottom_right: 3.0, + bottom_left: 0.0, + } + }; + button::Style { + background: Some(Background::Color(pair.color)), + border: Border { + radius: radius.into(), + color: palette.background.neutral.color, + width: 1.0, + }, + text_color: pair.text, + ..Default::default() + } + } + }; + let mut main = button(text(main_label).size(11)) + .style(half_style(true)) + .padding([4, 10]); + if let Some(m) = main_msg { + main = main.on_press(m); + } let caret = button( - container(crate::ui::icons::themed_arrow_down(9.0)).align_y(iced::Center), + container(crate::ui::icons::themed_arrow_down(9.0)) + .align_y(iced::Center) + .height(Length::Fixed(15.0)), ) .on_press(toggle) - .style(button::subtle) - .padding([4, 6]); - let head = row![main, caret].spacing(0).align_y(iced::Center); + .style(half_style(false)) + .padding([4, 10]); + // Overlap the halves by the 1px shared border so no double line shows + // where they meet. + let head = row![main, caret].spacing(-1.0).align_y(iced::Center); let popup: Element<'static, Message> = container( column(items.into_iter().map(|item| { container(item).width(Fill).into() @@ -1304,13 +1415,23 @@ 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> { +/// One right-click menu row, styled like the model-space context menu: +/// borderless subtle text rows, not toolbar buttons. +fn menu_row(label: String, msg: Message) -> Element<'static, Message> { + button(text(label).size(12)) + .on_press(msg) + .style(button::subtle) + .padding([4, 12]) + .width(Fill) + .into() +} + +/// Per-row right-click menu. Each item targets its own row, so the +/// operation applies to the row under the cursor. Nested rows report +/// per-entry errors from the engine, matching the CLI wording. +fn row_menu_for(index: usize) -> Element<'static, Message> { let item = |label: &str, op: XrefPaletteOp| -> Element<'static, Message> { - toolbar_btn(label.to_string(), Some(Message::XrefManagerOp(op))) + menu_row(label.to_string(), Message::XrefRowOp(index, op)) }; container( column![ @@ -1422,7 +1543,7 @@ fn xref_row<'a>( ) .on_press(Message::XrefManagerSelect(index)) .on_right_press(Message::XrefRowRightClick(index)); - iced_aw::ContextMenu::new(row, row_menu).into() + iced_aw::ContextMenu::new(row, move || row_menu_for(index)).into() } /// One tree-mode row: indent + optional expand arrow + file icon + name. @@ -1435,11 +1556,7 @@ fn tree_row( 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)), - ); - } + cells = cells.push(tree_indent(display.depth)); if show_expand { let key = entry.key; let arrow = button(crate::ui::icons::themed_arrow_toggle(is_expanded, 10.0)) @@ -1454,10 +1571,10 @@ fn tree_row( cells = cells.push(arrow); } cells = cells.push( - container(crate::ui::icons::themed_secondary(crate::ui::icons::DOC, 12.0)) + container(crate::ui::icons::themed_secondary(crate::ui::icons::DOC, 14.0)) .align_y(iced::Center), ); - cells = cells.push(text(entry.name.clone()).size(FONT_SZ)); + cells = cells.push(text(entry.name.clone()).size(TREE_FONT_SZ)); let index = display.index; let row = mouse_area( container(cells.spacing(4).width(Fill)) @@ -1477,17 +1594,16 @@ fn tree_row( } }) .padding(Padding { - top: 0.0, - bottom: 0.0, + top: 5.0, + bottom: 5.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() + iced_aw::ContextMenu::new(row, move || row_menu_for(index)).into() } /// Tree-mode host root: home icon + name with the current-drawing marker. @@ -1500,15 +1616,11 @@ fn tree_host_row(display: DisplayRow, host_name: &str) -> Element<'_, Message> { }; 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(tree_indent(display.depth)); cells = cells.push( - container(crate::ui::icons::themed_home(12.0)).align_y(iced::Center), + container(crate::ui::icons::themed_home(14.0)).align_y(iced::Center), ); - cells = cells.push(text(name).size(FONT_SZ)); + cells = cells.push(text(name).size(TREE_FONT_SZ)); container(cells.spacing(4).width(Fill)) .style(|theme: &Theme| { let pair = theme.palette().background.base; @@ -1519,12 +1631,11 @@ fn tree_host_row(display: DisplayRow, host_name: &str) -> Element<'_, Message> { } }) .padding(Padding { - top: 0.0, - bottom: 0.0, + top: 5.0, + bottom: 5.0, left: 8.0, right: 8.0, }) - .height(Length::Fixed(ROW_H)) .width(Fill) .into() } @@ -1663,9 +1774,9 @@ fn reference_preview(entry: &ReferenceEntry) -> Option { if IS_WASM { return None; } - if !matches!(entry.status, RefStatus::Loaded | RefStatus::Stale) { - return None; - } + // Any entry whose file resolves decodes — including Unloaded and + // Unreferenced rows — so previews don't depend on load state. Missing + // files fail the decode below and fall back to the placeholder. let found = entry.found_at.as_deref().filter(|s| !s.is_empty())?; match entry.kind { RefKind::DwgXref => { @@ -1856,6 +1967,16 @@ mod tests { assert_eq!(panel.table_content_width(), panel.col_widths.iter().sum::() + super::COL_GUTTER * 5.0); } + #[test] + fn table_split_drag_clamps() { + let mut panel = XrefManagerPanel::default(); + assert_eq!(panel.table_h, super::TABLE_H); + panel.drag_table_by(60.0); + assert_eq!(panel.table_h, super::TABLE_H + 60.0); + panel.drag_table_by(-10000.0); + assert_eq!(panel.table_h, super::TABLE_MIN_H); + } + #[test] fn image_format_reads_extension() { assert_eq!(image_format("C:/exa/plan.BMP"), "BMP"); @@ -2047,4 +2168,38 @@ mod tests { ); std::fs::remove_dir_all(&dir).ok(); } + + #[test] + fn preview_decodes_unloaded_entry_with_file() { + // Previews key off file resolvability, not load state: an Unloaded + // image whose file exists still decodes. + use crate::io::xref_model::UnloadKey; + let dir = std::env::temp_dir().join(format!( + "ocs_xref_preview_unloaded_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let doc = preview_doc(&dir, "img.png"); + let key = doc + .objects + .iter() + .find_map(|(h, o)| match o { + acadrust::objects::ObjectType::ImageDefinition(_) => Some(h.value()), + _ => None, + }) + .unwrap(); + let mut unloaded = std::collections::HashSet::new(); + unloaded.insert(UnloadKey::Direct(key)); + let no_prev = std::collections::HashMap::new(); + let mut panel = XrefManagerPanel::default(); + panel.refresh(&doc, &dir, &unloaded, &no_prev, "host", ""); + panel.toggle_select(0); + panel.refresh(&doc, &dir, &unloaded, &no_prev, "host", ""); + assert_eq!(panel.previews.len(), 1); + std::fs::remove_dir_all(&dir).ok(); + } }