Merge pull request #1613 from FreddyFunk/feat/clipboard-paste-menu-graying

feat: gray out paste menu when clipboard is empty or location unsupported
This commit is contained in:
Jeremy Soller 2026-02-19 19:00:04 -07:00 committed by GitHub
commit 4e0b44b5bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 173 additions and 20 deletions

View file

@ -73,8 +73,8 @@ use wayland_client::{Proxy, protocol::wl_output::WlOutput};
use crate::{ use crate::{
FxOrderMap, FxOrderMap,
clipboard::{ clipboard::{
ClipboardCopy, ClipboardKind, ClipboardPaste, ClipboardPasteImage, ClipboardPasteText, ClipboardCache, ClipboardCopy, ClipboardKind, ClipboardPaste, ClipboardPasteImage,
ClipboardPasteVideo, ClipboardPasteText, ClipboardPasteVideo,
}, },
config::{ config::{
AppTheme, Config, DesktopConfig, Favorite, IconSizes, State, TIME_CONFIG_ID, TabConfig, AppTheme, Config, DesktopConfig, Favorite, IconSizes, State, TIME_CONFIG_ID, TabConfig,
@ -407,6 +407,11 @@ pub enum Message {
PasteTextContents(PathBuf, ClipboardPasteText), PasteTextContents(PathBuf, ClipboardPasteText),
PasteVideo(PathBuf), PasteVideo(PathBuf),
PasteVideoContents(PathBuf, ClipboardPasteVideo), PasteVideoContents(PathBuf, ClipboardPasteVideo),
CheckClipboard,
CheckClipboardImage,
CheckClipboardVideo,
CheckClipboardText,
ClipboardCached(ClipboardCache),
PendingCancel(u64), PendingCancel(u64),
PendingCancelAll, PendingCancelAll,
PendingComplete(u64, OperationSelection), PendingComplete(u64, OperationSelection),
@ -761,9 +766,15 @@ pub struct App {
tab_drag_id: DragId, tab_drag_id: DragId,
auto_scroll_speed: Option<i16>, auto_scroll_speed: Option<i16>,
file_dialog_opt: Option<Dialog<Message>>, file_dialog_opt: Option<Dialog<Message>>,
clipboard_cache: ClipboardCache,
} }
impl App { 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<widget::Id>) -> Task<Message> { fn push_dialog(&mut self, page: DialogPage, focus_id: Option<widget::Id>) -> Task<Message> {
let t = self.dialog_pages.push_back(page); let t = self.dialog_pages.push_back(page);
if let Some(focus_id) = focus_id { if let Some(focus_id) = focus_id {
@ -2311,11 +2322,12 @@ impl Application for App {
tab_drag_id: DragId::new(), tab_drag_id: DragId::new(),
auto_scroll_speed: None, auto_scroll_speed: None,
file_dialog_opt: None, file_dialog_opt: None,
clipboard_cache: ClipboardCache::Empty,
#[cfg(all(feature = "wayland", feature = "desktop-applet"))] #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
layer_sizes: FxHashMap::default(), 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 { for location in flags.locations {
if let Some(path) = location.path_opt() if let Some(path) = location.path_opt()
@ -3658,15 +3670,39 @@ impl Application for App {
&& let Some(path) = tab.location.path_opt() && let Some(path) = tab.location.path_opt()
{ {
let to = path.clone(); let to = path.clone();
return clipboard::read_data::<ClipboardPaste>().map(move |contents_opt| {
match contents_opt { // Use cached clipboard data if available (needed for Wayland popups)
Some(contents) => { match &self.clipboard_cache {
cosmic::action::app(Message::PasteContents(to.clone(), contents)) ClipboardCache::Files(contents) => {
} return self
// No file data in clipboard, try image data .update(Message::PasteContents(to.clone(), contents.clone()));
None => cosmic::action::app(Message::PasteImage(to.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::<ClipboardPaste>().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) => { 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::<ClipboardPaste>().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::<ClipboardPasteImage>().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::<ClipboardPasteVideo>().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::<ClipboardPasteText>().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) => { Message::PendingCancel(id) => {
if let Some((_, controller)) = self.pending_operations.get(&id) { if let Some((_, controller)) = self.pending_operations.get(&id) {
controller.cancel(); controller.cancel();
@ -5042,6 +5120,8 @@ impl Application for App {
_ => {} _ => {}
}; };
} }
// Check clipboard when window gains focus
return self.update(Message::CheckClipboard);
} }
Message::Surface(action) => { Message::Surface(action) => {
return cosmic::task::message(cosmic::Action::Cosmic( return cosmic::task::message(cosmic::Action::Cosmic(
@ -5996,6 +6076,7 @@ impl Application for App {
&self.config, &self.config,
&self.modifiers, &self.modifiers,
&self.key_binds, &self.key_binds,
self.clipboard_has_content(),
)] )]
} }
@ -6084,7 +6165,11 @@ impl Application for App {
let entity = self.tab_model.active(); let entity = self.tab_model.active();
if let Some(tab) = self.tab_model.data::<Tab>(entity) { if let Some(tab) = self.tab_model.data::<Tab>(entity) {
let tab_view = tab 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)); .map(move |message| Message::TabMessage(Some(entity), message));
tab_column = tab_column.push(tab_view); tab_column = tab_column.push(tab_view);
} else { } else {
@ -6107,8 +6192,13 @@ impl Application for App {
WindowKind::ContextMenu(entity, id) => match self.tab_model.data::<Tab>(*entity) { WindowKind::ContextMenu(entity, id) => match self.tab_model.data::<Tab>(*entity) {
Some(tab) => { Some(tab) => {
return widget::autosize::autosize( return widget::autosize::autosize(
menu::context_menu(tab, &self.key_binds, &window.modifiers) menu::context_menu(
.map(|x| Message::TabMessage(Some(*entity), x)), tab,
&self.key_binds,
&window.modifiers,
self.clipboard_has_content(),
)
.map(|x| Message::TabMessage(Some(*entity), x)),
id.clone(), id.clone(),
) )
.into(); .into();
@ -6120,7 +6210,11 @@ impl Application for App {
let tab_view = match self.tab_model.data::<Tab>(*entity) { let tab_view = match self.tab_model.data::<Tab>(*entity) {
Some(tab) => tab 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)), .map(move |message| Message::TabMessage(Some(*entity), message)),
None => widget::vertical_space().into(), None => widget::vertical_space().into(),
}; };
@ -6221,6 +6315,8 @@ impl Application for App {
} }
#[cfg(all(feature = "wayland", feature = "desktop-applet"))] #[cfg(all(feature = "wayland", feature = "desktop-applet"))]
Event::Window(WindowEvent::Focused) => Some(Message::Focused(window_id)), 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::CloseRequested) => Some(Message::WindowClose),
Event::Window(WindowEvent::Opened { position: _, size }) => { Event::Window(WindowEvent::Opened { position: _, size }) => {
Some(Message::Size(window_id, size)) Some(Message::Size(window_id, size))

View file

@ -325,3 +325,14 @@ impl TryFrom<(Vec<u8>, 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,
}

View file

@ -1804,6 +1804,7 @@ impl Application for App {
&app.tab, &app.tab,
&app.key_binds, &app.key_binds,
&app.modifiers, &app.modifiers,
false, // Paste not used in dialogs
) )
.map(Message::TabMessage) .map(Message::TabMessage)
.map(cosmic::Action::App), .map(cosmic::Action::App),
@ -1988,7 +1989,7 @@ impl Application for App {
col = col.push( col = col.push(
self.tab self.tab
.view(&self.key_binds, &self.modifiers) .view(&self.key_binds, &self.modifiers, false)
.map(Message::TabMessage), .map(Message::TabMessage),
); );

View file

@ -59,6 +59,7 @@ pub fn context_menu<'a>(
tab: &Tab, tab: &Tab,
key_binds: &HashMap<KeyBind, Action>, key_binds: &HashMap<KeyBind, Action>,
modifiers: &Modifiers, modifiers: &Modifiers,
clipboard_paste_available: bool,
) -> Element<'a, tab::Message> { ) -> Element<'a, tab::Message> {
let find_key = |action: &Action| -> String { let find_key = |action: &Action| -> String {
for (key_bind, key_action) in key_binds { for (key_bind, key_action) in key_binds {
@ -75,6 +76,13 @@ pub fn context_menu<'a>(
color: Some(color.into()), 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 menu_item = |label, action| {
let key = find_key(&action); let key = find_key(&action);
@ -86,6 +94,18 @@ pub fn context_menu<'a>(
.on_press(tab::Message::ContextAction(action)) .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_name, sort_direction, _) = tab.sort_options();
let sort_item = |label, variant| { let sort_item = |label, variant| {
menu_item( menu_item(
@ -265,8 +285,10 @@ pub fn context_menu<'a>(
if tab.mode.multiple() { if tab.mode.multiple() {
children.push(menu_item(fl!("select-all"), Action::SelectAll).into()); 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()); 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? //TODO: only show if cosmic-settings is found?
@ -543,6 +565,7 @@ pub fn menu_bar<'a>(
config: &Config, config: &Config,
modifiers: &Modifiers, modifiers: &Modifiers,
key_binds: &HashMap<KeyBind, Action>, key_binds: &HashMap<KeyBind, Action>,
clipboard_paste_available: bool,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let sort_options = tab_opt.map(Tab::sort_options); let sort_options = tab_opt.map(Tab::sort_options);
let sort_item = |label, sort, dir| { 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() { let (delete_item, delete_item_action) = if in_trash || modifiers.shift() {
(fl!("delete-permanently"), Action::Delete) (fl!("delete-permanently"), Action::Delete)
} else { } else {
@ -637,7 +664,7 @@ pub fn menu_bar<'a>(
menu_button_optional(fl!("copy"), Action::Copy, selected > 0), menu_button_optional(fl!("copy"), Action::Copy, selected > 0),
menu_button_optional(fl!("move-to"), Action::MoveTo, 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!("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::Button(fl!("select-all"), None, Action::SelectAll),
menu::Item::Divider, menu::Item::Divider,
menu::Item::Button(fl!("history"), None, Action::EditHistory), menu::Item::Button(fl!("history"), None, Action::EditHistory),

View file

@ -1621,6 +1621,18 @@ impl Location {
_ => path, _ => 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<Message>); pub struct TaskWrapper(pub cosmic::Task<Message>);
@ -5855,6 +5867,7 @@ impl Tab {
key_binds: &'a HashMap<KeyBind, Action>, key_binds: &'a HashMap<KeyBind, Action>,
modifiers: &'a Modifiers, modifiers: &'a Modifiers,
size: Size, size: Size,
clipboard_paste_available: bool,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
// Update cached size // Update cached size
self.size_opt.set(Some(size)); self.size_opt.set(Some(size));
@ -5942,7 +5955,8 @@ impl Tab {
if let Some(point) = self.context_menu if let Some(point) = self.context_menu
&& (!cfg!(feature = "wayland") || !crate::is_wayland()) && (!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 popover = popover
.popup(context_menu) .popup(context_menu)
.position(widget::popover::Position::Point(point)); .position(widget::popover::Position::Point(point));
@ -6149,8 +6163,12 @@ impl Tab {
&'a self, &'a self,
key_binds: &'a HashMap<KeyBind, Action>, key_binds: &'a HashMap<KeyBind, Action>,
modifiers: &'a Modifiers, modifiers: &'a Modifiers,
clipboard_paste_available: bool,
) -> Element<'a, Message> { ) -> 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<Message> { pub fn subscription(&self, preview: bool) -> Subscription<Message> {