diff --git a/Cargo.lock b/Cargo.lock index f1d0d580..f53e00a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3446,6 +3446,7 @@ version = "0.1.42" dependencies = [ "clap", "color-eyre", + "komorebi-scrolling", "objc2-core-foundation", "schemars 1.2.3", "serde", @@ -3458,7 +3459,7 @@ dependencies = [ [[package]] name = "komorebi-scrolling" -version = "0.1.0" +version = "0.1.42" [[package]] name = "komorebi-shortcuts" diff --git a/komorebi-layouts/Cargo.toml b/komorebi-layouts/Cargo.toml index 95d53191..61e25e98 100644 --- a/komorebi-layouts/Cargo.toml +++ b/komorebi-layouts/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.42" edition = "2024" [dependencies] +komorebi-scrolling = { path = "../komorebi-scrolling" } clap = { workspace = true } color-eyre = { workspace = true } serde = { workspace = true } diff --git a/komorebi-layouts/src/arrangement.rs b/komorebi-layouts/src/arrangement.rs index ea895d21..00175b18 100644 --- a/komorebi-layouts/src/arrangement.rs +++ b/komorebi-layouts/src/arrangement.rs @@ -78,8 +78,10 @@ impl Arrangement for DefaultLayout { let keep_centered = layout_options .as_ref() .and_then(|o| { - o.scrolling - .map(|s| s.center_focused_column.unwrap_or_default()) + o.scrolling.map(|s| { + s.center_focused_column.unwrap_or_default() + == crate::scrolling::ScrollingCentering::Always + }) }) .unwrap_or(false); diff --git a/komorebi-layouts/src/default_layout.rs b/komorebi-layouts/src/default_layout.rs index cb4491eb..53769b97 100644 --- a/komorebi-layouts/src/default_layout.rs +++ b/komorebi-layouts/src/default_layout.rs @@ -234,14 +234,33 @@ pub struct LayoutOptions { pub row_ratios: Option<[Option; MAX_RATIOS]>, } +/// A centring is one of its names, or a boolean, which is what it used to be +#[cfg(feature = "schemars")] +fn centering_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "description": "When to centre the focused column: `Never` (the default), `Always`, or +`OnOverflow`, which is when it does not fit on the screen together with the column that +focus came from. A boolean is also accepted, where `true` is `Always` and `false` is `Never`", + "anyOf": [ + { "type": "boolean" }, + generator.subschema_for::(), + { "type": "null" } + ] + }) +} + #[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] /// Options for the Scrolling layout pub struct ScrollingLayoutOptions { /// Desired number of visible columns (default: 3) pub columns: usize, - /// With an odd number of visible columns, keep the focused window column centered - pub center_focused_column: Option, + /// When to centre the focused column: `Never` (the default), `Always`, or `OnOverflow`, which + /// is when it does not fit on the screen together with the column that focus came from. A + /// boolean is also accepted, where `true` is `Always` and `false` is `Never` + #[serde(default)] + #[cfg_attr(feature = "schemars", schemars(schema_with = "centering_schema"))] + pub center_focused_column: Option, } #[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)] diff --git a/komorebi-layouts/src/default_layout_tests.rs b/komorebi-layouts/src/default_layout_tests.rs index a94619a7..2e0addbf 100644 --- a/komorebi-layouts/src/default_layout_tests.rs +++ b/komorebi-layouts/src/default_layout_tests.rs @@ -419,7 +419,10 @@ mod layout_options_rules_tests { let row = opts.row_ratios.unwrap(); assert_eq!(row[0], Some(0.5)); assert_eq!(opts.scrolling.unwrap().columns, 4); - assert_eq!(opts.scrolling.unwrap().center_focused_column, Some(true)); + assert_eq!( + opts.scrolling.unwrap().center_focused_column, + Some(crate::scrolling::ScrollingCentering::Always) + ); assert_eq!(opts.grid.unwrap().rows, 2); } @@ -614,7 +617,10 @@ mod layout_default_entry_tests { let rules = entry.layout_options_rules.unwrap(); let r4 = &rules[&4]; assert_eq!(r4.scrolling.unwrap().columns, 5); - assert_eq!(r4.scrolling.unwrap().center_focused_column, Some(true)); + assert_eq!( + r4.scrolling.unwrap().center_focused_column, + Some(crate::scrolling::ScrollingCentering::Always) + ); // Rule doesn't inherit base fields - full replacement assert!(r4.column_ratios.is_none()); assert!(r4.grid.is_none()); diff --git a/komorebi-layouts/src/lib.rs b/komorebi-layouts/src/lib.rs index f9873d91..8010d3dc 100644 --- a/komorebi-layouts/src/lib.rs +++ b/komorebi-layouts/src/lib.rs @@ -16,6 +16,7 @@ pub mod direction; pub mod layout; pub mod operation_direction; pub mod rect; +pub mod scrolling; pub mod sizing; pub use arrangement::*; diff --git a/komorebi-layouts/src/scrolling.rs b/komorebi-layouts/src/scrolling.rs new file mode 100644 index 00000000..192aa101 --- /dev/null +++ b/komorebi-layouts/src/scrolling.rs @@ -0,0 +1,409 @@ +//! The platform-free half of komorebi's Scrolling layout, shared by every komorebi. +//! +//! The strip of columns itself is the `komorebi-scrolling` crate, which has no idea what a window +//! is, and this module is what is left of the glue between it and a window manager once everything +//! that touches an operating system is taken out: what the configuration and the layout options +//! come to for the strip, where the strip says that every container goes, and how much of a +//! resize becomes how much of a column. +//! +//! A host owns a [`ScrollingSpace`] per workspace, keyed by the id of each container, and asks +//! this module the questions that do not depend on the platform. How it carries out the answers +//! (animating, hiding, parking at an edge) is its own business. + +use crate::LayoutOptions; +use crate::OperationDirection; +use crate::Rect; +use crate::Sizing; +use komorebi_scrolling::CenterFocusedColumn; +use komorebi_scrolling::ColumnRecord; +use komorebi_scrolling::ColumnWidth; +use komorebi_scrolling::Options; +use komorebi_scrolling::ScrollingSpace; +use komorebi_scrolling::Viewport; +use komorebi_scrolling::Visibility; +use serde::Deserialize; +use serde::Serialize; +use std::sync::LazyLock; +use std::sync::Mutex; + +/// The model of a strip of columns, for hosts that depend on this crate and not on the model's. +pub use komorebi_scrolling as model; + +/// How many columns fit in the viewport when nothing says otherwise. +pub const DEFAULT_COLUMNS: usize = 3; + +/// The narrowest that a column can be made to be by resizing it. +pub const MINIMUM_COLUMN_WIDTH: i32 = 100; + +/// How short a window in a column of several can be made. +pub const MINIMUM_WINDOW_HEIGHT: i32 = 100; + +/// When the Scrolling layout centres the focused column +/// +/// A boolean is also accepted when reading, for the layout option that used to be one: `true` is +/// `Always` and `false` is `Never`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum ScrollingCentering { + /// Scroll just far enough to bring the focused column into view, and no further + #[default] + Never, + /// Keep the focused column in the middle of the screen + Always, + /// Centre the focused column when it does not fit on the screen together with the column that + /// focus came from + OnOverflow, +} + +impl<'de> Deserialize<'de> for ScrollingCentering { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Repr { + Flag(bool), + Name(String), + } + + match Repr::deserialize(deserializer)? { + Repr::Flag(true) => Ok(Self::Always), + Repr::Flag(false) => Ok(Self::Never), + Repr::Name(name) => match name.as_str() { + "Never" => Ok(Self::Never), + "Always" => Ok(Self::Always), + "OnOverflow" => Ok(Self::OnOverflow), + _ => Err(serde::de::Error::unknown_variant( + &name, + &["Never", "Always", "OnOverflow"], + )), + }, + } + } +} + +impl From for CenterFocusedColumn { + fn from(centering: ScrollingCentering) -> Self { + match centering { + ScrollingCentering::Never => Self::Never, + ScrollingCentering::Always => Self::Always, + ScrollingCentering::OnOverflow => Self::OnOverflow, + } + } +} + +/// What the layout options of a workspace come to for the model. +/// +/// `columns` says how many columns fit in the viewport, which is to say that a new column is a +/// `columns`th of its width. It does not say how many columns there are, and it does not stretch +/// a lone column to fill the viewport. +#[must_use] +pub fn scrolling_options(layout_options: Option) -> Options { + let scrolling = layout_options.and_then(|options| options.scrolling); + let columns = scrolling + .map_or(DEFAULT_COLUMNS, |scrolling| scrolling.columns) + .max(1); + let centering = scrolling + .and_then(|scrolling| scrolling.center_focused_column) + .unwrap_or_default(); + + Options { + default_width: ColumnWidth::Proportion(1.0 / columns as f64), + center_focused_column: centering.into(), + minimum_tile_height: MINIMUM_WINDOW_HEIGHT, + ..Options::default() + } +} + +/// Where a container goes, and how much of it is in view. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScrollingPlacement { + /// Where the container is in the strip, in the coordinates of the screen. A container that is + /// partly or wholly out of view has the rectangle that it really has, which reaches past the + /// work area. + pub rect: Rect, + /// How much of the container is in view. + pub visibility: Visibility, +} + +/// Make a space match the containers and the focus as they are now, creating it if there is none, +/// and with the options and the viewport that it should have. +pub fn sync( + space: &mut Option>, + options: Options, + viewport: Viewport, + ids: &[String], + focused: Option<&String>, +) { + let space = space.get_or_insert_with(|| ScrollingSpace::new(options.clone(), viewport)); + + if *space.options() != options { + space.set_options(options); + } + + if space.viewport() != viewport { + space.set_viewport(viewport); + } + + tell_of_arrivals(space, ids); + space.reconcile(ids, focused); +} + +/// Where every container goes in `work_area`, from left to right, with `container_padding` +/// taken off every side of each. +#[must_use] +pub fn place( + space: &ScrollingSpace, + work_area: &Rect, + container_padding: i32, +) -> Vec { + space + .layout() + .into_iter() + .map(|placement| { + let mut rect = Rect { + left: work_area.left + placement.rect.x, + top: work_area.top + placement.rect.y, + right: placement.rect.width, + bottom: placement.rect.height, + }; + rect.add_padding(container_padding); + + ScrollingPlacement { + rect, + visibility: placement.visibility, + } + }) + .collect() +} + +/// The viewport that is the size of a work area. +#[must_use] +pub const fn viewport_of(work_area: &Rect) -> Viewport { + Viewport { + width: work_area.right, + height: work_area.bottom, + } +} + +/// 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. A window that is alone in its column +/// is as tall as the screen, so resizing it vertically does nothing. +/// +/// The layouts that are made of splits move one edge of a container by half of `delta`, and +/// callers that want to move both, or to follow a mouse, rely on that, so a column changes +/// width, or a window height, by half of `delta` per edge too. +pub fn resize( + space: &mut ScrollingSpace, + id: &String, + direction: OperationDirection, + sizing: Sizing, + delta: i32, +) { + let change = f64::from(delta) / 2.0; + let change = match sizing { + Sizing::Increase => change, + Sizing::Decrease => -change, + }; + + if matches!(direction, OperationDirection::Up | OperationDirection::Down) { + space.resize_tile_height(id, change); + return; + } + + let Some(width) = space.column_width(id) else { + return; + }; + + let width = ((width + change).round() as i32).max(MINIMUM_COLUMN_WIDTH); + space.set_width(id, ColumnWidth::Fixed(width)); +} + +/// How many containers that were taken out of a workspace are waited for at another one. +const MAXIMUM_DEPARTED: usize = 32; + +/// What the columns of the containers that were taken out of a workspace were given, until they +/// are put into another, whose strip has never heard of them. +/// +/// A container goes from one workspace to another by being taken out of one and put into the +/// other, with nothing in between that both could be asked, so this is where it leaves what it +/// was given for the other to find. +static DEPARTED: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); + +/// Say what a container that is leaving a workspace was given. +pub fn note_departure(id: &str, record: ColumnRecord) { + let Ok(mut departed) = DEPARTED.lock() else { + return; + }; + + departed.retain(|(container, _)| container != id); + departed.push((id.to_string(), record)); + + if departed.len() > MAXIMUM_DEPARTED { + departed.remove(0); + } +} + +/// What a container that arrives was given where it was, if it came from a strip. +#[must_use] +pub fn take_departed(id: &str) -> Option { + let mut departed = DEPARTED.lock().ok()?; + let at = departed.iter().position(|(container, _)| container == id)?; + + Some(departed.remove(at).1) +} + +/// Tell a strip what the containers that it has not seen before were given where they came from, +/// before it is made to match the list of containers. +pub fn tell_of_arrivals(space: &mut ScrollingSpace, ids: &[String]) { + for id in ids { + if space.position(id).is_none() + && let Some(record) = take_departed(id) + { + space.arrive(id.clone(), record); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ScrollingLayoutOptions; + + fn options( + columns: usize, + center_focused_column: Option, + ) -> Option { + Some(LayoutOptions { + scrolling: Some(ScrollingLayoutOptions { + columns, + center_focused_column, + }), + grid: None, + column_ratios: None, + row_ratios: None, + }) + } + + #[test] + fn a_column_is_a_share_of_the_viewport_as_wide_as_the_columns_option_says() { + for columns in [1, 2, 3, 5] { + assert_eq!( + scrolling_options(options(columns, None)).default_width, + ColumnWidth::Proportion(1.0 / columns as f64) + ); + } + + assert_eq!( + scrolling_options(None).default_width, + ColumnWidth::Proportion(1.0 / DEFAULT_COLUMNS as f64) + ); + } + + #[test] + fn centring_is_what_the_layout_option_says_and_never_by_default() { + for (given, expected) in [ + (None, CenterFocusedColumn::Never), + (Some(ScrollingCentering::Never), CenterFocusedColumn::Never), + ( + Some(ScrollingCentering::Always), + CenterFocusedColumn::Always, + ), + ( + Some(ScrollingCentering::OnOverflow), + CenterFocusedColumn::OnOverflow, + ), + ] { + assert_eq!( + scrolling_options(options(3, given)).center_focused_column, + expected + ); + } + } + + #[test] + fn centring_reads_the_booleans_that_it_used_to_be() { + let read = |json: &str| serde_json::from_str::(json).unwrap(); + + assert_eq!(read("true"), ScrollingCentering::Always); + assert_eq!(read("false"), ScrollingCentering::Never); + assert_eq!(read("\"Never\""), ScrollingCentering::Never); + assert_eq!(read("\"Always\""), ScrollingCentering::Always); + assert_eq!(read("\"OnOverflow\""), ScrollingCentering::OnOverflow); + assert!(serde_json::from_str::("\"Sometimes\"").is_err()); + assert!(serde_json::from_str::("3").is_err()); + } + + #[test] + fn centring_is_written_as_its_name() { + assert_eq!( + serde_json::to_string(&ScrollingCentering::OnOverflow).unwrap(), + "\"OnOverflow\"" + ); + } + + #[test] + fn containers_are_placed_inside_the_work_area_with_their_padding() { + let work_area = Rect { + left: 100, + top: 50, + right: 1200, + bottom: 800, + }; + let ids: Vec = ["a", "b"].iter().map(ToString::to_string).collect(); + let mut space = None; + + sync( + &mut space, + scrolling_options(options(3, None)), + viewport_of(&work_area), + &ids, + ids.first(), + ); + + let placements = place(space.as_ref().unwrap(), &work_area, 10); + + assert_eq!(placements.len(), 2); + assert_eq!(placements[0].rect.left, 110); + assert_eq!(placements[0].rect.top, 60); + assert_eq!(placements[0].rect.right, 400 - 20); + assert_eq!(placements[0].rect.bottom, 800 - 20); + } + + #[test] + fn resizing_changes_the_width_by_half_of_the_delta_and_never_below_the_minimum() { + let work_area = Rect { + left: 0, + top: 0, + right: 1200, + bottom: 800, + }; + let ids = vec!["a".to_string()]; + let mut space = None; + + sync( + &mut space, + scrolling_options(options(3, None)), + viewport_of(&work_area), + &ids, + ids.first(), + ); + + let space = space.as_mut().unwrap(); + let id = ids[0].clone(); + + resize(space, &id, OperationDirection::Right, Sizing::Increase, 100); + assert_eq!(space.column_width(&id), Some(450.0)); + + resize( + space, + &id, + OperationDirection::Left, + Sizing::Decrease, + 10_000, + ); + assert_eq!( + space.column_width(&id), + Some(f64::from(MINIMUM_COLUMN_WIDTH)) + ); + } +} diff --git a/komorebi-scrolling/Cargo.toml b/komorebi-scrolling/Cargo.toml index 7e70ad9a..ff518ed0 100644 --- a/komorebi-scrolling/Cargo.toml +++ b/komorebi-scrolling/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "komorebi-scrolling" -version = "0.1.0" +version = "0.1.42" description = "A platform-free model of a scrollable strip of columns" edition = "2024" diff --git a/komorebi/src/scrolling.rs b/komorebi/src/scrolling.rs index 78beff65..6f3164aa 100644 --- a/komorebi/src/scrolling.rs +++ b/komorebi/src/scrolling.rs @@ -1,54 +1,28 @@ -//! The glue between a [`Workspace`] and the platform-free model of a strip of columns in -//! `komorebi-scrolling`. +//! The glue between a [`Workspace`] and the strip of columns of the Scrolling layout. //! -//! A container is a tile in the strip, and is known to it by the id of the container. +//! Everything that does not touch Windows 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. +use crate::container::Container; use crate::core::DefaultLayout; use crate::core::Layout; use crate::core::LayoutOptions; +use crate::core::OperationDirection; use crate::core::Rect; +use crate::core::ScrollingLayoutOptions; +use crate::core::Sizing; use crate::workspace::Workspace; -use komorebi_scrolling::CenterFocusedColumn; -use komorebi_scrolling::ColumnWidth; +use color_eyre::eyre; +use komorebi_layouts::scrolling; +use komorebi_layouts::scrolling::ScrollingPlacement; use komorebi_scrolling::Options; -use komorebi_scrolling::ScrollingSpace; -use komorebi_scrolling::Viewport; use komorebi_scrolling::Visibility; - -/// How many columns fit in the viewport when nothing says otherwise. -const DEFAULT_COLUMNS: usize = 3; - -/// Where a container goes, and how much of it is in view. -#[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) struct ScrollingPlacement { - /// Where the container is in the strip, in the coordinates of the screen. A container that is - /// partly or wholly out of view has the rectangle that it really has, which reaches past the - /// work area. - pub rect: Rect, - #[allow(dead_code)] - pub visibility: Visibility, -} +use std::sync::atomic::Ordering; /// What the layout options of a workspace come to for the model. -/// -/// `columns` says how many columns fit in the viewport, which is to say that a new column is a -/// `columns`th of its width. It does not say how many columns there are, and it does not stretch -/// a lone column to fill the viewport. pub(crate) fn scrolling_options(layout_options: Option) -> Options { - let scrolling = layout_options.and_then(|options| options.scrolling); - let columns = scrolling - .map_or(DEFAULT_COLUMNS, |scrolling| scrolling.columns) - .max(1); - let centering = match scrolling.and_then(|scrolling| scrolling.center_focused_column) { - Some(true) => CenterFocusedColumn::Always, - _ => CenterFocusedColumn::Never, - }; - - Options { - default_width: ColumnWidth::Proportion(1.0 / columns as f64), - center_focused_column: centering, - ..Options::default() - } + scrolling::scrolling_options(layout_options) } impl Workspace { @@ -57,31 +31,104 @@ impl Workspace { matches!(self.layout, Layout::Default(DefaultLayout::Scrolling)) } - /// Make the space match the containers, and the focus, as they are now. - fn sync_scrolling(&mut self, viewport: Viewport) { - let options = scrolling_options(self.effective_layout_options()); - let ids: Vec = self - .containers() + fn scrolling_ids(&self) -> Vec { + self.containers() .iter() .map(|container| container.id.clone()) - .collect(); + .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()); - let space = self - .scrolling - .get_or_insert_with(|| ScrollingSpace::new(options.clone(), viewport)); + scrolling::sync( + &mut self.scrolling, + options, + viewport, + &ids, + focused.as_ref(), + ); + } - if *space.options() != options { - space.set_options(options); + /// 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; } - if space.viewport() != viewport { - space.set_viewport(viewport); + // 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); } - space.reconcile(&ids, focused.as_ref()); + true } /// Where every container goes in `work_area`, from left to right, with `container_padding` @@ -91,32 +138,380 @@ impl Workspace { work_area: &Rect, container_padding: i32, ) -> Vec { - self.sync_scrolling(Viewport { - width: work_area.right, - height: work_area.bottom, - }); + self.sync_scrolling(scrolling::viewport_of(work_area)); - let Some(space) = &self.scrolling else { - return vec![]; + 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(), }; - space - .layout() - .into_iter() - .map(|placement| { - let mut rect = Rect { - left: work_area.left + placement.rect.x, - top: work_area.top + placement.rect.y, - right: placement.rect.width, - bottom: placement.rect.height, - }; - rect.add_padding(container_padding); + if moved { + probe.active_tile_index() + } else { + None + } + } - ScrollingPlacement { - rect, - visibility: placement.visibility, + /// 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, + } } - }) - .collect() + 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); + } + + Ok(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]) { + let focused_window = self + .focused_container() + .and_then(Container::focused_window) + .map(|window| window.hwnd); + + 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; + }; + + 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); + } + + if !cfg!(test) { + container.load_focused_window(); + } + } + + self.focus_container(idx); + } + + /// 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) -> bool { + let idx = self.focused_container_idx(); + + let Some(container) = self.containers_mut().get_mut(idx) else { + return false; + }; + + if container.windows().len() < 2 { + return false; + } + + let focused = container.focused_window().map(|window| window.hwnd); + 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 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 + if !cfg!(test) { + container.restore(); + } + + self.insert_container_at_idx(idx + 1 + offset, container); + count += 1; + } + + 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); + + 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 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(); + } + } + + /// 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(); + } + } + + /// 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); + } } }