diff --git a/src/app/mod.rs b/src/app/mod.rs index da913a1b..71f90e57 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -568,6 +568,8 @@ pub(super) struct OpenCADStudio { text_inline: Option, /// Which layout tab has its context menu open (None = closed). layout_context_menu: Option, + /// Which drawing tab has its context menu open (None = closed). + doc_tab_context_menu: Option, /// Cursor-anchored one-shot snap override menu (Shift+RMB): the canvas /// point it opened at, or `None` when closed (#337). snap_override_popup: Option, @@ -753,6 +755,10 @@ pub(super) struct OpenCADStudio { // ── Unsaved-changes dialog ──────────────────────────────────────────── /// Set when the user tries to close a tab or quit while there are unsaved changes. pending_close: Option, + /// Stable document ids waiting to be closed by "Close All" or + /// "Close All Other Drawings". Dirty drawings pause this queue at the + /// existing unsaved-changes dialog and resume after Save or Discard. + pending_tab_closes: std::collections::VecDeque, /// Latest save job per stable tab id. Older completions may finish, but /// cannot mark a newer document state clean or redirect its path. #[cfg(not(target_arch = "wasm32"))] @@ -1452,6 +1458,19 @@ pub enum Message { TabSwitch(usize), /// Close the given tab index. TabClose(usize), + /// Open/close the right-click menu for a drawing tab. + DocTabContextMenu(usize), + DocTabContextMenuClose, + /// Save every drawing that already has a file path. + DocTabSaveAll, + /// Close every non-Start drawing tab. + DocTabCloseAll, + /// Close every non-Start drawing tab except the given one. + DocTabCloseOthers(usize), + /// Copy the saved drawing's absolute path to the system clipboard. + DocTabCopyFullPath(usize), + /// Reveal the saved drawing in the platform file manager. + DocTabOpenFileLocation(usize), // ── Unsaved-changes confirmation dialog ─────────────────────────────── /// User clicked "Save" in the unsaved-changes dialog. UnsavedDialogSave, @@ -2493,6 +2512,7 @@ impl OpenCADStudio { mtext_editor: None, text_inline: None, layout_context_menu: None, + doc_tab_context_menu: None, snap_override_popup: None, axis_lock_dir: None, layout_rename_state: None, @@ -2512,6 +2532,7 @@ impl OpenCADStudio { active_interaction_index: None, queued_interaction_indices: std::collections::VecDeque::new(), pending_close: None, + pending_tab_closes: std::collections::VecDeque::new(), #[cfg(not(target_arch = "wasm32"))] active_save_jobs: std::collections::HashMap::new(), #[cfg(not(target_arch = "wasm32"))] diff --git a/src/app/update/command.rs b/src/app/update/command.rs index f676fa70..b5ca5d5d 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -23,6 +23,34 @@ use iced::{mouse, Point, Task}; impl OpenCADStudio { +pub(super) fn begin_tab_close_queue(&mut self, tab_ids: Vec) -> Task { + self.pending_tab_closes.clear(); + self.pending_tab_closes.extend(tab_ids); + self.continue_tab_close_queue() + } + + /// Close queued drawings until a dirty tab requires confirmation. Queue + /// entries use stable document ids because removing an earlier tab changes + /// every later vector index. + pub(super) fn continue_tab_close_queue(&mut self) -> Task { + let mut tasks = Vec::new(); + while let Some(tab_id) = self.pending_tab_closes.pop_front() { + let Some(idx) = self.tabs.iter().position(|tab| tab.id == tab_id) else { + continue; + }; + if self.tabs[idx].is_start { + continue; + } + if self.tabs[idx].dirty { + self.pending_close = Some(crate::app::PendingClose::Tab(idx)); + tasks.push(self.open_unsaved_dialog_window()); + break; + } + tasks.push(self.on_tab_close(idx)); + } + Task::batch(tasks) + } + pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // Start tab is fixed — close requests on it are no-ops. if self.tabs.get(idx).map_or(false, |t| t.is_start) { @@ -541,6 +569,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } if self.layout_rename_state.take().is_some() || self.layout_context_menu.take().is_some() + || self.doc_tab_context_menu.take().is_some() { return Task::none(); } diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 7079f004..7e85c6d4 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -214,7 +214,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven // last selection. #21. self.sync_ribbon_layers(); self.sync_ribbon_from_selection(); - return close_win; + return Task::batch([close_win, self.continue_tab_close_queue()]); } Some(crate::app::PendingClose::Quit) => { if let Some(idx) = self.tabs.iter().position(|t| t.dirty) { diff --git a/src/app/update/file.rs b/src/app/update/file.rs index a47fd403..2f7b2001 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -937,6 +937,7 @@ pub(super) fn on_open_file(&mut self) -> Task { self.pending_close = None; tasks.push(self.close_unsaved_dialog_window()); tasks.push(self.update(Message::TabClose(i))); + tasks.push(self.continue_tab_close_queue()); } crate::app::SaveContinuation::Quit if snapshot_is_current => { self.pending_close = None; @@ -1084,19 +1085,32 @@ pub(super) fn on_open_file(&mut self) -> Task { self.stamp_header_sysvars(i); self.sync_truck_solids_to_acis(i); self.stamp_thumbnail(i, version); - match crate::io::save_to_bytes(&self.tabs[i].scene.document, ext, version) { + let saved = + match crate::io::save_to_bytes(&self.tabs[i].scene.document, ext, version) { Ok(bytes) => { crate::sys::download_bytes(&filename, &bytes); self.tabs[i].dirty = false; self.command_line.push_output(&format!("Saved: {filename}")); + true } - Err(e) => self.command_line.push_error(&format!("Save failed: {e}")), - } + Err(e) => { + self.command_line.push_error(&format!("Save failed: {e}")); + false + } + }; // Continue a pending tab close. if self.save_dialog_for_unsaved { - if let Some(crate::app::PendingClose::Tab(idx)) = self.pending_close.take() { - let cont = self.update(Message::TabClose(idx)); - return Task::batch([close, cont]); + if saved { + if let Some(crate::app::PendingClose::Tab(idx)) = + self.pending_close.take() + { + let cont = self.update(Message::TabClose(idx)); + let rest = self.continue_tab_close_queue(); + return Task::batch([close, cont, rest]); + } + } else if self.pending_close.is_some() { + let retry = self.open_unsaved_dialog_window(); + return Task::batch([close, retry]); } } close diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index dd94a0b7..4221eefb 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -906,6 +906,7 @@ impl OpenCADStudio { } Message::TabSwitch(idx) => { + self.doc_tab_context_menu = None; if idx < self.tabs.len() { if idx != self.active_tab { // The attribute editor is tab-scoped; leaving its tab @@ -940,7 +941,116 @@ impl OpenCADStudio { Task::none() } - Message::TabClose(idx) => self.on_tab_close(idx), + Message::TabClose(idx) => { + self.doc_tab_context_menu = None; + self.on_tab_close(idx) + } + + Message::DocTabContextMenu(idx) => { + if self.tabs.get(idx).is_some_and(|tab| !tab.is_start) { + self.layout_context_menu = None; + self.doc_tab_context_menu = Some(idx); + } + Task::none() + } + + Message::DocTabContextMenuClose => { + self.doc_tab_context_menu = None; + Task::none() + } + + Message::DocTabSaveAll => { + self.doc_tab_context_menu = None; + self.dispatch_command("SAVEALL") + } + + Message::DocTabCloseAll => { + self.doc_tab_context_menu = None; + let ids = self + .tabs + .iter() + .filter(|tab| !tab.is_start) + .map(|tab| tab.id) + .collect(); + self.begin_tab_close_queue(ids) + } + + Message::DocTabCloseOthers(idx) => { + self.doc_tab_context_menu = None; + let Some(keep_id) = self.tabs.get(idx).filter(|tab| !tab.is_start).map(|t| t.id) + else { + return Task::none(); + }; + let switch = self.update(Message::TabSwitch(idx)); + let ids = self + .tabs + .iter() + .filter(|tab| !tab.is_start && tab.id != keep_id) + .map(|tab| tab.id) + .collect(); + Task::batch([switch, self.begin_tab_close_queue(ids)]) + } + + Message::DocTabCopyFullPath(idx) => { + self.doc_tab_context_menu = None; + #[cfg(not(target_arch = "wasm32"))] + { + let Some(path) = self.tabs.get(idx).and_then(|tab| tab.current_path.clone()) + else { + self.command_line + .push_error("Save the drawing before copying its file path."); + return Task::none(); + }; + let full_path = path.canonicalize().unwrap_or_else(|_| { + if path.is_absolute() { + path + } else { + std::env::current_dir() + .map(|dir| dir.join(&path)) + .unwrap_or(path) + } + }); + self.command_line + .push_output(&format!("Copied path: {}", full_path.display())); + return iced::clipboard::write(full_path.to_string_lossy().into_owned()); + } + #[cfg(target_arch = "wasm32")] + { + let _ = idx; + self.command_line + .push_error("Full file paths are unavailable in the web application."); + Task::none() + } + } + + Message::DocTabOpenFileLocation(idx) => { + self.doc_tab_context_menu = None; + #[cfg(not(target_arch = "wasm32"))] + { + let Some(path) = self.tabs.get(idx).and_then(|tab| tab.current_path.clone()) + else { + self.command_line + .push_error("Save the drawing before opening its file location."); + return Task::none(); + }; + match crate::sys::reveal_in_file_manager(&path) { + Ok(()) => self + .command_line + .push_output(&format!("Opened file location: {}", path.display())), + Err(error) => self + .command_line + .push_error(&format!("Could not open file location: {error}")), + } + Task::none() + } + #[cfg(target_arch = "wasm32")] + { + let _ = idx; + self.command_line + .push_error("File locations are unavailable in the web application."); + Task::none() + } + } Message::CommandInput(s) => { // Space submits (acts like Enter) so a command advances @@ -3227,6 +3337,7 @@ impl OpenCADStudio { .map(|be| be.block_name == name) .unwrap_or(false); if name != "Model" && !is_block_tab { + self.doc_tab_context_menu = None; self.layout_context_menu = Some(name); } Task::none() @@ -3936,6 +4047,7 @@ impl OpenCADStudio { // ── Unsaved-changes dialog ──────────────────────────────────── Message::UnsavedDialogCancel => { self.pending_close = None; + self.pending_tab_closes.clear(); self.close_unsaved_dialog_window() } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index f93cb7db..59a488c3 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -21,8 +21,8 @@ mod viewcube; use controls::{dyn_component_value, viewport_controls}; use overlay::{ - layout_context_menu_overlay, mtext_editor_overlay, position_canvas_overlay, qselect_overlay, - text_inline_overlay, viewport_context_menu_overlay, + doc_tab_context_menu_overlay, layout_context_menu_overlay, mtext_editor_overlay, + position_canvas_overlay, qselect_overlay, text_inline_overlay, viewport_context_menu_overlay, }; use viewcube::{viewcube_nav_controls, viewcube_ucs_picker, UCS_PICKER_W}; @@ -1596,6 +1596,27 @@ impl OpenCADStudio { iced::widget::Space::new().width(0).height(0).into() }; + let doc_tab_ctx_layer: Element<'_, Message> = + if let Some(idx) = self.doc_tab_context_menu { + if let Some(context_tab) = self.tabs.get(idx) { + let has_other_drawings = self + .tabs + .iter() + .enumerate() + .any(|(other_idx, tab)| other_idx != idx && !tab.is_start); + doc_tab_context_menu_overlay( + idx, + context_tab.current_path.as_deref(), + has_other_drawings, + win, + ) + } else { + iced::widget::Space::new().width(0).height(0).into() + } + } else { + iced::widget::Space::new().width(0).height(0).into() + }; + let snap_override_layer: Element<'_, Message> = if let Some(pos) = self.snap_override_popup { overlay::snap_override_overlay(pos) @@ -1629,6 +1650,7 @@ impl OpenCADStudio { sel_filter_layer, dropdown_layer, layout_ctx_layer, + doc_tab_ctx_layer, qselect_layer, snap_override_layer, open_progress_layer, @@ -2156,22 +2178,29 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele row![title_btn, close_btn].spacing(0).align_y(iced::Center) }; - items.push( - container(row_inner) - .style(move |_: &Theme| container::Style { - border: Border { - color: if is_active { - BORDER_COLOR - } else { - Color::TRANSPARENT - }, - width: if is_active { 1.0 } else { 0.0 }, - radius: 0.0.into(), - }, - ..Default::default() - }) - .into(), - ); + let tab_container = container(row_inner).style(move |_: &Theme| container::Style { + border: Border { + color: if is_active { + BORDER_COLOR + } else { + Color::TRANSPARENT + }, + width: if is_active { 1.0 } else { 0.0 }, + radius: 0.0.into(), + }, + ..Default::default() + }); + + let tab_element: Element<'_, Message> = if tab.is_start { + tab_container.into() + } else { + crate::ui::wrap_bar::PosReport::owned( + format!("DOC_TAB:{idx}"), + mouse_area(tab_container).on_right_press(Message::DocTabContextMenu(idx)), + ) + .into() + }; + items.push(tab_element); } let new_btn = button(text("+").size(14).color(Color { diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index db24def9..c9644901 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -711,6 +711,132 @@ pub(super) fn mtext_editor_overlay<'a>( ) } +// ── Drawing-tab right-click context menu ─────────────────────────────────── + +/// Right-click menu for a drawing tab. The scope deliberately contains only +/// the actions approved in issue #493. +pub(super) fn doc_tab_context_menu_overlay( + tab_idx: usize, + current_path: Option<&std::path::Path>, + has_other_drawings: bool, + win: (f32, f32), +) -> Element<'_, Message> { + const MENU_W: f32 = 210.0; + const MENU_H: f32 = 136.0; + const MENU_BG: Color = Color { + r: 0.17, + g: 0.17, + b: 0.17, + a: 1.0, + }; + const MENU_BORDER: Color = Color { + r: 0.35, + g: 0.35, + b: 0.35, + a: 1.0, + }; + const ITEM_HOVER: Color = Color { + r: 0.25, + g: 0.45, + b: 0.70, + a: 1.0, + }; + const TEXT_COLOR: Color = Color { + r: 0.88, + g: 0.88, + b: 0.88, + a: 1.0, + }; + const DISABLED_TEXT: Color = Color { + r: 0.43, + g: 0.43, + b: 0.43, + a: 1.0, + }; + + let item = |label: &'static str, msg: Option| { + let enabled = msg.is_some(); + let mut item = button(text(label).size(12).color(if enabled { + TEXT_COLOR + } else { + DISABLED_TEXT + })) + .style(move |_: &Theme, status| button::Style { + background: Some(Background::Color(if enabled { + match status { + button::Status::Hovered | button::Status::Pressed => ITEM_HOVER, + _ => Color::TRANSPARENT, + } + } else { + Color::TRANSPARENT + })), + text_color: if enabled { TEXT_COLOR } else { DISABLED_TEXT }, + border: Border::default(), + shadow: iced::Shadow::default(), + snap: false, + }) + .padding([4, 12]) + .width(Fill); + if let Some(msg) = msg { + item = item.on_press(msg); + } + item + }; + + let native_path_actions = cfg!(not(target_arch = "wasm32")) && current_path.is_some(); + let menu = container( + column![ + item("Save All", Some(Message::DocTabSaveAll)), + item("Close All", Some(Message::DocTabCloseAll)), + item( + "Close All Other Drawings", + has_other_drawings.then_some(Message::DocTabCloseOthers(tab_idx)), + ), + item( + "Copy Full File Path", + native_path_actions.then_some(Message::DocTabCopyFullPath(tab_idx)), + ), + item( + "Open File Location", + native_path_actions.then_some(Message::DocTabOpenFileLocation(tab_idx)), + ), + ] + .spacing(0) + .width(MENU_W), + ) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(MENU_BG)), + border: Border { + color: MENU_BORDER, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }) + .padding([4, 0]) + .width(iced::Length::Fixed(MENU_W)); + + let catcher = mouse_area( + container(Space::new().width(Fill).height(Fill)) + .width(Fill) + .height(Fill), + ) + .on_press(Message::DocTabContextMenuClose) + .on_right_press(Message::DocTabContextMenuClose); + + let bounds = crate::ui::wrap_bar::dropdown_bounds(&format!("DOC_TAB:{tab_idx}")); + let pos = bounds + .map(|b| { + iced::Point::new( + b.x.clamp(0.0, (win.0 - MENU_W).max(0.0)), + (b.y + b.height).clamp(0.0, (win.1 - MENU_H).max(0.0)), + ) + }) + .unwrap_or(iced::Point::new(4.0, 30.0)); + + stack![catcher, position_canvas_overlay(pos, menu.into())].into() +} + // ── Viewport right-click context menu ────────────────────────────────────── pub(super) fn viewport_context_menu_overlay( diff --git a/src/sys.rs b/src/sys.rs index eadfc27a..358f0c69 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -17,6 +17,37 @@ pub fn open_url(url: &str) { } } +/// Reveal a saved drawing in the native platform's file manager. +#[cfg(not(target_arch = "wasm32"))] +pub fn reveal_in_file_manager(path: &std::path::Path) -> Result<(), String> { + #[cfg(target_os = "windows")] + let status = std::process::Command::new("explorer.exe") + .arg("/select,") + .arg(path) + .status(); + + #[cfg(target_os = "macos")] + let status = std::process::Command::new("open") + .arg("-R") + .arg(path) + .status(); + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + let folder = path + .parent() + .ok_or_else(|| format!("Path has no parent folder: {}", path.display()))?; + return open::that(folder).map_err(|e| e.to_string()); + } + + #[cfg(any(target_os = "windows", target_os = "macos"))] + match status { + Ok(status) if status.success() => Ok(()), + Ok(status) => Err(format!("File manager exited with {status}")), + Err(error) => Err(error.to_string()), + } +} + /// Web: read text from the system clipboard via the async Clipboard API. /// iced's own `clipboard::read` is a no-op on the web (the browser clipboard is /// async + permission-gated), so the editor paste paths use this instead. The