diff --git a/src/app.rs b/src/app.rs index 4a88bc8..2fd34c3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -73,8 +73,8 @@ use wayland_client::{Proxy, protocol::wl_output::WlOutput}; use crate::{ FxOrderMap, clipboard::{ - ClipboardCopy, ClipboardKind, ClipboardPaste, ClipboardPasteImage, ClipboardPasteText, - ClipboardPasteVideo, + ClipboardCache, ClipboardCopy, ClipboardKind, ClipboardPaste, ClipboardPasteImage, + ClipboardPasteText, ClipboardPasteVideo, }, config::{ AppTheme, Config, DesktopConfig, Favorite, IconSizes, State, TIME_CONFIG_ID, TabConfig, @@ -407,6 +407,11 @@ pub enum Message { PasteTextContents(PathBuf, ClipboardPasteText), PasteVideo(PathBuf), PasteVideoContents(PathBuf, ClipboardPasteVideo), + CheckClipboard, + CheckClipboardImage, + CheckClipboardVideo, + CheckClipboardText, + ClipboardCached(ClipboardCache), PendingCancel(u64), PendingCancelAll, PendingComplete(u64, OperationSelection), @@ -761,9 +766,15 @@ pub struct App { tab_drag_id: DragId, auto_scroll_speed: Option, file_dialog_opt: Option>, + clipboard_cache: ClipboardCache, } impl App { + /// Returns true if the clipboard cache contains pasteable content + fn clipboard_has_content(&self) -> bool { + !matches!(self.clipboard_cache, ClipboardCache::Empty) + } + fn push_dialog(&mut self, page: DialogPage, focus_id: Option) -> Task { let t = self.dialog_pages.push_back(page); if let Some(focus_id) = focus_id { @@ -2311,11 +2322,12 @@ impl Application for App { tab_drag_id: DragId::new(), auto_scroll_speed: None, file_dialog_opt: None, + clipboard_cache: ClipboardCache::Empty, #[cfg(all(feature = "wayland", feature = "desktop-applet"))] layer_sizes: FxHashMap::default(), }; - let mut commands = vec![app.update_config()]; + let mut commands = vec![app.update_config(), app.update(Message::CheckClipboard)]; for location in flags.locations { if let Some(path) = location.path_opt() @@ -3658,15 +3670,39 @@ impl Application for App { && let Some(path) = tab.location.path_opt() { let to = path.clone(); - return clipboard::read_data::().map(move |contents_opt| { - match contents_opt { - Some(contents) => { - cosmic::action::app(Message::PasteContents(to.clone(), contents)) - } - // No file data in clipboard, try image data - None => cosmic::action::app(Message::PasteImage(to.clone())), + + // Use cached clipboard data if available (needed for Wayland popups) + match &self.clipboard_cache { + ClipboardCache::Files(contents) => { + return self + .update(Message::PasteContents(to.clone(), contents.clone())); } - }); + ClipboardCache::Image(contents) => { + return self + .update(Message::PasteImageContents(to.clone(), contents.clone())); + } + ClipboardCache::Video(contents) => { + return self + .update(Message::PasteVideoContents(to.clone(), contents.clone())); + } + ClipboardCache::Text(contents) => { + return self + .update(Message::PasteTextContents(to.clone(), contents.clone())); + } + ClipboardCache::Empty => { + // Cache is empty, try reading from clipboard directly + // (works when triggered from main window, e.g., Ctrl+V) + return clipboard::read_data::().map( + move |contents_opt| match contents_opt { + Some(contents) => cosmic::action::app(Message::PasteContents( + to.clone(), + contents, + )), + None => cosmic::action::app(Message::PasteImage(to.clone())), + }, + ); + } + } } } Message::PasteContents(to, mut contents) => { @@ -3781,6 +3817,48 @@ impl Application for App { } } } + Message::CheckClipboard => { + // Check if clipboard has any paste-able content and cache it + return clipboard::read_data::().map(|contents_opt| { + match contents_opt { + Some(contents) => cosmic::action::app(Message::ClipboardCached( + ClipboardCache::Files(contents), + )), + None => cosmic::action::app(Message::CheckClipboardImage), + } + }); + } + Message::CheckClipboardImage => { + return clipboard::read_data::().map(|contents_opt| { + match contents_opt { + Some(contents) => cosmic::action::app(Message::ClipboardCached( + ClipboardCache::Image(contents), + )), + None => cosmic::action::app(Message::CheckClipboardVideo), + } + }); + } + Message::CheckClipboardVideo => { + return clipboard::read_data::().map(|contents_opt| { + match contents_opt { + Some(contents) => cosmic::action::app(Message::ClipboardCached( + ClipboardCache::Video(contents), + )), + None => cosmic::action::app(Message::CheckClipboardText), + } + }); + } + Message::CheckClipboardText => { + return clipboard::read_data::().map(|contents_opt| { + cosmic::action::app(Message::ClipboardCached(match contents_opt { + Some(contents) => ClipboardCache::Text(contents), + None => ClipboardCache::Empty, + })) + }); + } + Message::ClipboardCached(cache) => { + self.clipboard_cache = cache; + } Message::PendingCancel(id) => { if let Some((_, controller)) = self.pending_operations.get(&id) { controller.cancel(); @@ -5042,6 +5120,8 @@ impl Application for App { _ => {} }; } + // Check clipboard when window gains focus + return self.update(Message::CheckClipboard); } Message::Surface(action) => { return cosmic::task::message(cosmic::Action::Cosmic( @@ -5996,6 +6076,7 @@ impl Application for App { &self.config, &self.modifiers, &self.key_binds, + self.clipboard_has_content(), )] } @@ -6084,7 +6165,11 @@ impl Application for App { let entity = self.tab_model.active(); if let Some(tab) = self.tab_model.data::(entity) { let tab_view = tab - .view(&self.key_binds, &self.modifiers) + .view( + &self.key_binds, + &self.modifiers, + self.clipboard_has_content(), + ) .map(move |message| Message::TabMessage(Some(entity), message)); tab_column = tab_column.push(tab_view); } else { @@ -6107,8 +6192,13 @@ impl Application for App { WindowKind::ContextMenu(entity, id) => match self.tab_model.data::(*entity) { Some(tab) => { return widget::autosize::autosize( - menu::context_menu(tab, &self.key_binds, &window.modifiers) - .map(|x| Message::TabMessage(Some(*entity), x)), + menu::context_menu( + tab, + &self.key_binds, + &window.modifiers, + self.clipboard_has_content(), + ) + .map(|x| Message::TabMessage(Some(*entity), x)), id.clone(), ) .into(); @@ -6120,7 +6210,11 @@ impl Application for App { let tab_view = match self.tab_model.data::(*entity) { Some(tab) => tab - .view(&self.key_binds, &window.modifiers) + .view( + &self.key_binds, + &window.modifiers, + self.clipboard_has_content(), + ) .map(move |message| Message::TabMessage(Some(*entity), message)), None => widget::vertical_space().into(), }; @@ -6221,6 +6315,8 @@ impl Application for App { } #[cfg(all(feature = "wayland", feature = "desktop-applet"))] Event::Window(WindowEvent::Focused) => Some(Message::Focused(window_id)), + #[cfg(not(all(feature = "wayland", feature = "desktop-applet")))] + Event::Window(WindowEvent::Focused) => Some(Message::CheckClipboard), Event::Window(WindowEvent::CloseRequested) => Some(Message::WindowClose), Event::Window(WindowEvent::Opened { position: _, size }) => { Some(Message::Size(window_id, size)) diff --git a/src/clipboard.rs b/src/clipboard.rs index bf84bee..637303e 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -325,3 +325,14 @@ impl TryFrom<(Vec, String)> for ClipboardPasteText { }) } } + +/// Cached clipboard content for paste operations. +/// This is needed because Wayland restricts clipboard access from popup windows. +#[derive(Clone, Debug)] +pub enum ClipboardCache { + Files(ClipboardPaste), + Image(ClipboardPasteImage), + Video(ClipboardPasteVideo), + Text(ClipboardPasteText), + Empty, +} diff --git a/src/dialog.rs b/src/dialog.rs index ff64889..f496b8c 100644 --- a/src/dialog.rs +++ b/src/dialog.rs @@ -1804,6 +1804,7 @@ impl Application for App { &app.tab, &app.key_binds, &app.modifiers, + false, // Paste not used in dialogs ) .map(Message::TabMessage) .map(cosmic::Action::App), @@ -1988,7 +1989,7 @@ impl Application for App { col = col.push( self.tab - .view(&self.key_binds, &self.modifiers) + .view(&self.key_binds, &self.modifiers, false) .map(Message::TabMessage), ); diff --git a/src/menu.rs b/src/menu.rs index c6a7b12..2a98a98 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -59,6 +59,7 @@ pub fn context_menu<'a>( tab: &Tab, key_binds: &HashMap, modifiers: &Modifiers, + clipboard_paste_available: bool, ) -> Element<'a, tab::Message> { let find_key = |action: &Action| -> String { for (key_bind, key_action) in key_binds { @@ -75,6 +76,13 @@ pub fn context_menu<'a>( color: Some(color.into()), } } + fn disabled_style(theme: &cosmic::Theme) -> TextStyle { + let mut color = theme.cosmic().background.component.on; + color.alpha *= 0.5; + TextStyle { + color: Some(color.into()), + } + } let menu_item = |label, action| { let key = find_key(&action); @@ -86,6 +94,18 @@ pub fn context_menu<'a>( .on_press(tab::Message::ContextAction(action)) }; + let menu_item_disabled = |label, action: Action| { + let key = find_key(&action); + menu_button!( + text::body(label).class(theme::Text::Custom(disabled_style)), + horizontal_space(), + text::body(key).class(theme::Text::Custom(disabled_style)) + ) + }; + + // Allow paste when clipboard has data and we're in a location that supports it + let can_paste = clipboard_paste_available && tab.location.supports_paste(); + let (sort_name, sort_direction, _) = tab.sort_options(); let sort_item = |label, variant| { menu_item( @@ -265,8 +285,10 @@ pub fn context_menu<'a>( if tab.mode.multiple() { children.push(menu_item(fl!("select-all"), Action::SelectAll).into()); } - if tab.location != Location::Recents { + if can_paste { children.push(menu_item(fl!("paste"), Action::Paste).into()); + } else { + children.push(menu_item_disabled(fl!("paste"), Action::Paste).into()); } //TODO: only show if cosmic-settings is found? @@ -543,6 +565,7 @@ pub fn menu_bar<'a>( config: &Config, modifiers: &Modifiers, key_binds: &HashMap, + clipboard_paste_available: bool, ) -> Element<'a, Message> { let sort_options = tab_opt.map(Tab::sort_options); let sort_item = |label, sort, dir| { @@ -574,6 +597,10 @@ pub fn menu_bar<'a>( } } + // Allow paste when clipboard has data and we're in a location that supports it + let can_paste = + clipboard_paste_available && tab_opt.is_some_and(|tab| tab.location.supports_paste()); + let (delete_item, delete_item_action) = if in_trash || modifiers.shift() { (fl!("delete-permanently"), Action::Delete) } else { @@ -637,7 +664,7 @@ pub fn menu_bar<'a>( menu_button_optional(fl!("copy"), Action::Copy, selected > 0), menu_button_optional(fl!("move-to"), Action::MoveTo, selected > 0), menu_button_optional(fl!("copy-to"), Action::CopyTo, selected > 0), - menu_button_optional(fl!("paste"), Action::Paste, selected > 0), + menu_button_optional(fl!("paste"), Action::Paste, can_paste), menu::Item::Button(fl!("select-all"), None, Action::SelectAll), menu::Item::Divider, menu::Item::Button(fl!("history"), None, Action::EditHistory), diff --git a/src/tab.rs b/src/tab.rs index 80cf085..01c2ea0 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -1621,6 +1621,18 @@ impl Location { _ => path, } } + + /// Returns true if this location supports paste operations (not Trash) + pub fn supports_paste(&self) -> bool { + matches!( + self, + Self::Desktop(..) + | Self::Path(..) + | Self::Search(..) + | Self::Recents + | Self::Network(_, _, Some(_)) + ) + } } pub struct TaskWrapper(pub cosmic::Task); @@ -5855,6 +5867,7 @@ impl Tab { key_binds: &'a HashMap, modifiers: &'a Modifiers, size: Size, + clipboard_paste_available: bool, ) -> Element<'a, Message> { // Update cached size self.size_opt.set(Some(size)); @@ -5942,7 +5955,8 @@ impl Tab { if let Some(point) = self.context_menu && (!cfg!(feature = "wayland") || !crate::is_wayland()) { - let context_menu = menu::context_menu(self, key_binds, modifiers); + let context_menu = + menu::context_menu(self, key_binds, modifiers, clipboard_paste_available); popover = popover .popup(context_menu) .position(widget::popover::Position::Point(point)); @@ -6149,8 +6163,12 @@ impl Tab { &'a self, key_binds: &'a HashMap, modifiers: &'a Modifiers, + clipboard_paste_available: bool, ) -> Element<'a, Message> { - widget::responsive(|size| self.view_responsive(key_binds, modifiers, size)).into() + widget::responsive(move |size| { + self.view_responsive(key_binds, modifiers, size, clipboard_paste_available) + }) + .into() } pub fn subscription(&self, preview: bool) -> Subscription {