On wayland, place context menus into popups, fixes #1090

This commit is contained in:
Jeremy Soller 2025-07-24 10:51:46 -06:00
parent 050e043867
commit 605f44763b
No known key found for this signature in database
GPG key ID: 670FDFB5428E05CA
7 changed files with 211 additions and 62 deletions

1
Cargo.lock generated
View file

@ -1484,6 +1484,7 @@ dependencies = [
"bzip2",
"chrono",
"compio",
"cosmic-client-toolkit",
"cosmic-mime-apps",
"dirs 6.0.0",
"env_logger",

View file

@ -17,6 +17,7 @@ icu = { version = "1.5.0", features = [
"compiled_data",
"icu_datetime_experimental",
] }
cctk = { git = "https://github.com/pop-os/cosmic-protocols", package = "cosmic-client-toolkit", rev = "178eb0b", optional = true }
cosmic-mime-apps = { git = "https://github.com/pop-os/cosmic-mime-apps.git", optional = true }
dirs = "6.0.0"
env_logger = "0.11"
@ -106,7 +107,7 @@ io-uring = ["compio/io-uring", "dep:io-uring"]
io-uring-bindgen = ["io-uring?/bindgen"]
jemalloc = ["dep:tikv-jemallocator"]
notify = ["dep:notify-rust"]
wayland = ["libcosmic/wayland", "dep:wayland-client"]
wayland = ["libcosmic/wayland", "dep:cctk", "dep:wayland-client"]
wgpu = ["libcosmic/wgpu"]
[profile.dev]

View file

@ -602,11 +602,12 @@ pub struct MounterData(MounterKey, MounterItem);
#[derive(Clone, Debug)]
pub enum WindowKind {
ContextMenu(Entity, widget::Id),
Desktop(Entity),
DesktopViewOptions,
Dialogs(widget::Id),
Preview(Option<Entity>, PreviewKind),
FileDialog(Option<Vec<PathBuf>>),
Preview(Option<Entity>, PreviewKind),
}
pub struct WatcherWrapper {
@ -671,7 +672,7 @@ pub struct App {
surface_names: HashMap<WindowId, String>,
toasts: widget::toaster::Toasts<Message>,
watcher_opt: Option<(Debouncer<RecommendedWatcher, FileIdMap>, HashSet<PathBuf>)>,
window_id_opt: Option<window::Id>,
pub(crate) window_id_opt: Option<window::Id>,
windows: HashMap<window::Id, WindowKind>,
nav_dnd_hover: Option<(Location, Instant)>,
tab_dnd_hover: Option<(Entity, Instant)>,
@ -1120,9 +1121,20 @@ impl App {
}
fn remove_window(&mut self, id: &window::Id) {
if let Some(WindowKind::Desktop(entity)) = self.windows.remove(id) {
// Remove the tab from the tab model
self.tab_model.remove(entity);
if let Some(window_kind) = self.windows.remove(id) {
match window_kind {
WindowKind::ContextMenu(entity, _) => {
// Close context menu
if let Some(tab) = self.tab_model.data_mut::<Tab>(entity) {
tab.context_menu = None;
}
}
WindowKind::Desktop(entity) => {
// Remove the tab from the tab model
self.tab_model.remove(entity);
}
_ => {}
}
}
}
@ -2410,8 +2422,10 @@ impl Application for App {
}
if let Some(tab) = self.tab_model.data_mut::<Tab>(entity) {
if tab.context_menu.is_some() {
tab.context_menu = None;
return Task::none();
return self.update(Message::TabMessage(
Some(entity),
tab::Message::ContextMenu(None),
));
}
if tab.edit_location.is_some() {
@ -3683,12 +3697,26 @@ impl Application for App {
return self.update_config();
}
Message::TabActivate(entity) => {
self.tab_model.activate(entity);
let mut tasks = Vec::new();
// Close old context menu
let active = self.tab_model.active();
if let Some(tab) = self.tab_model.data_mut::<Tab>(active) {
if tab.context_menu.is_some() {
tasks.push(self.update(Message::TabMessage(
Some(active),
tab::Message::ContextMenu(None),
)));
}
}
// Activate new tab
self.tab_model.activate(entity);
if let Some(tab) = self.tab_model.data::<Tab>(entity) {
self.activate_nav_model_location(&tab.location.clone());
}
return self.update_title();
tasks.push(self.update_title());
return Task::batch(tasks);
}
Message::TabNext => {
let len = self.tab_model.iter().count();
@ -3824,6 +3852,75 @@ impl Application for App {
self.update_tab(entity, tab_path, selection_paths),
]));
}
tab::Command::ContextMenu(point_opt) => {
#[cfg(feature = "wayland")]
match point_opt {
Some(point) => {
if crate::is_wayland() {
// Open context menu
use cctk::wayland_protocols::xdg::shell::client::xdg_positioner::{
Anchor, Gravity,
};
use cosmic::iced_runtime::platform_specific::wayland::popup::{
SctkPopupSettings, SctkPositioner,
};
let window_id = WindowId::unique();
self.windows.insert(
window_id.clone(),
WindowKind::ContextMenu(entity, widget::Id::unique()),
);
commands.push(self.update(Message::Surface(
cosmic::surface::action::app_popup(
move |app: &mut crate::App| -> SctkPopupSettings {
let anchor_rect = Rectangle {
x: point.x as i32,
y: point.y as i32,
width: 1,
height: 1,
};
let positioner = SctkPositioner {
size: None,
anchor_rect,
anchor: Anchor::None,
gravity: Gravity::BottomRight,
reactive: true,
..Default::default()
};
SctkPopupSettings {
parent: app
.window_id_opt
.unwrap_or_else(|| WindowId::NONE),
id: window_id,
positioner,
parent_size: None,
grab: true,
close_with_children: false,
input_zone: None,
}
},
None,
),
)));
}
}
None => {
// Destroy previous popup
let mut window_ids = Vec::new();
for (window_id, window_kind) in self.windows.iter() {
if let WindowKind::ContextMenu(e, _) = window_kind {
if *e == entity {
window_ids.push(*window_id);
}
}
}
for window_id in window_ids {
commands.push(self.update(Message::Surface(
cosmic::surface::action::destroy_popup(window_id),
)));
}
}
}
}
tab::Command::Delete(paths) => commands.push(self.delete(paths)),
tab::Command::DropFiles(to, from) => {
commands.push(self.update(Message::PasteContents(to, from)));
@ -4055,10 +4152,12 @@ impl Application for App {
}
}
Message::WindowUnfocus => {
/*TODO
let tab_entity = self.tab_model.active();
if let Some(tab) = self.tab_model.data_mut::<Tab>(tab_entity) {
tab.context_menu = None;
}
*/
}
Message::WindowCloseRequested(id) => {
self.remove_window(&id);
@ -4548,9 +4647,9 @@ impl Application for App {
};
}
}
Message::Surface(a) => {
Message::Surface(action) => {
return cosmic::task::message(cosmic::Action::Cosmic(
cosmic::app::Action::Surface(a),
cosmic::app::Action::Surface(action),
));
}
Message::SaveSortNames => {
@ -5583,6 +5682,19 @@ impl Application for App {
fn view_window(&self, id: WindowId) -> Element<Self::Message> {
let content = match self.windows.get(&id) {
Some(WindowKind::ContextMenu(entity, id)) => {
match self.tab_model.data::<Tab>(*entity) {
Some(tab) => {
return widget::autosize::autosize(
menu::context_menu(tab, &self.key_binds, &self.modifiers)
.map(|x| Message::TabMessage(Some(*entity), x)),
id.clone(),
)
.into()
}
None => widget::text("Unknown tab ID").into(),
}
}
Some(WindowKind::Desktop(entity)) => {
let mut tab_column = widget::column::with_capacity(3);

View file

@ -457,6 +457,7 @@ impl From<AppMessage> for Message {
AppMessage::ZoomIn(_entity_opt) => Message::ZoomIn,
AppMessage::ZoomOut(_entity_opt) => Message::ZoomOut,
AppMessage::NewItem(_entity_opt, true) => Message::NewFolder,
AppMessage::Surface(action) => Message::Surface(action),
unsupported => {
log::warn!("{unsupported:?} not supported in dialog mode");
Message::None
@ -1236,8 +1237,7 @@ impl Application for App {
}
if self.tab.context_menu.is_some() {
self.tab.context_menu = None;
return Task::none();
return self.update(Message::TabMessage(tab::Message::ContextMenu(None)));
}
if self.tab.edit_location.is_some() {
@ -1787,9 +1787,9 @@ impl Application for App {
tab::View::Grid => zoom_out(&mut config.icon_sizes.grid, 50, 500),
});
}
Message::Surface(a) => {
Message::Surface(action) => {
return cosmic::task::message(cosmic::Action::Cosmic(
cosmic::app::Action::Surface(a),
cosmic::app::Action::Surface(action),
));
}
}

View file

@ -52,6 +52,13 @@ pub fn home_dir() -> PathBuf {
}
}
pub fn is_wayland() -> bool {
matches!(
cosmic::app::cosmic::windowing_system(),
Some(cosmic::app::cosmic::WindowingSystem::Wayland)
)
}
/// Runs application in desktop mode
#[rustfmt::skip]
pub fn desktop() -> Result<(), Box<dyn std::error::Error>> {

View file

@ -32,7 +32,8 @@ pub struct MouseArea<'a, Message> {
on_release: Option<Box<dyn OnMouseButton<'a, Message>>>,
on_resize: Option<Box<dyn OnResize<'a, Message>>>,
on_right_press: Option<Box<dyn OnMouseButton<'a, Message>>>,
on_right_press_no_capture: Option<Box<dyn OnMouseButton<'a, Message>>>,
on_right_press_no_capture: bool,
on_right_press_window_position: bool,
on_right_release: Option<Box<dyn OnMouseButton<'a, Message>>>,
on_middle_press: Option<Box<dyn OnMouseButton<'a, Message>>>,
on_middle_release: Option<Box<dyn OnMouseButton<'a, Message>>>,
@ -103,10 +104,17 @@ impl<'a, Message> MouseArea<'a, Message> {
self
}
/// The message to emit on a right button press without capturing.
/// on_right_press will not capture input
#[must_use]
pub fn on_right_press_no_capture(mut self, message: impl OnMouseButton<'a, Message>) -> Self {
self.on_right_press_no_capture = Some(Box::new(message));
pub fn on_right_press_no_capture(mut self) -> Self {
self.on_right_press_no_capture = true;
self
}
/// on_right_press will provide window position instead of widget relative
#[must_use]
pub fn on_right_press_window_position(mut self) -> Self {
self.on_right_press_window_position = true;
self
}
@ -203,8 +211,8 @@ impl<'a, Message, F> OnMouseButton<'a, Message> for F where F: Fn(Option<Point>)
pub trait OnDrag<'a, Message>: Fn(Option<Rectangle>) -> Message + 'a {}
impl<'a, Message, F> OnDrag<'a, Message> for F where F: Fn(Option<Rectangle>) -> Message + 'a {}
pub trait OnResize<'a, Message>: Fn(Size, Rectangle) -> Message + 'a {}
impl<'a, Message, F> OnResize<'a, Message> for F where F: Fn(Size, Rectangle) -> Message + 'a {}
pub trait OnResize<'a, Message>: Fn(Rectangle) -> Message + 'a {}
impl<'a, Message, F> OnResize<'a, Message> for F where F: Fn(Rectangle) -> Message + 'a {}
pub trait OnScroll<'a, Message>: Fn(mouse::ScrollDelta) -> Option<Message> + 'a {}
impl<'a, Message, F> OnScroll<'a, Message> for F where
@ -223,7 +231,7 @@ struct State {
last_virtual_position: Option<Point>,
drag_initiated: Option<Point>,
prev_click: Option<(mouse::Click, Instant)>,
size: Option<Size>,
viewport: Option<Rectangle>,
}
impl State {
@ -286,7 +294,8 @@ impl<'a, Message> MouseArea<'a, Message> {
on_release: None,
on_resize: None,
on_right_press: None,
on_right_press_no_capture: None,
on_right_press_no_capture: false,
on_right_press_window_position: false,
on_right_release: None,
on_middle_press: None,
on_middle_release: None,
@ -507,10 +516,9 @@ fn update<Message: Clone>(
let layout_bounds = layout.bounds();
if let Some(message) = widget.on_resize.as_ref() {
let size = layout_bounds.size();
if state.size != Some(size) {
state.size = Some(size);
shell.publish(message(size, *viewport));
if state.viewport != Some(*viewport) {
state.viewport = Some(*viewport);
shell.publish(message(*viewport));
}
}
@ -635,17 +643,18 @@ fn update<Message: Clone>(
if let Some(message) = widget.on_right_press.as_ref() {
if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) = event {
shell.publish(message(cursor.position_in(layout_bounds)));
let point_opt = if widget.on_right_press_window_position {
cursor.position_over(layout_bounds)
} else {
cursor.position_in(layout_bounds)
};
shell.publish(message(point_opt));
return event::Status::Captured;
}
}
if let Some(message) = widget.on_right_press_no_capture.as_ref() {
if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) = event {
shell.publish(message(cursor.position_in(layout_bounds)));
return event::Status::Ignored;
if widget.on_right_press_no_capture {
return event::Status::Ignored;
} else {
return event::Status::Captured;
}
}
}

View file

@ -1508,6 +1508,7 @@ pub enum Command {
AddToSidebar(PathBuf),
AutoScroll(Option<f32>),
ChangeLocation(String, Location, Option<Vec<PathBuf>>),
ContextMenu(Option<Point>),
Delete(Vec<PathBuf>),
DropFiles(PathBuf, ClipboardPaste),
EmptyTrash,
@ -1566,9 +1567,9 @@ pub enum Message {
Reload,
RightClick(Option<usize>),
MiddleClick(usize),
Resize(Rectangle),
Scroll(Viewport),
ScrollTab(f32),
ScrollToFocus,
SearchContext(Location, SearchContextWrapper),
SearchReady(bool),
SelectAll,
@ -2384,6 +2385,7 @@ pub struct Tab {
pub location_context_menu_index: Option<usize>,
pub context_menu: Option<Point>,
pub mode: Mode,
pub offset_opt: Option<Vector>,
pub scroll_opt: Option<AbsoluteOffset>,
pub size_opt: Cell<Option<Size>>,
pub item_view_size_opt: Cell<Option<Size>>,
@ -2495,6 +2497,7 @@ impl Tab {
location_context_menu_point: None,
location_context_menu_index: None,
mode: Mode::App,
offset_opt: None,
scroll_opt: None,
size_opt: Cell::new(None),
item_view_size_opt: Cell::new(None),
@ -2861,6 +2864,7 @@ impl Tab {
let mut history_i_opt = None;
let mod_ctrl = modifiers.contains(Modifiers::CTRL) && self.mode.multiple();
let mod_shift = modifiers.contains(Modifiers::SHIFT) && self.mode.multiple();
let last_context_menu = self.context_menu;
match message {
Message::AddNetworkDrive => {
commands.push(Command::AddNetworkDrive);
@ -3087,6 +3091,7 @@ impl Tab {
self.edit_location = None;
if point_opt.is_none() || !mod_shift {
self.context_menu = point_opt;
//TODO: hack for clearing selecting when right clicking empty space
if self.context_menu.is_some() && self.last_right_click.take().is_none() {
if let Some(ref mut items) = self.items_opt {
@ -3578,7 +3583,16 @@ impl Tab {
item.highlighted = true;
}
}
Message::Resize(viewport) => {
self.offset_opt = Some(Vector::new(viewport.x, viewport.y));
// Scroll to ensure focused item still in view
if let Some(offset) = self.select_focus_scroll() {
commands.push(Command::Iced(
scrollable::scroll_to(self.scrollable_id.clone(), offset).into(),
));
}
}
Message::Scroll(viewport) => {
self.scroll_opt = Some(viewport.absolute_offset());
self.watch_drag = true;
@ -3595,13 +3609,6 @@ impl Tab {
.into(),
));
}
Message::ScrollToFocus => {
if let Some(offset) = self.select_focus_scroll() {
commands.push(Command::Iced(
scrollable::scroll_to(self.scrollable_id.clone(), offset).into(),
));
}
}
Message::SearchContext(location, context) => {
if location == self.location {
self.search_context = context.0;
@ -3872,6 +3879,18 @@ impl Tab {
));
}
//TODO: check for wayland
if self.context_menu != last_context_menu {
if last_context_menu.is_some() {
commands.push(Command::ContextMenu(None));
}
if let Some(point) = self.context_menu {
commands.push(Command::ContextMenu(Some(
point + self.offset_opt.unwrap_or_default(),
)));
}
}
// Change directory if requested
if let Some(mut location) = cd {
if matches!(self.mode, Mode::Desktop) {
@ -4480,9 +4499,9 @@ impl Tab {
Message::LocationContextMenuIndex(None)
})
} else {
mouse_area = mouse_area.on_right_press_no_capture(move |_point_opt| {
Message::LocationContextMenuIndex(Some(index))
})
mouse_area = mouse_area.on_right_press_no_capture().on_right_press(
move |_point_opt| Message::LocationContextMenuIndex(Some(index)),
)
}
let mouse_area = if let Location::Path(_) = &self.location {
@ -4739,9 +4758,9 @@ impl Tab {
column = column.push(button)
} else {
column = column.push(
mouse_area::MouseArea::new(button).on_right_press_no_capture(
move |_point_opt| Message::RightClick(Some(i)),
),
mouse_area::MouseArea::new(button)
.on_right_press_no_capture()
.on_right_press(move |_point_opt| Message::RightClick(Some(i))),
);
}
}
@ -5158,9 +5177,9 @@ impl Tab {
if self.context_menu.is_some() {
mouse_area
} else {
mouse_area.on_right_press_no_capture(move |_point_opt| {
Message::RightClick(Some(i))
})
mouse_area
.on_right_press_no_capture()
.on_right_press(move |_point_opt| Message::RightClick(Some(i)))
}
};
@ -5375,8 +5394,7 @@ impl Tab {
let mut mouse_area = mouse_area::MouseArea::new(item_view)
.on_press(move |_point_opt| Message::Click(None))
.on_release(|_| Message::ClickRelease(None))
//TODO: better way to keep focused item in view
.on_resize(|_, _| Message::ScrollToFocus)
.on_resize(Message::Resize)
.on_back_press(move |_point_opt| Message::GoPrevious)
.on_forward_press(move |_point_opt| Message::GoNext)
.on_scroll(|delta| respond_to_scroll_direction(delta, self.modifiers));
@ -5388,12 +5406,13 @@ impl Tab {
}
let mut popover = widget::popover(mouse_area);
if let Some(point) = self.context_menu {
let context_menu = menu::context_menu(self, key_binds, &self.modifiers);
popover = popover
.popup(context_menu)
.position(widget::popover::Position::Point(point));
if !cfg!(feature = "wayland") || !crate::is_wayland() {
let context_menu = menu::context_menu(self, key_binds, &self.modifiers);
popover = popover
.popup(context_menu)
.position(widget::popover::Position::Point(point));
}
}
let mut tab_column = widget::column::with_capacity(3);