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.
This commit is contained in:
LGUG2Z committed 2026-10-28 07:20:37 -07:00
1 parent 062fe57fac
commit 3cc5dceb7d
11 files changed
+325 -6

No files matched your search

+2 -2
View File
@@ -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.
+10
View File
@@ -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
```
+10 -3
View File
@@ -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
+16
View File
@@ -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
+3
View File
@@ -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),
+45
View File
@@ -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()?;
}
+70
View File
@@ -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();
+158
View File
@@ -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<FakeBackend>, 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<FakeBackend>, 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<FakeBackend>,
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<u32> {
wm.focused_workspace()
.unwrap()
.containers()
.iter()
.map(|container| container.focused_window().unwrap().id)
.collect()
}
fn locked(wm: &WindowManager) -> Vec<bool> {
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"))
);
}
+7 -1
View File
@@ -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);
}
+2
View File
@@ -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",
+2
View File
@@ -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