From 3cc5dceb7d7d36cbd67014763df6b8e2e02ea49d Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Tue, 27 Oct 2026 16:44:42 -0700 Subject: [PATCH] feat(wm): lock Containers can be locked since the model came in, and a locked container keeps its index while others are inserted, removed or swapped around it; nothing could lock one. `komorebic toggle-lock` locks the focused container, or unlocks it, and the `LockMonitorWorkspaceContainer` and `UnlockMonitorWorkspaceContainer` messages lock and unlock a container by the index of its monitor, its workspace and itself, for a program such as a status bar. The vm-commands sweep locks and unlocks a container. --- README.md | 4 +- docs/cli/toggle-lock.md | 10 ++ docs/dev/ARCHITECTURE.md | 13 ++- docs/usage/moving-windows.md | 16 +++ komorebi/src/core/mod.rs | 3 + komorebi/src/process_command.rs | 45 ++++++++ komorebi/src/window_manager.rs | 70 ++++++++++++ komorebi/tests/lock.rs | 158 +++++++++++++++++++++++++++ komorebic/src/main.rs | 8 +- komorebic/tests/cli.rs | 2 + nix/tests/vm/suites/commands/test.py | 2 + 11 files changed, 325 insertions(+), 6 deletions(-) create mode 100644 docs/cli/toggle-lock.md create mode 100644 komorebi/tests/lock.rs diff --git a/README.md b/README.md index 35121fdd..7b0828c3 100644 --- a/README.md +++ b/README.md @@ -140,8 +140,8 @@ another layout, `layout-ratios` and `scrolling-layout-columns` tune it, `toggle-tiling` stops it moving windows and starts it again, `preselect-direction` reserves a tile for the next window, `resize-edge` and `resize-axis` move the edges of the focused tile, `promote` brings the -focused window to the largest tile, and `toggle-float` takes it out of the -layout. The scrolling layout +focused window to the largest tile, `toggle-float` takes it out of the +layout, and `toggle-lock` keeps its container at its place. The scrolling layout is a strip of columns that runs past the edges of the monitor and scrolls to the window that has the focus. diff --git a/docs/cli/toggle-lock.md b/docs/cli/toggle-lock.md new file mode 100644 index 00000000..bb079116 --- /dev/null +++ b/docs/cli/toggle-lock.md @@ -0,0 +1,10 @@ +```text +Toggle a lock for the focused container, ensuring it will not be displaced by any new windows + +Usage: toggle-lock + +Options: + -h, --help + Print help + +``` diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md index a79c3dc1..00d68065 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -302,6 +302,11 @@ any other, so its events find it, and [`Workspace::update`] leaves it where it i floated window goes is [`WindowManagementBehaviour::toggle_float_placement`]: by default it stays where it is. +`ToggleLock` locks the focused container, or unlocks it ([`WindowManager::toggle_lock`]), and +`LockMonitorWorkspaceContainer` and `UnlockMonitorWorkspaceContainer` do the same for a +container given by its indices. A lock changes nothing until containers are inserted, removed or +swapped around it, which the workspace does through `LockableSequence`. + `ResizeWindowEdge` and `ResizeWindowAxis` resize the focused container ([`WindowManager::resize_window`]). A resize is an adjustment of the container's tile, kept in [`Workspace::resize_dimensions`], one per container, which the layout applies when it calculates @@ -370,7 +375,7 @@ module's unit. - [`window_manager`](../../komorebi/src/window_manager.rs): the [`WindowManager`] and the operations on it (tile the workspace in front, tile every monitor, take a minimized window out, move the focus, move the focused container, change the layout, resize or - promote the focused container, float a window). + promote the focused container, float a window, lock a container). - [`desktop`](../../komorebi/src/desktop.rs): loads the monitors and the open windows from the backend. - [`window_manager_event`](../../komorebi/src/window_manager_event.rs) and @@ -434,7 +439,7 @@ the fake. The window manager is its only caller, through [`backend`]. The contra | The window manager asks for a backend that was never installed | runtime error: [`backend`] panics, naming [`set_backend`] and [`with_backend`] | `# Panics` on [`backend`] | none | | A test's backend does not outlive the test | framework path: [`with_backend`] restores the previous one on drop | `with_backend_puts_the_previous_backend_back_when_the_closure_panics` | none | | A command about a missing window is an error, not a panic | test | `a_command_about_a_window_nobody_opened_is_an_error` and `a_command_about_a_closed_window_is_an_error` against the fake, `a_command_about_a_window_the_table_does_not_know_is_an_error_and_sends_nothing` against the KWin backend | none | -| A locked container keeps its index | framework path: a [`Workspace`] inserts, removes and swaps its containers through `LockableSequence` | `test_locked_containers_with_new_window` and `test_locked_containers_remove_window` | `containers_mut()` is the `VecDeque` itself | +| A locked container keeps its index | framework path: a [`Workspace`] inserts, removes and swaps its containers through `LockableSequence` | `test_locked_containers_with_new_window`, `test_locked_containers_remove_window`, `a_window_that_opens_does_not_push_a_locked_container_from_its_index` | `containers_mut()` is the `VecDeque` itself | | A window komorebi does not manage is never moved | framework path: `handle_event` asks [`Window::should_manage`] before it acts on any event but a destroy | `dialogs_and_other_non_normal_windows_are_left_alone` | none | | A destroyed window leaves whichever workspace has it | framework path: the destroy is looked up in the index of managed windows, which is rebuilt at startup and before and after every event | `a_window_that_was_open_before_komorebi_started_is_removed_when_it_closes` | none | | The daemon does not start without KWin, unless it is told to run against the fake | startup check: [`connect`] fails, and the daemon exits with the error, code 1 | `without_a_session_bus_komorebi_exits_with_an_error_naming_it` | `--fake-backend` | @@ -572,7 +577,8 @@ A window is shown, and ends up on a tile: [`komorebi/tests/scrolling.rs`](../../komorebi/tests/scrolling.rs), the layout options and the scrolling layout; [`komorebi/tests/resize.rs`](../../komorebi/tests/resize.rs), resizing; [`komorebi/tests/promote.rs`](../../komorebi/tests/promote.rs), promoting; - [`komorebi/tests/float.rs`](../../komorebi/tests/float.rs), floating. + [`komorebi/tests/float.rs`](../../komorebi/tests/float.rs), floating; + [`komorebi/tests/lock.rs`](../../komorebi/tests/lock.rs), locking. - [`nix/tests/vm/suites/scrolling`](../../nix/tests/vm/suites/scrolling/test.py): the scrolling strip in a real KWin, with its columns out of view off the screen. - [`nix/tests/vm/suites/commands`](../../nix/tests/vm/suites/commands/test.py): every layout, @@ -613,6 +619,7 @@ A window is shown, and ends up on a tile: [`Workspace::resize_dimensions`]: ../../komorebi/src/workspace.rs [`WindowManager::resize_window`]: ../../komorebi/src/window_manager.rs [`WindowManager::toggle_float`]: ../../komorebi/src/window_manager.rs +[`WindowManager::toggle_lock`]: ../../komorebi/src/window_manager.rs [`WindowManagementBehaviour::toggle_float_placement`]: ../../komorebi/src/core/mod.rs [`WindowManager::promote_container_to_front`]: ../../komorebi/src/window_manager.rs [`WindowManager::promote_container_swap`]: ../../komorebi/src/window_manager.rs diff --git a/docs/usage/moving-windows.md b/docs/usage/moving-windows.md index 75010824..4a8ff2c1 100644 --- a/docs/usage/moving-windows.md +++ b/docs/usage/moving-windows.md @@ -55,6 +55,22 @@ komorebic promote-window left [`komorebic promote-focus`](../cli/promote-focus.md) gives the focus to the window on the first tile and moves nothing. +## Locking a window in its place + +[`komorebic toggle-lock`](../cli/toggle-lock.md) locks the focused window's container at its +place in the order of the workspace, and unlocks it again. Windows that open, close or move +around a locked container leave it where it is: a new window that would go in its place goes +after it. A window moved onto the place of a locked one with `move`, `cycle-move` or +`promote-swap` is locked there instead, since the lock belongs to the place. + +```sh +komorebic toggle-lock +``` + +The `LockMonitorWorkspaceContainer` and `UnlockMonitorWorkspaceContainer` messages lock and +unlock a container by the index of its monitor, its workspace and itself, for a program such as +a status bar. They have no subcommand. + ## The pointer The pointer follows the window that moved, unless diff --git a/komorebi/src/core/mod.rs b/komorebi/src/core/mod.rs index 6917ca94..cff153ae 100644 --- a/komorebi/src/core/mod.rs +++ b/komorebi/src/core/mod.rs @@ -352,6 +352,9 @@ pub enum SocketMessage { PromoteSwap, PromoteFocus, PromoteWindow(OperationDirection), + LockMonitorWorkspaceContainer(usize, usize, usize), + UnlockMonitorWorkspaceContainer(usize, usize, usize), + ToggleLock, ToggleFloat, // Current Workspace Commands ChangeLayout(DefaultLayout), diff --git a/komorebi/src/process_command.rs b/komorebi/src/process_command.rs index ea47fb34..9a610dbb 100644 --- a/komorebi/src/process_command.rs +++ b/komorebi/src/process_command.rs @@ -37,6 +37,10 @@ //! already, and **CancelPreselect** gives the reserved place up. //! - **MouseFollowsFocus** and **ToggleMouseFollowsFocus** set whether the pointer is moved to //! the window that is given the focus. +//! - **ToggleLock** locks the focused container at its index, or unlocks it +//! ([`WindowManager::toggle_lock`]), and **LockMonitorWorkspaceContainer** and +//! **UnlockMonitorWorkspaceContainer** do the same for a container given by the index of its +//! monitor, its workspace and itself. //! - **ToggleFloat** takes the focused window out of the layout into the floating windows of the //! workspace, which komorebi does not move, or puts a floating window back //! ([`WindowManager::toggle_float`]). @@ -147,6 +151,8 @@ impl WindowManager { /// [`WindowManager::move_container_in_cycle_direction`] give. /// - There is no focused workspace to change the layout or the tiling of, to preselect a /// place in, or to resize or promote a container of, or it cannot be tiled again. + /// - The monitor or the workspace of `LockMonitorWorkspaceContainer` or + /// `UnlockMonitorWorkspaceContainer` is not there (a container that is not there is ignored). /// - The focused window cannot be floated or put back, for one of the reasons /// [`WindowManager::toggle_float`] gives. /// - The file of `QuickSave` or `Save` cannot be written, or the file of `QuickLoad` or @@ -274,11 +280,50 @@ impl WindowManager { self.update_focused_workspace(false, false)?; } + SocketMessage::LockMonitorWorkspaceContainer( + monitor_idx, + workspace_idx, + container_idx, + ) => { + let monitor = self + .monitors_mut() + .get_mut(monitor_idx) + .ok_or_eyre("no monitor at the given index")?; + + let workspace = monitor + .workspaces_mut() + .get_mut(workspace_idx) + .ok_or_eyre("no workspace at the given index")?; + + if let Some(container) = workspace.containers_mut().get_mut(container_idx) { + container.locked = true; + } + } + SocketMessage::UnlockMonitorWorkspaceContainer( + monitor_idx, + workspace_idx, + container_idx, + ) => { + let monitor = self + .monitors_mut() + .get_mut(monitor_idx) + .ok_or_eyre("no monitor at the given index")?; + + let workspace = monitor + .workspaces_mut() + .get_mut(workspace_idx) + .ok_or_eyre("no workspace at the given index")?; + + if let Some(container) = workspace.containers_mut().get_mut(container_idx) { + container.locked = false; + } + } SocketMessage::FlipLayout(layout_flip) => self.flip_layout(layout_flip)?, SocketMessage::ChangeLayout(layout) => self.change_workspace_layout_default(layout)?, SocketMessage::CycleLayout(direction) => self.cycle_layout(direction)?, SocketMessage::Retile => self.retile_all(false)?, SocketMessage::RetileWithResizeDimensions => self.retile_all(true)?, + SocketMessage::ToggleLock => self.toggle_lock()?, SocketMessage::ToggleTiling => { self.toggle_tiling()?; } diff --git a/komorebi/src/window_manager.rs b/komorebi/src/window_manager.rs index d511cc33..815fbd04 100644 --- a/komorebi/src/window_manager.rs +++ b/komorebi/src/window_manager.rs @@ -31,6 +31,7 @@ use crate::core::Rect; use crate::core::Sizing; use crate::core::WindowManagementBehaviour; use crate::desktop; +use crate::lockable_sequence::Lockable; use crate::monitor::Monitor; use crate::ring::Ring; use crate::window::Window; @@ -857,6 +858,24 @@ impl WindowManager { self.update_focused_workspace(self.mouse_follows_focus, false) } + /// Locks the focused container of the focused workspace at its index, or unlocks it + /// + /// A locked container stays at its index while containers are inserted, removed and swapped + /// around it. Nothing is retiled. + /// + /// # Errors + /// + /// There is no focused workspace. + #[tracing::instrument(skip(self))] + pub fn toggle_lock(&mut self) -> eyre::Result<()> { + let workspace = self.focused_workspace_mut()?; + if let Some(container) = workspace.focused_container_mut() { + // Toggle the locked flag + container.set_locked(!container.locked()); + } + Ok(()) + } + /// Turns the tiling of the focused workspace off, or back on and tiles it again /// /// While its tiling is off, the workspace keeps its containers and moves no window. @@ -1211,6 +1230,57 @@ mod tests { Ok(()) } + #[test] + fn test_toggle_lock() { + let (mut wm, _context) = setup_window_manager(); + + { + // Add monitor with default workspace to + let mut m = monitor::new( + 0, + Rect::default(), + Rect::default(), + "TestMonitor".to_string(), + "TestDeviceID".to_string(), + ); + + let workspace = m.focused_workspace_mut().unwrap(); + + // Create containers to add to the workspace + for _ in 0..3 { + let container = Container::default(); + workspace.add_container_to_back(container); + } + + wm.monitors_mut().push_back(m); + } + + { + // Ensure container 2 is not locked + let workspace = wm.focused_workspace_mut().unwrap(); + assert_eq!(workspace.focused_container_idx(), 2); + assert!(!workspace.focused_container().unwrap().locked); + } + + // Toggle lock on focused container + wm.toggle_lock().unwrap(); + + { + // Ensure container 2 is locked + let workspace = wm.focused_workspace_mut().unwrap(); + assert!(workspace.focused_container().unwrap().locked); + } + + // Toggle lock on focused container + wm.toggle_lock().unwrap(); + + { + // Ensure container 2 is not locked + let workspace = wm.focused_workspace_mut().unwrap(); + assert!(!workspace.focused_container().unwrap().locked); + } + } + #[test] fn test_float_window() { let (mut wm, _context) = setup_window_manager(); diff --git a/komorebi/tests/lock.rs b/komorebi/tests/lock.rs new file mode 100644 index 00000000..5abb45a7 --- /dev/null +++ b/komorebi/tests/lock.rs @@ -0,0 +1,158 @@ +//! Locking containers with `toggle-lock` and the messages that lock and unlock a container by its +//! indices, against an in-memory desktop: the messages go in through +//! [`WindowManager::process_command`], and what comes out is the order of the containers. +//! +//! Window ids are in the 26_000s. + +use komorebi::backend::fake::FakeBackend; +use komorebi::backend::with_backend; +use komorebi::core::SocketMessage; +use komorebi::window::Window; +use komorebi::window_manager::WindowManager; +use komorebi::window_manager_event::KWinNotification; +use komorebi::window_manager_event::SystemNotification; +use komorebi::window_manager_event::WindowManagerEvent; +use std::sync::Arc; + +fn added(id: u32) -> WindowManagerEvent { + WindowManagerEvent::Show( + SystemNotification::KWin(KWinNotification::WindowAdded), + Window::from(id), + ) +} + +fn activated(id: u32) -> WindowManagerEvent { + WindowManagerEvent::FocusChange( + SystemNotification::KWin(KWinNotification::WindowActivated), + Window::from(id), + ) +} + +/// A window manager over a fake desktop with one 1920x1080 monitor, and a window for each of +/// `ids`, opened in that order. The last one has the focus. +fn desktop_with(ids: &[u32]) -> (Arc, WindowManager) { + let fake = Arc::new(FakeBackend::with_one_monitor()); + let (_sender, receiver) = crossbeam_channel::bounded(1); + // unix socket paths are limited to ~104 bytes on macOS, which the temp dir alone can exceed + static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let socket = std::path::PathBuf::from(format!( + "/tmp/kmk-{}-{}.sock", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + )); + + let mut wm = with_backend(fake.clone(), || { + let mut wm = WindowManager::new(receiver, Some(socket)).unwrap(); + wm.init().unwrap(); + wm + }); + for &id in ids { + open(&fake, &mut wm, id); + } + + (fake, wm) +} + +fn open(fake: &Arc, wm: &mut WindowManager, id: u32) { + fake.open_window(id, "foot", "foot", &format!("terminal {id}")); + with_backend(fake.clone(), || wm.process_event(added(id))).unwrap(); +} + +fn send( + fake: &Arc, + wm: &mut WindowManager, + message: SocketMessage, +) -> color_eyre::Result<()> { + with_backend(fake.clone(), || wm.process_command(message, Vec::new())) +} + +/// The window of each container of the focused workspace, in order +fn order(wm: &WindowManager) -> Vec { + wm.focused_workspace() + .unwrap() + .containers() + .iter() + .map(|container| container.focused_window().unwrap().id) + .collect() +} + +fn locked(wm: &WindowManager) -> Vec { + wm.focused_workspace() + .unwrap() + .containers() + .iter() + .map(|container| container.locked) + .collect() +} + +#[test] +fn toggle_lock_locks_the_focused_container_and_unlocks_it() { + let (fake, mut wm) = desktop_with(&[26_001, 26_002]); + + send(&fake, &mut wm, SocketMessage::ToggleLock).unwrap(); + assert_eq!(locked(&wm), [false, true]); + + send(&fake, &mut wm, SocketMessage::ToggleLock).unwrap(); + assert_eq!(locked(&wm), [false, false]); +} + +#[test] +fn a_window_that_opens_does_not_push_a_locked_container_from_its_index() { + let (fake, mut wm) = desktop_with(&[26_011, 26_012]); + // the first window gets the focus, and its container is locked: a new window goes after it, + // where the second one is + with_backend(fake.clone(), || wm.process_event(activated(26_011))).unwrap(); + send( + &fake, + &mut wm, + SocketMessage::LockMonitorWorkspaceContainer(0, 0, 1), + ) + .unwrap(); + + open(&fake, &mut wm, 26_013); + + assert_eq!( + order(&wm), + [26_011, 26_012, 26_013], + "the second stays second" + ); + assert_eq!(locked(&wm), [false, true, false]); +} + +#[test] +fn a_container_is_locked_and_unlocked_by_its_indices() { + let (fake, mut wm) = desktop_with(&[26_021, 26_022]); + + send( + &fake, + &mut wm, + SocketMessage::LockMonitorWorkspaceContainer(0, 0, 0), + ) + .unwrap(); + assert_eq!(locked(&wm), [true, false]); + + send( + &fake, + &mut wm, + SocketMessage::UnlockMonitorWorkspaceContainer(0, 0, 0), + ) + .unwrap(); + assert_eq!(locked(&wm), [false, false]); + + // a container that is not there is ignored, a monitor that is not there is an error + send( + &fake, + &mut wm, + SocketMessage::LockMonitorWorkspaceContainer(0, 0, 9), + ) + .unwrap(); + assert_eq!( + send( + &fake, + &mut wm, + SocketMessage::LockMonitorWorkspaceContainer(3, 0, 0), + ) + .map_err(|error| error.to_string()), + Err(String::from("no monitor at the given index")) + ); +} diff --git a/komorebic/src/main.rs b/komorebic/src/main.rs index 45d75e63..c55c6a7d 100644 --- a/komorebic/src/main.rs +++ b/komorebic/src/main.rs @@ -16,7 +16,8 @@ //! next window and give it up. //! - `promote`, `promote-swap`, `promote-focus` and `promote-window` bring a window to the largest //! tile, or the focus to it. -//! - `toggle-float` takes the focused window out of the layout, or puts it back. +//! - `toggle-float` takes the focused window out of the layout, or puts it back, and +//! `toggle-lock` locks the focused container at its index, or unlocks it. //! - `resize-edge` and `resize-axis` resize the focused window by the `resize-delta`, and //! `quick-save-resize`, `quick-load-resize`, `save-resize` and `load-resize` save the sizes of //! the tiles of the focused workspace and load them back. @@ -307,6 +308,8 @@ enum SubCommand { ToggleTiling, /// Toggle floating mode for the focused window ToggleFloat, + /// Toggle a lock for the focused container, ensuring it will not be displaced by any new windows + ToggleLock, /// Enable or disable mouse follows focus on all workspaces #[clap(arg_required_else_help = true)] MouseFollowsFocus(MouseFollowsFocus), @@ -483,6 +486,9 @@ fn main() -> eyre::Result<()> { SubCommand::ToggleTiling => { send_message(&SocketMessage::ToggleTiling)?; } + SubCommand::ToggleLock => { + send_message(&SocketMessage::ToggleLock)?; + } SubCommand::State => { print_query(&SocketMessage::State); } diff --git a/komorebic/tests/cli.rs b/komorebic/tests/cli.rs index 1b856f63..27338ffd 100644 --- a/komorebic/tests/cli.rs +++ b/komorebic/tests/cli.rs @@ -210,6 +210,7 @@ fn the_layout_subcommands_send_the_layout_axis_or_direction_they_are_given() { ); sends(&["toggle-tiling"], r#"{"type":"ToggleTiling"}"#); sends(&["toggle-float"], r#"{"type":"ToggleFloat"}"#); + sends(&["toggle-lock"], r#"{"type":"ToggleLock"}"#); sends( &["preselect-direction", "up"], r#"{"type":"PreselectDirection","content":"Up"}"#, @@ -385,6 +386,7 @@ fn docgen_writes_the_help_of_every_subcommand_in_a_text_fence() { "stop.md", "subscribe-socket.md", "toggle-float.md", + "toggle-lock.md", "toggle-mouse-follows-focus.md", "toggle-tiling.md", "unsubscribe-socket.md", diff --git a/nix/tests/vm/suites/commands/test.py b/nix/tests/vm/suites/commands/test.py index 41332fbf..984b5f63 100644 --- a/nix/tests/vm/suites/commands/test.py +++ b/nix/tests/vm/suites/commands/test.py @@ -184,6 +184,8 @@ preselect-direction left cancel-preselect toggle-float toggle-float +toggle-lock +toggle-lock toggle-tiling toggle-tiling mouse-follows-focus enable