diff --git a/cosmic-comp-config/src/lib.rs b/cosmic-comp-config/src/lib.rs index e5e738830..e0bd181ab 100644 --- a/cosmic-comp-config/src/lib.rs +++ b/cosmic-comp-config/src/lib.rs @@ -102,6 +102,16 @@ pub struct CosmicCompConfig { /// Hide the cursor after this many seconds of pointer inactivity (None disables) pub cursor_hide_timeout: Option, pub activation_policy: ActivationPolicy, + /// Rules mapping an application id to the workspace (1-based index) new + /// windows of that app should open on, regardless of the active workspace. + pub workspace_assignments: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct WorkspaceAssignment { + pub app_id: String, + /// 1-based workspace number, matching what the workspaces UI shows. + pub workspace: u32, } impl Default for CosmicCompConfig { @@ -140,6 +150,7 @@ impl Default for CosmicCompConfig { appearance_settings: AppearanceConfig::default(), cursor_hide_timeout: None, activation_policy: ActivationPolicy::default(), + workspace_assignments: Vec::new(), } } } diff --git a/resources/i18n/en/cosmic_comp.ftl b/resources/i18n/en/cosmic_comp.ftl index 86d986194..dd82a88f4 100644 --- a/resources/i18n/en/cosmic_comp.ftl +++ b/resources/i18n/en/cosmic_comp.ftl @@ -16,6 +16,8 @@ window-menu-move = Move window-menu-resize = Resize window-menu-move-prev-workspace = Move to previous workspace window-menu-move-next-workspace = Move to next workspace +window-menu-pin-to-workspace = Always open on this workspace +window-menu-pinned-to-workspace = Always opens on workspace {$number} window-menu-stack = Create window stack window-menu-unstack-all = Unstack windows window-menu-unstack = Unstack window diff --git a/src/config/key_bindings.rs b/src/config/key_bindings.rs index 006f1d399..0bfa7d582 100644 --- a/src/config/key_bindings.rs +++ b/src/config/key_bindings.rs @@ -19,6 +19,14 @@ pub enum PrivateAction { shortcuts::action::ResizeEdge, shortcuts::State, ), + /// Opens the focused window's context menu at its top-left corner. + /// Hardcoded to Super+M rather than routed through the configurable + /// shortcuts system, so it works independent of cosmic-settings-config: + /// unlike a mouse right-click on the (server-side-decoration-only) + /// title bar, this reaches windows with client-side decorations too + /// (the majority of modern GTK4/Qt6/Electron apps), which otherwise have + /// no way to open this menu at all. + OpenWindowMenu, } /// Convert `cosmic_settings_config::shortcuts::State` to `smithay::backend::input::KeyState`. diff --git a/src/config/mod.rs b/src/config/mod.rs index 1d9b9c921..f9006e750 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -45,8 +45,8 @@ mod types; use cosmic::config::CosmicTk; pub use cosmic_comp_config::EdidProduct; use cosmic_comp_config::{ - ActivationPolicy, AppearanceConfig, CosmicCompConfig, KeyboardConfig, TileBehavior, XkbConfig, - XwaylandDescaling, XwaylandEavesdropping, ZoomConfig, + ActivationPolicy, AppearanceConfig, CosmicCompConfig, KeyboardConfig, TileBehavior, + WorkspaceAssignment, XkbConfig, XwaylandDescaling, XwaylandEavesdropping, ZoomConfig, input::{DeviceState as InputDeviceState, InputConfig, TouchpadOverride}, output::comp::{ OutputConfig, OutputInfo, OutputState, OutputsConfig, TransformDef, load_outputs, @@ -855,6 +855,16 @@ fn config_changed(config: cosmic_config::Config, keys: Vec, state: &mut get_config::(&config, "workspaces"); state.common.update_config(); } + "workspace_assignments" => { + let assignments = + get_config::>(&config, "workspace_assignments"); + state.common.config.cosmic_conf.workspace_assignments = assignments.clone(); + state + .common + .shell + .write() + .update_workspace_assignments(assignments); + } "autotile" => { let new = get_config::(&config, "autotile"); if new != state.common.config.cosmic_conf.autotile { diff --git a/src/input/actions.rs b/src/input/actions.rs index 3d017697d..8b98c77e4 100644 --- a/src/input/actions.rs +++ b/src/input/actions.rs @@ -18,7 +18,8 @@ use cosmic_settings_config::shortcuts; use cosmic_settings_config::shortcuts::action::{Direction, FocusDirection}; use smithay::{ input::{Seat, pointer::MotionEvent}, - utils::{Point, Serial}, + utils::{Point, SERIAL_COUNTER, Serial}, + wayland::seat::WaylandFocus, }; #[cfg(not(feature = "debug"))] use tracing::info; @@ -105,6 +106,36 @@ impl State { .finish_resize(direction, edge.into()); } } + + Action::Private(PrivateAction::OpenWindowMenu) => { + let Some(surface) = seat + .get_keyboard() + .and_then(|kb| kb.current_focus()) + .and_then(|focus| focus.wl_surface().map(|s| s.into_owned())) + else { + return; + }; + + let menu_serial = SERIAL_COUNTER.next_serial(); + let res = self.common.shell.read().menu_request( + false, + &surface, + seat, + menu_serial, + // No click position to anchor to (keyboard-triggered); open + // at the window's top-left corner. menu_request derives the + // window's own geometry from the surface internally. + Point::from((0, 0)), + false, + &self.common.config, + &self.common.event_loop_handle, + ); + if let Some((grab, focus)) = res { + seat.get_pointer() + .unwrap() + .set_grab(self, grab, menu_serial, focus); + } + } } } diff --git a/src/input/mod.rs b/src/input/mod.rs index 1114d50b5..c2c5255a7 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1846,6 +1846,35 @@ impl State { ))); } + // Open the focused window's context menu (Alt+Space, the traditional + // "window system menu" shortcut). Hardcoded rather than going through + // the configurable shortcuts system - see the PrivateAction::OpenWindowMenu + // doc comment for why. NOTE: Super+M is already bound to Maximize by + // default (see /usr/share/cosmic/com.system76.CosmicSettings.Shortcuts/v1/defaults) - + // do not reuse it here, it would silently shadow that binding. + if !shortcuts_inhibited + && event.state() == KeyState::Pressed + && modifiers.alt + && !modifiers.logo + && !modifiers.ctrl + && !modifiers.shift + && key_matches(Keysym::space) + { + seat.supressed_keys().add(&handle, None); + return FilterResult::Intercept(Some(( + Action::Private(PrivateAction::OpenWindowMenu), + shortcuts::Binding { + modifiers: shortcuts::Modifiers { + alt: true, + ..Default::default() + }, + keycode: None, + key: Some(Keysym::space), + description: None, + }, + ))); + } + if let Some(mut a11y_keyboard_monitor) = self.common.dbus_state.a11y_keyboard_monitor() { if event.state() == KeyState::Released { let removed = diff --git a/src/shell/grabs/menu/default.rs b/src/shell/grabs/menu/default.rs index c7418b6d9..27642b56f 100644 --- a/src/shell/grabs/menu/default.rs +++ b/src/shell/grabs/menu/default.rs @@ -1,3 +1,5 @@ +use cosmic_comp_config::WorkspaceAssignment; +use cosmic_config::ConfigSet; use cosmic_settings_config::shortcuts::Action; use smithay::{ input::pointer::MotionEvent, reexports::wayland_server::protocol::wl_surface::WlSurface, @@ -16,6 +18,7 @@ use crate::{ utils::{prelude::SeatExt, screenshot::screenshot_window}, wayland::protocols::workspace::WorkspaceHandle, }; +use tracing::error; use super::{Item, ResizeEdge}; @@ -246,6 +249,7 @@ pub fn window_items( let screenshot_clone = window.clone(); let stack_clone = window.clone(); let sticky_clone = window.clone(); + let pin_clone = window.clone(); let close_clone = window.clone(); vec![ @@ -545,6 +549,71 @@ pub fn window_items( }) .toggled(is_sticky), ), + Some({ + let app_id = window.active_window().app_id(); + let pinned_workspace = config + .cosmic_conf + .workspace_assignments + .iter() + .find(|rule| rule.app_id == app_id) + .map(|rule| rule.workspace); + let label = match pinned_workspace { + Some(number) => { + fl!("window-menu-pinned-to-workspace", number = (number as usize)) + } + None => fl!("window-menu-pin-to-workspace"), + }; + Item::new(label, move |handle| { + let app_id = app_id.clone(); + let mapped = pin_clone.clone(); + let _ = handle.insert_idle(move |state| { + let mut assignments = state + .common + .config + .cosmic_conf + .workspace_assignments + .clone(); + if assignments.iter().any(|rule| rule.app_id == app_id) { + assignments.retain(|rule| rule.app_id != app_id); + } else { + let shell = state.common.shell.read(); + let window = mapped.active_window(); + let Some(wl_surface) = window.wl_surface() else { + return; + }; + let Some((workspace_handle, output)) = + shell.workspace_for_surface(&wl_surface) + else { + return; + }; + let Some(idx) = shell + .workspaces + .spaces_for_output(&output) + .position(|space| space.handle == workspace_handle) + else { + return; + }; + drop(shell); + assignments.push(WorkspaceAssignment { + app_id: app_id.clone(), + workspace: (idx + 1) as u32, + }); + } + // Persist via cosmic-config; the compositor's own config + // watcher applies it (and other clients see it too). + if let Err(err) = state + .common + .config + .cosmic_helper + .set("workspace_assignments", &assignments) + { + error!(?err, "Failed to update workspace_assignments key"); + } + }); + }) + .toggled(pinned_workspace.is_some()) + .disabled(is_sticky) + }), Some(Item::Separator), if is_stacked { Some(Item::new(fl!("window-menu-close-all"), move |_handle| { diff --git a/src/shell/mod.rs b/src/shell/mod.rs index 8e6289854..2d06fb6b1 100644 --- a/src/shell/mod.rs +++ b/src/shell/mod.rs @@ -23,7 +23,7 @@ use crate::{ }, }; use cosmic_comp_config::{ - AppearanceConfig, TileBehavior, ZoomConfig, ZoomMovement, + AppearanceConfig, TileBehavior, WorkspaceAssignment, ZoomConfig, ZoomMovement, workspace::{PinnedWorkspace, WorkspaceLayout, WorkspaceMode}, }; use cosmic_config::ConfigSet; @@ -296,6 +296,7 @@ pub struct Shell { zoom_state: Option, appearance_conf: AppearanceConfig, tiling_exceptions: TilingExceptions, + workspace_assignments: Vec, #[cfg(feature = "debug")] pub debug_active: bool, @@ -400,7 +401,8 @@ fn create_workspace( WorkspaceCapabilities::Activate | WorkspaceCapabilities::SetTilingState | WorkspaceCapabilities::Pin - | WorkspaceCapabilities::Move, + | WorkspaceCapabilities::Move + | WorkspaceCapabilities::Rename, ); Workspace::new( workspace_handle, @@ -440,7 +442,8 @@ fn create_workspace_from_pinned( WorkspaceCapabilities::Activate | WorkspaceCapabilities::SetTilingState | WorkspaceCapabilities::Pin - | WorkspaceCapabilities::Move, + | WorkspaceCapabilities::Move + | WorkspaceCapabilities::Rename, ); if let Some(ref name) = pinned.name { @@ -1713,6 +1716,7 @@ impl Shell { appearance_conf: config.cosmic_conf.appearance_settings, zoom_state: None, tiling_exceptions, + workspace_assignments: config.cosmic_conf.workspace_assignments.clone(), #[cfg(feature = "debug")] debug_active: false, @@ -2839,7 +2843,7 @@ impl Shell { }; let pending_activation = self.pending_activations.remove(&(&window).into()); - let workspace_handle = match pending_activation { + let mut workspace_handle = match pending_activation { Some(ActivationContext::Workspace(handle)) => Some(handle), _ => None, }; @@ -2847,6 +2851,22 @@ impl Shell { let should_be_fullscreen = output.is_some(); let mut output = output.unwrap_or_else(|| seat.active_output()); + // App->workspace assignment: if this app_id has a configured home + // workspace, send the window there. Deliberately overrides any + // activation context, so the rule holds no matter where the app was + // launched from. `workspace` in the config is 1-based. + if let Some(rule) = { + let app_id = window.app_id(); + self.workspace_assignments + .iter() + .find(|rule| rule.app_id == app_id) + } { + let idx = rule.workspace.saturating_sub(1) as usize; + if let Some(ws) = self.workspaces.get(idx, &output) { + workspace_handle = Some(ws.handle); + } + } + // this is beyond stupid, just to make the borrow checker happy let workspace = if let Some(handle) = workspace_handle.filter(|handle| { self.workspaces @@ -5017,6 +5037,10 @@ impl Shell { self.tiling_exceptions = layout::TilingExceptions::new(exceptions); } + pub fn update_workspace_assignments(&mut self, assignments: Vec) { + self.workspace_assignments = assignments; + } + pub fn take_presentation_feedback( &self, output: &Output, diff --git a/src/wayland/handlers/workspace.rs b/src/wayland/handlers/workspace.rs index fc5c83598..8717f3e32 100644 --- a/src/wayland/handlers/workspace.rs +++ b/src/wayland/handlers/workspace.rs @@ -74,6 +74,20 @@ impl WorkspaceHandler for State { } } } + Request::Rename { workspace, name } => { + let mut shell = self.common.shell.write(); + if let Some(workspace_) = shell.workspaces.space_for_handle_mut(&workspace) { + workspace_.name = Some(name.clone()); + let pinned = workspace_.pinned; + self.common + .workspace_state + .update() + .set_workspace_name(&workspace, name); + if pinned { + shell.workspaces.persist(&self.common.config); + } + } + } Request::MoveBefore { workspace, other_workspace,