feat(scrolling): lay out the Scrolling layout with the scrolling model

The workspace keeps a ScrollingSpace of komorebi-scrolling, keyed by the id
of each container, in place of recomputing equal columns from the last
layout. A column can hold several windows. New commands and messages:
consume, expel, toggle-column-stack, scrolling-layout-full-width,
center-focus, expand and pan. Focus, move, resize, retile and closing a
window go through the strip, and a drag of an edge keeps it with the rest
of what the drag started from.

A window that is out of view rests at the edge it left by, with 5 pixels of
it on the screen and its size unchanged, and is hidden where another
monitor is next to that edge. A window that will not get as narrow as its
column widens the column, at the first layout after the windows have had
400 ms to draw. center_focused_column is Never, Always or OnOverflow, and
still reads a boolean.
This commit is contained in:
LGUG2Z committed 2026-10-30 14:44:54 -07:00
1 parent 3f51b3a0ca
commit db976212b1
14 files changed
+2053 -82

No files matched your search

+2
View File
@@ -70,7 +70,9 @@ pub use komorebi::core::MonocleFocusBehaviour;
pub use komorebi::core::MoveBehaviour;
pub use komorebi::core::OperationBehaviour;
pub use komorebi::core::OperationDirection;
pub use komorebi::core::PanDirection;
pub use komorebi::core::Rect;
pub use komorebi::core::ScrollingCentering;
pub use komorebi::core::Sizing;
pub use komorebi::core::SocketMessage;
pub use komorebi::core::StateQuery;
+9
View File
@@ -42,9 +42,11 @@ pub use komorebi_layouts::MAX_RATIO;
pub use komorebi_layouts::MAX_RATIOS;
pub use komorebi_layouts::MIN_RATIO;
pub use komorebi_layouts::OperationDirection;
pub use komorebi_layouts::PanDirection;
pub use komorebi_layouts::Rect;
pub use komorebi_layouts::ScrollingLayoutOptions;
pub use komorebi_layouts::Sizing;
pub use komorebi_layouts::scrolling::ScrollingCentering;
pub use komorebi_layouts::validate_ratios;
// Keep local modules that aren't in komorebi-layouts
@@ -619,6 +621,13 @@ pub enum SocketMessage {
ChangeLayout(DefaultLayout),
CycleLayout(CycleDirection),
ScrollingLayoutColumns(NonZeroUsize),
ScrollingLayoutFullWidth,
CenterFocus,
Expand,
Consume(OperationDirection),
Expel,
ToggleColumnStack,
Pan(PanDirection),
LayoutRatios(Option<Vec<f32>>, Option<Vec<f32>>),
// ChangeLayoutCustom(#[serde_as(as = "ResolvedPathBuf")] PathBuf),
FlipLayout(Axis),
+1
View File
@@ -83,6 +83,7 @@ pub mod monitor_reconciliator;
pub mod process_command;
pub mod process_event;
pub mod reconcile;
pub mod scrolling;
pub mod state;
pub mod static_config;
pub mod theme_manager;
+39 -23
View File
@@ -69,6 +69,17 @@
//! - **ScrollingLayoutColumns** sets how many columns the scrolling layout shows on the focused
//! workspace, and **LayoutRatios** the share of the work area its first columns and rows take,
//! and both tile the workspace again.
//! - **ScrollingLayoutFullWidth** makes the focused column of the scrolling layout as wide as the
//! screen, or puts it back, **CenterFocus** scrolls the strip so that the focused column is in
//! the middle, once, and **Expand** makes the focused column fill the room that the columns in
//! view leave.
//! - **Consume** takes the first window of the column on the right, or the last of the one on the
//! left, into the focused column, **Expel** takes the focused window out of its column into one
//! of its own on the right, and **ToggleColumnStack** stacks the windows of the focused column
//! or spreads the focused stack out into a column. They do nothing in the other layouts.
//! - **Pan** moves the view of the scrolling layout a column to the left or the right, without
//! moving the focus first, and lets it come to rest with a column against an edge of the
//! screen ([`WindowManager::pan`]).
//! - **InitialWorkspaceRule**, **InitialNamedWorkspaceRule**, **WorkspaceRule** and
//! **NamedWorkspaceRule** add a rule that sends the windows of an application to a workspace,
//! given by its indices or by its name: when they open for the first time, or always;
@@ -235,7 +246,6 @@ use crate::core::MonocleFocusBehaviour;
use crate::core::MoveBehaviour;
use crate::core::OperationDirection;
use crate::core::Rect;
use crate::core::ScrollingLayoutOptions;
use crate::core::SocketMessage;
use crate::core::StateQuery;
use crate::core::WindowContainerBehaviour;
@@ -1750,30 +1760,36 @@ impl WindowManager {
self.adjust_workspace_padding(sizing, adjustment)?;
}
SocketMessage::ScrollingLayoutColumns(count) => {
let focused_workspace = self.focused_workspace_mut()?;
let options = match focused_workspace.layout_options {
Some(mut opts) => {
if let Some(scrolling) = &mut opts.scrolling {
scrolling.columns = count.into();
}
opts
}
None => LayoutOptions {
scrolling: Some(ScrollingLayoutOptions {
columns: count.into(),
center_focused_column: Default::default(),
}),
grid: None,
column_ratios: None,
row_ratios: None,
},
};
focused_workspace.layout_options = Some(options);
self.focused_workspace_mut()?
.set_scrolling_columns(count.into());
self.update_focused_workspace(false, false)?;
}
SocketMessage::ScrollingLayoutFullWidth => {
self.focused_workspace_mut()?.toggle_scrolling_full_width();
self.update_focused_workspace(false, false)?;
}
SocketMessage::CenterFocus => {
self.focused_workspace_mut()?.center_scrolling_column();
self.update_focused_workspace(false, false)?;
}
SocketMessage::Expand => {
self.focused_workspace_mut()?.expand_scrolling_column();
self.update_focused_workspace(false, false)?;
}
SocketMessage::Consume(direction) => {
self.focused_workspace_mut()?.consume_scrolling(direction);
self.update_focused_workspace(false, false)?;
}
SocketMessage::Expel => {
self.focused_workspace_mut()?.expel_scrolling();
self.update_focused_workspace(false, false)?;
}
SocketMessage::ToggleColumnStack => {
self.focused_workspace_mut()?
.toggle_scrolling_column_stack()?;
self.update_focused_workspace(false, false)?;
}
SocketMessage::Pan(direction) => self.pan(direction)?,
SocketMessage::LayoutRatios(ref columns, ref rows) => {
use crate::core::validate_ratios;
+3
View File
@@ -811,6 +811,7 @@ impl WindowManager {
containers: workspace.containers().len(),
resize_dimensions: workspace.resize_dimensions.clone(),
latest_layout: workspace.latest_layout.clone(),
scrolling: workspace.scrolling.clone(),
stepped: false,
})
});
@@ -842,6 +843,7 @@ impl WindowManager {
{
workspace.resize_dimensions = live.resize_dimensions;
workspace.latest_layout = live.latest_layout;
workspace.scrolling = live.scrolling;
}
if let Some((wid, Some(started_from))) = pending {
@@ -1010,6 +1012,7 @@ impl WindowManager {
workspace.resize_dimensions = live.resize_dimensions;
workspace.latest_layout = live.latest_layout;
workspace.scrolling = live.scrolling;
}
if !self.apply_mouse_resize(window, started_from, new_position)? {
File diff suppressed because it is too large. Load diff
+2
View File
@@ -226,6 +226,8 @@ impl From<&WindowManager> for State {
.initial_window_placement_rules
.clone(),
promotion_swap_container_idx: None,
scrolling: None,
scrolling_placed: None,
})
.collect::<VecDeque<_>>();
ws.focus(monitor.workspaces.focused_idx());
+6
View File
@@ -64,6 +64,12 @@ pub fn live_resize_ends(id: u32) {
LIVE_RESIZED_WINDOWS.lock().retain(|w| *w != id);
}
/// Whether the user has hold of an edge of any window, whose neighbours are on their way to
/// where the layout puts them for as long as they do
pub fn live_resize_in_progress() -> bool {
!LIVE_RESIZED_WINDOWS.lock().is_empty()
}
fn is_live_resized(id: u32) -> bool {
LIVE_RESIZED_WINDOWS.lock().contains(&id)
}
+24 -2
View File
@@ -120,6 +120,9 @@ pub struct LiveResize {
pub resize_dimensions: Vec<Option<Rect>>,
/// The workspace's [`Workspace::latest_layout`] when the drag began
pub latest_layout: Vec<Rect>,
/// The strip of a Scrolling workspace when the drag began: the widths of its columns, and
/// where it is scrolled to, which every step starts from as well
pub scrolling: Option<komorebi_scrolling::ScrollingSpace<String>>,
/// whether the neighbours were moved while the drag was going on
pub stepped: bool,
}
@@ -1019,8 +1022,13 @@ impl WindowManager {
}
Some(new_idx) => {
let workspace = self.focused_workspace_mut()?;
workspace.swap_containers(origin_container_idx, new_idx);
workspace.focus_container(new_idx);
// a column of the Scrolling layout moves whole, and its tiles move within it,
// which is not a swap of two containers
if !(workspace.is_scrolling() && workspace.move_scrolling(direction)) {
workspace.swap_containers(origin_container_idx, new_idx);
workspace.focus_container(new_idx);
}
}
}
@@ -1883,6 +1891,16 @@ impl WindowManager {
}
WorkspaceLayer::Tiling => {
match workspace.layout {
Layout::Default(DefaultLayout::Scrolling) => {
tracing::debug!("resizing column");
workspace.resize_scrolling_column(direction, sizing, delta);
return if update {
self.update_focused_workspace(false, false)
} else {
Ok(())
};
}
Layout::Default(layout) => {
tracing::debug!("resizing window");
let len = NonZeroUsize::new(workspace.containers().len())
@@ -2057,6 +2075,10 @@ impl WindowManager {
// Reset any resize adjustments if we want to force a retile
if !preserve_resize_dimensions {
workspace.resize_dimensions.fill(None);
// and the widths and heights of the Scrolling layout, which are resize
// adjustments too
workspace.reset_scrolling_sizes();
}
if (workspace.wallpaper.is_some() || monitor_wp.is_some())
+124 -45
View File
@@ -11,6 +11,7 @@
//! [`Workspace::work_area_offset_rules`]), and its layout options come from the workspace or from
//! the defaults of its layout.
use crate::HIDDEN_WINDOWS;
use crate::REGEX_IDENTIFIERS;
use crate::backend::backend;
use crate::container::Container;
@@ -39,6 +40,7 @@ use crate::window::should_act;
use crate::window::should_act_individual;
use color_eyre::eyre;
use color_eyre::eyre::OptionExt;
use komorebi_scrolling::ScrollingSpace;
use komorebi_themes::KomorebiTheme;
use komorebi_themes::KomorebiThemeCustom;
use serde::Deserialize;
@@ -127,6 +129,17 @@ pub struct Workspace {
/// container
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_window_placement_rules: Option<InitialWindowPlacementRules>,
/// The strip of columns that the Scrolling layout lays containers out in, which is made when
/// the layout first tiles the workspace ([`Workspace::layout_scrolling`])
#[serde(skip)]
#[cfg_attr(feature = "schemars", schemars(skip))]
pub(crate) scrolling: Option<ScrollingSpace<String>>,
/// When the windows of a Scrolling workspace were last sent to where the strip puts them, and
/// how wide each container was made to be, for telling when the sizes that they report can be
/// believed ([`crate::scrolling::WIDTH_SETTLE`])
#[serde(skip)]
#[cfg_attr(feature = "schemars", schemars(skip))]
pub(crate) scrolling_placed: Option<crate::scrolling::Placed>,
}
/// Which of a workspace's two kinds of windows the focus, move and resize commands act on: the
@@ -188,6 +201,8 @@ impl Default for Workspace {
preselected_container_idx: None,
initial_window_placement_rules: None,
promotion_swap_container_idx: None,
scrolling: None,
scrolling_placed: None,
}
}
}
@@ -410,7 +425,7 @@ impl Workspace {
/// 1. Try threshold match from rules (highest matching threshold wins)
/// 2. If a rule matches -> use it (full replacement of base)
/// 3. Else -> use the base `layout_options`
fn effective_layout_options(&self) -> Option<LayoutOptions> {
pub(crate) fn effective_layout_options(&self) -> Option<LayoutOptions> {
let container_count = self.containers().len();
let has_workspace_overrides =
@@ -462,6 +477,12 @@ impl Workspace {
/// Returns the index of the container whose tile is on the `direction` side of the focused
/// one in the workspace's layout, or `None` when there is none on that side
pub fn new_idx_for_direction(&self, direction: OperationDirection) -> Option<usize> {
// in a strip, left and right are the neighbouring columns and up and down are the tiles of
// the column, which the strip is the one to say
if self.is_scrolling() && self.scrolling.is_some() {
return self.scrolling_idx_for_direction(direction);
}
let len = NonZeroUsize::new(self.containers().len())?;
direction.destination(
@@ -542,6 +563,8 @@ impl Workspace {
next
} else if self.containers().is_empty() {
0
} else if let Some(idx) = self.scrolling_insertion_idx() {
idx
} else {
self.resolve_placement_index(window)
};
@@ -837,8 +860,12 @@ impl Workspace {
.ok_or_eyre("there is no window")?;
if container.windows().is_empty() {
let container_id = container.id.clone();
self.remove_container_by_idx(container_idx);
self.focus_previous_container();
if !self.focus_after_scrolling_removal(&container_id) {
self.focus_previous_container();
}
} else {
container.load_focused_window()?;
if let Some(window) = container.focused_window() {
@@ -852,6 +879,9 @@ impl Workspace {
// this fn respects locked container indexes - we should use it for pretty much everything
// except monocle and maximize toggles
pub fn remove_container_by_idx(&mut self, idx: usize) -> Option<Container> {
// wherever it goes next, it goes as wide as it was
self.note_scrolling_departure(idx);
let container = self.containers_mut().remove_respecting_locks(idx);
if idx < self.resize_dimensions.len() {
@@ -1826,6 +1856,22 @@ impl Workspace {
// the compositor's own maximize, which is the work area and not the padded one
window.maximize()?;
} else if !self.containers().is_empty() {
let is_scrolling = self.is_scrolling();
let around_window = container_padding + border_offset + border_width;
// a window that would not get as narrow as its column was learned of once the
// windows have had time to draw at the sizes that they were sent, and not
// while one is being dragged, whose neighbours are on their way
if is_scrolling
&& self
.scrolling_placed
.as_ref()
.is_some_and(crate::scrolling::Placed::has_settled)
&& !crate::window::live_resize_in_progress()
{
self.adopt_actual_widths(around_window, adjusted_work_area.right);
}
let effective_layout_options = self.effective_layout_options();
tracing::debug!(
@@ -1835,31 +1881,55 @@ impl Workspace {
self.layout_options,
self.layout_options_rules.len(),
);
let mut layouts = self.layout.as_boxed_arrangement().calculate(
&adjusted_work_area,
NonZeroUsize::new(self.containers().len()).ok_or_eyre(
"there must be at least one container to calculate a workspace layout",
)?,
Some(container_padding),
self.layout_flip,
&self.resize_dimensions,
self.focused_container_idx(),
effective_layout_options,
&self.latest_layout,
);
// the containers that rest where another monitor is seen from, which are hidden
let mut hidden = vec![];
let mut layouts = if is_scrolling {
let monitors: Vec<Rect> = backend()
.monitors()
.map(|monitors| monitors.iter().map(|monitor| monitor.size).collect())
.unwrap_or_default();
let others =
crate::scrolling::other_monitors(&self.globals.work_area, &monitors);
self.layout_scrolling(&adjusted_work_area, container_padding)
.into_iter()
.map(|placement| {
let (rect, hide) = crate::scrolling::rest(
placement.rect,
placement.visibility,
&adjusted_work_area,
&others,
);
hidden.push(hide);
rect
})
.collect()
} else {
self.layout.as_boxed_arrangement().calculate(
&adjusted_work_area,
NonZeroUsize::new(self.containers().len()).ok_or_eyre(
"there must be at least one container to calculate a workspace layout",
)?,
Some(container_padding),
self.layout_flip,
&self.resize_dimensions,
self.focused_container_idx(),
effective_layout_options,
&self.latest_layout,
)
};
let resize_dimensions_is_empty = self.resize_dimensions.is_empty();
let is_scrolling =
matches!(self.layout, Layout::Default(DefaultLayout::Scrolling));
let containers = self.containers_mut();
for (i, container) in containers.iter_mut().enumerate() {
if let Some(layout) = layouts.get_mut(i) {
// The scrolling layout is a strip that is meant to run past the edges of
// the work area, with the windows outside the viewport sitting off screen
// (KWin does not constrain where a window is put). Everything else is
// clamped to the work area so it cannot bleed onto an adjacent monitor.
// the work area, and says itself where each window rests when it is out
// of view (KWin does not constrain where a window is put). Everything
// else is clamped to the work area so it cannot bleed onto an adjacent
// monitor.
if !is_scrolling {
// horizontal
if layout.left < adjusted_work_area.left {
@@ -1889,6 +1959,29 @@ impl Workspace {
layout.add_padding(border_offset);
layout.add_padding(border_width);
if hidden.get(i).copied().unwrap_or_default() {
// it is seen on another monitor wherever it rests here
for window in container.windows() {
window.hide();
}
continue;
}
// a window that was hidden for resting past an edge with another
// monitor next to it is shown before it is put where the strip says
let front = container.focused_window().map(|window| window.id);
let restore = is_scrolling
&& front.is_some_and(|front| {
crate::scrolling::should_restore_when_placed(
front,
Some(front),
&HIDDEN_WINDOWS.lock(),
)
});
if restore && let Some(window) = container.focused_window() {
window.restore();
}
for window in container.windows_mut() {
let current_rect = window.rect().unwrap_or_default();
let current_percentage =
@@ -1896,7 +1989,9 @@ impl Workspace {
let proposed_percentage =
work_area.percentage_within_horizontal_bounds(layout);
let percentage_override = proposed_percentage == 0.0
// the strip puts a window where it goes, however far
let percentage_override = !is_scrolling
&& proposed_percentage == 0.0
&& current_percentage > 0.1
&& current_percentage < 100.0
&& resize_dimensions_is_empty;
@@ -1911,6 +2006,15 @@ impl Workspace {
}
self.latest_layout = layouts;
if is_scrolling {
self.scrolling_placed = Some(crate::scrolling::Placed::new(
self.containers()
.iter()
.zip(self.latest_layout.iter())
.map(|(container, layout)| (container.id.clone(), layout.right)),
));
}
}
Ok(())
})();
@@ -2078,9 +2182,6 @@ impl Workspace {
Layout::Default(DefaultLayout::UltrawideVerticalStack) => {
self.enforce_resize_for_ultrawide();
}
Layout::Default(DefaultLayout::Scrolling) => {
self.enforce_resize_for_scrolling();
}
_ => self.enforce_no_resize(),
}
}
@@ -2308,28 +2409,6 @@ impl Workspace {
}
}
fn enforce_resize_for_scrolling(&mut self) {
let resize_dimensions = &mut self.resize_dimensions;
match resize_dimensions.len() {
0 | 1 => self.enforce_no_resize(),
_ => {
let len = resize_dimensions.len();
for (i, rect) in resize_dimensions.iter_mut().enumerate() {
if let Some(rect) = rect {
rect.top = 0;
rect.bottom = 0;
if i == 0 {
rect.left = 0;
} else if i == len - 1 {
rect.right = 0;
}
}
}
}
}
}
fn enforce_no_resize(&mut self) {
for rect in self.resize_dimensions.iter_mut().flatten() {
rect.left = 0;
+25 -5
View File
@@ -71,8 +71,10 @@ fn in_view(rect: Rect) -> bool {
rect.left >= 0 && rect.left + rect.right <= 1920
}
/// A window out of view rests at the edge of the work area that it left by, with a few pixels of
/// it still on the screen
fn out_of_view(rect: Rect) -> bool {
rect.left >= 1920 || rect.left + rect.right <= 0
rect.left >= 1920 - 30 || rect.left + rect.right <= 30
}
#[test]
@@ -139,13 +141,18 @@ fn the_scrolling_strip_runs_past_the_edge_of_the_work_area_with_the_focused_wind
"the first three are scrolled out of view: {rects:?}"
);
}
// not clamped: every column is as wide as the others, at the same distance from the next
let pitch = rects[4].left - rects[3].left;
assert_eq!(rects[0].left, rects[3].left - 3 * pitch, "{rects:?}");
// not clamped: every column is as wide as the others, and the ones out of view keep their
// size and rest against the edge that they left by
assert!(
rects.iter().all(|rect| rect.right == rects[0].right),
"{rects:?}"
);
for rect in &rects[..3] {
assert!(
rect.left + rect.right > 0 && rect.left + rect.right <= 30,
"a few pixels of it are on the screen: {rects:?}"
);
}
}
#[test]
@@ -187,17 +194,30 @@ fn scrolling_layout_columns_sets_how_many_columns_are_in_view() {
for id in 22_031..=22_034 {
open(&fake, &mut wm, id);
}
let width = |id: u32| fake.rect_of(id).unwrap().right;
let three = width(22_031);
// a column is as wide as a share of the screen: a quarter of it is narrower than a third
send(&fake, &mut wm, columns(4));
let four: Vec<Rect> = (22_031..=22_034)
.map(|id| fake.rect_of(id).unwrap())
.collect();
assert!(four.iter().all(|rect| in_view(*rect)), "{four:?}");
assert!(four.iter().all(|rect| rect.right < three), "{four:?}");
assert!(
four.iter().all(|rect| rect.right * 4 <= 1920),
"four of them fit across the screen: {four:?}"
);
assert!(
four.iter()
.all(|rect| (rect.right - four[0].right).abs() <= 1),
"and they are as wide as each other: {four:?}"
);
send(&fake, &mut wm, columns(1));
let one: Vec<Rect> = (22_031..=22_034)
.map(|id| fake.rect_of(id).unwrap())
.collect();
assert!(one[3].right > 1800, "one fills the screen: {one:?}");
assert_eq!(
one.iter().filter(|rect| in_view(**rect)).count(),
1,
+401 -7
View File
@@ -1,14 +1,18 @@
//! The scrolling layout, against an in-memory desktop.
//! The scrolling layout, against an in-memory desktop: columns of several windows and the commands
//! that make them, the width and the place of a column, panning the view, where a window that is
//! out of view rests, and what a drag of the edge of a column does.
//!
//! KWin lets a window sit anywhere, so the columns of a strip that are past the edge of a monitor
//! can be on the next monitor over. Those windows still belong to the monitor whose strip they are
//! in.
//! can be on the next monitor over. Those windows are hidden, and still belong to the monitor whose
//! strip they are in.
//!
//! Window ids are in the 10_000s.
use komorebi::backend::fake::FakeBackend;
use komorebi::backend::with_backend;
use komorebi::core::DefaultLayout;
use komorebi::core::OperationDirection;
use komorebi::core::PanDirection;
use komorebi::core::Rect;
use komorebi::core::SocketMessage;
use komorebi::window::Window;
@@ -52,7 +56,7 @@ fn open(fake: &FakeBackend, id: u32) {
#[test]
fn a_scrolling_strip_on_the_next_monitor_over_does_not_take_focus_with_it() {
// KWin lets a window sit anywhere, so the part of the strip past the right edge of the first
// monitor is on the second one. Those windows still belong to the first.
// monitor would be on the second one. Those windows are hidden, and still belong to the first.
let fake = FakeBackend::with_one_monitor();
fake.add_monitor(
2,
@@ -98,10 +102,12 @@ fn a_scrolling_strip_on_the_next_monitor_over_does_not_take_focus_with_it() {
// (the compositor's focus event is what scrolls the strip, and the fake sends none)
wm.update_focused_workspace(false, false)?;
let past_the_edge = fake.rect_of(10_204).unwrap();
// the part of the strip past the edge would be on the second monitor, so it is hidden
// there and not left on the screen of another monitor
assert!(
past_the_edge.left >= 1920,
"the test needs a window on the second monitor, got {past_the_edge:?}"
fake.is_hidden(10_204),
"the test needs a window past the edge, hidden: {:?}",
fake.rect_of(10_204)
);
wm.process_event(WindowManagerEvent::FocusChange(
@@ -117,3 +123,391 @@ fn a_scrolling_strip_on_the_next_monitor_over_does_not_take_focus_with_it() {
"focus moved to the monitor the strip spills onto"
);
}
fn send(fake: &Arc<FakeBackend>, wm: &mut WindowManager, message: SocketMessage) {
with_backend(fake.clone(), || wm.process_command(message, Vec::new())).unwrap();
}
/// A window manager on one monitor, the scrolling layout with `columns` columns in view, and
/// windows `first..first + count`, the last of them focused
fn strip(columns: usize, first: u32, count: u32) -> (Arc<FakeBackend>, WindowManager) {
let (fake, mut wm) = desktop_on(FakeBackend::with_one_monitor());
for id in first..first + count {
open(&fake, id);
}
send(
&fake,
&mut wm,
SocketMessage::ChangeLayout(DefaultLayout::Scrolling),
);
send(
&fake,
&mut wm,
SocketMessage::ScrollingLayoutColumns(std::num::NonZeroUsize::new(columns).unwrap()),
);
with_backend(fake.clone(), || {
for id in first..first + count {
wm.process_event(added(id)).unwrap();
}
});
(fake, wm)
}
fn focus(fake: &Arc<FakeBackend>, wm: &mut WindowManager, direction: OperationDirection) {
send(fake, wm, SocketMessage::FocusWindow(direction));
// the compositor reports the focus it was asked to give, which is what scrolls the strip
let id = wm.focused_window().unwrap().id;
with_backend(fake.clone(), || {
wm.process_event(WindowManagerEvent::FocusChange(
SystemNotification::KWin(KWinNotification::WindowActivated),
Window::from(id),
))
})
.unwrap();
}
fn rect(fake: &FakeBackend, id: u32) -> Rect {
fake.rect_of(id).unwrap()
}
fn in_view(rect: Rect) -> bool {
rect.left >= 0 && rect.left + rect.right <= 1920
}
#[test]
fn consuming_puts_two_windows_in_one_column_and_expelling_takes_one_out() {
let (fake, mut wm) = strip(3, 10_301, 3);
focus(&fake, &mut wm, OperationDirection::Left);
focus(&fake, &mut wm, OperationDirection::Left);
assert_eq!(wm.focused_window().unwrap().id, 10_301);
send(
&fake,
&mut wm,
SocketMessage::Consume(OperationDirection::Right),
);
let (first, second, third) = (
rect(&fake, 10_301),
rect(&fake, 10_302),
rect(&fake, 10_303),
);
assert_eq!(first.left, second.left, "one column: {first:?} {second:?}");
assert_eq!(first.right, second.right);
assert!(
second.top >= first.top + first.bottom,
"one above the other"
);
assert!(first.bottom < 600, "and each has a share of the height");
assert_ne!(second.left, third.left);
assert_eq!(wm.focused_window().unwrap().id, 10_301, "focus stays");
send(&fake, &mut wm, SocketMessage::Expel);
let (first, second) = (rect(&fake, 10_301), rect(&fake, 10_302));
assert_ne!(first.left, second.left, "a column each again");
assert_eq!(first.top, second.top);
}
#[test]
fn a_column_is_moved_and_focused_whole_and_its_windows_up_and_down() {
let (fake, mut wm) = strip(3, 10_311, 3);
focus(&fake, &mut wm, OperationDirection::Left);
focus(&fake, &mut wm, OperationDirection::Left);
send(
&fake,
&mut wm,
SocketMessage::Consume(OperationDirection::Right),
);
// right goes to the next column, and down to the other window of this one
focus(&fake, &mut wm, OperationDirection::Down);
assert_eq!(wm.focused_window().unwrap().id, 10_312);
focus(&fake, &mut wm, OperationDirection::Up);
assert_eq!(wm.focused_window().unwrap().id, 10_311);
focus(&fake, &mut wm, OperationDirection::Right);
assert_eq!(wm.focused_window().unwrap().id, 10_313);
// the column of two moves as one: the window of the column on the right is now before it
focus(&fake, &mut wm, OperationDirection::Left);
send(
&fake,
&mut wm,
SocketMessage::MoveWindow(OperationDirection::Right),
);
assert!(rect(&fake, 10_313).left < rect(&fake, 10_311).left);
assert_eq!(rect(&fake, 10_311).left, rect(&fake, 10_312).left);
}
#[test]
fn toggling_the_stack_of_a_column_stacks_its_windows_and_spreads_them_out_again() {
let (fake, mut wm) = strip(3, 10_321, 3);
focus(&fake, &mut wm, OperationDirection::Left);
focus(&fake, &mut wm, OperationDirection::Left);
send(
&fake,
&mut wm,
SocketMessage::Consume(OperationDirection::Right),
);
send(&fake, &mut wm, SocketMessage::ToggleColumnStack);
assert_eq!(wm.focused_workspace().unwrap().containers().len(), 2);
let shown = [10_321, 10_322]
.into_iter()
.filter(|id| !fake.is_hidden(*id))
.count();
assert_eq!(shown, 1, "one window of the stack shows at a time");
assert_eq!(
rect(&fake, 10_321).bottom,
rect(&fake, 10_323).bottom,
"and takes the height of the column"
);
send(&fake, &mut wm, SocketMessage::ToggleColumnStack);
assert_eq!(wm.focused_workspace().unwrap().containers().len(), 3);
assert!(
!fake.is_hidden(10_321) && !fake.is_hidden(10_322),
"every window of a stack that is spread out is shown"
);
assert_eq!(rect(&fake, 10_321).left, rect(&fake, 10_322).left);
assert_ne!(rect(&fake, 10_321).top, rect(&fake, 10_322).top);
}
#[test]
fn a_column_is_made_as_wide_as_the_screen_and_put_back() {
let (fake, mut wm) = strip(3, 10_331, 2);
let before = rect(&fake, 10_332);
send(&fake, &mut wm, SocketMessage::ScrollingLayoutFullWidth);
let full = rect(&fake, 10_332);
assert!(full.right > 1800, "{full:?}");
assert!(in_view(full), "{full:?}");
send(&fake, &mut wm, SocketMessage::ScrollingLayoutFullWidth);
assert!((rect(&fake, 10_332).right - before.right).abs() <= 1);
}
#[test]
fn expanding_fills_the_room_that_the_columns_in_view_leave() {
let (fake, mut wm) = strip(3, 10_341, 2);
let before = rect(&fake, 10_342);
send(&fake, &mut wm, SocketMessage::Expand);
let after = rect(&fake, 10_342);
assert!(after.right > before.right + 400, "{before:?} {after:?}");
assert_eq!(rect(&fake, 10_341).left, 10, "the column before it stays");
}
#[test]
fn centring_the_focus_puts_the_focused_column_in_the_middle_of_the_screen() {
let (fake, mut wm) = strip(3, 10_351, 4);
focus(&fake, &mut wm, OperationDirection::Left);
focus(&fake, &mut wm, OperationDirection::Left);
send(&fake, &mut wm, SocketMessage::CenterFocus);
let focused = rect(&fake, wm.focused_window().unwrap().id);
let middle = focused.left + focused.right / 2;
assert!((middle - 960).abs() <= 12, "{focused:?}");
}
#[test]
fn panning_moves_the_view_a_column_and_focus_goes_to_a_column_in_view() {
let (fake, mut wm) = strip(3, 10_361, 6);
for _ in 0..5 {
focus(&fake, &mut wm, OperationDirection::Left);
}
assert_eq!(wm.focused_window().unwrap().id, 10_361);
let before = rect(&fake, 10_364);
send(&fake, &mut wm, SocketMessage::Pan(PanDirection::Right));
let after = rect(&fake, 10_364);
assert!(
after.left < before.left,
"the view went right: {before:?} {after:?}"
);
let focused = wm.focused_window().unwrap().id;
assert_ne!(focused, 10_361, "the first column is out of view");
assert!(in_view(rect(&fake, focused)), "and focus is in view");
send(&fake, &mut wm, SocketMessage::Pan(PanDirection::Left));
assert_eq!(rect(&fake, 10_364).left, before.left, "and back");
}
#[test]
fn a_window_that_scrolls_out_of_view_keeps_its_size_and_rests_at_the_edge() {
let (fake, mut wm) = strip(2, 10_371, 4);
let in_front = rect(&fake, 10_374);
let first = rect(&fake, 10_371);
assert_eq!(first.right, in_front.right, "the size is the same");
assert!(
first.left + first.right > 0 && first.left + first.right <= 30,
"a few pixels of it are on the screen: {first:?}"
);
assert!(
!fake.is_hidden(10_371),
"with no monitor next to it, it is not hidden"
);
// and it comes back where the strip puts it when the focus goes to it
for _ in 0..3 {
focus(&fake, &mut wm, OperationDirection::Left);
}
with_backend(fake.clone(), || {
wm.process_event(WindowManagerEvent::FocusChange(
SystemNotification::KWin(KWinNotification::WindowActivated),
Window::from(10_371),
))
.unwrap();
});
assert!(in_view(rect(&fake, 10_371)));
}
#[test]
fn a_window_that_is_hidden_past_the_edge_of_a_monitor_is_shown_where_the_strip_scrolls_to_it() {
let fake = FakeBackend::with_one_monitor();
fake.add_monitor(
2,
"Virtual-1",
Rect {
left: 1920,
top: 0,
right: 1920,
bottom: 1080,
},
Rect {
left: 1920,
top: 0,
right: 1920,
bottom: 1050,
},
);
let (fake, mut wm) = desktop_on(fake);
for id in 10_381..=10_384 {
open(&fake, id);
}
send(
&fake,
&mut wm,
SocketMessage::ChangeLayout(DefaultLayout::Scrolling),
);
send(
&fake,
&mut wm,
SocketMessage::ScrollingLayoutColumns(std::num::NonZeroUsize::new(2).unwrap()),
);
with_backend(fake.clone(), || {
for id in 10_381..=10_384 {
wm.process_event(added(id)).unwrap();
}
});
for _ in 0..3 {
focus(&fake, &mut wm, OperationDirection::Left);
}
assert!(
fake.is_hidden(10_384),
"past the right edge, next to a monitor"
);
assert!(!fake.is_hidden(10_381));
// a window that rests or overhangs past an edge with a monitor next to it is hidden, so no
// window of the strip reaches the other monitor
send(&fake, &mut wm, SocketMessage::Pan(PanDirection::Right));
for id in 10_381..=10_384 {
let rect = rect(&fake, id);
assert!(
fake.is_hidden(id) || rect.left + rect.right <= 1920,
"{id} reaches the monitor next to it: {rect:?}"
);
}
// and it comes back, shown, when the focus goes to it
for _ in 0..2 {
focus(&fake, &mut wm, OperationDirection::Right);
}
assert_eq!(wm.focused_window().unwrap().id, 10_384);
assert!(!fake.is_hidden(10_384));
assert!(in_view(rect(&fake, 10_384)));
}
#[test]
fn a_window_that_will_not_get_as_narrow_widens_its_column_and_the_rest_make_room() {
let (fake, mut wm) = desktop_on(FakeBackend::with_one_monitor());
for id in 10_391..=10_393 {
open(&fake, id);
}
// a client with a minimum width, as a GTK 4 application or Firefox has
fake.set_min_size(10_392, 1300, 100);
send(
&fake,
&mut wm,
SocketMessage::ChangeLayout(DefaultLayout::Scrolling),
);
with_backend(fake.clone(), || {
for id in 10_391..=10_393 {
wm.process_event(added(id)).unwrap();
}
});
let (wide, after) = (rect(&fake, 10_392), rect(&fake, 10_393));
assert!(wide.right >= 1300, "{wide:?}");
assert!(
after.left < wide.left + wide.right,
"until it is learned, the window covers its neighbour: {wide:?} {after:?}"
);
// the windows have had time to draw at the sizes they were sent, and the next layout learns
// what they are
std::thread::sleep(std::time::Duration::from_millis(500));
send(
&fake,
&mut wm,
SocketMessage::ScrollingLayoutColumns(std::num::NonZeroUsize::new(3).unwrap()),
);
let (wide, after) = (rect(&fake, 10_392), rect(&fake, 10_393));
assert!(wide.right >= 1300, "{wide:?}");
assert!(
after.left >= wide.left + wide.right,
"nothing overlaps now: {wide:?} {after:?}"
);
}
#[test]
fn the_left_edge_of_a_column_is_where_it_is_dragged_to() {
let (fake, mut wm) = strip(3, 10_401, 3);
let before = rect(&fake, 10_403);
with_backend(fake.clone(), || {
wm.process_event(WindowManagerEvent::ResizeStart(
SystemNotification::KWin(KWinNotification::InteractiveResizeStarted),
Window::from(10_403),
))
.unwrap();
let dragged = Rect {
left: before.left - 100,
right: before.right + 100,
..before
};
fake.move_window_by_user(10_403, dragged);
wm.process_event(WindowManagerEvent::ResizeEnd(
SystemNotification::KWin(KWinNotification::InteractiveResizeFinished),
Window::from(10_403),
))
.unwrap();
});
let after = rect(&fake, 10_403);
// to the pixel, give or take what a width that is not a whole number of them rounds to
assert!(
(after.left - (before.left - 100)).abs() <= 2,
"{before:?} {after:?}"
);
assert!(
((after.left + after.right) - (before.left + before.right)).abs() <= 2,
"the right edge stays: {before:?} {after:?}"
);
}
+55
View File
@@ -14,6 +14,10 @@
//! 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.
//! - `consume`, `expel` and `toggle-column-stack` put windows in the same column of the Scrolling
//! layout and take them out, `scrolling-layout-full-width`, `center-focus` and `expand` make the
//! focused column as wide as the screen, put it in the middle and fill the room that is left,
//! and `pan` moves the view along the strip without moving the focus.
//! - `stack` puts the focused window in one container with the window on one side of it, so that
//! they share a tile and only one is in sight, `unstack` takes it out again, and `stack-all` and
//! `unstack-all` do it for the whole workspace and for the focused stack. `cycle-stack` brings
@@ -130,6 +134,7 @@ use komorebi_client::FocusFollowsMouseImplementation;
use komorebi_client::MoveBehaviour;
use komorebi_client::OperationBehaviour;
use komorebi_client::OperationDirection;
use komorebi_client::PanDirection;
use komorebi_client::PathExt;
use komorebi_client::Rect;
use komorebi_client::Sizing;
@@ -207,6 +212,7 @@ macro_rules! gen_enum_subcommand_args {
gen_enum_subcommand_args! {
Focus: OperationDirection,
Move: OperationDirection,
Consume: OperationDirection,
PreselectDirection: OperationDirection,
CycleFocus: CycleDirection,
CycleMove: CycleDirection,
@@ -732,6 +738,13 @@ struct ScrollingLayoutColumns {
count: NonZeroUsize,
}
#[derive(Parser)]
struct Pan {
/// The way that the view goes
#[clap(value_enum)]
direction: PanDirection,
}
#[derive(Parser)]
struct LayoutRatios {
/// Column width ratios (space-separated values between 0.1 and 0.9)
@@ -1097,6 +1110,27 @@ enum SubCommand {
/// Set the number of visible columns for the Scrolling layout on the focused workspace
#[clap(arg_required_else_help = true)]
ScrollingLayoutColumns(ScrollingLayoutColumns),
/// Make the focused column of the Scrolling layout as wide as the screen, or put it back to
/// the width that it had
ScrollingLayoutFullWidth,
/// Scroll the Scrolling layout so that the focused column is in the middle of the screen, once
CenterFocus,
/// Make the focused column of the Scrolling layout fill the room that the columns in view leave
/// on the screen, which does nothing in any other layout
Expand,
/// Take the neighbouring window on one side into the focused column of the Scrolling layout
#[clap(arg_required_else_help = true)]
Consume(Consume),
/// Take the focused window out of its column in the Scrolling layout and put it in a column
/// of its own on the right
Expel,
/// Stack the windows of the focused column of the Scrolling layout, or spread the focused
/// stack out into a column with a window in each slot
ToggleColumnStack,
/// Move the view of the Scrolling layout a column to the left or the right, which does
/// nothing in any other layout
#[clap(arg_required_else_help = true)]
Pan(Pan),
/// Set the layout column and row ratios for the focused workspace
LayoutRatios(LayoutRatios),
// /// Load a custom layout from file for the focused workspace
@@ -2049,6 +2083,27 @@ fn main() -> eyre::Result<()> {
SubCommand::ScrollingLayoutColumns(args) => {
send_message(&SocketMessage::ScrollingLayoutColumns(args.count))?;
}
SubCommand::ScrollingLayoutFullWidth => {
send_message(&SocketMessage::ScrollingLayoutFullWidth)?;
}
SubCommand::CenterFocus => {
send_message(&SocketMessage::CenterFocus)?;
}
SubCommand::Expand => {
send_message(&SocketMessage::Expand)?;
}
SubCommand::Consume(args) => {
send_message(&SocketMessage::Consume(args.operation_direction))?;
}
SubCommand::Expel => {
send_message(&SocketMessage::Expel)?;
}
SubCommand::ToggleColumnStack => {
send_message(&SocketMessage::ToggleColumnStack)?;
}
SubCommand::Pan(args) => {
send_message(&SocketMessage::Pan(args.direction))?;
}
SubCommand::LayoutRatios(args) => {
if args.columns.is_none() && args.rows.is_none() {
println!(
+34
View File
@@ -311,6 +311,33 @@ fn scrolling_layout_columns_and_layout_ratios_send_what_they_are_given() {
);
}
#[test]
fn the_commands_of_the_columns_of_the_scrolling_layout_send_their_message() {
sends(
&["scrolling-layout-full-width"],
r#"{"type":"ScrollingLayoutFullWidth"}"#,
);
sends(&["center-focus"], r#"{"type":"CenterFocus"}"#);
sends(&["expand"], r#"{"type":"Expand"}"#);
sends(
&["consume", "right"],
r#"{"type":"Consume","content":"Right"}"#,
);
sends(&["expel"], r#"{"type":"Expel"}"#);
sends(&["toggle-column-stack"], r#"{"type":"ToggleColumnStack"}"#);
sends(&["pan", "left"], r#"{"type":"Pan","content":"Left"}"#);
}
#[test]
fn pan_and_consume_want_a_direction() {
let runtime = Runtime::new();
for arguments in [&["pan"][..], &["pan", "up"], &["consume"]] {
let output = runtime.komorebic(arguments);
assert!(!output.status.success(), "{arguments:?}: {output:?}");
}
}
#[test]
fn layout_ratios_without_ratios_sends_nothing_and_says_so() {
let runtime = Runtime::new();
@@ -1118,6 +1145,7 @@ fn docgen_writes_the_help_of_every_subcommand_in_a_text_fence() {
"border-width.md",
"border.md",
"cancel-preselect.md",
"center-focus.md",
"change-layout.md",
"check.md",
"clear-all-workspace-rules.md",
@@ -1130,6 +1158,7 @@ fn docgen_writes_the_help_of_every_subcommand_in_a_text_fence() {
"close.md",
"completions.md",
"configuration.md",
"consume.md",
"container-padding.md",
"cross-monitor-move-behaviour.md",
"cycle-empty-workspace.md",
@@ -1153,6 +1182,8 @@ fn docgen_writes_the_help_of_every_subcommand_in_a_text_fence() {
"enforce-workspace-rules.md",
"ensure-named-workspaces.md",
"ensure-workspaces.md",
"expand.md",
"expel.md",
"export-kde-shortcuts.md",
"flip-layout.md",
"focus-last-workspace.md",
@@ -1202,6 +1233,7 @@ fn docgen_writes_the_help_of_every_subcommand_in_a_text_fence() {
"named-workspace-tiling.md",
"new-workspace.md",
"notification-schema.md",
"pan.md",
"preselect-direction.md",
"promote-focus.md",
"promote-swap.md",
@@ -1218,6 +1250,7 @@ fn docgen_writes_the_help_of_every_subcommand_in_a_text_fence() {
"retile.md",
"save-resize.md",
"scrolling-layout-columns.md",
"scrolling-layout-full-width.md",
"send-to-last-workspace.md",
"send-to-monitor-workspace.md",
"send-to-monitor.md",
@@ -1234,6 +1267,7 @@ fn docgen_writes_the_help_of_every_subcommand_in_a_text_fence() {
"stop.md",
"subscribe-socket.md",
"swap-workspaces-with-monitor.md",
"toggle-column-stack.md",
"toggle-cross-monitor-move-behaviour.md",
"toggle-float-override.md",
"toggle-float.md",