feat(wm): promote

The first tile of a layout is its largest, and the place for the window being
worked on. `komorebic promote` moves the focused window there, with the windows
that were before it each moving one tile on; `promote-swap` swaps it with the
window that has the first tile, and from the first tile swaps it back with the
window it was last swapped with; `promote-window left|right|up|down` promotes
the window on that side of the focused one; and `promote-focus` gives the focus
to the first tile and moves nothing.

The grid layout has no largest tile, so the four do nothing there.

The vm-commands sweep gains the four.
This commit is contained in:
LGUG2Z committed 2026-10-28 07:20:37 -07:00
1 parent 95eb4d5039
commit 554d77aed9
17 files changed
+419 -6

No files matched your search

+3 -2
View File
@@ -138,8 +138,9 @@ one in the layout, or to the next one in the workspace's order, and `move` and
again. `change-layout`, `cycle-layout` and `flip-layout` tile the workspace in
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, and `resize-edge`
and `resize-axis` move the edges of the focused tile. The scrolling layout
`preselect-direction` reserves a tile for the next window, `resize-edge` and
`resize-axis` move the edges of the focused tile, and `promote` brings the
focused window to the largest tile. 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
Promote the user focus to the largest window container
Usage: promote-focus
Options:
-h, --help
Print help
```
+10
View File
@@ -0,0 +1,10 @@
```text
Promote the focused window to the largest tile by swapping container indices with the largest tile
Usage: promote-swap
Options:
-h, --help
Print help
```
+14
View File
@@ -0,0 +1,14 @@
```text
Promote the window in the specified direction
Usage: promote-window <OPERATION_DIRECTION>
Arguments:
<OPERATION_DIRECTION>
[possible values: left, right, up, down]
Options:
-h, --help
Print help
```
+10
View File
@@ -0,0 +1,10 @@
```text
Promote the focused window to the largest tile via container removal and re-insertion
Usage: promote
Options:
-h, --help
Print help
```
+17 -3
View File
@@ -285,6 +285,15 @@ focused container has changed, which scrolls the strip to it. KWin keeps part of
resizes on an output, so the bridge asks again for the position of a window KWin pulled back
within a second of the command, three times at most ([`bridge.js`]'s `settle`).
`Promote` moves the focused container to index 0, the largest tile of every layout but the grid,
through `LockableSequence`, so the containers before it move one index on
([`WindowManager::promote_container_to_front`]). `PromoteSwap` swaps it with the container at
index 0 and records the index it came from in [`Workspace::promotion_swap_container_idx`], so
that from index 0 it swaps back ([`WindowManager::promote_container_swap`]). `PromoteFocus`
focuses the container at index 0 ([`WindowManager::promote_focus_to_front`]), and
`PromoteWindow` focuses the container in a direction and promotes it. On the grid layout they
change nothing.
`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
@@ -352,8 +361,8 @@ module's unit.
changes its containers.
- [`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 the
focused container).
window out, move the focus, move the focused container, change the layout, resize or
promote the focused 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
@@ -553,7 +562,8 @@ A window is shown, and ends up on a tile:
commands against the fake;
[`komorebi/tests/layout_options.rs`](../../komorebi/tests/layout_options.rs) and
[`komorebi/tests/scrolling.rs`](../../komorebi/tests/scrolling.rs), the layout options and the
scrolling layout; [`komorebi/tests/resize.rs`](../../komorebi/tests/resize.rs), resizing.
scrolling layout; [`komorebi/tests/resize.rs`](../../komorebi/tests/resize.rs), resizing;
[`komorebi/tests/promote.rs`](../../komorebi/tests/promote.rs), promoting.
- [`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,
@@ -593,6 +603,10 @@ A window is shown, and ends up on a tile:
[`Workspace::layout_options`]: ../../komorebi/src/workspace.rs
[`Workspace::resize_dimensions`]: ../../komorebi/src/workspace.rs
[`WindowManager::resize_window`]: ../../komorebi/src/window_manager.rs
[`WindowManager::promote_container_to_front`]: ../../komorebi/src/window_manager.rs
[`WindowManager::promote_container_swap`]: ../../komorebi/src/window_manager.rs
[`WindowManager::promote_focus_to_front`]: ../../komorebi/src/window_manager.rs
[`Workspace::promotion_swap_container_idx`]: ../../komorebi/src/workspace.rs
[`WindowManager::resize_delta`]: ../../komorebi/src/window_manager.rs
[`Workspace::latest_layout`]: ../../komorebi/src/workspace.rs
[`LayoutOptions`]: ../../komorebi/src/core/mod.rs
+5
View File
@@ -33,6 +33,11 @@ komorebic cycle-focus next
komorebic cycle-focus previous
```
## The largest tile
[`komorebic promote-focus`](../cli/promote-focus.md) focuses the window on the first tile of the
layout, which is its largest ([Moving windows](moving-windows.md#to-the-largest-tile)).
## The pointer
By default the pointer moves to the middle of the window that is given the focus. komorebi asks
+23
View File
@@ -32,6 +32,29 @@ komorebic cycle-move next
komorebic cycle-move previous
```
## To the largest tile
The first tile of a layout is the largest one: the left half in BSP and the vertical stack, the
top half in the horizontal stack, the middle of the ultrawide vertical stack and the right half
in the right main vertical stack. In the columns, rows and scrolling layouts it is the first
column or row. The grid layout is left alone: these commands do nothing there.
[`komorebic promote`](../cli/promote.md) moves the focused window to the first tile, and the
windows that were before it each move one tile on.
[`komorebic promote-swap`](../cli/promote-swap.md) swaps the focused window with the window on
the first tile instead, and from the first tile swaps it back with the window it was last
swapped with. [`komorebic promote-window`](../cli/promote-window.md) promotes the window on one
side of the focused one.
```sh
komorebic promote
komorebic promote-swap
komorebic promote-window left
```
[`komorebic promote-focus`](../cli/promote-focus.md) gives the focus to the window on the first
tile and moves nothing.
## The pointer
The pointer follows the window that moved, unless
+4
View File
@@ -348,6 +348,10 @@ pub enum SocketMessage {
CycleMoveWindow(CycleDirection),
ResizeWindowEdge(OperationDirection, Sizing),
ResizeWindowAxis(Axis, Sizing),
Promote,
PromoteSwap,
PromoteFocus,
PromoteWindow(OperationDirection),
// Current Workspace Commands
ChangeLayout(DefaultLayout),
CycleLayout(CycleDirection),
+13 -1
View File
@@ -20,6 +20,11 @@
//! of it, or with the next or the previous one, and tile the workspace again
//! ([`WindowManager::move_container_in_direction`],
//! [`WindowManager::move_container_in_cycle_direction`]).
//! - **Promote** moves the focused container to the largest tile, **PromoteSwap** swaps it with
//! the container there (and back), **PromoteFocus** gives that container the focus, and
//! **PromoteWindow** promotes the container on one side of the focused one
//! ([`WindowManager::promote_container_to_front`], [`WindowManager::promote_container_swap`],
//! [`WindowManager::promote_focus_to_front`]).
//! - **ChangeLayout**, **CycleLayout** and **FlipLayout** give the focused workspace another
//! layout, or mirror it, and **ToggleTiling** turns its tiling off and on
//! ([`WindowManager::change_workspace_layout_default`], [`WindowManager::cycle_layout`],
@@ -138,7 +143,7 @@ impl WindowManager {
/// [`WindowManager::move_container_in_direction`] and
/// [`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 a container of, or it cannot be tiled again.
/// place in, or to resize or promote a container of, or it cannot be tiled again.
/// - The file of `QuickSave` or `Save` cannot be written, or the file of `QuickLoad` or
/// `Load` cannot be read or is not a list of resize adjustments.
/// - The window manager fails to retile, for one of the reasons
@@ -159,6 +164,13 @@ impl WindowManager {
let initial_state = State::from(self.as_ref());
match message {
SocketMessage::Promote => self.promote_container_to_front()?,
SocketMessage::PromoteSwap => self.promote_container_swap()?,
SocketMessage::PromoteFocus => self.promote_focus_to_front()?,
SocketMessage::PromoteWindow(direction) => {
self.focus_container_in_direction(direction)?;
self.promote_container_to_front()?
}
SocketMessage::FocusWindow(direction) => {
self.focus_container_in_direction(direction)?;
}
+1
View File
@@ -90,6 +90,7 @@ impl From<&WindowManager> for State {
tile: workspace.tile,
globals: workspace.globals,
preselected_container_idx: None,
promotion_swap_container_idx: None,
})
.collect::<VecDeque<_>>();
ws.focus(monitor.workspaces.focused_idx());
+86
View File
@@ -606,6 +606,92 @@ impl WindowManager {
Ok(())
}
/// Moves the focused container to the first index of the focused workspace, which is the
/// largest tile, and tiles the workspace again: the containers before it each move one index
/// on
///
/// The grid layout has no largest tile, and nothing changes there.
///
/// # Errors
///
/// There is no focused workspace, it has no containers, or it cannot be tiled again.
#[tracing::instrument(skip(self))]
pub fn promote_container_to_front(&mut self) -> eyre::Result<()> {
let workspace = self.focused_workspace_mut()?;
if matches!(workspace.layout, Layout::Default(DefaultLayout::Grid)) {
tracing::debug!("ignoring promote command for grid layout");
return Ok(());
}
tracing::info!("promoting container");
workspace.promote_container()?;
self.update_focused_workspace(self.mouse_follows_focus, true)
}
/// Swaps the focused container with the one at the first index, the largest tile, and tiles
/// the workspace again; when the focused container is the first already, swaps it back with
/// the container it was swapped with last
///
/// The grid layout has no largest tile, and nothing changes there.
///
/// # Errors
///
/// There is no focused workspace, or it cannot be tiled again.
#[tracing::instrument(skip(self))]
pub fn promote_container_swap(&mut self) -> eyre::Result<()> {
let workspace = self.focused_workspace_mut()?;
let focused_container_idx = workspace.focused_container_idx();
let primary_idx = match workspace.layout {
Layout::Default(_) => 0,
};
if matches!(workspace.layout, Layout::Default(DefaultLayout::Grid)) {
tracing::debug!("ignoring promote-swap command for grid layout");
return Ok(());
}
let primary_tile_is_focused = focused_container_idx == primary_idx;
if primary_tile_is_focused && let Some(swap_idx) = workspace.promotion_swap_container_idx {
workspace.swap_containers(focused_container_idx, swap_idx);
} else {
workspace.promotion_swap_container_idx = Some(focused_container_idx);
workspace.swap_containers(focused_container_idx, primary_idx);
}
self.update_focused_workspace(self.mouse_follows_focus, true)
}
/// Gives the focus to the container at the first index, the largest tile, without moving
/// anything
///
/// The grid layout has no largest tile, and nothing changes there.
///
/// # Errors
///
/// There is no focused workspace, or the backend refuses to focus the window.
#[tracing::instrument(skip(self))]
pub fn promote_focus_to_front(&mut self) -> eyre::Result<()> {
let workspace = self.focused_workspace_mut()?;
if matches!(workspace.layout, Layout::Default(DefaultLayout::Grid)) {
tracing::info!("ignoring promote focus command for grid layout");
return Ok(());
}
tracing::info!("promoting focus");
let target_idx = match workspace.layout {
Layout::Default(_) => 0,
};
workspace.focus_container(target_idx);
self.update_focused_workspace(self.mouse_follows_focus, true)
}
/// Mirrors the layout of the focused workspace along `layout_flip`, on top of the way it is
/// mirrored already, and tiles the workspace again
///
+28
View File
@@ -39,6 +39,8 @@ pub struct Workspace {
pub globals: WorkspaceGlobals,
#[serde(skip_serializing_if = "Option::is_none")]
pub preselected_container_idx: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub promotion_swap_container_idx: Option<usize>,
}
impl_ring_elements!(Workspace, Container);
@@ -57,6 +59,7 @@ impl Default for Workspace {
globals: Default::default(),
tile: true,
preselected_container_idx: None,
promotion_swap_container_idx: None,
}
}
}
@@ -340,6 +343,31 @@ impl Workspace {
container
}
/// Moves the focused container to the first index, the largest tile in every layout but the
/// grid, and keeps the focus on it
///
/// # Errors
///
/// The workspace has no containers.
pub fn promote_container(&mut self) -> eyre::Result<()> {
let focused_idx = self.focused_container_idx();
let container = self
.containers_mut()
.remove_respecting_locks(focused_idx)
.ok_or_eyre("there is no container")?;
let primary_idx = match &self.layout {
Layout::Default(_) => 0,
};
let insertion_idx = self
.containers_mut()
.insert_respecting_locks(primary_idx, container);
self.focus_container(insertion_idx);
Ok(())
}
pub fn add_container_to_back(&mut self, container: Container) {
self.containers_mut().push_back(container);
self.focus_last_container();
+153
View File
@@ -0,0 +1,153 @@
//! Bringing a window to the largest tile with `promote`, `promote-swap`, `promote-window`, and the
//! focus to it with `promote-focus`, against an in-memory desktop: the messages go in through
//! [`WindowManager::process_command`], and what comes out is the order of the containers, where
//! the backend is asked to put each window, and which window has the focus.
//!
//! Window ids are in the 24_000s.
use komorebi::backend::fake::Command;
use komorebi::backend::fake::FakeBackend;
use komorebi::backend::with_backend;
use komorebi::core::DefaultLayout;
use komorebi::core::OperationDirection;
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),
)
}
/// A window manager over a fake desktop with one 1920x1080 monitor in `layout`, and a window for
/// each of `ids`, opened in that order. The last one has the focus, and the commands it took to
/// get there are cleared.
fn desktop_with(layout: DefaultLayout, 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/kmp-{}-{}.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
});
send(&fake, &mut wm, SocketMessage::ChangeLayout(layout));
for &id in ids {
fake.open_window(id, "foot", "foot", &format!("terminal {id}"));
with_backend(fake.clone(), || wm.process_event(added(id))).unwrap();
}
fake.clear_commands();
(fake, wm)
}
fn send(fake: &Arc<FakeBackend>, wm: &mut WindowManager, message: SocketMessage) {
with_backend(fake.clone(), || wm.process_command(message, Vec::new())).unwrap();
}
/// 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 focused(wm: &WindowManager) -> u32 {
wm.focused_window().unwrap().id
}
#[test]
fn promote_moves_the_focused_window_to_the_largest_tile_and_the_others_along() {
let (fake, mut wm) = desktop_with(DefaultLayout::BSP, &[24_001, 24_002, 24_003]);
let largest = fake.rect_of(24_001);
send(&fake, &mut wm, SocketMessage::Promote);
assert_eq!(order(&wm), [24_003, 24_001, 24_002]);
assert_eq!(focused(&wm), 24_003);
assert_eq!(fake.rect_of(24_003), largest);
}
#[test]
fn promote_swap_swaps_with_the_largest_tile_and_swaps_back() {
let (fake, mut wm) = desktop_with(DefaultLayout::BSP, &[24_011, 24_012, 24_013]);
send(&fake, &mut wm, SocketMessage::PromoteSwap);
assert_eq!(order(&wm), [24_013, 24_012, 24_011]);
assert_eq!(focused(&wm), 24_013);
send(&fake, &mut wm, SocketMessage::PromoteSwap);
assert_eq!(
order(&wm),
[24_011, 24_012, 24_013],
"the largest tile is swapped back"
);
assert_eq!(focused(&wm), 24_013);
}
#[test]
fn promote_focus_gives_the_focus_to_the_largest_tile_and_moves_nothing() {
let ids = [24_021, 24_022, 24_023];
let (fake, mut wm) = desktop_with(DefaultLayout::BSP, &ids);
let tiles: Vec<_> = ids.iter().map(|&id| fake.rect_of(id)).collect();
send(&fake, &mut wm, SocketMessage::PromoteFocus);
assert_eq!(focused(&wm), 24_021);
assert_eq!(order(&wm), ids);
assert_eq!(
ids.iter().map(|&id| fake.rect_of(id)).collect::<Vec<_>>(),
tiles
);
assert!(
fake.commands().contains(&Command::Focus(24_021, true)),
"{:?}",
fake.commands()
);
}
#[test]
fn promote_window_promotes_the_window_on_that_side() {
let (fake, mut wm) = desktop_with(DefaultLayout::Columns, &[24_031, 24_032, 24_033]);
send(
&fake,
&mut wm,
SocketMessage::PromoteWindow(OperationDirection::Left),
);
assert_eq!(order(&wm), [24_032, 24_031, 24_033]);
assert_eq!(focused(&wm), 24_032);
}
#[test]
fn nothing_is_promoted_in_the_grid_layout() {
let ids = [24_041, 24_042, 24_043];
let (fake, mut wm) = desktop_with(DefaultLayout::Grid, &ids);
send(&fake, &mut wm, SocketMessage::Promote);
send(&fake, &mut wm, SocketMessage::PromoteSwap);
send(&fake, &mut wm, SocketMessage::PromoteFocus);
assert_eq!(order(&wm), ids);
assert_eq!(focused(&wm), 24_043);
assert!(fake.commands().is_empty(), "{:?}", fake.commands());
}
+23
View File
@@ -14,6 +14,8 @@
//! workspace, `scrolling-layout-columns` and `layout-ratios` tune it, `toggle-tiling` turns its
//! tiling off and on, and `preselect-direction` and `cancel-preselect` reserve the place of the
//! 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.
//! - `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.
@@ -95,6 +97,7 @@ gen_enum_subcommand_args! {
// WatchConfiguration: BooleanState,
MouseFollowsFocus: BooleanState,
Query: StateQuery,
PromoteWindow: OperationDirection,
}
#[derive(Parser)]
@@ -289,6 +292,14 @@ enum SubCommand {
/// Flip the layout on the focused workspace
#[clap(arg_required_else_help = true)]
FlipLayout(FlipLayout),
/// Promote the focused window to the largest tile via container removal and re-insertion
Promote,
/// Promote the focused window to the largest tile by swapping container indices with the largest tile
PromoteSwap,
/// Promote the user focus to the largest window container
PromoteFocus,
/// Promote the window in the specified direction
PromoteWindow(PromoteWindow),
/// Force the retiling of all managed windows
Retile,
/// Toggle window tiling on the focused workspace
@@ -451,6 +462,18 @@ fn main() -> eyre::Result<()> {
SubCommand::Retile => {
send_message(&SocketMessage::Retile)?;
}
SubCommand::Promote => {
send_message(&SocketMessage::Promote)?;
}
SubCommand::PromoteSwap => {
send_message(&SocketMessage::PromoteSwap)?;
}
SubCommand::PromoteFocus => {
send_message(&SocketMessage::PromoteFocus)?;
}
SubCommand::PromoteWindow(args) => {
send_message(&SocketMessage::PromoteWindow(args.operation_direction))?;
}
SubCommand::ToggleTiling => {
send_message(&SocketMessage::ToggleTiling)?;
}
+15
View File
@@ -284,6 +284,17 @@ fn the_resize_subcommands_send_what_they_are_given() {
);
}
#[test]
fn the_promote_subcommands_send_their_message() {
sends(&["promote"], r#"{"type":"Promote"}"#);
sends(&["promote-swap"], r#"{"type":"PromoteSwap"}"#);
sends(&["promote-focus"], r#"{"type":"PromoteFocus"}"#);
sends(
&["promote-window", "right"],
r#"{"type":"PromoteWindow","content":"Right"}"#,
);
}
#[test]
fn mouse_follows_focus_is_set_with_enable_and_disable_or_toggled() {
sends(
@@ -355,6 +366,10 @@ fn docgen_writes_the_help_of_every_subcommand_in_a_text_fence() {
"mouse-follows-focus.md",
"move.md",
"preselect-direction.md",
"promote-focus.md",
"promote-swap.md",
"promote-window.md",
"promote.md",
"query.md",
"quick-load-resize.md",
"quick-save-resize.md",
+4
View File
@@ -165,6 +165,10 @@ resize-axis horizontal increase
resize-axis vertical decrease
resize-delta 50
resize-delta 100
promote
promote-swap
promote-focus
promote-window left
retile
flip-layout horizontal
flip-layout vertical