From db976212b19a8459e691fd8fcaacfbbf2ef7e6c4 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Fri, 30 Oct 2026 12:41:30 -0700 Subject: [PATCH] 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. --- komorebi-client/src/lib.rs | 2 + komorebi/src/core/mod.rs | 9 + komorebi/src/lib.rs | 1 + komorebi/src/process_command.rs | 62 +- komorebi/src/process_event.rs | 3 + komorebi/src/scrolling.rs | 1328 ++++++++++++++++++++++++++++++ komorebi/src/state.rs | 2 + komorebi/src/window.rs | 6 + komorebi/src/window_manager.rs | 26 +- komorebi/src/workspace.rs | 169 +++- komorebi/tests/layout_options.rs | 30 +- komorebi/tests/scrolling.rs | 408 ++++++++- komorebic/src/main.rs | 55 ++ komorebic/tests/cli.rs | 34 + 14 files changed, 2053 insertions(+), 82 deletions(-) create mode 100644 komorebi/src/scrolling.rs diff --git a/komorebi-client/src/lib.rs b/komorebi-client/src/lib.rs index dba8ee74..3b55f8bd 100644 --- a/komorebi-client/src/lib.rs +++ b/komorebi-client/src/lib.rs @@ -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; diff --git a/komorebi/src/core/mod.rs b/komorebi/src/core/mod.rs index c42fec46..93e30dc5 100644 --- a/komorebi/src/core/mod.rs +++ b/komorebi/src/core/mod.rs @@ -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>, Option>), // ChangeLayoutCustom(#[serde_as(as = "ResolvedPathBuf")] PathBuf), FlipLayout(Axis), diff --git a/komorebi/src/lib.rs b/komorebi/src/lib.rs index 8114e9b3..a273fd81 100644 --- a/komorebi/src/lib.rs +++ b/komorebi/src/lib.rs @@ -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; diff --git a/komorebi/src/process_command.rs b/komorebi/src/process_command.rs index 6fc5a07c..6446c300 100644 --- a/komorebi/src/process_command.rs +++ b/komorebi/src/process_command.rs @@ -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; diff --git a/komorebi/src/process_event.rs b/komorebi/src/process_event.rs index a762f856..b464d6db 100644 --- a/komorebi/src/process_event.rs +++ b/komorebi/src/process_event.rs @@ -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)? { diff --git a/komorebi/src/scrolling.rs b/komorebi/src/scrolling.rs new file mode 100644 index 00000000..49c61664 --- /dev/null +++ b/komorebi/src/scrolling.rs @@ -0,0 +1,1328 @@ +//! The glue between a [`Workspace`] and the strip of columns of the Scrolling layout. +//! +//! Everything that does not touch the desktop lives in [`komorebi_layouts::scrolling`] and the +//! `komorebi-scrolling` crate, so that every komorebi shares it. What is here is what needs a +//! [`Workspace`]: a container is a tile in the strip, and is known to it by its id. +//! +//! The strip says where every container goes, in the coordinates of the screen, with what is out +//! of view reaching past the work area. `parked` and `rest` say where a window that is out of +//! view rests: at the edge it left by with a few pixels of it on the screen and its size +//! unchanged, and hidden ([`crate::window::Window::hide`]) where the edge has another monitor +//! next to it, since KWin lets a window be on any monitor it overlaps. `adopt_actual_widths` +//! widens the column of a window that will not get as narrow as its column is. +//! +//! [`WindowManager::pan`] moves the view along the strip without moving the focus. + +use crate::container::Container; +use crate::core::DefaultLayout; +use crate::core::Layout; +use crate::core::LayoutOptions; +use crate::core::OperationDirection; +use crate::core::PanDirection; +use crate::core::Rect; +use crate::core::ScrollingLayoutOptions; +use crate::core::Sizing; +use crate::window_manager::WindowManager; +use crate::workspace::Workspace; +use color_eyre::eyre; +use komorebi_layouts::scrolling; +use komorebi_layouts::scrolling::ScrollingPlacement; +use komorebi_scrolling::ColumnWidth; +use komorebi_scrolling::Options; +use komorebi_scrolling::Visibility; +use std::collections::HashMap; +use std::collections::HashSet; +use std::time::Duration; +use std::time::Instant; + +/// What the layout options of a workspace come to for the model. +pub(crate) fn scrolling_options(layout_options: Option) -> Options { + scrolling::scrolling_options(layout_options) +} + +/// How much wider than its column a window can be before it is taken to refuse to be narrower. +const WIDTH_TOLERANCE: i32 = 16; + +/// How long after the windows were last sent to where the layout put them what they say about their +/// size can be trusted to be what they will stay at. The compositor reports the frame of a window +/// once its client has drawn at the size that it was asked for, a moment after the command, and a +/// window that has not got there yet is not a window that refuses to. +pub(crate) const WIDTH_SETTLE: Duration = Duration::from_millis(400); + +/// Where the windows of a Scrolling workspace were last sent: how wide each container was made to +/// be, and when +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Placed { + at: Instant, + widths: HashMap, +} + +impl Placed { + /// The windows have just been sent to where the strip puts them, `widths` being the width of + /// the tile of each container by its id + pub(crate) fn new(widths: impl IntoIterator) -> Self { + Self { + at: Instant::now(), + widths: widths.into_iter().collect(), + } + } + + /// Whether the windows have had [`WIDTH_SETTLE`] to draw at the sizes that they were sent + pub(crate) fn has_settled(&self) -> bool { + self.at.elapsed() >= WIDTH_SETTLE + } +} + +/// How much of a window that has scrolled out of view is left on the screen, so that it has an +/// edge to slide back in from. +const PARKED_VISIBLE_PIXELS: i32 = 5; + +/// Where a window with this rectangle rests when the strip says how much of it is in view. +/// +/// A window that scrolled out of view comes to rest at the edge of the screen that it left by, +/// with a few pixels of it still on screen, and keeps its size, so that nothing has to be resized +/// to bring it back and an animation back into view is short. One that is partly or wholly in +/// view is where the strip puts it. +pub(crate) fn parked(rect: Rect, visibility: Visibility, work_area: &Rect) -> Rect { + match visibility { + Visibility::OffLeft => Rect { + left: work_area.left - rect.right + PARKED_VISIBLE_PIXELS, + ..rect + }, + Visibility::OffRight => Rect { + left: work_area.left + work_area.right - PARKED_VISIBLE_PIXELS, + ..rect + }, + _ => rect, + } +} + +/// Whether two rectangles have any area in common. +fn overlaps(a: &Rect, b: &Rect) -> bool { + a.left < b.left + b.right + && b.left < a.left + a.right + && a.top < b.top + b.bottom + && b.top < a.top + a.bottom +} + +/// The monitors other than the one that has the work area, which is the one that contains it. +pub(crate) fn other_monitors(work_area: &Rect, monitors: &[Rect]) -> Vec { + monitors + .iter() + .filter(|monitor| { + !(monitor.left <= work_area.left + && monitor.top <= work_area.top + && monitor.left + monitor.right >= work_area.left + work_area.right + && monitor.top + monitor.bottom >= work_area.top + work_area.bottom) + }) + .copied() + .collect() +} + +/// Cut a rectangle down to what is inside the work area. +pub(crate) fn clamp_to_work_area(rect: &mut Rect, work_area: &Rect) { + if rect.left < work_area.left { + rect.right -= work_area.left - rect.left; + rect.left = work_area.left; + } + + let right_edge = work_area.left + work_area.right; + if rect.left + rect.right > right_edge { + rect.right = right_edge - rect.left; + } + + if rect.top < work_area.top { + rect.bottom -= work_area.top - rect.top; + rect.top = work_area.top; + } + + let bottom_edge = work_area.top + work_area.bottom; + if rect.top + rect.bottom > bottom_edge { + rect.bottom = bottom_edge - rect.top; + } +} + +/// Where a window rests, and whether it has to be hidden to rest there, when `others` are the +/// other monitors. +/// +/// A window is seen on whichever monitor it overlaps. One that rests or overhangs past an edge that +/// another monitor is next to would be seen there, so a window that scrolled out of view is hidden +/// instead of parked, and a window that is partly in view is made narrower to fit the work area. +/// Without a monitor next to it, the window is where [`parked`] puts it. +pub(crate) fn rest( + rect: Rect, + visibility: Visibility, + work_area: &Rect, + others: &[Rect], +) -> (Rect, bool) { + let at = parked(rect, visibility, work_area); + let seen_elsewhere = others.iter().any(|monitor| overlaps(&at, monitor)); + + match visibility { + Visibility::Visible => (at, false), + Visibility::Clipped if seen_elsewhere => { + let mut clamped = at; + clamp_to_work_area(&mut clamped, work_area); + (clamped, false) + } + Visibility::OffLeft | Visibility::OffRight if seen_elsewhere => (at, true), + _ => (at, false), + } +} + +/// Whether a window that is about to be put where the strip says should be shown first, because it +/// was hidden when it went out of view past an edge with another monitor next to it. +/// +/// Only the window that is showing in its container is: the others are the tabs at the back of a +/// stack, which are hidden because they are at the back, and showing them would put them in front +/// of the one that is in front, and the system would take the one that was shown for the one that +/// was chosen. +pub(crate) fn should_restore_when_placed( + window: u32, + focused_in_container: Option, + hidden: &HashSet, +) -> bool { + focused_in_container == Some(window) && hidden.contains(&window) +} + +impl Workspace { + /// Learn from what windows turned out to be when they were sent to where the layout put them, + /// which is that some of them are wider than the layout said and will not be any narrower. + /// + /// A column is never made narrower than the window in it needs, and everything after it is + /// placed where it ends. Only ever wider: what an app will not do is a fact about the app, and + /// a limit that came and went would make the strip bounce between two layouts. + /// + /// `around_window` is what a column takes up around its window on either side, and the strip + /// is `viewport_width` wide. Returns whether any column changed. + /// + /// What the compositor says about a window is only the size that it will stay at once it has had + /// time to draw at the size that it was asked for, so this is for [`Workspace::update`] to call + /// when the windows were last placed [`WIDTH_SETTLE`] ago, and not while a window is being + /// dragged by an edge. + pub(crate) fn adopt_actual_widths(&mut self, around_window: i32, viewport_width: i32) -> bool { + let (Some(space), Some(placed)) = (self.scrolling.as_mut(), self.scrolling_placed.as_ref()) + else { + return false; + }; + + let mut adopted = false; + + for container in self.containers.elements() { + // a container that was not there when the windows were placed has nothing to compare + let Some(placed_width) = placed.widths.get(&container.id).copied() else { + continue; + }; + + // a stack is one tile and as wide as the widest of its windows needs + let excess = container + .windows() + .iter() + // a window that is hidden has not been where it was told to be + .filter(|window| !crate::HIDDEN_WINDOWS.lock().contains(&window.id)) + // and one that is fullscreen was not sent where the strip puts it + .filter(|window| !window.is_fullscreen()) + .filter_map(|window| window.rect().ok()) + .map(|actual| actual.right - placed_width) + .max() + .unwrap_or_default(); + + // a window that is not wider than it was sent to be, give or take what rounding, the + // borders of the window and a terminal's grid of cells do to it, has nothing to say + if excess <= WIDTH_TOLERANCE { + continue; + } + + let Some(width) = space.column_width(&container.id) else { + continue; + }; + let Some((min, max)) = space.width_limits(&container.id) else { + continue; + }; + + let needed = ((width.round() as i32) + excess) + .min(viewport_width) + .max(around_window); + + if min.is_some_and(|min| min >= needed) { + continue; + } + + tracing::info!( + "a window in column {} is {excess}px wider than the {placed_width}px that it was placed at, so the column will not be narrower than {needed}px", + container.id + ); + + space.set_width_limits(&container.id, Some(needed), max); + adopted = true; + } + + adopted + } + + /// Whether the workspace is laid out as a strip of columns. + pub(crate) fn is_scrolling(&self) -> bool { + matches!(self.layout, Layout::Default(DefaultLayout::Scrolling)) + } + + fn scrolling_ids(&self) -> Vec { + self.containers() + .iter() + .map(|container| container.id.clone()) + .collect() + } + + /// Set how many columns the Scrolling layout shows at once, keeping every other layout option. + pub fn set_scrolling_columns(&mut self, columns: usize) { + let mut options = self.layout_options.unwrap_or(LayoutOptions { + scrolling: None, + grid: None, + column_ratios: None, + row_ratios: None, + }); + + match &mut options.scrolling { + Some(scrolling) => scrolling.columns = columns, + None => { + options.scrolling = Some(ScrollingLayoutOptions { + columns, + center_focused_column: None, + }); + } + } + + self.layout_options = Some(options); + } + + /// Make the space match the containers, and the focus, as they are now. + fn sync_scrolling(&mut self, viewport: komorebi_scrolling::Viewport) { + let options = scrolling_options(self.effective_layout_options()); + let ids = self.scrolling_ids(); + let focused = self + .focused_container() + .map(|container| container.id.clone()); + + scrolling::sync( + &mut self.scrolling, + options, + viewport, + &ids, + focused.as_ref(), + ); + } + + /// Say what the column of the container at this index was given, as it is about to be taken + /// out of the workspace, for the workspace that it is put in to find. + pub(crate) fn note_scrolling_departure(&self, idx: usize) { + if let Some(space) = &self.scrolling + && let Some(container) = self.containers().get(idx) + && let Some(record) = space.column_record(&container.id) + { + scrolling::note_departure(&container.id, record); + } + } + + /// Make the space match the containers and the focus as they are now, for a space that is + /// there to be made to match. + /// + /// This is for a change that is about to be made to the containers, and that the space is + /// going to be told about directly. The space has to know what there is before it is told + /// what goes. + pub(crate) fn sync_scrolling_containers(&mut self) { + let ids = self.scrolling_ids(); + let focused = self + .focused_container() + .map(|container| container.id.clone()); + + if let Some(space) = self.scrolling.as_mut() { + scrolling::tell_of_arrivals(space, &ids); + space.reconcile(&ids, focused.as_ref()); + } + } + + /// Give focus to what the strip says should have it after the container `removed` has gone, + /// and tell whether the strip had anything to say. + /// + /// Focus goes back to the column that the closed one was opened next to, if nothing has + /// moved focus since, and otherwise to the column that takes its place on the right. Focus that + /// is not on the closed column stays where it is. This is not what the other layouts do, which + /// always move focus to the left. + pub(crate) fn focus_after_scrolling_removal(&mut self, removed: &str) -> bool { + let Some(space) = self.scrolling.as_mut() else { + return false; + }; + + if !space.remove_column(&removed.to_string()) { + return false; + } + + // a container is a tile, and the strip says where in reading order the one that has focus + // is, which is the index of the container + if let Some(idx) = space.active_tile_index() { + self.focus_container(idx); + } + + true + } + + /// Where every container goes in `work_area`, from left to right, with `container_padding` + /// taken off every side of each. + pub(crate) fn layout_scrolling( + &mut self, + work_area: &Rect, + container_padding: i32, + ) -> Vec { + self.sync_scrolling(scrolling::viewport_of(work_area)); + + self.scrolling + .as_ref() + .map(|space| scrolling::place(space, work_area, container_padding)) + .unwrap_or_default() + } + + /// Where the container that a new window gets goes, if the strip has a say: after the last + /// tile of the column that has focus. + /// + /// A new window is a column of its own beside the focused one, and not a tile in the middle of + /// it, which is what the container after the focused one would be if the column has other + /// tiles below. + pub(crate) fn scrolling_insertion_idx(&self) -> Option { + if !self.is_scrolling() { + return None; + } + + let space = self.scrolling.as_ref()?; + let focused = self.focused_container()?.id.clone(); + let last = space.column_of(&focused)?.pop()?; + + self.containers() + .iter() + .position(|container| container.id == last) + .map(|idx| idx + 1) + } + + /// Where focus would go from the focused container in this direction, in the strip as it is + /// now, or `None` if it would not go anywhere. + /// + /// Left and right are the neighbouring columns, and focus goes to the tile that had it last + /// in the one it arrives at. Up and down are the tiles of the focused column, and stop at + /// the ends. + pub(crate) fn scrolling_idx_for_direction( + &self, + direction: OperationDirection, + ) -> Option { + // the strip may not have heard about everything that happened since the last layout, so + // this goes by a copy of it that has + let mut probe = self.scrolling.clone()?; + let ids = self.scrolling_ids(); + let focused = self + .focused_container() + .map(|container| container.id.clone()); + probe.reconcile(&ids, focused.as_ref()); + + let moved = match direction { + OperationDirection::Left => probe.focus_left(), + OperationDirection::Right => probe.focus_right(), + OperationDirection::Up => probe.focus_up(), + OperationDirection::Down => probe.focus_down(), + }; + + if moved { + probe.active_tile_index() + } else { + None + } + } + + /// Make the order of the containers the order of the tiles of the strip, and focus the tile + /// that the strip says has it. + /// + /// Most of what the strip does leaves the order alone. What moves a tile past another does + /// not, and the list of containers is what is true, so it is made to match. + fn apply_scrolling_order(&mut self) { + let Some(space) = self.scrolling.as_ref() else { + return; + }; + + let order = space.ids(); + let focused = space.active_tile_index(); + + let containers = self.containers_mut(); + let mut containers_in_order: Vec<_> = containers.drain(..).collect(); + containers_in_order.sort_by_key(|container| { + order + .iter() + .position(|id| *id == container.id) + .unwrap_or(usize::MAX) + }); + containers.extend(containers_in_order); + + if let Some(idx) = focused { + self.focus_container(idx); + } + + // resize adjustments are not used in this layout, and must not follow a container + // to where it no longer is + self.resize_dimensions = vec![None; self.containers().len()]; + } + + /// Make the space agree with which container has focus, and give back the id of that + /// container, for a command that is about the focused column. + /// + /// There is nothing to do until a layout has made the space, and in a layout that is not + /// Scrolling. + fn focused_scrolling_column(&mut self) -> Option { + if !self.is_scrolling() || self.scrolling.is_none() { + return None; + } + + self.sync_scrolling_containers(); + + self.focused_container() + .map(|container| container.id.clone()) + } + + /// Move the focused column one place to the left or right, or the focused tile one place up + /// or down in its column, and tell whether anything moved. + /// + /// A column moves with all of its tiles. + pub(crate) fn move_scrolling(&mut self, direction: OperationDirection) -> bool { + let Some(id) = self.focused_scrolling_column() else { + return false; + }; + + let Some(space) = self.scrolling.as_mut() else { + return false; + }; + + let moved = match direction { + OperationDirection::Up => space.move_tile_up(), + OperationDirection::Down => space.move_tile_down(), + OperationDirection::Left | OperationDirection::Right => match space.position(&id) { + Some(at) => { + let to = match direction { + OperationDirection::Left => at.checked_sub(1), + _ => Some(at + 1).filter(|to| *to < space.len()), + }; + + match to { + Some(to) => { + space.move_column(&id, to); + true + } + None => false, + } + } + None => false, + }, + }; + + if moved { + self.apply_scrolling_order(); + } + + moved + } + + /// Take the neighbouring tile on one side into the focused column: the first tile of the + /// column on the right to the bottom of it, or the last tile of the column on the left to the + /// top of it. Up and down mean nothing here. + pub(crate) fn consume_scrolling(&mut self, direction: OperationDirection) { + if self.focused_scrolling_column().is_none() { + return; + } + + let Some(space) = self.scrolling.as_mut() else { + return; + }; + + let changed = match direction { + OperationDirection::Left => space.consume_left(), + OperationDirection::Right => space.consume_right(), + OperationDirection::Up | OperationDirection::Down => false, + }; + + if changed { + self.apply_scrolling_order(); + } + } + + /// Take the focused tile out of its column and put it in a column of its own on the right. + pub(crate) fn expel_scrolling(&mut self) { + if self.focused_scrolling_column().is_none() { + return; + } + + let Some(space) = self.scrolling.as_mut() else { + return; + }; + + if space.expel() { + self.apply_scrolling_order(); + } + } + + /// Stack the windows of the focused column in one tile, or spread the stack in the focused tile + /// out into the tiles of a column, whichever it is, and tell whether there was anything to do. + /// + /// A column of several tiles becomes one tile that is a stack of all of their windows, in the + /// order that they were in, with the window that had focus still showing. A tile that is a stack + /// becomes a column of a tile for each of its windows, in the order of its tabs, with focus on + /// the window that had it. Either way the column keeps its width, but the heights that its tiles + /// were given are not remembered for when it is toggled back. + pub(crate) fn toggle_scrolling_column_stack(&mut self) -> eyre::Result { + let Some(id) = self.focused_scrolling_column() else { + return Ok(false); + }; + + let Some(column) = self + .scrolling + .as_ref() + .and_then(|space| space.column_of(&id)) + else { + return Ok(false); + }; + + if column.len() > 1 { + self.stack_scrolling_column(&column)?; + + return Ok(true); + } + + self.unstack_scrolling_column() + } + + /// Put the windows of all of the containers with these ids in the first of them, in order. + fn stack_scrolling_column(&mut self, column: &[String]) -> eyre::Result<()> { + let focused_window = self + .focused_container() + .and_then(Container::focused_window) + .map(|window| window.id); + + let mut windows = vec![]; + for id in &column[1..] { + if let Some(idx) = self.containers().iter().position(|c| c.id == *id) + && let Some(container) = self.remove_container_by_idx(idx) + { + windows.extend(container.windows().iter().copied()); + } + } + + let Some(idx) = self.containers().iter().position(|c| c.id == column[0]) else { + return Ok(()); + }; + + if let Some(container) = self.containers_mut().get_mut(idx) { + for window in windows { + container.add_window(&window)?; + } + + // the window that had focus is the one that is showing + if let Some(focused) = focused_window + && let Some(at) = container.idx_for_window(focused) + { + container.focus_window(at); + } + + container.load_focused_window()?; + } + + self.focus_container(idx); + + Ok(()) + } + + /// Put each of the windows of the focused container in a container of its own, in the same + /// column, and tell whether it had more than one. + fn unstack_scrolling_column(&mut self) -> eyre::Result { + let idx = self.focused_container_idx(); + + let Some(container) = self.containers_mut().get_mut(idx) else { + return Ok(false); + }; + + if container.windows().len() < 2 { + return Ok(false); + } + + let focused = container.focused_window().map(|window| window.id); + let first_id = container.id.clone(); + + let mut windows: Vec<_> = container.windows_mut().drain(..).collect(); + let rest = windows.split_off(1); + container.windows_mut().extend(windows); + container.focus_window(0); + + // the window that stays was one of the tabs, and may be the one that was out of sight, so + // it is put where it is shown as well, or its column is left empty until focus reaches it + container.restore()?; + + // the strip is about to be told that there are more containers than it has columns for, + // and so each of them is a column, until they are put in this one + let mut count = 0; + for (offset, window) in rest.into_iter().enumerate() { + let mut container = Container::default(); + container.add_window(&window)?; + + // it was one of the tabs that were out of sight, and has to be put where it is shown + container.restore()?; + + self.insert_container_at_idx(idx + 1 + offset, container); + count += 1; + } + + // inserting a container gives it focus, and the strip scrolls to bring the focused column + // into view, which it would do for a column that is only there until it is put in this + // one, and then not scroll back. Focus stays on the column that they are put in. + self.focus_container(idx); + self.sync_scrolling_containers(); + + if let Some(space) = self.scrolling.as_mut() { + space.focus(&first_id); + for _ in 0..count { + space.consume_right(); + } + } + + // focus is on the tile that had the window that had it + let focused_idx = focused + .and_then(|focused| self.container_idx_for_window(focused)) + .unwrap_or(idx); + self.focus_container(focused_idx); + + Ok(true) + } + + /// Put every column back to the width that the `columns` option gives, which forgets every + /// width that was set by resizing, by a preset, or by taking the full width, and every window + /// back to an equal share of the height of its column. The narrowest that windows were found + /// to accept is forgotten as well. + /// + /// The focused column stays where it is on screen unless it has to scroll to stay in view. + pub(crate) fn reset_scrolling_sizes(&mut self) { + if let Some(space) = self.scrolling.as_mut() { + space.reset_widths(); + space.reset_heights(); + + // what windows were found to refuse is forgotten too, to be found out again if it is + // still so: a limit that was learned wrongly would otherwise survive the one command + // that is there to put everything back + for id in space.ids() { + space.set_width_limits(&id, None, None); + } + } + } + + /// Make the focused column as wide as the screen, or give it back the width that it had. + pub(crate) fn toggle_scrolling_full_width(&mut self) { + if let Some(id) = self.focused_scrolling_column() + && let Some(space) = self.scrolling.as_mut() + { + space.toggle_full_width(&id); + } + } + + /// Make the focused column as wide as it can be without scrolling anything else out of view, + /// which is to fill the room that the columns in view leave. Only the Scrolling layout has + /// columns to expand. + pub(crate) fn expand_scrolling_column(&mut self) { + if self.is_scrolling() + && self.focused_scrolling_column().is_some() + && let Some(space) = self.scrolling.as_mut() + { + space.expand_to_available_width(); + } + } + + /// Start moving the strip across the screen, and tell whether there is one to move. Only the + /// Scrolling layout has a strip, and not before a layout has made it. + pub(crate) fn begin_scrolling_pan(&mut self) -> bool { + if !self.is_scrolling() || self.scrolling.is_none() { + return false; + } + + self.sync_scrolling_containers(); + + match self.scrolling.as_mut() { + Some(space) => { + space.begin_pan(); + true + } + None => false, + } + } + + /// Move the strip by `dx` pixels on the screen, to the right if it is positive. Nothing else + /// changes: not which container has focus, nor the width of any. + pub(crate) fn pan_scrolling(&mut self, dx: f64) { + if let Some(space) = self.scrolling.as_mut() { + space.pan_by(dx); + } + } + + /// Let go of the strip, going at `velocity` pixels a second on the screen, to the right if it + /// is positive, and let it come to rest, which is with a column against an edge of the screen. + /// Focus goes to the nearest column that is fully in view if the focused one is not, and + /// this tells whether it moved. + pub(crate) fn end_scrolling_pan(&mut self, velocity: f64) -> bool { + let Some(space) = self.scrolling.as_mut() else { + return false; + }; + + if space.pan_end(velocity).is_none() { + return false; + } + + let focused = space.active_tile_index(); + let before = self.focused_container_idx(); + + match focused { + Some(idx) if idx != before => { + self.focus_container(idx); + true + } + _ => false, + } + } + + /// How far one step of a pan goes, if nothing says: a column of the width that a new one + /// is given. + pub(crate) fn scrolling_pan_step(&self) -> Option { + let space = self.scrolling.as_ref()?; + let viewport = f64::from(space.viewport().width); + + let step = match space.options().default_width { + ColumnWidth::Proportion(share) => viewport * share, + ColumnWidth::Fixed(width) => f64::from(width), + }; + + Some(step.round() as i32) + } + + /// Centre the focused column on the screen, this once. + pub(crate) fn center_scrolling_column(&mut self) { + if self.focused_scrolling_column().is_some() + && let Some(space) = self.scrolling.as_mut() + { + space.center_column(); + } + } + + /// Make the focused column wider or narrower with the horizontal edges, and the focused window + /// taller or shorter in its column with the vertical ones. + pub(crate) fn resize_scrolling_column( + &mut self, + direction: OperationDirection, + sizing: Sizing, + delta: i32, + ) { + let Some(id) = self + .focused_container() + .map(|container| container.id.clone()) + else { + return; + }; + + if let Some(space) = self.scrolling.as_mut() { + scrolling::resize(space, &id, direction, sizing, delta); + } + } +} + +impl WindowManager { + /// Move the view of the focused workspace one step, a column, to the left or the right, and + /// let it come to rest with a column against an edge of the screen. Focus goes to the nearest + /// column in view if the focused one is not. Nothing happens in a layout that is not Scrolling. + /// + /// # Errors + /// + /// There is no focused workspace, or it cannot be tiled again. + #[tracing::instrument(skip(self))] + pub fn pan(&mut self, direction: PanDirection) -> eyre::Result<()> { + let workspace = self.focused_workspace_mut()?; + + if !workspace.begin_scrolling_pan() { + return Ok(()); + } + + let step = workspace + .scrolling_pan_step() + .and_then(|step| u32::try_from(step).ok()) + .unwrap_or_default(); + + workspace.pan_scrolling(direction.strip_offset(step)); + let focus_moved = workspace.end_scrolling_pan(0.0); + + self.update_focused_workspace(focus_moved && self.mouse_follows_focus, focus_moved) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::HIDDEN_WINDOWS; + use crate::backend::fake::FakeBackend; + use crate::backend::with_backend; + use crate::window::Window; + use std::sync::Arc; + + const AREA: Rect = Rect { + left: 100, + top: 50, + right: 1200, + bottom: 800, + }; + + fn workspace(columns: usize, count: u32) -> Workspace { + workspace_from(columns, 1, count) + } + + /// A workspace of containers of one window each, with window handles from `first` on. + fn workspace_from(columns: usize, first: u32, count: u32) -> Workspace { + let mut workspace = Workspace { + layout: Layout::Default(DefaultLayout::Scrolling), + ..Workspace::default() + }; + workspace.set_scrolling_columns(columns); + + for id in first..first + count { + let mut container = Container::default(); + container.add_window(&Window::from(id)).unwrap(); + let idx = workspace.containers().len(); + workspace.insert_container_at_idx(idx, container); + } + + workspace + } + + fn ids(workspace: &Workspace) -> Vec { + workspace + .containers() + .iter() + .filter_map(|container| container.focused_window().map(|window| window.id)) + .collect() + } + + #[test] + fn opening_a_window_does_not_resize_the_others() { + let mut workspace = workspace(3, 1); + let before = workspace.layout_scrolling(&AREA, 0)[0].rect.right; + + for id in 2..=5 { + let mut container = Container::default(); + container.add_window(&Window::from(id)).unwrap(); + let idx = workspace + .scrolling_insertion_idx() + .unwrap_or_else(|| workspace.containers().len()); + workspace.insert_container_at_idx(idx, container); + } + + let placements = workspace.layout_scrolling(&AREA, 0); + + assert_eq!(placements.len(), 5); + assert!(placements.iter().all(|p| p.rect.right == before)); + assert_eq!(before, AREA.right / 3); + } + + #[test] + fn consuming_puts_two_windows_in_one_column_and_expelling_takes_one_out() { + let mut workspace = workspace(3, 3); + workspace.layout_scrolling(&AREA, 0); + workspace.focus_container(0); + + workspace.consume_scrolling(OperationDirection::Right); + let placements = workspace.layout_scrolling(&AREA, 0); + + // the first two windows are one above the other, in the same column + assert_eq!(placements[0].rect.left, placements[1].rect.left); + assert!(placements[1].rect.top > placements[0].rect.top); + assert_ne!(placements[1].rect.left, placements[2].rect.left); + assert_eq!(ids(&workspace), vec![1, 2, 3]); + + workspace.expel_scrolling(); + let placements = workspace.layout_scrolling(&AREA, 0); + assert_ne!(placements[0].rect.left, placements[1].rect.left); + } + + #[test] + fn a_column_moves_whole_and_the_containers_follow_its_order() { + let mut workspace = workspace(3, 3); + workspace.layout_scrolling(&AREA, 0); + workspace.focus_container(0); + + assert!(workspace.move_scrolling(OperationDirection::Right)); + assert_eq!(ids(&workspace), vec![2, 1, 3]); + assert_eq!(workspace.focused_container_idx(), 1); + } + + #[test] + fn the_commands_do_nothing_in_a_layout_that_is_not_scrolling() { + let mut workspace = workspace(3, 3); + workspace.layout_scrolling(&AREA, 0); + workspace.layout = Layout::Default(DefaultLayout::BSP); + + assert!(!workspace.move_scrolling(OperationDirection::Right)); + workspace.expel_scrolling(); + assert_eq!(ids(&workspace), vec![1, 2, 3]); + } + + #[test] + fn a_window_out_of_view_rests_at_the_edge_that_it_left_by() { + let rect = Rect { + left: -500, + top: 50, + right: 400, + bottom: 800, + }; + + let left = parked(rect, Visibility::OffLeft, &AREA); + assert_eq!(left.left + left.right, AREA.left + PARKED_VISIBLE_PIXELS); + assert_eq!(left.right, 400); + + let right = parked(rect, Visibility::OffRight, &AREA); + assert_eq!(right.left, AREA.left + AREA.right - PARKED_VISIBLE_PIXELS); + + assert_eq!(parked(rect, Visibility::Visible, &AREA), rect); + } + + #[test] + fn retiling_forgets_what_windows_were_found_to_refuse() { + let mut workspace = workspace(3, 3); + workspace.layout_scrolling(&AREA, 0); + + let id = workspace.containers().iter().next().unwrap().id.clone(); + let space = workspace.scrolling.as_mut().unwrap(); + space.set_width_limits(&id, Some(900), None); + assert_eq!(workspace.layout_scrolling(&AREA, 0)[0].rect.right, 900); + + workspace.reset_scrolling_sizes(); + + assert_eq!( + workspace.layout_scrolling(&AREA, 0)[0].rect.right, + AREA.right / 3 + ); + } + + fn monitor(left: i32) -> Rect { + Rect { + left, + top: 0, + right: 1400, + bottom: 900, + } + } + + #[test] + fn the_monitor_with_the_work_area_is_not_one_of_the_others() { + let monitors = [monitor(-1400), monitor(0), monitor(1400)]; + let others = other_monitors(&AREA, &monitors); + + assert_eq!(others, vec![monitor(-1400), monitor(1400)]); + } + + #[test] + fn a_window_that_would_be_seen_on_a_neighbouring_monitor_is_hidden_or_made_narrower() { + let work_area = Rect { + left: 0, + top: 0, + right: 1400, + bottom: 900, + }; + let others = [monitor(1400)]; + let wide = Rect { + left: -1000, + top: 0, + right: 400, + bottom: 900, + }; + + // out to the left, with nothing there, it rests at the edge + let (rect, hidden) = rest(wide, Visibility::OffLeft, &work_area, &others); + assert!(!hidden); + assert_eq!(rect.left + rect.right, PARKED_VISIBLE_PIXELS); + + // out to the right, where there is a monitor, it is hidden + let off_right = Rect { left: 2000, ..wide }; + let (_, hidden) = rest(off_right, Visibility::OffRight, &work_area, &others); + assert!(hidden); + + // partly in view at the right edge it is cut down to the work area + let clipped = Rect { left: 1200, ..wide }; + let (rect, hidden) = rest(clipped, Visibility::Clipped, &work_area, &others); + assert!(!hidden); + assert_eq!(rect.left + rect.right, 1400); + + // and without a monitor there it is left as it is + let (rect, _) = rest(clipped, Visibility::Clipped, &work_area, &[]); + assert_eq!(rect, clipped); + } + + #[test] + fn only_the_window_at_the_front_of_a_stack_is_shown_when_it_is_placed() { + let hidden: HashSet = [10, 11].into_iter().collect(); + + // the one at the front, if it was hidden, is shown + assert!(should_restore_when_placed(10, Some(10), &hidden)); + // the one at the back stays hidden, whatever is hidden + assert!(!should_restore_when_placed(11, Some(10), &hidden)); + // one that is not hidden has nothing to be shown from + assert!(!should_restore_when_placed(12, Some(12), &hidden)); + // and a container with no window in front shows none + assert!(!should_restore_when_placed(10, None, &hidden)); + } + + fn lefts(placements: &[ScrollingPlacement]) -> Vec { + placements.iter().map(|p| p.rect.left).collect() + } + + #[test] + fn stacking_a_column_and_spreading_it_out_again_leaves_everything_where_it_was() { + // three columns fit across, and the strip is exactly as wide as the screen, as in a + // column of two windows that is the last of three + let mut workspace = workspace(3, 4); + workspace.layout_scrolling(&AREA, 0); + workspace.focus_container(0); + workspace.layout_scrolling(&AREA, 0); + workspace.focus_container(2); + workspace.layout_scrolling(&AREA, 0); + workspace.consume_scrolling(OperationDirection::Right); + + let before = lefts(&workspace.layout_scrolling(&AREA, 0)); + let focused = workspace.focused_container_idx(); + + assert!(workspace.toggle_scrolling_column_stack().unwrap()); + assert_eq!(workspace.containers().len(), 3); + workspace.layout_scrolling(&AREA, 0); + + assert!(workspace.toggle_scrolling_column_stack().unwrap()); + assert_eq!(workspace.containers().len(), 4); + + assert_eq!(lefts(&workspace.layout_scrolling(&AREA, 0)), before); + assert_eq!(workspace.focused_container_idx(), focused); + } + + #[test] + fn every_window_of_a_stack_that_is_spread_out_is_shown() { + // handles of their own, since the list of what is hidden is shared with every test + let mut workspace = workspace_from(3, 9001, 3); + workspace.layout_scrolling(&AREA, 0); + workspace.focus_container(1); + workspace.consume_scrolling(OperationDirection::Right); + workspace.layout_scrolling(&AREA, 0); + + assert!(workspace.toggle_scrolling_column_stack().unwrap()); + + // the tab at the back is the one that is hidden, whichever of the two it is + for (focused, hidden) in [(1, 9002), (0, 9003)] { + let idx = workspace.focused_container_idx(); + let container = workspace.containers_mut().get_mut(idx).unwrap(); + container.focus_window(focused); + container.load_focused_window().unwrap(); + assert!(HIDDEN_WINDOWS.lock().contains(&hidden)); + + assert!(workspace.toggle_scrolling_column_stack().unwrap()); + assert_eq!(workspace.containers().len(), 3); + assert!( + !HIDDEN_WINDOWS.lock().contains(&hidden), + "window {hidden} was left hidden by spreading the stack out" + ); + + assert!(workspace.toggle_scrolling_column_stack().unwrap()); + } + } + + #[test] + fn expanding_fills_the_room_that_the_columns_in_view_leave() { + let mut workspace = workspace(3, 2); + workspace.layout_scrolling(&AREA, 0); + workspace.focus_container(1); + let before = workspace.layout_scrolling(&AREA, 0); + + workspace.expand_scrolling_column(); + let after = workspace.layout_scrolling(&AREA, 0); + + // the focused column takes the third of the screen that was empty, and no more + let width = |placements: &[ScrollingPlacement], idx: usize| { + placements[idx].rect.right - placements[idx].rect.left + }; + assert!(width(&after, 1) > width(&before, 1) + AREA.right / 4); + assert_eq!(after[1].rect.right, before[1].rect.right + (AREA.right / 3)); + assert_eq!(after[0], before[0]); + } + + #[test] + fn expanding_does_nothing_where_there_are_no_columns() { + let mut workspace = Workspace { + layout: Layout::Default(DefaultLayout::BSP), + ..Workspace::default() + }; + let mut container = Container::default(); + container.add_window(&Window::from(9101)).unwrap(); + workspace.insert_container_at_idx(0, container); + + workspace.expand_scrolling_column(); + + assert!(workspace.scrolling.is_none()); + } + + #[test] + fn panning_moves_the_strip_and_leaves_focus_where_it_is_until_it_is_let_go() { + let mut workspace = workspace(3, 6); + workspace.layout_scrolling(&AREA, 0); + workspace.focus_container(0); + let before = workspace.layout_scrolling(&AREA, 0); + + assert!(workspace.begin_scrolling_pan()); + workspace.pan_scrolling(-300.0); + let panned = workspace.layout_scrolling(&AREA, 0); + + assert_eq!( + panned[1].rect.left, + before[1].rect.left - 300, + "the strip moved" + ); + assert_eq!(workspace.focused_container_idx(), 0, "and focus did not"); + } + + #[test] + fn a_strip_that_is_let_go_comes_to_rest_and_focus_goes_to_a_column_in_view() { + let mut workspace = workspace(3, 6); + workspace.layout_scrolling(&AREA, 0); + workspace.focus_container(0); + + assert!(workspace.begin_scrolling_pan()); + // three columns to the left of the screen, which is where the first three are + workspace.pan_scrolling(-1200.0); + assert!(workspace.end_scrolling_pan(0.0), "focus moved"); + + let rested = workspace.layout_scrolling(&AREA, 0); + let focused = workspace.focused_container_idx(); + + assert!(focused >= 3, "to a column that is in view, not {focused}"); + assert_eq!(rested[focused].visibility, Visibility::Visible); + } + + #[test] + fn there_is_no_strip_to_pan_in_another_layout_or_before_there_is_one() { + let mut workspace = workspace(3, 6); + assert!(!workspace.begin_scrolling_pan(), "no layout yet"); + assert!(!workspace.end_scrolling_pan(0.0)); + + workspace.layout_scrolling(&AREA, 0); + workspace.layout = Layout::Default(DefaultLayout::BSP); + assert!(!workspace.begin_scrolling_pan()); + } + + #[test] + fn a_step_of_a_pan_is_a_column_of_the_width_that_a_new_one_has() { + let mut workspace = workspace(3, 6); + assert_eq!(workspace.scrolling_pan_step(), None, "no layout yet"); + + workspace.layout_scrolling(&AREA, 0); + assert_eq!(workspace.scrolling_pan_step(), Some(400)); + + workspace.set_scrolling_columns(4); + workspace.layout_scrolling(&AREA, 0); + assert_eq!(workspace.scrolling_pan_step(), Some(300)); + } + + /// Send each window of the workspace to where the strip puts it, as an update does, and say + /// that the windows were placed + fn place_windows(workspace: &mut Workspace) { + let placements = workspace.layout_scrolling(&AREA, 0); + let widths: Vec<_> = workspace + .containers() + .iter() + .zip(&placements) + .map(|(container, placement)| { + container + .focused_window() + .unwrap() + .set_position(&placement.rect) + .unwrap(); + (container.id.clone(), placement.rect.right) + }) + .collect(); + + workspace.scrolling_placed = Some(Placed::new(widths)); + } + + #[test] + fn a_window_that_will_not_get_as_narrow_as_its_column_widens_the_column() { + let fake = Arc::new(FakeBackend::with_one_monitor()); + + with_backend(fake.clone(), || { + for id in 9201..=9203 { + fake.open_window(id, "foot", "foot", &format!("window {id}")); + } + fake.set_min_size(9202, 900, 100); + + let mut workspace = workspace_from(3, 9201, 3); + place_windows(&mut workspace); + let before = workspace.layout_scrolling(&AREA, 0); + assert!( + before[2].rect.left < before[1].rect.left + 900, + "the window covers the one after it until the layout learns" + ); + + assert!(workspace.adopt_actual_widths(0, AREA.right)); + let after = workspace.layout_scrolling(&AREA, 0); + assert_eq!(after[1].rect.right, 900); + assert_eq!(after[2].rect.left, after[1].rect.left + 900); + + // what is known is not learned again, and retiling forgets it + place_windows(&mut workspace); + assert!(!workspace.adopt_actual_widths(0, AREA.right)); + workspace.reset_scrolling_sizes(); + assert_eq!( + workspace.layout_scrolling(&AREA, 0)[1].rect.right, + AREA.right / 3 + ); + }); + } + + #[test] + fn what_a_window_that_is_hidden_or_a_little_over_its_column_says_is_not_taken_for_a_minimum() { + let fake = Arc::new(FakeBackend::with_one_monitor()); + + with_backend(fake.clone(), || { + for id in 9211..=9213 { + fake.open_window(id, "foot", "foot", &format!("window {id}")); + } + // a terminal rounds to whole cells, which is a few pixels over + fake.set_min_size(9211, 400 + 10, 100); + // and a window that was hidden was never where it was sent + fake.set_min_size(9212, 1000, 100); + + let mut workspace = workspace_from(3, 9211, 3); + place_windows(&mut workspace); + Window::from(9212).hide(); + + assert!(!workspace.adopt_actual_widths(0, AREA.right)); + Window::from(9212).restore(); + }); + } + + #[test] + fn a_container_that_was_not_there_when_the_windows_were_placed_is_left_alone() { + let fake = Arc::new(FakeBackend::with_one_monitor()); + + with_backend(fake.clone(), || { + for id in 9221..=9222 { + fake.open_window(id, "foot", "foot", &format!("window {id}")); + } + fake.set_min_size(9222, 1000, 100); + + let mut workspace = workspace_from(3, 9221, 2); + place_windows(&mut workspace); + workspace.scrolling_placed = Some(Placed::new([])); + + assert!(!workspace.adopt_actual_widths(0, AREA.right)); + }); + } + + #[test] + fn the_windows_have_not_settled_just_after_they_were_placed() { + assert!(!Placed::new([]).has_settled()); + } +} diff --git a/komorebi/src/state.rs b/komorebi/src/state.rs index 4fbbd3e2..b22229fe 100644 --- a/komorebi/src/state.rs +++ b/komorebi/src/state.rs @@ -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::>(); ws.focus(monitor.workspaces.focused_idx()); diff --git a/komorebi/src/window.rs b/komorebi/src/window.rs index bddeb8a3..6c3a7f60 100644 --- a/komorebi/src/window.rs +++ b/komorebi/src/window.rs @@ -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) } diff --git a/komorebi/src/window_manager.rs b/komorebi/src/window_manager.rs index b90bc648..3faaf05a 100644 --- a/komorebi/src/window_manager.rs +++ b/komorebi/src/window_manager.rs @@ -120,6 +120,9 @@ pub struct LiveResize { pub resize_dimensions: Vec>, /// The workspace's [`Workspace::latest_layout`] when the drag began pub latest_layout: Vec, + /// 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>, /// 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()) diff --git a/komorebi/src/workspace.rs b/komorebi/src/workspace.rs index cc4eb972..cb1dda4c 100644 --- a/komorebi/src/workspace.rs +++ b/komorebi/src/workspace.rs @@ -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, + /// 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>, + /// 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, } /// 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 { + pub(crate) fn effective_layout_options(&self) -> Option { 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 { + // 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 { + // 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 = 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; diff --git a/komorebi/tests/layout_options.rs b/komorebi/tests/layout_options.rs index 1e5626fd..01c5b59e 100644 --- a/komorebi/tests/layout_options.rs +++ b/komorebi/tests/layout_options.rs @@ -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 = (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 = (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, diff --git a/komorebi/tests/scrolling.rs b/komorebi/tests/scrolling.rs index 685d111b..8a52ab50 100644 --- a/komorebi/tests/scrolling.rs +++ b/komorebi/tests/scrolling.rs @@ -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, 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, 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, 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:?}" + ); +} diff --git a/komorebic/src/main.rs b/komorebic/src/main.rs index e8262017..1f6da9c1 100644 --- a/komorebic/src/main.rs +++ b/komorebic/src/main.rs @@ -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!( diff --git a/komorebic/tests/cli.rs b/komorebic/tests/cli.rs index 1b942386..8dc9c1a1 100644 --- a/komorebic/tests/cli.rs +++ b/komorebic/tests/cli.rs @@ -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",