From 6c4eb621d3df8fc479658b1fa1d7057015d54317 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Tue, 27 Oct 2026 22:36:25 -0700 Subject: [PATCH] feat(scrolling): add a platform-free model of a scrollable strip of columns Ported from komorebi-for-mac (feature/moar-scrolling). The crate has no dependencies and no knowledge of windows; it is not used by komorebi yet. --- Cargo.lock | 5 + Cargo.toml | 1 + deny.toml | 5 + komorebi-scrolling/Cargo.toml | 9 + komorebi-scrolling/src/geometry.rs | 44 + komorebi-scrolling/src/lib.rs | 150 +++ komorebi-scrolling/src/options.rs | 88 ++ komorebi-scrolling/src/space.rs | 1712 ++++++++++++++++++++++++ komorebi-scrolling/src/swipe.rs | 133 ++ komorebi-scrolling/src/view.rs | 143 ++ komorebi-scrolling/tests/common/mod.rs | 67 + komorebi-scrolling/tests/heights.rs | 280 ++++ komorebi-scrolling/tests/pan.rs | 290 ++++ komorebi-scrolling/tests/properties.rs | 663 +++++++++ komorebi-scrolling/tests/rules.rs | 1331 ++++++++++++++++++ komorebi-scrolling/tests/tiles.rs | 721 ++++++++++ komorebi/Cargo.toml | 1 + 17 files changed, 5643 insertions(+) create mode 100644 komorebi-scrolling/Cargo.toml create mode 100644 komorebi-scrolling/src/geometry.rs create mode 100644 komorebi-scrolling/src/lib.rs create mode 100644 komorebi-scrolling/src/options.rs create mode 100644 komorebi-scrolling/src/space.rs create mode 100644 komorebi-scrolling/src/swipe.rs create mode 100644 komorebi-scrolling/src/view.rs create mode 100644 komorebi-scrolling/tests/common/mod.rs create mode 100644 komorebi-scrolling/tests/heights.rs create mode 100644 komorebi-scrolling/tests/pan.rs create mode 100644 komorebi-scrolling/tests/properties.rs create mode 100644 komorebi-scrolling/tests/rules.rs create mode 100644 komorebi-scrolling/tests/tiles.rs diff --git a/Cargo.lock b/Cargo.lock index 96aa356c..f1d0d580 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3338,6 +3338,7 @@ dependencies = [ "ed25519-dalek", "hotwatch", "komorebi-layouts", + "komorebi-scrolling", "komorebi-themes", "lazy_static", "miow", @@ -3455,6 +3456,10 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "komorebi-scrolling" +version = "0.1.0" + [[package]] name = "komorebi-shortcuts" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a3e9d1c4..db998136 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "komorebi-client", "komorebi-gui", "komorebi-layouts", + "komorebi-scrolling", "komorebic", "komorebic-no-console", "komorebi-bar", diff --git a/deny.toml b/deny.toml index 4c7b831e..c1b6953e 100644 --- a/deny.toml +++ b/deny.toml @@ -52,6 +52,11 @@ crate = "komorebi-client" expression = "LicenseRef-Komorebi-2.0" license-files = [] +[[licenses.clarify]] +crate = "komorebi-scrolling" +expression = "LicenseRef-Komorebi-2.0" +license-files = [] + [[licenses.clarify]] crate = "komorebi-layouts" expression = "LicenseRef-Komorebi-2.0" diff --git a/komorebi-scrolling/Cargo.toml b/komorebi-scrolling/Cargo.toml new file mode 100644 index 00000000..7e70ad9a --- /dev/null +++ b/komorebi-scrolling/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "komorebi-scrolling" +version = "0.1.0" +description = "A platform-free model of a scrollable strip of columns" +edition = "2024" + +# This crate deliberately has no dependencies: it is geometry and a state machine, and it is +# meant to be usable by every komorebi, whatever it draws windows with. +[dependencies] diff --git a/komorebi-scrolling/src/geometry.rs b/komorebi-scrolling/src/geometry.rs new file mode 100644 index 00000000..1d925088 --- /dev/null +++ b/komorebi-scrolling/src/geometry.rs @@ -0,0 +1,44 @@ +/// The size of the area that the strip scrolls through. +/// +/// This is the working area of a monitor: whatever is left once the host has taken out the menu +/// bar, the dock and any padding it wants. The model never learns where that area sits on a +/// screen. Every coordinate it returns is relative to the area's top left corner, and the host +/// adds its own offset. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Viewport { + /// How wide the area is, in pixels. + pub width: i32, + /// How tall the area is, in pixels. + pub height: i32, +} + +/// A rectangle in viewport coordinates: the origin is the top left corner of the [`Viewport`] and +/// `y` grows downwards. +/// +/// Unlike komorebi's own `Rect`, whose `right` and `bottom` hold a width and a height, a +/// `width` here is a width and `x + width` is the right edge. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Rect { + /// The distance of the left edge from the left edge of the viewport. + pub x: i32, + /// The distance of the top edge from the top edge of the viewport. + pub y: i32, + /// How wide the rectangle is. + pub width: i32, + /// How tall the rectangle is. + pub height: i32, +} + +impl Rect { + /// The x coordinate of the right edge. + #[must_use] + pub const fn right(&self) -> i32 { + self.x + self.width + } + + /// The y coordinate of the bottom edge. + #[must_use] + pub const fn bottom(&self) -> i32 { + self.y + self.height + } +} diff --git a/komorebi-scrolling/src/lib.rs b/komorebi-scrolling/src/lib.rs new file mode 100644 index 00000000..165a1409 --- /dev/null +++ b/komorebi-scrolling/src/lib.rs @@ -0,0 +1,150 @@ +//! A model of a strip of columns that a viewport scrolls across, with no idea what a window is. +//! +//! The model is for a window manager in which windows sit in a row that is as long as it needs to +//! be, and the screen shows a part of it. It owns the questions that make that feel right: how +//! wide each column is, which part of the strip is in view, and how the view moves when a column is +//! opened, closed, resized or focused. It does not own anything that touches the operating system, +//! so the same model can sit under a window manager for any of them. +//! +//! # What the host does +//! +//! The host gives the model a [`Viewport`] and a key for each column, and tells it what happens: +//! a window opened, a window closed, focus moved. After each, the host asks for the +//! [`layout`](ScrollingSpace::layout) and carries it out. How it carries it out is the host's +//! business, and so are the things that differ between platforms: whether windows that are out of +//! view are hidden or parked at an edge, how a window that refuses to be a width is dealt with +//! (see [`set_width_limits`](ScrollingSpace::set_width_limits)), and animation. The model says +//! where everything goes. It never says how to get there. +//! +//! A host that already keeps its own ordered list of windows can hand that list over after every +//! change with [`reconcile`](ScrollingSpace::reconcile), instead of reporting each change. +//! +//! # Columns of tiles +//! +//! A column holds one tile, or several one above the other, which share the height of the +//! viewport. The key that the host gives the model is the key of a tile. Everything that takes a +//! key takes the key of any tile in a column, and means the column that it is in. A column has the +//! width, the limits and the preset that it had whatever tiles come and go. +//! +//! The tiles are kept in **reading order**: left to right by column, and top to bottom within one. +//! That is the order of [`ids`](ScrollingSpace::ids), of [`layout`](ScrollingSpace::layout), and of +//! the list that a host hands to [`reconcile`](ScrollingSpace::reconcile). A column is a run of that +//! list, so the host's list is always a valid strip, and the model never disagrees with it about +//! the order. Of the things that change a column's tiles, [`consume_right`], [`consume_left`] and +//! tiles that are added or removed leave reading order alone or change it as the list would. The +//! ones that move a tile past another ([`expel`], [`move_tile_up`], [`move_tile_down`] and +//! [`move_column`]) change it, and a host that keeps its own list reads the new order from +//! [`ids`](ScrollingSpace::ids) and makes its list match. +//! +//! [`consume_right`]: ScrollingSpace::consume_right +//! [`consume_left`]: ScrollingSpace::consume_left +//! [`expel`]: ScrollingSpace::expel +//! [`move_tile_up`]: ScrollingSpace::move_tile_up +//! [`move_tile_down`]: ScrollingSpace::move_tile_down +//! [`move_column`]: ScrollingSpace::move_column +//! +//! A column remembers which of its tiles had focus last, and that is the one that has it again +//! when focus returns to the column. +//! +//! # Moving the strip by hand +//! +//! A pan is the user dragging the strip across the viewport with a finger or a wheel, and it is +//! the one thing that moves the viewport without moving focus. [`begin_pan`], [`pan_by`] and +//! [`pan_end`] bracket it: nothing but the position of the strip changes between the first two, +//! so that the host can lay out and draw every step, and letting go comes to rest with a column +//! aligned to an edge of the viewport, nearest to where the strip would carry on to at the speed +//! that it was going. A [`SwipeTracker`] says how fast that was from the movements that the host +//! saw, and has no clock of its own, so the same movements always give the same answer. +//! +//! [`begin_pan`]: ScrollingSpace::begin_pan +//! [`pan_by`]: ScrollingSpace::pan_by +//! [`pan_end`]: ScrollingSpace::pan_end +//! +//! # Coordinates +//! +//! Everything is measured from the top left corner of the [`Viewport`], in whole pixels in the +//! results and in floating point inside, so that proportions do not accumulate rounding errors. +//! A column that is out of view has coordinates outside of `0..width`, which are the true ones. +//! +//! # The rules that make it feel like a strip +//! +//! These are the properties that the rest of the crate is built to keep, and each is tested. +//! +//! 1. **A column's width is its own.** Opening a column, closing one or focusing one never changes +//! another's width. A strip with more columns than fit is simply wider than the viewport. +//! 2. **The focused column does not move by itself.** Opening, closing or resizing a column +//! to its left does not move it on screen. Only a change to the focused column itself, or a +//! change of focus, can scroll the viewport. +//! 3. **Scrolling is as little as it can be.** A column that is already in view when it gets focus +//! does not scroll the strip, and one that is not is brought in until it is just in view. +//! 4. **Temporary changes are undone.** Closing a column that was just opened next to the previous +//! one returns focus to it, and the viewport to where it was. Leaving fullscreen puts the +//! viewport back too. +//! 5. **A column wider than the viewport starts at its left edge.** +//! 6. **Proportions include the gaps.** `n` columns of `1 / n` fill the viewport exactly, however +//! big the gaps are. +//! 7. **Fullscreen is a column like any other**, which the others can be scrolled to either side of. +//! +//! # Example +//! +//! ``` +//! use komorebi_scrolling::{ColumnWidth, Options, ScrollingSpace, Viewport, Visibility}; +//! +//! let options = Options { +//! default_width: ColumnWidth::Proportion(0.5), +//! ..Options::default() +//! }; +//! let mut space = ScrollingSpace::new(options, Viewport { width: 1200, height: 800 }); +//! +//! // Open four windows, each next to the one before, and in focus. +//! for id in 0..4_u32 { +//! space.add_column(id, None, None, true); +//! } +//! +//! // Opening windows did not change how wide any of them is. The strip has scrolled just far +//! // enough to keep the newest in view, which leaves the first two out of it. +//! let layout = space.layout(); +//! assert!(layout.iter().all(|placement| placement.rect.width == 600)); +//! assert_eq!(layout[3].rect.x, 600); +//! assert_eq!(layout[0].visibility, Visibility::OffLeft); +//! assert_eq!(layout[1].visibility, Visibility::OffLeft); +//! +//! // Moving focus to a window that is already in view does not scroll. +//! space.focus_left(); +//! assert_eq!(space.layout()[3].rect.x, 600); +//! +//! // Moving it to one that is not scrolls just far enough to bring it in. +//! space.focus_index(0); +//! assert_eq!(space.layout()[0].rect.x, 0); +//! ``` +//! +//! # Prior art +//! +//! The behaviour follows what the [niri] documentation and design notes say about its scrollable +//! tiling, and [Paneru] showed that the approach works on macOS. The code here was written from +//! those descriptions and shares none with either. +//! +//! [niri]: https://github.com/YaLTeR/niri +//! [Paneru]: https://github.com/karinushka/paneru + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +mod geometry; +mod options; +mod space; +mod swipe; +mod view; + +pub use geometry::Rect; +pub use geometry::Viewport; +pub use options::CenterFocusedColumn; +pub use options::ColumnWidth; +pub use options::Options; +pub use options::TileHeight; +pub use space::ColumnRecord; +pub use space::Placement; +pub use space::ScrollingSpace; +pub use space::Snap; +pub use space::Visibility; +pub use swipe::SwipeTracker; diff --git a/komorebi-scrolling/src/options.rs b/komorebi-scrolling/src/options.rs new file mode 100644 index 00000000..eece581b --- /dev/null +++ b/komorebi-scrolling/src/options.rs @@ -0,0 +1,88 @@ +/// How wide a column is. +/// +/// The width is part of the column, not of the strip: opening or closing other columns never +/// changes it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ColumnWidth { + /// A share of the viewport width, where gaps are taken out first, so that `n` columns of + /// `1 / n` always tile the viewport exactly, whatever the gaps are. + Proportion(f64), + /// An exact width in pixels, which the gaps do not affect. + Fixed(i32), +} + +/// How tall a tile in a column of several is. +/// +/// A tile that has been given no height is [`Auto`](Self::Auto): it takes an equal share of what the +/// tiles with a height of their own leave. Giving one tile a height makes the others in its column +/// `Auto`, so that there is always something to take up the rest. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum TileHeight { + /// An equal share of what is left over. + #[default] + Auto, + /// A share of the height of the column, once the gaps between its tiles are taken out, so that + /// tiles of `1 / n` always tile the column exactly. + Proportion(f64), + /// An exact height in pixels. + Fixed(i32), +} + +/// When focusing a column should also centre it in the viewport. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CenterFocusedColumn { + /// Never: a column that is out of view is scrolled in just far enough to be fully visible, and + /// a column that is already in view does not move the strip at all. + #[default] + Never, + /// Always centre the focused column. + Always, + /// Centre the focused column only if it does not fit in the viewport together with its + /// neighbour on the side that focus came from. + OnOverflow, +} + +/// How a [`ScrollingSpace`](crate::ScrollingSpace) behaves. +#[derive(Clone, Debug, PartialEq)] +pub struct Options { + /// The space between columns and between the columns and the edges of the viewport, in pixels. + /// + /// A host that applies its own padding to every window will normally leave this at zero. + pub gaps: i32, + /// The width of a column that has never been given one. + /// + /// A column that has never been resized, or given a width when it was added, is as wide as + /// this, and changes width when this does. Opening a column never changes it, so opening + /// columns never resizes any. + pub default_width: ColumnWidth, + /// The widths that cycling through presets steps between, in order. + pub preset_widths: Vec, + /// When to centre the focused column. + pub center_focused_column: CenterFocusedColumn, + /// Centre a lone column, whatever `center_focused_column` says. + pub always_center_single_column: bool, + /// The heights that cycling through preset heights steps between, in order, each a share of + /// the height of the column once the gaps between its tiles are taken out. + pub preset_heights: Vec, + /// The least that a tile is made to be tall, in pixels, as long as the column has room for + /// that many tiles of that height. Zero means that there is no least. + pub minimum_tile_height: i32, +} + +impl Default for Options { + fn default() -> Self { + Self { + gaps: 0, + default_width: ColumnWidth::Proportion(0.5), + preset_widths: vec![ + ColumnWidth::Proportion(1.0 / 3.0), + ColumnWidth::Proportion(0.5), + ColumnWidth::Proportion(2.0 / 3.0), + ], + center_focused_column: CenterFocusedColumn::Never, + always_center_single_column: false, + preset_heights: vec![1.0 / 3.0, 0.5, 2.0 / 3.0], + minimum_tile_height: 0, + } + } +} diff --git a/komorebi-scrolling/src/space.rs b/komorebi-scrolling/src/space.rs new file mode 100644 index 00000000..1b2ffb0e --- /dev/null +++ b/komorebi-scrolling/src/space.rs @@ -0,0 +1,1712 @@ +use crate::CenterFocusedColumn; +use crate::ColumnWidth; +use crate::Options; +use crate::Rect; +use crate::TileHeight; +use crate::Viewport; +use crate::view; + +/// How far a float may be off when deciding whether a column is inside the viewport. Widths come +/// from proportions, so the edges of columns that fit exactly are rarely exact. +const EPSILON: f64 = 1e-6; + +/// How far a column may stick out of the viewport, or into the gap at its edge, and still count +/// as fully in view. Widths are whole pixels and edges are not, so a column that was made to fill +/// the room that is left can end up a fraction of a pixel too wide, and scrolling the strip by +/// that fraction must not make its neighbour count as out of view. +const TOLERANCE: f64 = 1.0; + +#[derive(Clone, Debug, PartialEq)] +struct Column { + /// The key of the first tile, from the top, which is also the key of the column. + id: K, + /// The other tiles, from the top, after the first. + rest: Vec, + /// Which tile had focus last, counting from the top: the one that gets focus again when the + /// column does. + last_tile: usize, + /// The width that this column was given, or `None` if it has never been given one, which + /// makes it as wide as [`Options::default_width`] says, whatever that is at the moment. + width: Option, + /// The preset that `width` was last taken from, so that cycling carries on from there. + preset: Option, + /// The column takes the whole viewport width. `width` is kept, to go back to. + full_width: bool, + fullscreen: bool, + /// Limits that the host found out by trying: a window that would not get any narrower than + /// `min_width`, or any wider than `max_width`. + min_width: Option, + max_width: Option, +} + +impl Column { + fn new(id: K, width: Option) -> Self { + Self { + id, + rest: Vec::new(), + last_tile: 0, + width, + preset: None, + full_width: false, + fullscreen: false, + min_width: None, + max_width: None, + } + } + + fn tile_count(&self) -> usize { + 1 + self.rest.len() + } + + /// The keys of the tiles, from the top. + fn tiles(&self) -> impl Iterator { + std::iter::once(&self.id).chain(self.rest.iter()) + } + + fn tile_index(&self, id: &K) -> Option { + self.tiles().position(|tile| tile == id) + } + + /// The tile that has focus when the column does. + fn last(&self) -> &K { + self.tiles().nth(self.last_tile).unwrap_or(&self.id) + } + + /// Give the column these tiles, from the top, keeping focus on the tile that had it if it is + /// still there. + fn set_tiles(&mut self, tiles: Vec) { + let last = self.last().clone(); + + let mut tiles = tiles.into_iter(); + if let Some(first) = tiles.next() { + self.id = first; + } + self.rest = tiles.collect(); + + self.last_tile = self.tile_index(&last).unwrap_or(0); + } +} + +/// Where every column is in the strip, which is a function of nothing but their widths. +struct Geometry { + /// The width of each column. + widths: Vec, + /// The distance from the start of the strip to the left edge of each column. + xs: Vec, +} + +/// The columns that are fully in view, and what they take up. +struct Group { + leftmost: usize, + /// Their widths, with a gap after each. + taken: f64, + /// Whether the group has any column in it besides the active one. + has_others: bool, +} + +/// How much of a column is in the viewport. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Visibility { + /// All of it is in the viewport. + Visible, + /// Some of it is, and some of it is past an edge. + Clipped, + /// None of it is: it is past the left edge. + OffLeft, + /// None of it is: it is past the right edge. + OffRight, +} + +/// Where a column goes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Placement { + /// The id that the column was added with. + pub id: K, + /// Where the column is, in viewport coordinates. This is not clamped to the viewport: a + /// column that is partly or wholly out of view has the rectangle that it really has, so a + /// host that cannot clip has all the information it needs to decide what to do instead. + pub rect: Rect, + /// How much of the rectangle is in the viewport. + pub visibility: Visibility, + /// Whether this is the column that has focus. + pub active: bool, +} + +/// What a column was given: its width, the preset that it came from, whether it takes the whole +/// viewport, and the limits that were found out about its window. +/// +/// A tile that goes to another strip takes this with it, so that it is not as wide there as the +/// default again, and [`ScrollingSpace::arrive`] is how the other strip is told. +#[derive(Clone, Debug, PartialEq)] +pub struct ColumnRecord { + width: Option, + preset: Option, + full_width: bool, + min_width: Option, + max_width: Option, +} + +/// How many tiles that are on their way to this strip it waits for before it forgets the oldest. +const MAXIMUM_ARRIVALS: usize = 32; + +/// Where a pan came to rest. +#[derive(Clone, Debug, PartialEq)] +pub struct Snap { + /// Where the left edge of the viewport was in the strip when the pan was let go. + pub from: f64, + /// Where it is now, which is what the strip is laid out for. Something that animates the + /// release goes from `from` to this. + pub to: f64, + /// The tile that has focus now. It is the one that had it before the pan if that is still in + /// view where the pan came to rest, and otherwise a tile that is. + pub active: K, +} + +/// An ordered strip of columns, a viewport onto it, and the rules for how the viewport moves. +/// +/// # What is stored +/// +/// Each column has a width of its own, so the strip is as wide as its columns make it, and +/// opening or closing a column does not change any other. +/// +/// The viewport is stored as one number: where the left edge of the **active** column is on +/// screen. Every other position follows from that and the widths. This has a useful consequence: +/// anything that changes the columns to the left of the active one, such as opening one, closing +/// one or making one wider, leaves that number alone, and so the active column does not move. +/// +/// # Changing which column is active +/// +/// Moving focus keeps the viewport where it is and then scrolls by the least amount that brings +/// the newly active column fully into view. Moving focus to a column that is already in view +/// therefore does not scroll at all. +/// +/// Two things are remembered so that temporary changes can be undone, and both are forgotten as +/// soon as focus moves to some other column: +/// +/// * When a column is opened next to the active one and made active, where the old active column +/// was on screen is remembered. If the new column is closed before focus goes anywhere else, +/// focus returns to the old column and the viewport is put back as it was. +/// * When the active column goes fullscreen, where it was on screen is remembered, so that +/// leaving fullscreen puts the viewport back. +/// +/// # Keys +/// +/// Columns are identified by a key `K` of the host's choosing, usually the id of whatever the +/// host keeps its windows in. All of a column's state is looked up by that key, so it stays with +/// the column when columns are reordered. +#[derive(Clone, Debug, PartialEq)] +pub struct ScrollingSpace { + options: Options, + viewport: Viewport, + columns: Vec>, + /// The index of the active column. Meaningless when there are no columns. + active: usize, + /// Where the left edge of the active column is, measured from the left edge of the viewport. + active_x: f64, + /// Set when a column opened next to the active one was made active: where the old active + /// column's left edge was. + return_to: Option, + /// Set when the active column goes fullscreen: where its left edge was. + restore_x: Option, + /// The tiles that have been given a height, and what it is. A tile that is not here is + /// [`TileHeight::Auto`], and so does not need to be remembered. + heights: Vec<(K, TileHeight)>, + /// What tiles that are expected to be added brought with them from another strip. + arrivals: Vec<(K, ColumnRecord)>, +} + +impl ScrollingSpace { + /// An empty strip. + #[must_use] + pub fn new(options: Options, viewport: Viewport) -> Self { + Self { + options: Options { + gaps: options.gaps.max(0), + ..options + }, + viewport, + columns: Vec::new(), + active: 0, + active_x: 0.0, + return_to: None, + restore_x: None, + heights: Vec::new(), + arrivals: Vec::new(), + } + } + + /// The number of columns. + #[must_use] + pub fn len(&self) -> usize { + self.columns.len() + } + + /// The number of tiles, which is more than the number of columns when a column has more than + /// one. + #[must_use] + pub fn tile_count(&self) -> usize { + self.columns.iter().map(Column::tile_count).sum() + } + + /// Whether there are no columns. + #[must_use] + pub fn is_empty(&self) -> bool { + self.columns.is_empty() + } + + /// The keys of the tiles in reading order: from left to right by column, and from top to + /// bottom within one. + /// + /// A host that keeps its tiles in a list keeps that list in this order, which is what + /// [`reconcile`](Self::reconcile) works out the columns from. + #[must_use] + pub fn ids(&self) -> Vec { + self.columns + .iter() + .flat_map(|column| column.tiles().cloned()) + .collect() + } + + /// Where the column that has the tile with this key is in the strip, counting from the left. + #[must_use] + pub fn position(&self, id: &K) -> Option { + self.columns + .iter() + .position(|column| column.tile_index(id).is_some()) + } + + /// The keys of the tiles in the column that has the tile with this key, from the top. + #[must_use] + pub fn column_of(&self, id: &K) -> Option> { + let column = &self.columns[self.position(id)?]; + + Some(column.tiles().cloned().collect()) + } + + /// The index of the column that has focus. + #[must_use] + pub fn active_index(&self) -> Option { + (!self.columns.is_empty()).then_some(self.active) + } + + /// The key of the tile that has focus, which is the one that last had it in the active column. + #[must_use] + pub fn active_id(&self) -> Option<&K> { + self.columns.get(self.active).map(Column::last) + } + + /// Where the tile that has focus is in reading order, which is its place in + /// [`ids`](Self::ids). + #[must_use] + pub fn active_tile_index(&self) -> Option { + let column = self.columns.get(self.active)?; + let before: usize = self.columns[..self.active] + .iter() + .map(Column::tile_count) + .sum(); + + Some(before + column.last_tile) + } + + /// Where the left edge of the active column is on screen, measured from the left edge of the + /// viewport. + #[must_use] + pub fn active_x(&self) -> Option { + (!self.columns.is_empty()).then_some(self.active_x) + } + + /// How wide the column with this key is, in pixels, not rounded. + /// + /// This is what the column is, limits and fullscreen included, which is not always what it + /// was asked to be. It is what to start from to make a column a little wider or narrower. + #[must_use] + pub fn column_width(&self, id: &K) -> Option { + self.columns + .iter() + .find(|column| column.tile_index(id).is_some()) + .map(|column| self.width_of(column)) + } + + /// How far into the strip the left edge of the viewport is. + /// + /// This is the number that something animating the scrolling would interpolate. + #[must_use] + pub fn view_offset(&self) -> f64 { + if self.columns.is_empty() { + return 0.0; + } + + self.geometry().xs[self.active] - self.active_x + } + + /// The options in force. + #[must_use] + pub fn options(&self) -> &Options { + &self.options + } + + /// The viewport in force. + #[must_use] + pub fn viewport(&self) -> Viewport { + self.viewport + } + + /// Change the options. The active column stays where it is, unless it has to move to be in + /// view or to be centred. + pub fn set_options(&mut self, options: Options) { + self.options = Options { + gaps: options.gaps.max(0), + ..options + }; + + let presets = self.options.preset_widths.len(); + for column in &mut self.columns { + if column.preset.is_some_and(|preset| preset >= presets) { + column.preset = None; + } + } + + self.reveal(None); + } + + /// Change the size of the viewport. Columns that are proportions of it change width with it. + /// The active column stays where it is, unless it has to move to be in view or to be centred. + pub fn set_viewport(&mut self, viewport: Viewport) { + self.viewport = viewport; + self.reveal(None); + } + + /// Add a column. + /// + /// * `at` is where in the strip it goes, and it defaults to directly to the right of the + /// active column. + /// * `width` is how wide it is. Without one the column has never been given a width, so it is + /// as wide as [`Options::default_width`] says, and goes on being as that changes, until it is + /// resized. + /// * With `activate`, the new column gets focus, and the viewport scrolls just far enough to + /// show it. Without, the active column does not move, whether the new column goes to its + /// left or its right. The first column of an empty strip always gets focus. + /// + /// No other column changes width. + pub fn add_column( + &mut self, + id: K, + at: Option, + width: Option, + activate: bool, + ) { + debug_assert!( + self.position(&id).is_none(), + "the key is already in the strip" + ); + + let mut column = Column::new(id, width); + + // a tile that came from another strip is as wide as it was there, unless it is told + if width.is_none() + && let Some(at) = self + .arrivals + .iter() + .position(|(tile, _)| *tile == column.id) + { + let (_, record) = self.arrivals.remove(at); + + column.width = record.width; + column.preset = record + .preset + .filter(|preset| *preset < self.options.preset_widths.len()); + column.full_width = record.full_width; + column.min_width = record.min_width; + column.max_width = record.max_width; + } + + if self.columns.is_empty() { + self.columns.push(column); + self.reset_view(); + self.reveal(None); + return; + } + + let previous_active = self.active; + let previous_x = self.active_x; + let index = at.map_or(previous_active + 1, |at| at.min(self.columns.len())); + + self.columns.insert(index, column); + if index <= previous_active { + // It is the same column, in the same place on screen, with one more column before it. + self.active += 1; + // And the column that was to be returned to is no longer the one next to this one. + self.return_to = None; + } + + if activate { + self.activate(index); + + // Only a column directly to the right of the old active one can be returned from: that + // is the one that is left of it when it is closed. + if index == previous_active + 1 { + self.return_to = Some(previous_x); + } + } + } + + /// Remove the tile with this key, and with it its column if it was the only one in it, and + /// tell whether there was one. + /// + /// A column that has other tiles goes on being a column, with the same width, and the tiles + /// that were below the one that went move up. If the tile had focus, focus goes to the one + /// that took its place, or to the one above if there is none. Nothing moves sideways. + /// + /// When it is the only tile in its column, the column goes: + /// + /// * A column to the left of the active one goes without the active column moving. + /// * A column to the right of it goes without anything else changing. + /// * If the active column goes, focus returns to the column it was opened next to if nothing + /// has changed focus since, with the viewport as it was. Otherwise the column to its right + /// becomes active, or the last column if there is none, with the viewport where it was. + pub fn remove_column(&mut self, id: &K) -> bool { + let Some(index) = self.position(id) else { + return false; + }; + + // a tile that is gone takes the height it was given with it + self.heights.retain(|(tile, _)| tile != id); + + if self.columns[index].tile_count() > 1 { + let column = &mut self.columns[index]; + let removed = column.tile_index(id).unwrap_or(0); + let last = column.last_tile; + + let mut tiles: Vec = column.tiles().cloned().collect(); + tiles.remove(removed); + + // the tile that was focused is gone, so the one that takes its place is + let next = match removed.cmp(&last) { + std::cmp::Ordering::Less => last - 1, + std::cmp::Ordering::Equal => last.min(tiles.len() - 1), + std::cmp::Ordering::Greater => last, + }; + + column.id = tiles.remove(0); + column.rest = tiles; + column.last_tile = next; + + return true; + } + + let was_active = index == self.active; + let view_pos = self.geometry().xs[self.active] - self.active_x; + + self.columns.remove(index); + + if self.columns.is_empty() { + self.reset_view(); + return true; + } + + if index < self.active { + self.active -= 1; + self.return_to = None; + } else if was_active { + self.restore_x = None; + + match self.return_to.take() { + Some(x) if index > 0 => { + self.active = index - 1; + self.active_x = x; + } + _ => { + self.active = index.min(self.columns.len() - 1); + self.active_x = self.geometry().xs[self.active] - view_pos; + } + } + + self.reveal(None); + } + + true + } + + /// Give focus to the tile with this key, and tell whether there was one. + pub fn focus(&mut self, id: &K) -> bool { + match self.position(id) { + Some(index) => { + let column = &mut self.columns[index]; + column.last_tile = column.tile_index(id).unwrap_or(0); + + self.activate(index); + true + } + None => false, + } + } + + /// Give focus to the tile above the one that has it in its column, and tell whether there was + /// one. Nothing is above the first tile, and nothing goes round. + pub fn focus_up(&mut self) -> bool { + match self.columns.get_mut(self.active) { + Some(column) if column.last_tile > 0 => { + column.last_tile -= 1; + true + } + _ => false, + } + } + + /// Give focus to the tile below the one that has it in its column, and tell whether there was + /// one. Nothing is below the last tile, and nothing goes round. + pub fn focus_down(&mut self) -> bool { + match self.columns.get_mut(self.active) { + Some(column) if column.last_tile + 1 < column.tile_count() => { + column.last_tile += 1; + true + } + _ => false, + } + } + + /// Give focus to the column at this position, and tell whether there was one. + pub fn focus_index(&mut self, index: usize) -> bool { + if index < self.columns.len() { + self.activate(index); + true + } else { + false + } + } + + /// Give focus to the column on the left, and tell whether there was one. + pub fn focus_left(&mut self) -> bool { + match self.active.checked_sub(1) { + Some(index) if !self.columns.is_empty() => self.focus_index(index), + _ => false, + } + } + + /// Give focus to the column on the right, and tell whether there was one. + pub fn focus_right(&mut self) -> bool { + !self.columns.is_empty() && self.focus_index(self.active + 1) + } + + /// Move the column with this key to a new position in the strip. + /// + /// If it is the active column, it goes on being the active column and the viewport follows it. + /// Otherwise the active column does not move. + pub fn move_column(&mut self, id: &K, to: usize) { + let Some(from) = self.position(id) else { + return; + }; + + let to = to.min(self.columns.len() - 1); + if from == to { + return; + } + + let view_pos = self.geometry().xs[self.active] - self.active_x; + + let column = self.columns.remove(from); + self.columns.insert(to, column); + self.return_to = None; + + if from == self.active { + self.active = to; + self.active_x = self.geometry().xs[to] - view_pos; + self.reveal(None); + } else if from < self.active && to >= self.active { + self.active -= 1; + } else if from > self.active && to <= self.active { + self.active += 1; + } + } + + /// Take the first tile of the column on the right and put it at the bottom of the active + /// column, and tell whether there was one. + /// + /// The column on the right goes if that was its only tile. Nothing else changes: the tile + /// that is taken is the one that comes next in reading order, so the order of the tiles is + /// what it was. + pub fn consume_right(&mut self) -> bool { + if self.columns.is_empty() || self.active + 1 >= self.columns.len() { + return false; + } + + let next = self.active + 1; + let taken = if self.columns[next].rest.is_empty() { + self.columns.remove(next).id + } else { + let column = &mut self.columns[next]; + let new_first = column.rest.remove(0); + column.last_tile = column.last_tile.saturating_sub(1); + + std::mem::replace(&mut column.id, new_first) + }; + + // a tile that joins another column is as tall as an equal share of it + self.heights.retain(|(tile, _)| *tile != taken); + self.columns[self.active].rest.push(taken); + + true + } + + /// Take the last tile of the column on the left and put it at the top of the active column, + /// and tell whether there was one. + /// + /// The column on the left goes if that was its only tile, and the active column does not move + /// on screen when it does. The tile that has focus goes on having it. The order of the tiles + /// is what it was. + pub fn consume_left(&mut self) -> bool { + if self.columns.is_empty() || self.active == 0 { + return false; + } + + let previous = self.active - 1; + let taken = if self.columns[previous].rest.is_empty() { + let column = self.columns.remove(previous); + self.active -= 1; + column.id + } else { + let column = &mut self.columns[previous]; + let taken = column.rest.pop().expect("there is more than one tile"); + column.last_tile = column.last_tile.min(column.tile_count() - 1); + + taken + }; + + // the strip is different on the left, and so there is nothing to return to there + self.return_to = None; + + self.heights.retain(|(tile, _)| *tile != taken); + + let column = &mut self.columns[self.active]; + let old_first = std::mem::replace(&mut column.id, taken); + column.rest.insert(0, old_first); + column.last_tile += 1; + + true + } + + /// Take the tile that has focus out of its column and put it in a column of its own on the + /// right, with the default width, and tell whether it was in a column with others. + /// + /// The tile goes on having focus, and the viewport scrolls only if it has to. When it was + /// not the last tile in its column it moves past the tiles that were below it, so the order of + /// the tiles is no longer what it was, and the host has to change its list to match. + pub fn expel(&mut self) -> bool { + if self.columns.is_empty() || self.columns[self.active].tile_count() == 1 { + return false; + } + + let column = &mut self.columns[self.active]; + let index = column.last_tile; + + let mut tiles: Vec = column.tiles().cloned().collect(); + let tile = tiles.remove(index); + + // a column of its own is as tall as it is + self.heights.retain(|(other, _)| *other != tile); + + let column = &mut self.columns[self.active]; + column.id = tiles.remove(0); + column.rest = tiles; + // the tile that took its place is next to it, as are all the others + column.last_tile = index.min(column.tile_count() - 1); + + let expelled = self.active + 1; + self.columns.insert(expelled, Column::new(tile, None)); + self.activate(expelled); + + true + } + + /// Swap the tile that has focus with the one above it, and tell whether there was one. The + /// tile goes on having focus. The order of the tiles changes, and the host has to change its + /// list to match. + pub fn move_tile_up(&mut self) -> bool { + self.swap_tile(-1) + } + + /// Swap the tile that has focus with the one below it, and tell whether there was one. The + /// tile goes on having focus. The order of the tiles changes, and the host has to change its + /// list to match. + pub fn move_tile_down(&mut self) -> bool { + self.swap_tile(1) + } + + fn swap_tile(&mut self, by: isize) -> bool { + let Some(column) = self.columns.get_mut(self.active) else { + return false; + }; + + let Some(to) = column + .last_tile + .checked_add_signed(by) + .filter(|to| *to < column.tile_count()) + else { + return false; + }; + + let mut tiles: Vec = column.tiles().cloned().collect(); + tiles.swap(column.last_tile, to); + + column.id = tiles.remove(0); + column.rest = tiles; + column.last_tile = to; + + true + } + + /// Give a column an exact width. + /// + /// If it is the active column, its left edge stays where it is and the viewport scrolls only + /// if it has to. A column to the left of the active one can change width without the active + /// column moving. + pub fn set_width(&mut self, id: &K, width: ColumnWidth) { + let Some(index) = self.position(id) else { + return; + }; + + let column = &mut self.columns[index]; + column.width = Some(width); + column.preset = None; + column.full_width = false; + + self.after_resize(index); + } + + /// Give a column the next preset width, or the previous one. + /// + /// A column that has not been given a preset yet gets the first one that is wider than it + /// (or the first one, if none are), or the last one that is narrower (or the last one) when + /// going backwards. After that, the presets are stepped through in order, wrapping. + pub fn cycle_preset_width(&mut self, id: &K, forward: bool) { + let Some(index) = self.position(id) else { + return; + }; + + let count = self.options.preset_widths.len(); + if count == 0 { + return; + } + + let next = match self.columns[index].preset { + Some(current) if current < count => { + if forward { + (current + 1) % count + } else { + (current + count - 1) % count + } + } + _ => { + let width = self.width_of(&self.columns[index]); + let presets: Vec = self + .options + .preset_widths + .iter() + .map(|preset| self.resolve(*preset)) + .collect(); + + if forward { + presets + .iter() + .position(|preset| *preset > width + 0.5) + .unwrap_or(0) + } else { + presets + .iter() + .rposition(|preset| *preset < width - 0.5) + .unwrap_or(count - 1) + } + } + }; + + let preset = self.options.preset_widths[next]; + let column = &mut self.columns[index]; + column.width = Some(preset); + column.preset = Some(next); + column.full_width = false; + + self.after_resize(index); + } + + /// Forget every width that any column was given, so that all of them are as wide as + /// [`Options::default_width`] says again. + /// + /// Presets and full width are forgotten with the widths, and fullscreen is not: that is a + /// state that something else put a column in, and it is the host's to take it out of. What the + /// host found out that a window will not accept stays too, since that is a fact about the + /// window and not a choice that was made about the strip. + /// + /// The active column keeps its left edge on screen and the viewport scrolls only if it has to. + pub fn reset_widths(&mut self) { + for column in &mut self.columns { + column.width = None; + column.preset = None; + column.full_width = false; + } + + if !self.columns.is_empty() { + self.after_resize(self.active); + } + } + + /// Make a column as wide as the viewport, or give it back the width that it had. + pub fn toggle_full_width(&mut self, id: &K) { + let Some(index) = self.position(id) else { + return; + }; + + let column = &mut self.columns[index]; + column.full_width = !column.full_width; + + self.after_resize(index); + } + + /// Make a column fill the viewport, or put it back. + /// + /// A fullscreen column is still a column in the strip: the others stay where they are on + /// either side of it, and focus can move away from it and back. When the active column goes + /// fullscreen, the viewport is remembered, and put back when it stops being fullscreen. + pub fn set_fullscreen(&mut self, id: &K, fullscreen: bool) { + let Some(index) = self.position(id) else { + return; + }; + + if self.columns[index].fullscreen == fullscreen { + return; + } + + self.columns[index].fullscreen = fullscreen; + + if index != self.active { + return; + } + + if fullscreen { + self.restore_x = Some(self.active_x); + } else if let Some(x) = self.restore_x.take() { + self.active_x = x; + } + + self.reveal(None); + } + + /// Tell the model what a column's window turned out to accept. + /// + /// A window may refuse to be narrower than some width, or wider than some. If the host finds + /// out, say with a `min`, the column is never narrower than that again, and the columns + /// after it are placed where it really ends instead of overlapping it. + pub fn set_width_limits(&mut self, id: &K, min: Option, max: Option) { + let Some(index) = self.position(id) else { + return; + }; + + let column = &mut self.columns[index]; + column.min_width = min; + column.max_width = max; + + self.after_resize(index); + } + + /// The limits that the host has told the model about for a column: the narrowest and the widest + /// that it can be, or `None` if there is no such column. + /// + /// Learning that a window needs more room than it was given means raising a limit that may + /// already be there, which takes knowing what is there. + pub fn width_limits(&self, id: &K) -> Option<(Option, Option)> { + let column = &self.columns[self.position(id)?]; + + Some((column.min_width, column.max_width)) + } + + /// What the column that has this tile was given, to take to another strip, if there is such a + /// tile. + #[must_use] + pub fn column_record(&self, id: &K) -> Option { + let column = &self.columns[self.position(id)?]; + + Some(ColumnRecord { + width: column.width, + preset: column.preset, + full_width: column.full_width, + min_width: column.min_width, + max_width: column.max_width, + }) + } + + /// Say that the tile with this key is expected to be added, from another strip, and that + /// the column that it is in when it is added is to be what its column was there. + /// + /// Nothing happens if the tile is already in the strip. A tile that is added with a width + /// of its own has that width. Only so many arrivals are waited for, and the oldest are + /// forgotten first, so a tile that never comes does not stay for ever. + pub fn arrive(&mut self, id: K, record: ColumnRecord) { + if self.position(&id).is_some() { + return; + } + + self.arrivals.retain(|(tile, _)| *tile != id); + self.arrivals.push((id, record)); + + if self.arrivals.len() > MAXIMUM_ARRIVALS { + self.arrivals.remove(0); + } + } + + /// Give a tile a height, and make the others in its column [`TileHeight::Auto`] so that they + /// take up what it leaves. A tile that is alone in its column fills it, so there is nothing + /// to give it, and nothing happens. [`TileHeight::Auto`] gives the tile back an equal share + /// and leaves the others as they are. + /// + /// Nothing moves sideways, so the strip does not scroll. + pub fn set_tile_height(&mut self, id: &K, height: TileHeight) { + let Some(index) = self.position(id) else { + return; + }; + + let tiles: Vec = self.columns[index].tiles().cloned().collect(); + if tiles.len() == 1 { + return; + } + + match height { + TileHeight::Auto => self.heights.retain(|(tile, _)| tile != id), + height => { + self.heights.retain(|(tile, _)| !tiles.contains(tile)); + self.heights.push((id.clone(), height)); + } + } + } + + /// Make a tile taller or shorter by `delta` pixels from the height that it is now, and give it + /// that height as [`set_tile_height`](Self::set_tile_height) does. It is never made shorter + /// than [`Options::minimum_tile_height`], nor so tall that the others are. + pub fn resize_tile_height(&mut self, id: &K, delta: f64) { + let Some(current) = self.tile_height(id) else { + return; + }; + let Some(index) = self.position(id) else { + return; + }; + + let column = &self.columns[index]; + let count = column.tile_count(); + if count == 1 || !delta.is_finite() { + return; + } + + let (_, column_height, between) = self.column_extent(column); + let room = column_height - (count - 1) as f64 * between; + let minimum = f64::from(self.options.minimum_tile_height.max(1)); + let most = (room - minimum * (count - 1) as f64).max(minimum); + + let height = (current + delta).clamp(minimum, most); + self.set_tile_height(id, TileHeight::Fixed(height.round() as i32)); + } + + /// Give a tile the next preset height, or the previous one, as a share of the column. + /// + /// The first preset that is taller than the tile is now is the next one, or the first if there + /// is none, and the last that is shorter is the previous, or the last if there is none. After + /// that the presets are stepped through in order, and wrap. + pub fn cycle_preset_height(&mut self, id: &K, forward: bool) { + let Some(current) = self.tile_height(id) else { + return; + }; + let Some(index) = self.position(id) else { + return; + }; + + let column = &self.columns[index]; + let count = column.tile_count(); + let presets = &self.options.preset_heights; + if count == 1 || presets.is_empty() { + return; + } + + let (_, column_height, between) = self.column_extent(column); + let room = column_height - (count - 1) as f64 * between; + + let next = if forward { + presets + .iter() + .position(|preset| preset * room > current + 0.5) + .unwrap_or(0) + } else { + presets + .iter() + .rposition(|preset| preset * room < current - 0.5) + .unwrap_or(presets.len() - 1) + }; + + let preset = presets[next]; + self.set_tile_height(id, TileHeight::Proportion(preset)); + } + + /// Give every tile in the column that has this tile an equal share of its height again. + pub fn reset_tile_heights(&mut self, id: &K) { + let Some(index) = self.position(id) else { + return; + }; + + let tiles: Vec = self.columns[index].tiles().cloned().collect(); + self.heights.retain(|(tile, _)| !tiles.contains(tile)); + } + + /// Forget every height that any tile was given. + pub fn reset_heights(&mut self) { + self.heights.clear(); + } + + /// Start a pan, which is the user moving the strip across the viewport by hand, and forget + /// the viewport that was to be put back: they chose where it is now. + pub fn begin_pan(&mut self) { + self.return_to = None; + self.restore_x = None; + } + + /// Move the strip by `dx` on screen, in pixels, to the right if it is positive. Between + /// [`begin_pan`](Self::begin_pan) and [`pan_end`](Self::pan_end) nothing else changes: no + /// width, and not which column is active, so that the active column is only ever moved by the + /// user. The layout is what it should be at every step, and a column may be partly out of + /// view, or the whole strip may be beyond its ends by up to the width of the viewport. + pub fn pan_by(&mut self, dx: f64) { + if self.columns.is_empty() || !dx.is_finite() { + return; + } + + let geometry = self.geometry(); + let (min, max) = self.view_range(&geometry); + let overscroll = f64::from(self.viewport.width); + + let position = geometry.xs[self.active] - self.active_x - dx; + let position = position.clamp(min - overscroll, max + overscroll); + + self.active_x = geometry.xs[self.active] - position; + } + + /// Let go of the strip, moving at `velocity` pixels a second on screen, in the same direction + /// as [`pan_by`](Self::pan_by), and let it come to rest. Gives back where, or `None` if there + /// is nothing in the strip. + /// + /// It comes to rest with a column aligned to an edge of the viewport (or all of them centred, + /// where that is what the options ask for), at the alignment nearest to where it would get to + /// in about a third of a second at the speed that it was going. It never rests beyond the first + /// or the last column, whatever the speed. + /// + /// The tile that has focus keeps it if it is fully in view where the strip comes to rest. + /// Otherwise focus goes to a tile that is, which is the nearest column to it that is, and the + /// result says which. + pub fn pan_end(&mut self, velocity: f64) -> Option> { + if self.columns.is_empty() { + return None; + } + + let geometry = self.geometry(); + let view_width = f64::from(self.viewport.width); + let gaps = f64::from(self.options.gaps); + let (min, max) = self.view_range(&geometry); + + let from = geometry.xs[self.active] - self.active_x; + let velocity = if velocity.is_finite() { -velocity } else { 0.0 }; + + // where the viewport's left edge is when each column is aligned in each of the ways that + // a column can be aligned + let mut alignments = Vec::with_capacity(self.columns.len() * 2); + for (index, width) in geometry.widths.iter().enumerate() { + let x = geometry.xs[index]; + + if self.centers_focused_column() { + alignments.push(x - view::center(*width, view_width)); + } else { + alignments.push(x - view::fit(f64::NEG_INFINITY, *width, view_width, gaps)); + alignments.push(x - view::fit(f64::INFINITY, *width, view_width, gaps)); + } + } + + let mut to = view::snap(from, velocity, &alignments, min, max); + + // the columns that are fully in view where it comes to rest + let fully_visible = |position: f64, index: usize| { + let x = geometry.xs[index] - position; + let width = geometry.widths[index]; + + if width >= view_width { + return x.abs() < EPSILON; + } + + let margin = ((view_width - width) / 2.0).clamp(0.0, gaps); + x >= margin - EPSILON && x + width <= view_width - margin + EPSILON + }; + + let mut active = if fully_visible(to, self.active) { + Some(self.active) + } else { + (0..self.columns.len()) + .filter(|index| fully_visible(to, *index)) + .min_by_key(|index| index.abs_diff(self.active)) + }; + + if active.is_none() { + // not a single column fits where it came to rest, which happens when the columns are + // wider than the viewport, so the column nearest the middle is aligned instead + let middle = to + view_width / 2.0; + let nearest = (0..self.columns.len()) + .min_by(|a, b| { + let from_middle = |index: usize| { + (geometry.xs[index] + geometry.widths[index] / 2.0 - middle).abs() + }; + + from_middle(*a).total_cmp(&from_middle(*b)) + }) + .unwrap_or(self.active); + + to = geometry.xs[nearest] + - if self.centers_focused_column() { + view::center(geometry.widths[nearest], view_width) + } else { + view::fit( + f64::NEG_INFINITY, + geometry.widths[nearest], + view_width, + gaps, + ) + }; + active = Some(nearest); + } + + let active = active.unwrap_or(self.active); + + if active != self.active { + self.active = active; + } + self.active_x = geometry.xs[self.active] - to; + self.return_to = None; + self.restore_x = None; + + Some(Snap { + from, + to, + active: self.columns[self.active].last().clone(), + }) + } + + /// The furthest left and furthest right that the left edge of the viewport can be in the + /// strip: with the first column aligned to the left, and with the last aligned to the right, + /// or the same if the strip fits in the viewport. + fn view_range(&self, geometry: &Geometry) -> (f64, f64) { + let view_width = f64::from(self.viewport.width); + let gaps = f64::from(self.options.gaps); + let last = self.columns.len() - 1; + + let (first_aligned, last_aligned) = if self.centers_focused_column() { + ( + -view::center(geometry.widths[0], view_width), + geometry.xs[last] - view::center(geometry.widths[last], view_width), + ) + } else { + ( + -view::fit(f64::NEG_INFINITY, geometry.widths[0], view_width, gaps), + geometry.xs[last] + - view::fit(f64::INFINITY, geometry.widths[last], view_width, gaps), + ) + }; + + (first_aligned, last_aligned.max(first_aligned)) + } + + /// Centre the active column, this once. + pub fn center_column(&mut self) { + if self.columns.is_empty() || self.columns[self.active].fullscreen { + return; + } + + let width = self.geometry().widths[self.active]; + self.active_x = view::center(width, f64::from(self.viewport.width)); + } + + /// Centre all of the columns that are fully in view, as a group, this once. + /// + /// Nothing happens if the active column is not one of them. + pub fn center_visible_columns(&mut self) { + if self.columns.is_empty() || self.centers_focused_column() { + return; + } + + let geometry = self.geometry(); + let Some(group) = self.fully_visible(&geometry) else { + return; + }; + + let gaps = f64::from(self.options.gaps); + let view_width = f64::from(self.viewport.width); + let view_pos = geometry.xs[self.active] - self.active_x; + + // What is left over once the columns and the gaps between them are taken out is split + // between the two sides. + let margin = (view_width - group.taken + gaps) / 2.0; + let leftmost_x = geometry.xs[group.leftmost] - view_pos; + + self.active_x += margin - leftmost_x; + self.reveal(None); + } + + /// Make the active column as wide as it can be without hiding any of the columns that are + /// fully in view with it, and bring the leftmost of those to the left edge. + /// + /// If the active column is the only one in view, it takes the whole viewport width instead, + /// which [`toggle_full_width`](Self::toggle_full_width) can undo. + pub fn expand_to_available_width(&mut self) { + if self.columns.is_empty() { + return; + } + + let active = &self.columns[self.active]; + if active.fullscreen || active.full_width { + return; + } + + let id = active.id.clone(); + + if self.centers_focused_column() { + self.toggle_full_width(&id); + return; + } + + let geometry = self.geometry(); + let Some(group) = self.fully_visible(&geometry) else { + return; + }; + + let gaps = f64::from(self.options.gaps); + let view_width = f64::from(self.viewport.width); + + // The group has a gap after each column, and there is one more before the first. + let available = view_width - gaps - group.taken; + if available <= 0.0 { + return; + } + + if !group.has_others { + self.toggle_full_width(&id); + return; + } + + let view_pos = geometry.xs[self.active] - self.active_x; + let leftmost_x = geometry.xs[group.leftmost] - view_pos; + let width = geometry.widths[self.active] + available; + + let column = &mut self.columns[self.active]; + column.width = Some(ColumnWidth::Fixed(width.floor() as i32)); + column.preset = None; + column.full_width = false; + + self.active_x += gaps - leftmost_x; + self.reveal(None); + } + + /// Make the strip hold exactly these keys in exactly this order, with `focused` active, by + /// changing as little as possible. + /// + /// This is for a host that keeps its own list of tiles, which it changes by its own rules and + /// not through this model. Given the list after each change, the strip behaves as if every + /// change had been made through it: tiles that are no longer in the list are removed, new ones + /// are added (and made active, if one is `focused`), the others are moved into order, and + /// focus follows. Columns that are in both keep their width. + /// + /// The list is in reading order, and the columns are worked out from it: a tile that was in + /// a column with the tile before it is in a column with it still, and every other tile + /// starts a column of its own. So a tile that is new starts a column, and a list that the + /// host changed in a way that the model would not have is made sense of, with the list the + /// one that is right. + pub fn reconcile(&mut self, ids: &[K], focused: Option<&K>) { + let mut wanted: Vec<&K> = Vec::with_capacity(ids.len()); + for id in ids { + if !wanted.contains(&id) { + wanted.push(id); + } + } + + let gone: Vec = self + .ids() + .into_iter() + .filter(|id| !wanted.contains(&id)) + .collect(); + for id in &gone { + self.remove_column(id); + } + + // the tiles that are with the tile before them, which every column but the first tile of + // it is + let joined: Vec = self + .columns + .iter() + .flat_map(|column| column.rest.iter().cloned()) + .collect(); + + let mut runs: Vec> = Vec::new(); + for id in &wanted { + match runs.last_mut() { + Some(run) if joined.contains(id) => run.push((*id).clone()), + _ => runs.push(vec![(*id).clone()]), + } + } + + // every column that is there takes the tiles of its run. A tile that is not in the run of + // the column that had it has gone to another, which takes it. + for column in &mut self.columns { + if let Some(run) = runs.iter().find(|run| run[0] == column.id) + && column.tiles().ne(run.iter()) + { + column.set_tiles(run.clone()); + } + } + + for (index, run) in runs.iter().enumerate() { + if self.columns.iter().all(|column| column.id != run[0]) { + let activate = focused.is_some_and(|focused| run.contains(focused)); + self.add_column(run[0].clone(), Some(index), None, activate); + + if let Some(at) = self.position(&run[0]) { + self.columns[at].set_tiles(run.clone()); + } + } + } + + for (index, run) in runs.iter().enumerate() { + if self.columns[index].id != run[0] { + self.move_column(&run[0], index); + } + } + + if let Some(focused) = focused { + self.focus(focused); + } + } + + /// Where every column goes, from left to right. + #[must_use] + pub fn layout(&self) -> Vec> { + let geometry = self.geometry(); + let view_pos = if self.columns.is_empty() { + 0.0 + } else { + geometry.xs[self.active] - self.active_x + }; + + let gaps = self.options.gaps; + let Viewport { width, height } = self.viewport; + + let mut placements = Vec::with_capacity(self.tile_count()); + + for (index, column) in self.columns.iter().enumerate() { + let left = geometry.xs[index] - view_pos; + let right = left + geometry.widths[index]; + + // Edges are rounded, not widths, so that columns that touch in the strip still do + // once they are in pixels. + let x = left.round() as i32; + let end = right.round() as i32; + + let (top, column_height, between) = if column.fullscreen { + (0, height, 0) + } else { + (gaps, (height - 2 * gaps).max(1), gaps) + }; + + let visibility = if end <= 0 { + Visibility::OffLeft + } else if x >= width { + Visibility::OffRight + } else if x >= 0 && end <= width { + Visibility::Visible + } else { + Visibility::Clipped + }; + + // The tiles share the height of the column, with a gap between each, and edges are + // rounded here too so that the tiles meet. + let heights = self.tile_heights(column, f64::from(column_height), f64::from(between)); + let mut tile_top = f64::from(top); + + for ((tile, id), tile_height) in column.tiles().enumerate().zip(heights) { + let y = tile_top.round() as i32; + let bottom = (tile_top + tile_height).round() as i32; + tile_top += tile_height + f64::from(between); + + placements.push(Placement { + id: id.clone(), + rect: Rect { + x, + y, + width: end - x, + height: (bottom - y).max(1), + }, + visibility, + active: index == self.active && tile == column.last_tile, + }); + } + } + + placements + } + + /// The height that a tile was given, if it was given one. + fn height_given(&self, id: &K) -> Option { + self.heights + .iter() + .find(|(tile, _)| tile == id) + .map(|(_, height)| *height) + } + + /// How tall each tile of a column is, from the top, in pixels and not rounded, in a column of + /// `column_height` with `between` between its tiles. + /// + /// Tiles with a height of their own have it, and the others share what is left equally, but + /// no tile is made shorter than [`Options::minimum_tile_height`] unless the column has not got + /// the room for that many tiles that tall, when they are all the same. + fn tile_heights(&self, column: &Column, column_height: f64, between: f64) -> Vec { + let count = column.tile_count(); + let room = (column_height - (count - 1) as f64 * between).max(count as f64); + let equal = room / count as f64; + let minimum = f64::from(self.options.minimum_tile_height.max(0)); + + if count == 1 || room < minimum * count as f64 { + return vec![equal; count]; + } + + let given: Vec> = column + .tiles() + .map(|id| match self.height_given(id)? { + TileHeight::Auto => None, + TileHeight::Proportion(proportion) => Some((room * proportion).max(minimum)), + TileHeight::Fixed(pixels) => Some(f64::from(pixels).max(minimum)), + }) + .collect(); + + let others = given.iter().filter(|height| height.is_none()).count(); + let taken: f64 = given.iter().flatten().sum(); + + // with nothing to take up the rest, what was asked for is what each gets, in proportion + if others == 0 { + return given.iter().flatten().map(|h| h * room / taken).collect(); + } + + // what is asked for cannot leave the others with less than the least + let most = room - minimum * others as f64; + let scale = if taken > most { most / taken } else { 1.0 }; + let left = room - taken * scale; + + given + .into_iter() + .map(|height| height.map_or(left / others as f64, |height| height * scale)) + .collect() + } + + /// How tall the tile with this key is, in pixels and not rounded, or `None` if there is no + /// such tile. + #[must_use] + pub fn tile_height(&self, id: &K) -> Option { + let column = &self.columns[self.position(id)?]; + let (_, column_height, between) = self.column_extent(column); + + let heights = self.tile_heights(column, column_height, between); + Some(heights[column.tile_index(id)?]) + } + + /// Where a column starts from the top, how tall it is, and the gap between its tiles. + fn column_extent(&self, column: &Column) -> (f64, f64, f64) { + let gaps = f64::from(self.options.gaps); + let height = f64::from(self.viewport.height); + + if column.fullscreen { + (0.0, height, 0.0) + } else { + (gaps, (height - 2.0 * gaps).max(1.0), gaps) + } + } + + /// What a width comes to in pixels in the current viewport, before any limits. + fn resolve(&self, width: ColumnWidth) -> f64 { + match width { + ColumnWidth::Proportion(proportion) => { + let gaps = f64::from(self.options.gaps); + (f64::from(self.viewport.width) - gaps) * proportion - gaps + } + ColumnWidth::Fixed(pixels) => f64::from(pixels), + } + } + + /// How wide a column is, in pixels. + fn width_of(&self, column: &Column) -> f64 { + if column.fullscreen { + return f64::from(self.viewport.width).max(1.0); + } + + let mut width = if column.full_width { + self.resolve(ColumnWidth::Proportion(1.0)) + } else { + self.resolve(column.width.unwrap_or(self.options.default_width)) + }; + + if let Some(max) = column.max_width { + width = width.min(f64::from(max)); + } + + if let Some(min) = column.min_width { + width = width.max(f64::from(min)); + } + + width.max(1.0) + } + + fn geometry(&self) -> Geometry { + let gaps = f64::from(self.options.gaps); + let widths: Vec = self + .columns + .iter() + .map(|column| self.width_of(column)) + .collect(); + + let mut xs = Vec::with_capacity(widths.len()); + let mut x = 0.0; + for width in &widths { + xs.push(x); + x += width + gaps; + } + + Geometry { widths, xs } + } + + fn reset_view(&mut self) { + self.active = 0; + self.active_x = 0.0; + self.return_to = None; + self.restore_x = None; + } + + /// Whether the active column is centred whatever else is the case. + fn centers_focused_column(&self) -> bool { + self.options.center_focused_column == CenterFocusedColumn::Always + || (self.options.always_center_single_column && self.columns.len() <= 1) + } + + /// Whether the active column should be centred, having just become active, coming from the + /// column at `from` if there was one. + fn should_center(&self, geometry: &Geometry, from: Option) -> bool { + if self.centers_focused_column() { + return true; + } + + let (CenterFocusedColumn::OnOverflow, Some(from)) = + (self.options.center_focused_column, from) + else { + return false; + }; + + if from == self.active { + return false; + } + + // Not the column that focus came from, but the one next to the new column on that side: + // jumping a long way does not make the new column any more likely to need centring. + let neighbour = if from > self.active { + self.active + 1 + } else { + self.active - 1 + }; + + let left = geometry.xs[neighbour].min(geometry.xs[self.active]); + let right = (geometry.xs[neighbour] + geometry.widths[neighbour]) + .max(geometry.xs[self.active] + geometry.widths[self.active]); + + let gaps = f64::from(self.options.gaps); + right - left + 2.0 * gaps > f64::from(self.viewport.width) + } + + /// Put the active column where it belongs in view, starting from where it is now. + fn reveal(&mut self, from: Option) { + if self.columns.is_empty() { + return; + } + + let geometry = self.geometry(); + let width = geometry.widths[self.active]; + let view_width = f64::from(self.viewport.width); + let gaps = f64::from(self.options.gaps); + + self.active_x = if self.columns[self.active].fullscreen { + 0.0 + } else if self.should_center(&geometry, from) { + view::center(width, view_width) + } else { + view::fit(self.active_x, width, view_width, gaps) + }; + } + + /// Make another column active, keeping the viewport and scrolling only as far as is needed. + fn activate(&mut self, index: usize) { + if index == self.active { + return; + } + + let geometry = self.geometry(); + let view_pos = geometry.xs[self.active] - self.active_x; + let previous = self.active; + + self.active = index; + self.active_x = geometry.xs[index] - view_pos; + self.return_to = None; + self.restore_x = None; + + self.reveal(Some(previous)); + } + + /// A column changed width. Only the active column needs anything done about it: the columns + /// to its right are placed from wherever it ends, and the ones to its left are placed from + /// where it starts, which has not changed. + fn after_resize(&mut self, index: usize) { + if index == self.active { + self.reveal(None); + } + } + + /// The columns that are fully in view, with at least a gap on either side, if the active one + /// is among them. + fn fully_visible(&self, geometry: &Geometry) -> Option { + let gaps = f64::from(self.options.gaps); + let view_width = f64::from(self.viewport.width); + let view_pos = geometry.xs[self.active] - self.active_x; + + let mut leftmost = None; + let mut taken = 0.0; + let mut has_active = false; + let mut has_others = false; + + for (index, (x, width)) in geometry.xs.iter().zip(&geometry.widths).enumerate() { + let x = x - view_pos; + + if x < gaps - TOLERANCE { + continue; + } + + if x + width + gaps > view_width + TOLERANCE { + break; + } + + leftmost.get_or_insert(index); + if index == self.active { + has_active = true; + } else { + has_others = true; + } + + taken += width + gaps; + } + + has_active.then_some(Group { + leftmost: leftmost?, + taken, + has_others, + }) + } +} diff --git a/komorebi-scrolling/src/swipe.rs b/komorebi-scrolling/src/swipe.rs new file mode 100644 index 00000000..ac9f3087 --- /dev/null +++ b/komorebi-scrolling/src/swipe.rs @@ -0,0 +1,133 @@ +use std::collections::VecDeque; + +/// How far back a gesture is looked at to tell how fast it is going, in seconds. +const HISTORY_SECONDS: f64 = 0.150; + +/// Works out how fast a gesture is going, from the last 150 milliseconds of it. +/// +/// A host gives it every movement of a finger or a wheel with when it happened, and asks how fast +/// the gesture is going when it lets go. It never looks at a clock, so what it says is the same +/// for the same movements whenever they are given. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SwipeTracker { + /// When each movement happened, in seconds, and how far it went. + history: VecDeque<(f64, f64)>, +} + +impl SwipeTracker { + /// A tracker that has seen nothing. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Forget everything, for the start of a gesture. + pub fn reset(&mut self) { + self.history.clear(); + } + + /// Say that the gesture moved by `delta` at `time`, in seconds. The times only have to be in + /// order and in the same unit as each other. + pub fn push(&mut self, time: f64, delta: f64) { + self.history.push_back((time, delta)); + + while let Some((oldest, _)) = self.history.front() { + if time - oldest > HISTORY_SECONDS { + self.history.pop_front(); + } else { + break; + } + } + } + + /// How fast the gesture is going, in the unit of the deltas per second, which is the distance + /// covered from the first movement that is looked at to the last, over the time that it took. + /// + /// Nothing is moving as far as can be told from fewer than two movements, or from movements + /// that all happened at the same time. + #[must_use] + pub fn velocity(&self) -> f64 { + let (Some((first, _)), Some((last, _))) = (self.history.front(), self.history.back()) + else { + return 0.0; + }; + + let span = last - first; + if span <= 0.0 { + return 0.0; + } + + // the first movement is where the span starts, and the distance is what happened after it + let distance: f64 = self.history.iter().skip(1).map(|(_, delta)| delta).sum(); + + distance / span + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_steady_gesture_goes_at_the_speed_that_it_goes() { + let mut tracker = SwipeTracker::new(); + + // 10 units every 10 milliseconds is 1000 a second + for step in 0..10 { + tracker.push(f64::from(step) * 0.010, 10.0); + } + + assert!((tracker.velocity() - 1000.0).abs() < 1e-9); + } + + #[test] + fn what_is_too_old_is_not_looked_at() { + let mut tracker = SwipeTracker::new(); + + // fast, and then a pause, and then slow + for step in 0..5 { + tracker.push(f64::from(step) * 0.010, 100.0); + } + for step in 0..5 { + tracker.push(1.0 + f64::from(step) * 0.010, 1.0); + } + + assert!((tracker.velocity() - 100.0).abs() < 1e-9); + } + + #[test] + fn it_goes_the_other_way_when_the_gesture_does() { + let mut tracker = SwipeTracker::new(); + for step in 0..4 { + tracker.push(f64::from(step) * 0.010, -5.0); + } + + assert!(tracker.velocity() < 0.0); + } + + #[test] + fn not_enough_to_tell_is_not_moving() { + let mut tracker = SwipeTracker::new(); + assert_eq!(tracker.velocity(), 0.0); + + tracker.push(0.0, 50.0); + assert_eq!(tracker.velocity(), 0.0); + + // several at once do not make a span + tracker.push(0.0, 50.0); + assert_eq!(tracker.velocity(), 0.0); + } + + #[test] + fn a_reset_forgets_the_gesture_before() { + let mut tracker = SwipeTracker::new(); + for step in 0..4 { + tracker.push(f64::from(step) * 0.010, 50.0); + } + + tracker.reset(); + + assert_eq!(tracker, SwipeTracker::new()); + assert_eq!(tracker.velocity(), 0.0); + } +} diff --git a/komorebi-scrolling/src/view.rs b/komorebi-scrolling/src/view.rs new file mode 100644 index 00000000..0ad30d4c --- /dev/null +++ b/komorebi-scrolling/src/view.rs @@ -0,0 +1,143 @@ +//! The arithmetic of where a column sits in the viewport. +//! +//! Everything here is a pure function of numbers, so that the rules for scrolling can be tested +//! without building a strip first. `x` is always the distance from the left edge of the viewport +//! to the left edge of a column. + +/// Where the left edge of a column of `width` should sit so that the column is fully in view, +/// moving it no further than necessary from where it is now (`x`). +/// +/// A column that is already in view is left exactly where it is. A column that is not is brought +/// in until it sits `gaps` away from the nearest edge of the viewport, or as close to that as +/// there is room for, since a column that nearly fills the viewport leaves less than a gap on +/// each side. A column that is as wide as the viewport or wider cannot be in view, so it is +/// aligned with the left edge, where the start of it can be seen. +pub(crate) fn fit(x: f64, width: f64, view_width: f64, gaps: f64) -> f64 { + if width >= view_width { + return 0.0; + } + + // The margin cannot be more than what is left over, split between the two sides. This also + // keeps the range below from being empty. + let margin = ((view_width - width) / 2.0).clamp(0.0, gaps); + x.clamp(margin, view_width - margin - width) +} + +/// Where the left edge of a column of `width` sits when it is centred, or at the left edge if it +/// is too wide to be centred. +pub(crate) fn center(width: f64, view_width: f64) -> f64 { + if width >= view_width { + 0.0 + } else { + (view_width - width) / 2.0 + } +} + +/// How far ahead of where it is released a pan is taken to be going, in seconds. A pan that is +/// let go while it is still moving carries on for about this long, which is what a flick is. +pub(crate) const PAN_PROJECTION_SECONDS: f64 = 0.333; + +/// Which of the `alignments` a pan comes to rest at, as a position of the left edge of the viewport +/// in the strip. +/// +/// The pan was let go at `position` moving at `velocity`, in the same units per second and in the +/// direction that the position grows, and is taken to carry on for [`PAN_PROJECTION_SECONDS`]. It +/// ends at the alignment that is nearest to where that gets to, with ties going to the first. The +/// strip has `min` and `max` for its ends, and nothing outside of them is where it can come to +/// rest, however hard it was flicked. If there is no alignment in range it rests at the end that +/// the projection is nearest to. +pub(crate) fn snap(position: f64, velocity: f64, alignments: &[f64], min: f64, max: f64) -> f64 { + let max = max.max(min); + let projected = (position + velocity * PAN_PROJECTION_SECONDS).clamp(min, max); + + let mut best: Option = None; + for alignment in alignments { + let alignment = alignment.clamp(min, max); + + if best.is_none_or(|best| (alignment - projected).abs() < (best - projected).abs()) { + best = Some(alignment); + } + } + + best.unwrap_or(projected) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_pan_that_is_let_go_standing_still_rests_at_the_nearest_alignment() { + let alignments = [0.0, 300.0, 600.0, 900.0]; + + assert_eq!(snap(140.0, 0.0, &alignments, 0.0, 900.0), 0.0); + assert_eq!(snap(160.0, 0.0, &alignments, 0.0, 900.0), 300.0); + assert_eq!(snap(300.0, 0.0, &alignments, 0.0, 900.0), 300.0); + } + + #[test] + fn a_flick_carries_on_in_the_direction_that_it_was_going() { + let alignments = [0.0, 300.0, 600.0, 900.0]; + + // 1500 a second for a third of a second is 500 further on + assert_eq!(snap(100.0, 1500.0, &alignments, 0.0, 900.0), 600.0); + assert_eq!(snap(700.0, -1500.0, &alignments, 0.0, 900.0), 300.0); + } + + #[test] + fn a_pan_cannot_end_beyond_the_ends_of_the_strip() { + let alignments = [0.0, 300.0, 600.0, 900.0]; + + assert_eq!(snap(500.0, 90_000.0, &alignments, 0.0, 900.0), 900.0); + assert_eq!(snap(500.0, -90_000.0, &alignments, 0.0, 900.0), 0.0); + // and one that is let go beyond them comes back + assert_eq!(snap(-200.0, 0.0, &alignments, 0.0, 900.0), 0.0); + assert_eq!(snap(1200.0, 0.0, &alignments, 0.0, 900.0), 900.0); + } + + #[test] + fn with_nothing_to_align_to_the_pan_rests_where_the_projection_says() { + assert_eq!(snap(100.0, 0.0, &[], 0.0, 900.0), 100.0); + assert_eq!(snap(100.0, 0.0, &[], 300.0, 300.0), 300.0); + } + + #[test] + fn a_column_in_view_stays_where_it_is() { + assert_eq!(fit(300.0, 400.0, 1200.0, 16.0), 300.0); + // right up against either margin is still in view + assert_eq!(fit(16.0, 400.0, 1200.0, 16.0), 16.0); + assert_eq!(fit(784.0, 400.0, 1200.0, 16.0), 784.0); + } + + #[test] + fn a_column_that_is_out_of_view_is_brought_in_by_the_least_amount() { + // off the right edge: its right edge ends up one gap from the edge of the viewport + assert_eq!(fit(1000.0, 400.0, 1200.0, 16.0), 784.0); + // off the left edge: its left edge ends up one gap from the edge of the viewport + assert_eq!(fit(-300.0, 400.0, 1200.0, 16.0), 16.0); + // partly in view counts as out of view + assert_eq!(fit(900.0, 400.0, 1200.0, 0.0), 800.0); + assert_eq!(fit(-1.0, 400.0, 1200.0, 0.0), 0.0); + } + + #[test] + fn a_column_that_nearly_fills_the_viewport_gets_what_room_there_is() { + // 1180 leaves 20 pixels, so the margin is 10 and not the 16 that was asked for + assert_eq!(fit(500.0, 1180.0, 1200.0, 16.0), 10.0); + assert_eq!(fit(-500.0, 1180.0, 1200.0, 16.0), 10.0); + } + + #[test] + fn a_column_that_cannot_be_in_view_is_aligned_with_the_left_edge() { + assert_eq!(fit(500.0, 1200.0, 1200.0, 16.0), 0.0); + assert_eq!(fit(500.0, 3000.0, 1200.0, 16.0), 0.0); + assert_eq!(fit(-500.0, 3000.0, 1200.0, 16.0), 0.0); + } + + #[test] + fn centring() { + assert_eq!(center(400.0, 1200.0), 400.0); + assert_eq!(center(1200.0, 1200.0), 0.0); + assert_eq!(center(2000.0, 1200.0), 0.0); + } +} diff --git a/komorebi-scrolling/tests/common/mod.rs b/komorebi-scrolling/tests/common/mod.rs new file mode 100644 index 00000000..484bbfae --- /dev/null +++ b/komorebi-scrolling/tests/common/mod.rs @@ -0,0 +1,67 @@ +#![allow(dead_code)] + +use komorebi_scrolling::ColumnWidth; +use komorebi_scrolling::Options; +use komorebi_scrolling::Placement; +use komorebi_scrolling::Rect; +use komorebi_scrolling::ScrollingSpace; +use komorebi_scrolling::Viewport; + +pub const VIEW: Viewport = Viewport { + width: 1200, + height: 800, +}; + +/// Options where every new column is `proportion` of the viewport wide, and there are no gaps. +pub fn options(proportion: f64) -> Options { + Options { + default_width: ColumnWidth::Proportion(proportion), + ..Options::default() + } +} + +/// A strip of `count` columns, keyed 0, 1, 2..., each opened next to the one before and in focus. +pub fn strip(options: Options, count: u32) -> ScrollingSpace { + let mut space = ScrollingSpace::new(options, VIEW); + for id in 0..count { + space.add_column(id, None, None, true); + } + + space +} + +pub fn placement(space: &ScrollingSpace, id: u32) -> Placement { + space + .layout() + .into_iter() + .find(|placement| placement.id == id) + .unwrap_or_else(|| panic!("there is no column {id}")) +} + +pub fn rect(space: &ScrollingSpace, id: u32) -> Rect { + placement(space, id).rect +} + +pub fn x_of(space: &ScrollingSpace, id: u32) -> i32 { + rect(space, id).x +} + +pub fn width_of(space: &ScrollingSpace, id: u32) -> i32 { + rect(space, id).width +} + +pub fn widths(space: &ScrollingSpace) -> Vec<(u32, i32)> { + space + .layout() + .into_iter() + .map(|placement| (placement.id, placement.rect.width)) + .collect() +} + +pub fn rects(space: &ScrollingSpace) -> Vec<(u32, Rect)> { + space + .layout() + .into_iter() + .map(|placement| (placement.id, placement.rect)) + .collect() +} diff --git a/komorebi-scrolling/tests/heights.rs b/komorebi-scrolling/tests/heights.rs new file mode 100644 index 00000000..e5527159 --- /dev/null +++ b/komorebi-scrolling/tests/heights.rs @@ -0,0 +1,280 @@ +//! How tall the tiles of a column are: equal by default, and one of them can be given a height, +//! with the others taking up what is left. +//! +//! The viewport is 1200 by 800 and there are no gaps unless a test says so. + +mod common; + +use common::*; +use komorebi_scrolling::Options; +use komorebi_scrolling::ScrollingSpace; +use komorebi_scrolling::TileHeight; + +/// A strip of `tiles` tiles in one column, with 0 in focus, and then one more column +fn column_of(options: Options, tiles: u32) -> ScrollingSpace { + let mut space = strip(options, tiles + 1); + space.focus(&0); + for _ in 1..tiles { + space.consume_right(); + } + + space +} + +fn heights_of(space: &ScrollingSpace, tiles: u32) -> Vec { + (0..tiles).map(|id| rect(space, id).height).collect() +} + +#[test] +fn tiles_share_the_height_equally_until_one_is_given_a_height() { + let space = column_of(options(0.25), 3); + + let heights = heights_of(&space, 3); + assert!(heights.iter().all(|h| (h - 267).abs() <= 1), "{heights:?}"); + assert_eq!(heights.iter().sum::(), 800); +} + +#[test] +fn a_tile_that_is_given_a_height_has_it_and_the_others_share_the_rest() { + let mut space = column_of(options(0.25), 3); + + space.set_tile_height(&1, TileHeight::Fixed(500)); + + assert_eq!(heights_of(&space, 3), [150, 500, 150]); + assert_eq!(space.tile_height(&1), Some(500.0)); +} + +#[test] +fn a_height_can_be_a_share_of_the_column() { + let mut space = column_of(options(0.25), 2); + + space.set_tile_height(&0, TileHeight::Proportion(0.25)); + + assert_eq!(heights_of(&space, 2), [200, 600]); +} + +#[test] +fn a_share_of_the_column_takes_the_gaps_between_the_tiles_out_first() { + let options = Options { + gaps: 10, + ..options(0.25) + }; + let mut space = column_of(options, 2); + + // 800 less 10 above, 10 below and 10 between is 770 + space.set_tile_height(&0, TileHeight::Proportion(0.5)); + + assert_eq!(heights_of(&space, 2), [385, 385]); + assert_eq!(rect(&space, 0).y, 10); + assert_eq!(rect(&space, 1).bottom(), 790); +} + +#[test] +fn giving_a_tile_a_height_gives_the_others_back_an_equal_share() { + let mut space = column_of(options(0.25), 3); + space.set_tile_height(&0, TileHeight::Fixed(500)); + + space.set_tile_height(&2, TileHeight::Fixed(300)); + + // 0 is no longer as tall as it was given, and shares what 2 leaves with 1 + assert_eq!(heights_of(&space, 3), [250, 250, 300]); +} + +#[test] +fn a_tile_can_be_given_back_an_equal_share_by_itself() { + let mut space = column_of(options(0.25), 3); + space.set_tile_height(&1, TileHeight::Fixed(500)); + + space.set_tile_height(&1, TileHeight::Auto); + + let heights = heights_of(&space, 3); + assert!(heights.iter().all(|h| (h - 267).abs() <= 1), "{heights:?}"); +} + +#[test] +fn a_tile_that_is_alone_fills_its_column_whatever_it_is_given() { + let mut space = strip(options(0.25), 2); + + space.set_tile_height(&0, TileHeight::Fixed(100)); + space.resize_tile_height(&0, -50.0); + + assert_eq!(rect(&space, 0).height, 800); +} + +#[test] +fn what_one_tile_is_given_cannot_leave_the_others_less_than_the_minimum() { + let options = Options { + minimum_tile_height: 200, + ..options(0.25) + }; + let mut space = column_of(options, 3); + + space.set_tile_height(&1, TileHeight::Fixed(700)); + + // 800 less two tiles of at least 200 + assert_eq!(heights_of(&space, 3), [200, 400, 200]); +} + +#[test] +fn a_tile_is_not_given_less_than_the_minimum() { + let options = Options { + minimum_tile_height: 200, + ..options(0.25) + }; + let mut space = column_of(options, 2); + + space.set_tile_height(&0, TileHeight::Fixed(50)); + + assert_eq!(heights_of(&space, 2), [200, 600]); +} + +#[test] +fn a_column_without_room_for_the_minimum_shares_equally() { + let options = Options { + minimum_tile_height: 300, + ..options(0.25) + }; + let mut space = column_of(options, 3); + space.set_tile_height(&0, TileHeight::Fixed(500)); + + let heights = heights_of(&space, 3); + assert!(heights.iter().all(|h| (h - 267).abs() <= 1), "{heights:?}"); +} + +#[test] +fn resizing_a_tile_starts_from_the_height_that_it_has() { + let mut space = column_of(options(0.25), 2); + assert_eq!(heights_of(&space, 2), [400, 400]); + + space.resize_tile_height(&0, 100.0); + assert_eq!(heights_of(&space, 2), [500, 300]); + + space.resize_tile_height(&0, -250.0); + assert_eq!(heights_of(&space, 2), [250, 550]); +} + +#[test] +fn resizing_a_tile_stops_at_the_minimum_and_at_what_the_others_need() { + let options = Options { + minimum_tile_height: 150, + ..options(0.25) + }; + let mut space = column_of(options, 3); + + space.resize_tile_height(&1, 5000.0); + assert_eq!(heights_of(&space, 3), [150, 500, 150]); + + space.resize_tile_height(&1, -5000.0); + assert_eq!(rect(&space, 1).height, 150); +} + +#[test] +fn preset_heights_are_stepped_through_in_order_and_wrap() { + let mut space = column_of(options(0.25), 2); + let mut seen = vec![]; + + // a third, a half and two thirds of 800, and the tile starts at a half + for _ in 0..4 { + space.cycle_preset_height(&0, true); + seen.push(rect(&space, 0).height); + } + assert_eq!(seen, [533, 267, 400, 533]); + + let mut seen = vec![]; + for _ in 0..3 { + space.cycle_preset_height(&0, false); + seen.push(rect(&space, 0).height); + } + assert_eq!(seen, [400, 267, 533]); +} + +#[test] +fn resetting_the_heights_of_a_column_leaves_the_other_columns_alone() { + let mut space = column_of(options(0.25), 2); + space.consume_right(); + space.set_tile_height(&1, TileHeight::Fixed(600)); + assert_eq!(rect(&space, 1).height, 600); + + space.reset_tile_heights(&0); + + assert_eq!(heights_of(&space, 3).iter().sum::(), 800); + assert!((rect(&space, 0).height - 267).abs() <= 1); +} + +#[test] +fn every_height_can_be_forgotten() { + let mut space = column_of(options(0.25), 2); + space.set_tile_height(&0, TileHeight::Fixed(600)); + + space.reset_heights(); + + assert_eq!(heights_of(&space, 2), [400, 400]); +} + +#[test] +fn a_height_stays_with_its_tile_when_the_tile_moves() { + let mut space = column_of(options(0.25), 3); + space.set_tile_height(&0, TileHeight::Fixed(500)); + + space.move_tile_down(); + + // 0 moved below 1, and it is 0 that is 500 tall wherever it is + assert_eq!(space.ids()[..3], [1, 0, 2]); + assert_eq!(heights_of(&space, 3), [500, 150, 150]); + assert!(rect(&space, 1).y < rect(&space, 0).y); +} + +#[test] +fn a_tile_that_changes_column_starts_with_an_equal_share() { + let mut space = column_of(options(0.25), 2); + space.set_tile_height(&0, TileHeight::Fixed(600)); + + space.expel(); + + // 0 is on its own now, in a column of its own, and 1 is too + assert_eq!(rect(&space, 0).height, 800); + + space.consume_right(); + // and the tile that joined it is as tall as an equal share, as it is + assert_eq!(space.column_of(&0), Some(vec![0, 2])); + assert_eq!(rect(&space, 0).height, 400); + assert_eq!(rect(&space, 2).height, 400); +} + +#[test] +fn a_tile_that_is_closed_takes_its_height_with_it() { + let mut space = column_of(options(0.25), 3); + space.set_tile_height(&1, TileHeight::Fixed(500)); + + space.remove_column(&1); + + assert_eq!(rect(&space, 0).height, 400); + assert_eq!(rect(&space, 2).height, 400); +} + +#[test] +fn heights_do_not_move_anything_sideways() { + let mut space = column_of(options(0.25), 3); + let x = space.active_x(); + let widths_before = widths(&space); + + space.resize_tile_height(&0, 100.0); + space.cycle_preset_height(&0, true); + + assert_eq!(space.active_x(), x); + assert_eq!(widths(&space), widths_before); +} + +#[test] +fn saying_what_is_there_keeps_the_heights() { + let mut space = column_of(options(0.25), 3); + space.set_tile_height(&1, TileHeight::Fixed(500)); + let before = space.clone(); + + let ids = space.ids(); + let focused = space.active_id().copied(); + space.reconcile(&ids, focused.as_ref()); + + assert_eq!(space, before); + assert_eq!(heights_of(&space, 3), [150, 500, 150]); +} diff --git a/komorebi-scrolling/tests/pan.rs b/komorebi-scrolling/tests/pan.rs new file mode 100644 index 00000000..81fdf19e --- /dev/null +++ b/komorebi-scrolling/tests/pan.rs @@ -0,0 +1,290 @@ +//! Moving the strip by hand: a pan is a drag that the user starts, moves and lets go of, perhaps +//! still moving, and the strip comes to rest with a column aligned to an edge. +//! +//! The viewport is 1200 by 800 and there are no gaps unless a test says so. Eight columns of 300 +//! are 2400 wide, so the viewport's left edge can be anywhere from 0 to 1200 in it. + +mod common; + +use common::*; +use komorebi_scrolling::CenterFocusedColumn; +use komorebi_scrolling::ColumnWidth; +use komorebi_scrolling::Options; +use komorebi_scrolling::ScrollingSpace; +use komorebi_scrolling::Visibility; + +fn visible_ids(space: &ScrollingSpace) -> Vec { + space + .layout() + .into_iter() + .filter(|placement| placement.visibility == Visibility::Visible) + .map(|placement| placement.id) + .collect() +} + +#[test] +fn a_pan_moves_the_strip_by_exactly_what_it_is_given_and_nothing_else() { + let mut space = strip(options(0.25), 8); + assert_eq!(space.view_offset(), 1200.0); + let widths_before = widths(&space); + + space.begin_pan(); + space.pan_by(100.0); + + // the strip moved to the right on screen, so the viewport is further left in it + assert_eq!(space.view_offset(), 1100.0); + assert_eq!(space.active_x(), Some(1000.0)); + assert_eq!(x_of(&space, 7), 1000); + assert_eq!(widths(&space), widths_before); + assert_eq!( + space.active_id(), + Some(&7), + "the active column is the user's to move" + ); + + space.pan_by(-350.0); + assert_eq!(space.view_offset(), 1450.0); +} + +#[test] +fn letting_go_standing_still_rests_at_the_nearest_alignment() { + let mut space = strip(options(0.25), 8); + + space.begin_pan(); + space.pan_by(500.0); + let snap = space.pan_end(0.0).unwrap(); + + // from 1200 to 700, which is nearer 600 than it is 900 + assert_eq!(snap.from, 700.0); + assert_eq!(snap.to, 600.0); + assert_eq!(space.view_offset(), 600.0); + + // columns 2 to 5 are in view, and focus moved to the nearest of them to where it was + assert_eq!(visible_ids(&space), [2, 3, 4, 5]); + assert_eq!(snap.active, 5); + assert_eq!(space.active_id(), Some(&5)); + assert_eq!(x_of(&space, 5), 900); +} + +#[test] +fn a_flick_carries_on_in_the_direction_it_was_going() { + let mut space = strip(options(0.25), 8); + space.focus(&0); + assert_eq!(space.view_offset(), 0.0); + + // the finger moved left by 100, so the viewport is further right in the strip, and it was + // going on at 1500 pixels a second when it was let go, which is another 500 or so + space.begin_pan(); + space.pan_by(-100.0); + let snap = space.pan_end(-1500.0).unwrap(); + + assert_eq!(snap.to, 600.0); + assert_eq!(visible_ids(&space), [2, 3, 4, 5]); +} + +#[test] +fn the_strip_never_comes_to_rest_past_either_end_however_hard_it_was_flicked() { + let mut space = strip(options(0.25), 8); + space.focus(&4); + + space.begin_pan(); + let snap = space.pan_end(-1_000_000.0).unwrap(); + assert_eq!(snap.to, 1200.0); + assert_eq!(x_of(&space, 7), 900); + + space.begin_pan(); + let snap = space.pan_end(1_000_000.0).unwrap(); + assert_eq!(snap.to, 0.0); + assert_eq!(x_of(&space, 0), 0); +} + +#[test] +fn a_pan_that_goes_past_the_end_and_is_let_go_comes_back() { + let mut space = strip(options(0.25), 8); + + space.begin_pan(); + space.pan_by(-500.0); + // 500 past the last column, and it is just as far as it was told + assert_eq!(space.view_offset(), 1700.0); + + let snap = space.pan_end(0.0).unwrap(); + + assert_eq!(snap.from, 1700.0); + assert_eq!(snap.to, 1200.0); +} + +#[test] +fn how_far_past_the_end_a_pan_can_go_is_limited() { + let mut space = strip(options(0.25), 8); + + space.begin_pan(); + space.pan_by(-1_000_000.0); + + // by about the width of the viewport + assert_eq!(space.view_offset(), 2400.0); + space.pan_by(f64::NAN); + assert_eq!(space.view_offset(), 2400.0); +} + +#[test] +fn focus_stays_where_it_is_when_it_is_still_in_view_after_the_pan() { + let mut space = strip(options(0.25), 8); + space.focus(&3); + assert_eq!(space.view_offset(), 900.0); + + space.begin_pan(); + space.pan_by(300.0); + let snap = space.pan_end(0.0).unwrap(); + + // column 3 is at the left edge and the pan took it a column to the right + assert_eq!(snap.to, 600.0); + assert_eq!(snap.active, 3); + assert_eq!(space.active_id(), Some(&3)); + assert_eq!(x_of(&space, 3), 300); +} + +#[test] +fn focus_goes_to_the_nearest_column_in_view_when_the_active_one_is_not() { + let mut space = strip(options(0.25), 8); + space.focus(&0); + + space.begin_pan(); + space.pan_by(-900.0); + let snap = space.pan_end(0.0).unwrap(); + + assert_eq!(snap.to, 900.0); + assert_eq!(visible_ids(&space), [3, 4, 5, 6]); + assert_eq!(snap.active, 3, "the one of them that is nearest to 0"); +} + +#[test] +fn the_tile_that_has_focus_in_a_column_is_the_one_that_gets_it() { + let mut space = strip(options(0.25), 8); + space.focus(&5); + space.consume_right(); + space.focus(&6); + space.focus(&7); + // seven columns of 300, one of which has two tiles, with 7 in the last of them + assert_eq!(space.view_offset(), 1200.0); + + space.begin_pan(); + space.pan_by(600.0); + let snap = space.pan_end(0.0).unwrap(); + + // 7 is out of view, and the nearest column that is in view is the one with 5 and 6, which + // remembers that 6 had focus + assert_eq!(snap.to, 600.0); + assert_eq!(snap.active, 6); +} + +#[test] +fn gaps_are_left_between_the_strip_and_the_edges() { + let options = Options { + gaps: 16, + ..options(0.25) + }; + let mut space = strip(options, 8); + space.focus(&0); + + space.begin_pan(); + space.pan_by(500.0); + space.pan_end(0.0); + assert_eq!( + x_of(&space, 0), + 16, + "the first column is a gap from the edge" + ); + + space.begin_pan(); + space.pan_end(-1_000_000.0); + let last = placement(&space, 7).rect; + assert_eq!(last.right(), 1200 - 16); +} + +#[test] +fn a_strip_that_fits_in_the_viewport_stays_where_it_starts() { + let mut space = strip(options(0.25), 2); + + space.begin_pan(); + space.pan_by(400.0); + let snap = space.pan_end(5000.0).unwrap(); + + assert_eq!(snap.to, 0.0); + assert_eq!(x_of(&space, 0), 0); + assert_eq!(x_of(&space, 1), 300); +} + +#[test] +fn columns_wider_than_the_viewport_rest_at_their_left_edge() { + let mut space = strip(options(0.25), 3); + for id in 0..3 { + space.set_width(&id, ColumnWidth::Fixed(1500)); + } + + space.begin_pan(); + space.pan_by(700.0); + let snap = space.pan_end(0.0).unwrap(); + + // none of them fits, so the nearest to the middle is aligned, and is the active one + let active = placement(&space, snap.active); + assert_eq!(active.rect.x, 0); + assert_eq!(active.visibility, Visibility::Clipped); +} + +#[test] +fn when_columns_are_centred_the_pan_rests_with_one_in_the_middle() { + let options = Options { + center_focused_column: CenterFocusedColumn::Always, + ..options(0.25) + }; + let mut space = strip(options, 8); + space.focus(&3); + + space.begin_pan(); + space.pan_by(130.0); + let snap = space.pan_end(0.0).unwrap(); + + // 450 is where the left edge of the column would be if it were centred in 1200 + assert_eq!(x_of(&space, snap.active), 450); +} + +#[test] +fn a_pan_forgets_the_view_that_was_to_be_put_back() { + let mut space = strip(options(0.25), 3); + space.focus(&0); + // 9 is opened next to 0, so closing it would put focus back on 0 + space.add_column(9, None, None, true); + + space.begin_pan(); + space.remove_column(&9); + + // the user moved the strip since, so it is a column that takes the place of the one that went + assert_eq!(space.active_id(), Some(&1)); +} + +#[test] +fn nothing_in_the_strip_means_nothing_to_pan() { + let mut space = ScrollingSpace::::new(options(0.25), VIEW); + + space.begin_pan(); + space.pan_by(100.0); + + assert_eq!(space.pan_end(100.0), None); +} + +#[test] +fn a_pan_that_is_let_go_is_laid_out_like_any_other_strip() { + let mut space = strip(options(0.25), 8); + + space.begin_pan(); + space.pan_by(333.0); + space.pan_end(250.0); + + // the placements say which columns are where, and the active one is fully in view + let layout = space.layout(); + let active = layout.iter().find(|placement| placement.active).unwrap(); + assert_eq!(active.visibility, Visibility::Visible); + for pair in layout.windows(2) { + assert_eq!(pair[1].rect.x - pair[0].rect.right(), 0); + } +} diff --git a/komorebi-scrolling/tests/properties.rs b/komorebi-scrolling/tests/properties.rs new file mode 100644 index 00000000..198b3a99 --- /dev/null +++ b/komorebi-scrolling/tests/properties.rs @@ -0,0 +1,663 @@ +//! Random sequences of every operation, checked after each step against the properties that must +//! hold whatever has been done: the strip is laid out sanely, the focused column is in view, and +//! the focused column only moves when something to do with it happens. +//! +//! The sequences are generated from a seed, so a failure names the seed and prints the steps, +//! and can be replayed exactly. + +use komorebi_scrolling::CenterFocusedColumn; +use komorebi_scrolling::ColumnWidth; +use komorebi_scrolling::Options; +use komorebi_scrolling::ScrollingSpace; +use komorebi_scrolling::TileHeight; +use komorebi_scrolling::Viewport; +use komorebi_scrolling::Visibility; +use std::collections::HashMap; + +const SEEDS: u64 = 600; +const STEPS: usize = 90; + +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + // a linear congruential generator is plenty for choosing between a few options + self.0 = self + .0 + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + self.0 >> 33 + } + + fn below(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } + + fn pick(&mut self, options: &[T]) -> T { + options[self.below(options.len())] + } +} + +fn random_width(rng: &mut Rng) -> ColumnWidth { + if rng.below(3) == 0 { + ColumnWidth::Fixed(rng.pick(&[150, 400, 700, 1000, 1700])) + } else { + ColumnWidth::Proportion(rng.pick(&[0.2, 0.25, 1.0 / 3.0, 0.5, 0.75, 1.0, 1.5])) + } +} + +/// What the strip looked like before a step. +struct Before { + active: Option, + active_x: Option, + widths: HashMap, +} + +impl Before { + fn of(space: &ScrollingSpace) -> Self { + Self { + active: space.active_id().copied(), + active_x: space.active_x(), + widths: space + .layout() + .into_iter() + .map(|placement| (placement.id, placement.rect.width)) + .collect(), + } + } +} + +/// What a step promises about what it leaves alone. +struct Promise { + /// The active column is the same one and is in the same place. + active_stays: bool, + /// The columns whose width the step is allowed to change. `None` means any may change. + widths_that_may_change: Option>, +} + +impl Promise { + fn nothing_moves() -> Self { + Self { + active_stays: true, + widths_that_may_change: Some(vec![]), + } + } +} + +fn step( + rng: &mut Rng, + space: &mut ScrollingSpace, + next_id: &mut u32, + log: &mut Vec, +) -> Promise { + if space.is_empty() { + let id = *next_id; + *next_id += 1; + log.push(format!("add {id} to the empty strip")); + space.add_column(id, None, None, true); + + return Promise { + active_stays: false, + widths_that_may_change: Some(vec![]), + }; + } + + let ids = space.ids(); + let count = ids.len(); + let active = *space.active_id().unwrap(); + let target = ids[rng.below(count)]; + // a column is one thing: what happens to a tile happens to the column that it is in + let in_active_column = space.position(&target) == space.position(&active); + + match rng.below(29) { + 0 | 1 => { + let id = *next_id; + *next_id += 1; + let activate = rng.below(2) == 0; + let at = (rng.below(3) == 0).then(|| rng.below(count + 1)); + let width = (rng.below(3) == 0).then(|| random_width(rng)); + log.push(format!( + "add {id} at {at:?} width {width:?} activate {activate}" + )); + space.add_column(id, at, width, activate); + + Promise { + active_stays: !activate, + widths_that_may_change: Some(vec![]), + } + } + 2 => { + log.push(format!("remove {target}")); + space.remove_column(&target); + + Promise { + active_stays: target != active, + widths_that_may_change: Some(vec![]), + } + } + 3 | 4 => { + log.push(format!("focus {target}")); + space.focus(&target); + + Promise { + active_stays: target == active, + widths_that_may_change: Some(vec![]), + } + } + 5 => { + let width = random_width(rng); + log.push(format!("set width of {target} to {width:?}")); + space.set_width(&target, width); + + Promise { + active_stays: !in_active_column, + widths_that_may_change: Some(vec![target]), + } + } + 6 => { + let forward = rng.below(2) == 0; + log.push(format!("cycle the preset of {target}, forward {forward}")); + space.cycle_preset_width(&target, forward); + + Promise { + active_stays: !in_active_column, + widths_that_may_change: Some(vec![target]), + } + } + 7 => { + log.push(format!("toggle full width of {target}")); + space.toggle_full_width(&target); + + Promise { + active_stays: !in_active_column, + widths_that_may_change: Some(vec![target]), + } + } + 8 => { + let fullscreen = rng.below(2) == 0; + log.push(format!("set fullscreen of {target} to {fullscreen}")); + space.set_fullscreen(&target, fullscreen); + + Promise { + active_stays: !in_active_column, + widths_that_may_change: Some(vec![target]), + } + } + 9 => { + let min = (rng.below(2) == 0).then(|| rng.pick(&[100, 500, 900])); + // a maximum below the minimum is not something a window can report + let max = if min.is_some() { + None + } else { + (rng.below(2) == 0).then(|| rng.pick(&[300, 800, 1300])) + }; + log.push(format!("limit {target} to {min:?} and {max:?}")); + space.set_width_limits(&target, min, max); + + Promise { + active_stays: !in_active_column, + widths_that_may_change: Some(vec![target]), + } + } + 10 => { + let to = rng.below(count); + log.push(format!("move {target} to {to}")); + space.move_column(&target, to); + + Promise { + active_stays: !in_active_column, + widths_that_may_change: Some(vec![]), + } + } + 11 => { + log.push("centre the column".into()); + space.center_column(); + + Promise { + active_stays: false, + widths_that_may_change: Some(vec![]), + } + } + 12 => { + log.push("centre the visible columns".into()); + space.center_visible_columns(); + + Promise { + active_stays: false, + widths_that_may_change: Some(vec![]), + } + } + 13 => { + log.push("expand to the available width".into()); + space.expand_to_available_width(); + + Promise { + active_stays: false, + widths_that_may_change: Some(vec![active]), + } + } + 14 => { + let viewport = rng.pick(&[ + Viewport { + width: 1200, + height: 800, + }, + Viewport { + width: 900, + height: 700, + }, + Viewport { + width: 1600, + height: 900, + }, + ]); + log.push(format!("set the viewport to {viewport:?}")); + space.set_viewport(viewport); + + Promise { + active_stays: false, + widths_that_may_change: None, + } + } + 15 => { + log.push("reset the widths".into()); + space.reset_widths(); + + Promise { + active_stays: false, + widths_that_may_change: None, + } + } + 16 => { + log.push("consume to the right".into()); + space.consume_right(); + + // the tile that was taken has the width of the column that took it + Promise { + active_stays: true, + widths_that_may_change: None, + } + } + 17 => { + log.push("consume to the left".into()); + space.consume_left(); + + Promise { + active_stays: true, + widths_that_may_change: None, + } + } + 18 => { + log.push("expel".into()); + space.expel(); + + Promise { + active_stays: false, + widths_that_may_change: None, + } + } + 19 => { + log.push("focus up".into()); + space.focus_up(); + + Promise { + active_stays: false, + widths_that_may_change: Some(vec![]), + } + } + 20 => { + log.push("focus down".into()); + space.focus_down(); + + Promise { + active_stays: false, + widths_that_may_change: Some(vec![]), + } + } + 21 => { + log.push("move the tile up".into()); + space.move_tile_up(); + + Promise { + active_stays: true, + widths_that_may_change: Some(vec![]), + } + } + 22 => { + log.push("move the tile down".into()); + space.move_tile_down(); + + Promise { + active_stays: true, + widths_that_may_change: Some(vec![]), + } + } + 23 => { + let dx = rng.pick(&[-700.0, -250.0, -40.0, 40.0, 250.0, 700.0]); + let velocity = rng.pick(&[0.0, 300.0, -300.0, 3000.0, -3000.0]); + log.push(format!( + "pan by {dx} and then {}, let go at {velocity}", + dx / 2.0 + )); + space.begin_pan(); + space.pan_by(dx); + space.pan_by(dx / 2.0); + space.pan_end(velocity); + + Promise { + active_stays: false, + widths_that_may_change: Some(vec![]), + } + } + 24 => { + let height = rng.pick(&[ + TileHeight::Auto, + TileHeight::Fixed(90), + TileHeight::Fixed(500), + TileHeight::Fixed(3000), + TileHeight::Proportion(0.2), + TileHeight::Proportion(0.9), + ]); + log.push(format!("give {target} the height {height:?}")); + space.set_tile_height(&target, height); + + Promise { + active_stays: true, + widths_that_may_change: Some(vec![]), + } + } + 25 => { + let delta = rng.pick(&[-400.0, -60.0, 60.0, 400.0]); + log.push(format!("resize the height of {target} by {delta}")); + space.resize_tile_height(&target, delta); + + Promise { + active_stays: true, + widths_that_may_change: Some(vec![]), + } + } + 26 => { + let forward = rng.below(2) == 0; + log.push(format!( + "cycle the preset height of {target}, forward {forward}" + )); + space.cycle_preset_height(&target, forward); + + Promise { + active_stays: true, + widths_that_may_change: Some(vec![]), + } + } + 27 => { + log.push(format!("reset the heights of the column of {target}")); + space.reset_tile_heights(&target); + + Promise { + active_stays: true, + widths_that_may_change: Some(vec![]), + } + } + _ => { + // the host reports the strip as it is: that must change nothing at all + let ids = space.ids(); + let focused = space.active_id().copied(); + log.push("reconcile with exactly what is there".into()); + space.reconcile(&ids, focused.as_ref()); + + Promise::nothing_moves() + } + } +} + +fn check( + space: &ScrollingSpace, + before: &Before, + promise: &Promise, + gaps: i32, +) -> Result<(), String> { + let layout = space.layout(); + let viewport = space.viewport(); + + // the strip is ordered, and its keys are what they say they are + let ids = space.ids(); + if layout + .iter() + .map(|placement| placement.id) + .collect::>() + != ids + { + return Err("the layout is not in the order of the keys".into()); + } + let mut sorted = ids.clone(); + sorted.sort_unstable(); + sorted.dedup(); + if sorted.len() != ids.len() { + return Err("a key is in the strip twice".into()); + } + + // columns do not overlap, and the space between them is the gap, give or take rounding. Tiles + // of a column are one above the other with a gap between, or none if it is fullscreen + for pair in layout.windows(2) { + if pair[1].rect.x == pair[0].rect.x { + let between = pair[1].rect.y - pair[0].rect.bottom(); + if pair[1].rect.width != pair[0].rect.width + || pair[1].visibility != pair[0].visibility + || !(-1..=gaps + 1).contains(&between) + { + return Err(format!( + "tiles {} and {} are not stacked: {:?} and {:?}", + pair[0].id, pair[1].id, pair[0].rect, pair[1].rect + )); + } + + continue; + } + + let between = pair[1].rect.x - pair[0].rect.right(); + if (between - gaps).abs() > 1 { + return Err(format!( + "there are {between} pixels between columns {} and {}, and the gap is {gaps}", + pair[0].id, pair[1].id + )); + } + } + + // the tiles of a column fill it, whatever they were given: the last ends where the column does + for (index, placement) in layout.iter().enumerate() { + let last_in_column = layout + .get(index + 1) + .is_none_or(|next| next.rect.x != placement.rect.x); + + if last_in_column + && placement.rect.bottom() + != layout + .iter() + .filter(|p| p.rect.x == placement.rect.x) + .map(|p| p.rect.bottom()) + .max() + .unwrap_or(0) + { + return Err("the last tile of a column is not the lowest".into()); + } + + let column: Vec<_> = layout + .iter() + .filter(|p| p.rect.x == placement.rect.x) + .collect(); + if last_in_column && column.len() > 1 { + let end = placement.rect.bottom(); + let expected = if column[0].rect.y == 0 { + viewport.height + } else { + viewport.height - gaps + }; + if (end - expected).abs() > 1 { + return Err(format!( + "the tiles of the column at {} end at {end} and the column ends at {expected}", + placement.rect.x + )); + } + } + } + + for placement in &layout { + if placement.rect.width < 1 || placement.rect.height < 1 { + return Err(format!("column {} has an empty rectangle", placement.id)); + } + } + + if layout.is_empty() { + return Ok(()); + } + + // exactly one column is active, and it is in view, or starts at the left edge if it is too wide + let active: Vec<_> = layout.iter().filter(|placement| placement.active).collect(); + if active.len() != 1 { + return Err(format!("{} columns are active", active.len())); + } + + let active = active[0]; + if Some(&active.id) != space.active_id() { + return Err("the active column is not the one that is reported as active".into()); + } + + if active.rect.width >= viewport.width { + if active.rect.x != 0 { + return Err(format!( + "the active column is as wide as the viewport or wider, and starts at {}", + active.rect.x + )); + } + } else if active.visibility != Visibility::Visible { + return Err(format!( + "the active column {} is {:?} at {:?}", + active.id, active.visibility, active.rect + )); + } + + // what a step promised to leave alone + if promise.active_stays { + if before.active != space.active_id().copied() { + return Err(format!( + "the active column changed from {:?} to {:?}", + before.active, + space.active_id() + )); + } + if before.active_x != space.active_x() { + return Err(format!( + "the active column moved from {:?} to {:?}", + before.active_x, + space.active_x() + )); + } + } + + if let Some(allowed) = &promise.widths_that_may_change { + // a column has one width, so a step that may change the width of a tile may change the + // width of the tiles that it is in a column with + let columns: Vec = layout + .iter() + .filter(|placement| allowed.contains(&placement.id)) + .map(|placement| placement.rect.x) + .collect(); + + for placement in &layout { + if allowed.contains(&placement.id) || columns.contains(&placement.rect.x) { + continue; + } + + if let Some(was) = before.widths.get(&placement.id) + && (placement.rect.width - was).abs() > 1 + { + return Err(format!( + "the width of column {} changed from {was} to {}", + placement.id, placement.rect.width + )); + } + } + } + + // saying what is there changes nothing: not what is where, and not anything that the strip + // remembers either + let mut again = space.clone(); + again.reconcile(&ids, space.active_id()); + if again.layout() != layout { + return Err("reconciling with exactly what is there changed the layout".into()); + } + if again != *space { + return Err( + "reconciling with exactly what is there changed what the strip remembers".into(), + ); + } + + // the active tile is where reading order says + let index = layout.iter().position(|placement| placement.active); + if index != space.active_tile_index() { + return Err(format!( + "the active tile is at {index:?} in the layout and at {:?} by the strip", + space.active_tile_index() + )); + } + + // every tile is in a column with the tiles next to it, and a column is as many tiles as the + // strip says + if layout.len() != space.tile_count() || ids.len() != space.tile_count() { + return Err("the number of tiles is not the number of tiles".into()); + } + + Ok(()) +} + +fn run(seed: u64) { + let mut rng = Rng(seed.wrapping_mul(0x9E37_79B9).wrapping_add(1)); + + let gaps = rng.pick(&[0, 8, 16]); + let options = Options { + gaps, + default_width: random_width(&mut rng), + center_focused_column: rng.pick(&[ + CenterFocusedColumn::Never, + CenterFocusedColumn::Always, + CenterFocusedColumn::OnOverflow, + ]), + always_center_single_column: rng.below(2) == 0, + ..Options::default() + }; + let mut space = ScrollingSpace::new( + options.clone(), + Viewport { + width: 1200, + height: 800, + }, + ); + + let mut next_id = 0; + let mut log = vec![]; + + for index in 0..STEPS { + let before = Before::of(&space); + let promise = step(&mut rng, &mut space, &mut next_id, &mut log); + + if let Err(why) = check(&space, &before, &promise, gaps) { + let steps: Vec = log + .iter() + .enumerate() + .map(|(i, step)| format!(" {i:>3}: {step}")) + .collect(); + + panic!( + "seed {seed}, after step {index}: {why}\noptions: {options:?}\nsteps:\n{}\nlayout: {:#?}", + steps.join("\n"), + space.layout() + ); + } + } +} + +#[test] +fn whatever_is_done_the_strip_stays_sane_and_the_focused_column_stays_put() { + for seed in 0..SEEDS { + run(seed); + } +} diff --git a/komorebi-scrolling/tests/rules.rs b/komorebi-scrolling/tests/rules.rs new file mode 100644 index 00000000..2be15ba1 --- /dev/null +++ b/komorebi-scrolling/tests/rules.rs @@ -0,0 +1,1331 @@ +//! The rules that the crate documentation promises, one test each, written as specifications. +//! +//! Unless a test says otherwise, the viewport is 1200 by 800, there are no gaps, and columns are +//! a quarter of the viewport wide (300), so four of them fit. + +mod common; + +use common::*; +use komorebi_scrolling::CenterFocusedColumn; +use komorebi_scrolling::ColumnWidth; +use komorebi_scrolling::Options; +use komorebi_scrolling::Rect; +use komorebi_scrolling::ScrollingSpace; +use komorebi_scrolling::Viewport; +use komorebi_scrolling::Visibility; + +// Rule 1: a column's width is its own + +#[test] +fn opening_columns_never_changes_the_width_of_the_others() { + let mut space = ScrollingSpace::new(options(0.25), VIEW); + + for id in 0..9 { + space.add_column(id, None, None, true); + + for (_, width) in widths(&space) { + assert_eq!(width, 300, "after opening column {id}"); + } + } +} + +#[test] +fn closing_columns_never_changes_the_width_of_the_others() { + let mut space = strip(options(0.25), 6); + space.set_width(&2, ColumnWidth::Fixed(500)); + let before = widths(&space); + + space.remove_column(&0); + space.remove_column(&5); + + let expected: Vec<_> = before + .into_iter() + .filter(|(id, _)| *id != 0 && *id != 5) + .collect(); + assert_eq!(widths(&space), expected); +} + +#[test] +fn moving_focus_never_changes_a_width() { + let mut space = strip(options(0.25), 6); + space.set_width(&1, ColumnWidth::Fixed(555)); + let before = widths(&space); + + for id in [0, 3, 5, 1, 4] { + space.focus(&id); + assert_eq!(widths(&space), before); + } +} + +#[test] +fn a_strip_with_more_columns_than_fit_is_wider_than_the_viewport() { + let space = strip(options(0.5), 6); + let layout = space.layout(); + + let start = layout.first().unwrap().rect.x; + let end = layout.last().unwrap().rect.right(); + assert_eq!(end - start, 6 * 600); +} + +#[test] +fn a_lone_column_is_not_stretched_to_fill_the_viewport() { + let space = strip(options(0.5), 1); + + assert_eq!( + rect(&space, 0), + Rect { + x: 0, + y: 0, + width: 600, + height: 800 + } + ); +} + +// Rule 2: the focused column does not move by itself + +/// A strip of eight columns with column 5 in focus, and what column 5's place on screen is. +fn strip_with_a_focused_column() -> (ScrollingSpace, i32) { + let mut space = strip(options(0.25), 8); + space.focus(&5); + + let x = x_of(&space, 5); + (space, x) +} + +#[test] +fn nothing_that_happens_to_other_columns_moves_the_focused_one() { + type Change = Box)>; + + let changes: Vec<(&str, Change)> = vec![ + ( + "opening a column at the far left", + Box::new(|s| s.add_column(100, Some(0), None, false)), + ), + ( + "opening a column directly to the left of it", + Box::new(|s| s.add_column(100, Some(5), None, false)), + ), + ( + "opening a column directly to the right of it", + Box::new(|s| s.add_column(100, None, None, false)), + ), + ( + "opening a column at the far right", + Box::new(|s| s.add_column(100, Some(99), None, false)), + ), + ( + "closing a column to the left", + Box::new(|s| assert!(s.remove_column(&1))), + ), + ( + "closing the column directly to the left", + Box::new(|s| assert!(s.remove_column(&4))), + ), + ( + "closing a column to the right", + Box::new(|s| assert!(s.remove_column(&7))), + ), + ( + "widening a column to the left", + Box::new(|s| s.set_width(&1, ColumnWidth::Fixed(900))), + ), + ( + "narrowing a column to the left", + Box::new(|s| s.set_width(&4, ColumnWidth::Fixed(100))), + ), + ( + "widening a column to the right", + Box::new(|s| s.set_width(&6, ColumnWidth::Fixed(900))), + ), + ( + "cycling the preset of a column to the left", + Box::new(|s| s.cycle_preset_width(&2, true)), + ), + ( + "making a column to the left full width", + Box::new(|s| s.toggle_full_width(&3)), + ), + ( + "a column to the left being found to have a minimum width", + Box::new(|s| s.set_width_limits(&4, Some(800), None)), + ), + ( + "a column to the left going fullscreen", + Box::new(|s| s.set_fullscreen(&2, true)), + ), + ( + "moving a column from the left to further left", + Box::new(|s| s.move_column(&3, 0)), + ), + ( + "moving a column from the left to the right of the focused one", + Box::new(|s| s.move_column(&1, 7)), + ), + ( + "moving a column from the right to the left of the focused one", + Box::new(|s| s.move_column(&7, 1)), + ), + ]; + + for (what, change) in changes { + let (mut space, x) = strip_with_a_focused_column(); + + change(&mut space); + + assert_eq!(space.active_id(), Some(&5), "{what} changed what has focus"); + assert_eq!(x_of(&space, 5), x, "{what} moved the focused column"); + } +} + +#[test] +fn widening_the_focused_column_keeps_its_left_edge_until_it_no_longer_fits() { + let mut space = strip(options(0.25), 8); + space.focus(&5); + let x = x_of(&space, 5); + assert_eq!(x, 300); + + space.set_width(&5, ColumnWidth::Fixed(600)); + assert_eq!(x_of(&space, 5), x, "it fits, so it stays"); + + space.set_width(&5, ColumnWidth::Fixed(1000)); + assert_eq!( + rect(&space, 5).right(), + 1200, + "it does not fit, so it scrolls just far enough to" + ); +} + +// Rule 3: scrolling is as little as it can be + +#[test] +fn focusing_a_column_that_is_in_view_does_not_scroll() { + let mut space = strip(options(0.25), 8); + let before = rects(&space); + + // columns 4 to 7 are in view + for id in [6, 4, 5, 7, 4] { + space.focus(&id); + assert_eq!(rects(&space), before, "focusing column {id} scrolled"); + } +} + +#[test] +fn focusing_a_column_off_the_left_edge_brings_it_in_at_the_left_edge() { + let mut space = strip(options(0.25), 8); + + space.focus(&3); + + assert_eq!(rect(&space, 3).x, 0); + assert_eq!(placement(&space, 3).visibility, Visibility::Visible); + // and the strip moved by exactly that much, no more + assert_eq!(rect(&space, 4).x, 300); + assert_eq!(rect(&space, 6).x, 900); +} + +#[test] +fn focusing_a_column_off_the_right_edge_brings_it_in_at_the_right_edge() { + let mut space = strip(options(0.25), 8); + space.focus(&1); + assert_eq!(rect(&space, 1).x, 0); + + space.focus(&6); + + assert_eq!(rect(&space, 6).right(), 1200); + assert_eq!(rect(&space, 5).x, 600); +} + +#[test] +fn a_column_that_is_partly_in_view_counts_as_out_of_view() { + let mut space = strip(options(0.25), 8); + space.focus(&3); + space.set_width(&3, ColumnWidth::Fixed(700)); + space.focus(&1); + // column 1 is at the left edge and 2 follows it, so column 3 starts at 600 and ends at 1300, + // and is cut off by the right edge + assert_eq!(rect(&space, 3).x, 600); + assert_eq!(placement(&space, 3).visibility, Visibility::Clipped); + + space.focus(&3); + + assert_eq!(placement(&space, 3).visibility, Visibility::Visible); + assert_eq!(rect(&space, 3).right(), 1200); +} + +#[test] +fn gaps_are_left_between_a_column_that_is_brought_in_and_the_edge() { + let options = Options { + gaps: 16, + ..options(0.25) + }; + let mut space = strip(options, 8); + + // each column is 1184 * 0.25 - 16 = 280 wide + assert_eq!(width_of(&space, 0), 280); + assert_eq!( + rect(&space, 7).right(), + 1200 - 16, + "brought in from the right" + ); + + space.focus(&3); + assert_eq!(rect(&space, 3).x, 16, "brought in from the left"); +} + +// Rule 4: temporary changes are undone + +#[test] +fn closing_a_column_that_was_just_opened_returns_to_the_one_before_it() { + let mut space = strip(options(0.25), 8); + space.focus(&3); + space.focus(&6); + assert_eq!( + rect(&space, 6).x, + 900, + "column 6 is at the right edge, with column 7 beyond" + ); + let before = rects(&space); + + space.add_column(100, None, None, true); + assert_eq!(space.active_id(), Some(&100)); + assert_ne!(rects(&space), before); + + space.remove_column(&100); + + assert_eq!(space.active_id(), Some(&6)); + assert_eq!(rects(&space), before, "the view is back where it was"); +} + +#[test] +fn once_focus_has_moved_closing_a_column_does_not_return() { + let mut space = strip(options(0.25), 8); + space.focus(&3); + space.focus(&6); + + space.add_column(100, None, None, true); + space.focus(&5); + space.focus(&100); + space.remove_column(&100); + + // there is nothing to return to, so the column that took the closed column's place has focus + assert_eq!(space.active_id(), Some(&7)); +} + +#[test] +fn closing_the_column_to_return_to_cancels_the_return() { + let mut space = strip(options(0.25), 8); + space.focus(&3); + space.focus(&6); + + space.add_column(100, None, None, true); + space.remove_column(&0); + space.remove_column(&100); + + assert_eq!(space.active_id(), Some(&7)); +} + +#[test] +fn opening_a_column_between_the_two_cancels_the_return() { + let mut space = strip(options(0.25), 8); + space.focus(&3); + space.focus(&6); + + space.add_column(100, None, None, true); + // a column now sits between 6 and 100, so 6 is no longer the one that 100 was opened next to + let at = space.position(&100).unwrap(); + space.add_column(101, Some(at), None, false); + assert_eq!(space.ids()[6..9], [6, 101, 100]); + space.remove_column(&100); + + assert_eq!( + space.active_id(), + Some(&7), + "the column after the closed one has focus" + ); +} + +#[test] +fn a_column_opened_anywhere_else_is_not_returned_from() { + let mut space = strip(options(0.25), 8); + space.focus(&6); + + space.add_column(100, Some(0), None, true); + space.remove_column(&100); + + // it was on the far left, so it was not next to the old column and nothing is remembered + assert_eq!(space.active_id(), Some(&0)); +} + +#[test] +fn closing_the_focused_column_otherwise_focuses_the_one_that_takes_its_place() { + let mut space = strip(options(0.25), 8); + space.focus(&4); + let view_pos = space.view_offset(); + + space.remove_column(&4); + + assert_eq!(space.active_id(), Some(&5)); + assert_eq!( + space.view_offset(), + view_pos, + "the viewport stays put when it can" + ); +} + +#[test] +fn closing_the_last_column_focuses_the_one_before_it() { + let mut space = strip(options(0.25), 8); + + space.remove_column(&7); + + assert_eq!(space.active_id(), Some(&6)); + assert_eq!(placement(&space, 6).visibility, Visibility::Visible); +} + +#[test] +fn closing_every_column_leaves_an_empty_strip_that_can_be_used_again() { + let mut space = strip(options(0.5), 3); + + for id in 0..3 { + assert!(space.remove_column(&id)); + } + assert!(space.is_empty()); + assert!(space.layout().is_empty()); + assert_eq!(space.active_id(), None); + + space.add_column(7, None, None, true); + assert_eq!(rect(&space, 7).x, 0); + assert_eq!(space.active_id(), Some(&7)); +} + +#[test] +fn leaving_fullscreen_puts_the_viewport_back() { + let mut space = strip(options(0.25), 8); + space.focus(&6); + let before = rects(&space); + + space.set_fullscreen(&6, true); + assert_eq!( + rect(&space, 6), + Rect { + x: 0, + y: 0, + width: 1200, + height: 800 + } + ); + + space.set_fullscreen(&6, false); + assert_eq!(rects(&space), before); +} + +#[test] +fn the_viewport_is_not_put_back_after_focus_has_been_somewhere_else() { + let mut space = strip(options(0.25), 8); + space.focus(&6); + + space.set_fullscreen(&6, true); + space.focus(&2); + space.focus(&6); + space.set_fullscreen(&6, false); + + // it is just brought into view, like any column + assert_eq!(placement(&space, 6).visibility, Visibility::Visible); +} + +// Rule 5: a column wider than the viewport starts at its left edge + +#[test] +fn a_column_wider_than_the_viewport_is_aligned_with_the_left_edge() { + let mut space = strip(options(0.25), 4); + + space.set_width(&2, ColumnWidth::Fixed(2000)); + space.focus(&2); + assert_eq!(rect(&space, 2).x, 0); + assert_eq!(rect(&space, 2).width, 2000); + + // and still is after going away and coming back + space.focus(&0); + space.focus(&2); + assert_eq!(rect(&space, 2).x, 0); +} + +#[test] +fn a_column_as_wide_as_the_viewport_is_aligned_with_the_left_edge_too() { + let mut space = strip(options(0.25), 3); + + space.set_width(&1, ColumnWidth::Fixed(1200)); + space.focus(&1); + + assert_eq!(rect(&space, 1).x, 0); +} + +// Rule 6: proportions include the gaps + +#[test] +fn columns_that_add_up_to_the_viewport_tile_it_whatever_the_gaps() { + for gaps in [0, 1, 7, 16, 25] { + for count in 1..=8_u32 { + let options = Options { + gaps, + ..options(1.0 / f64::from(count)) + }; + let space = strip(options, count); + let layout = space.layout(); + + assert_eq!( + layout.first().unwrap().rect.x, + gaps, + "gaps {gaps}, columns {count}" + ); + assert_eq!( + layout.last().unwrap().rect.right(), + 1200 - gaps, + "gaps {gaps}, columns {count}" + ); + + for pair in layout.windows(2) { + let gap = pair[1].rect.x - pair[0].rect.right(); + assert_eq!( + gap, gaps, + "gaps {gaps}, columns {count}, after column {}", + pair[0].id + ); + } + } + } +} + +#[test] +fn a_fixed_width_does_not_depend_on_the_gaps() { + let options = Options { + gaps: 40, + default_width: ColumnWidth::Fixed(500), + ..Options::default() + }; + let space = strip(options, 3); + + for (_, width) in widths(&space) { + assert_eq!(width, 500); + } +} + +#[test] +fn columns_leave_a_gap_at_the_top_and_bottom_too() { + let options = Options { + gaps: 16, + ..options(0.5) + }; + let space = strip(options, 1); + + let rect = rect(&space, 0); + assert_eq!((rect.y, rect.bottom()), (16, 800 - 16)); +} + +// Rule 7: fullscreen is a column like any other + +#[test] +fn a_fullscreen_column_can_be_scrolled_away_from_and_back_to() { + let mut space = strip(options(0.5), 3); + space.focus(&1); + space.set_fullscreen(&1, true); + + assert_eq!(placement(&space, 0).visibility, Visibility::OffLeft); + assert_eq!(placement(&space, 2).visibility, Visibility::OffRight); + + // focus moves on to the next column, which is brought in from the right + space.focus(&2); + assert_eq!(placement(&space, 2).visibility, Visibility::Visible); + assert_eq!(rect(&space, 2).right(), 1200); + + // the fullscreen column is still fullscreen, and still where it was in the strip: it is as + // wide as the viewport, so with the 600 of column 2 in view, half of it is still showing + let fullscreen = placement(&space, 1); + assert_eq!(fullscreen.rect.width, 1200); + assert_eq!(fullscreen.rect.height, 800); + assert_eq!(fullscreen.rect.x, -600); + assert_eq!(fullscreen.visibility, Visibility::Clipped); + + space.focus(&1); + assert_eq!( + rect(&space, 1), + Rect { + x: 0, + y: 0, + width: 1200, + height: 800 + } + ); +} + +#[test] +fn a_fullscreen_column_ignores_the_gaps() { + let options = Options { + gaps: 16, + ..options(0.5) + }; + let mut space = strip(options, 2); + + space.set_fullscreen(&1, true); + + assert_eq!( + rect(&space, 1), + Rect { + x: 0, + y: 0, + width: 1200, + height: 800 + } + ); +} + +// Centring + +fn centered(mode: CenterFocusedColumn) -> Options { + Options { + center_focused_column: mode, + ..options(0.25) + } +} + +#[test] +fn with_always_the_focused_column_is_always_centred() { + let mut space = strip(centered(CenterFocusedColumn::Always), 7); + + for id in [3, 0, 6, 2, 4] { + space.focus(&id); + assert_eq!(rect(&space, id).x, 450, "column {id}"); + } +} + +#[test] +fn a_column_that_is_too_wide_to_centre_is_aligned_with_the_left_edge() { + let mut space = strip(centered(CenterFocusedColumn::Always), 3); + space.set_width(&1, ColumnWidth::Fixed(1500)); + + space.focus(&1); + + assert_eq!(rect(&space, 1).x, 0); +} + +#[test] +fn with_never_a_column_in_view_is_not_centred() { + let mut space = strip(centered(CenterFocusedColumn::Never), 3); + assert_eq!(rect(&space, 0).x, 0); + + space.focus(&0); + + assert_eq!(rect(&space, 0).x, 0); +} + +#[test] +fn on_overflow_a_column_that_fits_beside_the_last_one_is_just_brought_in() { + // 480 wide: two of them are 960, which fits + let options = Options { + center_focused_column: CenterFocusedColumn::OnOverflow, + ..options(0.4) + }; + let mut space = strip(options, 4); + space.focus(&0); + assert_eq!(rect(&space, 0).x, 0); + + space.focus(&1); + assert_eq!(rect(&space, 1).x, 480, "in view, so it does not move"); + + space.focus(&2); + assert_eq!(rect(&space, 2).right(), 1200, "brought in from the right"); +} + +#[test] +fn on_overflow_a_column_that_does_not_fit_beside_the_last_one_is_centred() { + // 720 wide: two of them are 1440, which does not + let options = Options { + center_focused_column: CenterFocusedColumn::OnOverflow, + ..options(0.6) + }; + let mut space = strip(options, 4); + space.focus(&0); + + space.focus(&1); + assert_eq!(rect(&space, 1).x, 240, "(1200 - 720) / 2"); + + space.focus(&0); + assert_eq!(rect(&space, 0).x, 240); +} + +#[test] +fn a_lone_column_can_be_kept_centred_whatever_the_mode() { + let options = Options { + always_center_single_column: true, + ..options(0.5) + }; + let mut space = strip(options, 1); + assert_eq!(rect(&space, 0).x, 300); + + space.add_column(1, None, None, true); + assert_ne!( + rect(&space, 1).x, + 300, + "with two columns it is the usual rule again" + ); + + space.remove_column(&1); + assert_eq!(rect(&space, 0).x, 300); +} + +#[test] +fn centre_column_centres_the_focused_column_once() { + let mut space = strip(options(0.25), 8); + space.focus(&6); + + space.center_column(); + assert_eq!(rect(&space, 6).x, 450); + + // it is not sticky: the next column that is in view does not move the strip + space.focus(&7); + assert_eq!(rect(&space, 6).x, 450); +} + +#[test] +fn centre_visible_columns_centres_the_group_that_is_in_view() { + let mut space = strip(options(0.25), 2); + assert_eq!((x_of(&space, 0), x_of(&space, 1)), (0, 300)); + + space.center_visible_columns(); + + assert_eq!((x_of(&space, 0), x_of(&space, 1)), (300, 600)); +} + +#[test] +fn centre_visible_columns_does_nothing_if_the_focused_column_is_not_fully_in_view() { + let mut space = strip(options(0.25), 8); + space.set_width(&7, ColumnWidth::Fixed(2000)); + let before = rects(&space); + + space.center_visible_columns(); + + assert_eq!(rects(&space), before); +} + +#[test] +fn expand_fills_what_the_columns_in_view_leave_free() { + let mut space = strip(options(0.25), 2); + space.focus(&0); + + space.expand_to_available_width(); + + assert_eq!(width_of(&space, 0), 900); + assert_eq!(x_of(&space, 0), 0); + assert_eq!(x_of(&space, 1), 900); + assert_eq!(rect(&space, 1).right(), 1200); +} + +#[test] +fn expand_on_the_only_column_in_view_makes_it_full_width_which_can_be_undone() { + let mut space = strip(options(0.25), 1); + + space.expand_to_available_width(); + assert_eq!(width_of(&space, 0), 1200); + + space.toggle_full_width(&0); + assert_eq!(width_of(&space, 0), 300); +} + +#[test] +fn expand_again_does_not_make_the_column_full_width_when_the_columns_add_up_to_a_fraction_over() { + // thirds of 5092 are 1697.33 wide, so what is left after one that was made 200 wider does + // not come to a whole number of pixels + let mut space = ScrollingSpace::new( + options(1.0 / 3.0), + Viewport { + width: 5092, + height: 800, + }, + ); + space.add_column(0, None, None, true); + space.add_column(1, None, None, true); + space.set_width(&1, ColumnWidth::Fixed(1897)); + + space.expand_to_available_width(); + let once = rects(&space); + assert_eq!(width_of(&space, 1), 3394); + + for _ in 0..3 { + space.expand_to_available_width(); + assert_eq!(rects(&space), once); + } +} + +// Widths + +#[test] +fn presets_are_stepped_through_in_order_and_wrap() { + let mut space = strip(options(0.5), 1); + // 1/3, 1/2 and 2/3 are the default presets + let mut seen = vec![]; + + for _ in 0..4 { + space.cycle_preset_width(&0, true); + seen.push(width_of(&space, 0)); + } + + // a column at 1/2 is not at a preset yet, so the first step goes to the next one up + assert_eq!(seen, [800, 400, 600, 800]); +} + +#[test] +fn presets_can_be_stepped_through_backwards() { + let mut space = strip(options(0.5), 1); + let mut seen = vec![]; + + for _ in 0..4 { + space.cycle_preset_width(&0, false); + seen.push(width_of(&space, 0)); + } + + assert_eq!(seen, [400, 800, 600, 400]); +} + +#[test] +fn a_column_narrower_than_every_preset_starts_at_the_first_one() { + let mut space = strip(options(0.25), 1); + + space.cycle_preset_width(&0, true); + + assert_eq!(width_of(&space, 0), 400); +} + +#[test] +fn setting_a_width_by_hand_forgets_the_preset() { + let mut space = strip(options(0.5), 1); + space.cycle_preset_width(&0, true); + assert_eq!(width_of(&space, 0), 800); + + space.set_width(&0, ColumnWidth::Proportion(0.5)); + space.cycle_preset_width(&0, true); + + // from 600 the next one up is 800 again, not the one after 800 + assert_eq!(width_of(&space, 0), 800); +} + +#[test] +fn full_width_takes_the_whole_viewport_less_the_gaps_and_gives_the_width_back() { + // with gaps of 8 a quarter is (1200 - 8) / 4 - 8 = 290, which is a whole number of pixels + let options = Options { + gaps: 8, + ..options(0.25) + }; + let mut space = strip(options, 2); + assert_eq!(width_of(&space, 1), 290); + + space.toggle_full_width(&1); + assert_eq!(width_of(&space, 1), 1200 - 16); + assert_eq!(x_of(&space, 1), 8); + + space.toggle_full_width(&1); + assert_eq!(width_of(&space, 1), 290); +} + +#[test] +fn a_width_limit_that_is_found_out_is_respected_and_the_columns_after_it_make_room() { + let mut space = strip(options(0.25), 3); + space.focus(&0); + let next = x_of(&space, 1); + + space.set_width_limits(&0, Some(700), None); + + assert_eq!(width_of(&space, 0), 700); + assert_eq!(x_of(&space, 1), next + 400); + + space.set_width_limits(&0, None, None); + assert_eq!(width_of(&space, 0), 300); + assert_eq!(x_of(&space, 1), next); +} + +#[test] +fn the_limits_that_a_column_was_given_can_be_read_back() { + let mut space = strip(options(0.25), 2); + assert_eq!(space.width_limits(&0), Some((None, None))); + assert_eq!(space.width_limits(&9), None); + + space.set_width_limits(&0, Some(700), None); + space.set_width_limits(&1, None, Some(200)); + + assert_eq!(space.width_limits(&0), Some((Some(700), None))); + assert_eq!(space.width_limits(&1), Some((None, Some(200)))); + + // a column that goes takes its limits with it + space.remove_column(&0); + assert_eq!(space.width_limits(&0), None); +} + +#[test] +fn a_maximum_width_limits_a_column() { + let mut space = strip(options(0.5), 1); + + space.set_width_limits(&0, None, Some(400)); + + assert_eq!(width_of(&space, 0), 400); +} + +#[test] +fn resetting_the_widths_puts_every_column_back_to_the_default() { + let mut space = strip(options(0.25), 5); + space.set_width(&0, ColumnWidth::Fixed(555)); + space.cycle_preset_width(&1, true); + space.toggle_full_width(&2); + space.set_width(&3, ColumnWidth::Proportion(0.75)); + space.add_column(9, None, Some(ColumnWidth::Proportion(0.4)), false); + + space.reset_widths(); + + assert!(widths(&space).iter().all(|(_, width)| *width == 300)); +} + +#[test] +fn columns_that_were_reset_follow_the_default_again() { + let mut space = strip(options(0.25), 3); + space.set_width(&1, ColumnWidth::Fixed(500)); + + space.reset_widths(); + space.set_options(options(0.5)); + + assert!(widths(&space).iter().all(|(_, width)| *width == 600)); +} + +#[test] +fn resetting_the_widths_forgets_the_preset_cycling_was_at() { + let mut space = strip(options(0.5), 1); + space.cycle_preset_width(&0, true); + space.cycle_preset_width(&0, true); + assert_eq!(width_of(&space, 0), 400); + + space.reset_widths(); + space.cycle_preset_width(&0, true); + + // from 600 the first step up is 800, not the preset after 400 + assert_eq!(width_of(&space, 0), 800); +} + +#[test] +fn resetting_the_widths_keeps_the_left_edge_of_the_active_column() { + let mut space = strip(options(0.25), 6); + space.focus(&3); + space.set_width(&1, ColumnWidth::Fixed(500)); + space.set_width(&2, ColumnWidth::Fixed(200)); + let edge = x_of(&space, 3); + + space.reset_widths(); + + // the columns before it are wider or narrower, and it has not moved + assert_eq!(x_of(&space, 3), edge); + assert_eq!(space.active_id(), Some(&3)); +} + +#[test] +fn resetting_the_widths_scrolls_the_active_column_only_as_far_as_it_has_to() { + let mut space = strip(options(0.25), 3); + space.set_width(&2, ColumnWidth::Fixed(1000)); + assert_eq!(x_of(&space, 2), 200); + + space.reset_widths(); + + // the column is a quarter of the viewport again, and its left edge is where it was + assert_eq!(width_of(&space, 2), 300); + assert_eq!(x_of(&space, 2), 200); +} + +#[test] +fn resetting_the_widths_brings_the_active_column_back_in_when_it_grows_past_the_edge() { + let mut space = strip(options(0.25), 6); + space.set_width(&5, ColumnWidth::Fixed(100)); + assert_eq!(x_of(&space, 5), 900); + + // the default is wider than the column was, and it would end past the right edge + space.set_options(options(0.5)); + space.reset_widths(); + + assert_eq!(width_of(&space, 5), 600); + assert_eq!(x_of(&space, 5), 600); +} + +#[test] +fn resetting_the_widths_keeps_what_was_found_out_about_a_window() { + let mut space = strip(options(0.25), 3); + space.set_width_limits(&1, Some(700), None); + space.set_width_limits(&2, None, Some(200)); + space.set_width(&1, ColumnWidth::Fixed(500)); + + space.reset_widths(); + + assert_eq!(widths(&space), [(0, 300), (1, 700), (2, 200)]); +} + +#[test] +fn resetting_the_widths_leaves_fullscreen_alone() { + let mut space = strip(options(0.25), 3); + space.focus(&1); + space.set_fullscreen(&1, true); + space.set_width(&2, ColumnWidth::Fixed(500)); + + space.reset_widths(); + + assert_eq!(width_of(&space, 1), 1200); + assert_eq!(width_of(&space, 2), 300); + + space.set_fullscreen(&1, false); + assert_eq!(width_of(&space, 1), 300); +} + +#[test] +fn resetting_the_widths_of_an_empty_strip_does_nothing() { + let mut space = ScrollingSpace::::new(options(0.25), VIEW); + + space.reset_widths(); + + assert!(space.is_empty()); +} + +// The viewport and the options + +#[test] +fn proportions_follow_the_viewport() { + let mut space = strip(options(0.5), 2); + + space.set_viewport(Viewport { + width: 1000, + height: 600, + }); + + assert_eq!(widths(&space), [(0, 500), (1, 500)]); + assert_eq!(rect(&space, 0).height, 600); + assert_eq!(placement(&space, 1).visibility, Visibility::Visible); +} + +#[test] +fn shrinking_the_viewport_keeps_the_focused_column_in_view() { + let mut space = strip( + Options { + default_width: ColumnWidth::Fixed(500), + ..Options::default() + }, + 3, + ); + assert_eq!(placement(&space, 2).visibility, Visibility::Visible); + + space.set_viewport(Viewport { + width: 700, + height: 800, + }); + + assert_eq!(placement(&space, 2).visibility, Visibility::Visible); +} + +#[test] +fn changing_the_options_applies_them_to_the_columns_there_are() { + let mut space = strip(options(0.25), 3); + + space.set_options(Options { + gaps: 10, + center_focused_column: CenterFocusedColumn::Always, + ..options(0.25) + }); + + // each is 1190 * 0.25 - 10 = 287.5 wide, and the focused one is centred + assert_eq!(placement(&space, 2).rect.x, (1200 - 287) / 2); +} + +#[test] +fn a_negative_gap_is_no_gap() { + let space = strip( + Options { + gaps: -5, + ..options(0.5) + }, + 2, + ); + + assert_eq!(space.options().gaps, 0); + assert_eq!(widths(&space), [(0, 600), (1, 600)]); +} + +// Visibility + +#[test] +fn every_column_says_how_much_of_it_is_in_view() { + let mut space = strip(options(0.25), 8); + space.focus(&3); + space.set_width(&3, ColumnWidth::Fixed(450)); + + let visibility: Vec<_> = space + .layout() + .into_iter() + .map(|placement| placement.visibility) + .collect(); + + // column 3 is at the left edge and is 450 wide, so column 4 starts at 450 and column 5 at + // 750, and 6 at 1050, which ends at 1350: past the right edge + assert_eq!( + visibility, + [ + Visibility::OffLeft, + Visibility::OffLeft, + Visibility::OffLeft, + Visibility::Visible, + Visibility::Visible, + Visibility::Visible, + Visibility::Clipped, + Visibility::OffRight, + ] + ); +} + +#[test] +fn a_column_that_ends_exactly_at_the_left_edge_is_off_it() { + let mut space = strip(options(0.25), 8); + space.focus(&4); + + assert_eq!(rect(&space, 3).right(), 0); + assert_eq!(placement(&space, 3).visibility, Visibility::OffLeft); +} + +#[test] +fn exactly_one_column_is_active() { + let mut space = strip(options(0.25), 5); + space.focus(&2); + + let active: Vec<_> = space + .layout() + .into_iter() + .filter(|placement| placement.active) + .map(|placement| placement.id) + .collect(); + + assert_eq!(active, [2]); + assert_eq!(space.active_index(), Some(2)); +} + +// Reconciling with a list that the host keeps + +#[test] +fn reconcile_gives_the_same_strip_as_making_each_change_through_the_model() { + // a window opens (and takes focus), another opens, the first one closes, focus moves, and a + // window moves one place to the left + let steps: [(&[u32], u32); 5] = [ + (&[0, 1, 2, 3, 4, 5], 5), + (&[0, 1, 2, 3, 4, 5, 6], 6), + (&[0, 2, 3, 4, 5, 6], 2), + (&[0, 2, 3, 4, 5, 6], 5), + (&[0, 2, 3, 5, 4, 6], 5), + ]; + + let mut reconciled = ScrollingSpace::new(options(0.25), VIEW); + let mut direct = ScrollingSpace::new(options(0.25), VIEW); + + for id in 0..6 { + direct.add_column(id, None, None, true); + } + reconciled.reconcile(steps[0].0, Some(&steps[0].1)); + assert_eq!(rects(&reconciled), rects(&direct)); + + direct.add_column(6, None, None, true); + reconciled.reconcile(steps[1].0, Some(&steps[1].1)); + assert_eq!(rects(&reconciled), rects(&direct)); + + direct.remove_column(&1); + direct.focus(&2); + reconciled.reconcile(steps[2].0, Some(&steps[2].1)); + assert_eq!(rects(&reconciled), rects(&direct)); + + direct.focus(&5); + reconciled.reconcile(steps[3].0, Some(&steps[3].1)); + assert_eq!(rects(&reconciled), rects(&direct)); + + direct.move_column(&5, 3); + reconciled.reconcile(steps[4].0, Some(&steps[4].1)); + assert_eq!(rects(&reconciled), rects(&direct)); + assert_eq!(reconciled.ids(), [0, 2, 3, 5, 4, 6]); +} + +#[test] +fn reconcile_keeps_the_width_of_columns_that_were_already_there() { + let mut space = strip(options(0.25), 3); + space.set_width(&1, ColumnWidth::Fixed(777)); + + space.reconcile(&[0, 1, 2, 3], Some(&3)); + + assert_eq!(width_of(&space, 1), 777); + assert_eq!(width_of(&space, 3), 300); +} + +#[test] +fn reconcile_ignores_a_key_that_is_listed_twice() { + let mut space = ScrollingSpace::new(options(0.25), VIEW); + + space.reconcile(&[1, 2, 2, 3, 1], Some(&3)); + + assert_eq!(space.ids(), [1, 2, 3]); +} + +#[test] +fn reconcile_with_nothing_empties_the_strip() { + let mut space = strip(options(0.25), 3); + + space.reconcile(&[], None); + + assert!(space.is_empty()); +} + +#[test] +fn reconcile_without_a_focused_key_leaves_focus_where_it_is() { + let mut space = strip(options(0.25), 4); + space.focus(&1); + + space.reconcile(&[0, 1, 2, 3], None); + + assert_eq!(space.active_id(), Some(&1)); +} + +#[test] +fn reconcile_when_the_focused_column_is_the_one_that_went_gives_focus_to_what_the_host_says() { + let mut space = strip(options(0.25), 4); + space.focus(&1); + + space.reconcile(&[0, 2, 3], Some(&3)); + + assert_eq!(space.active_id(), Some(&3)); + assert_eq!(space.ids(), [0, 2, 3]); +} + +#[test] +fn focus_movement_helpers_report_whether_they_moved() { + let mut space = strip(options(0.25), 3); + space.focus(&0); + + assert!(!space.focus_left()); + assert!(space.focus_right()); + assert!(space.focus_right()); + assert!(!space.focus_right()); + assert!(space.focus_left()); + assert!(!space.focus(&99)); + assert!(!space.focus_index(99)); + assert!(!space.remove_column(&99)); + assert_eq!(space.active_id(), Some(&1)); + + let mut empty: ScrollingSpace = ScrollingSpace::new(options(0.25), VIEW); + assert!(!empty.focus_left()); + assert!(!empty.focus_right()); +} + +#[test] +fn moving_the_focused_column_takes_focus_and_the_viewport_with_it() { + let mut space = strip(options(0.25), 8); + space.focus(&5); + + space.move_column(&5, 1); + + assert_eq!(space.active_id(), Some(&5)); + assert_eq!(space.active_index(), Some(1)); + assert_eq!(placement(&space, 5).visibility, Visibility::Visible); + assert_eq!(rect(&space, 5).x, 0); +} + +// Asking questions, and comparing + +#[test] +fn a_column_can_be_asked_how_wide_it_is() { + let mut space = strip(options(0.25), 3); + assert_eq!(space.column_width(&1), Some(300.0)); + + space.set_width(&1, ColumnWidth::Fixed(555)); + assert_eq!(space.column_width(&1), Some(555.0)); + + space.toggle_full_width(&1); + assert_eq!(space.column_width(&1), Some(1200.0)); + + assert_eq!(space.column_width(&99), None); +} + +#[test] +fn the_width_that_is_asked_for_is_not_rounded_to_pixels() { + // with gaps of 10 a quarter is (1200 - 10) / 4 - 10 = 287.5 wide + let options = Options { + gaps: 10, + ..options(0.25) + }; + let space = strip(options, 2); + + assert_eq!(space.column_width(&0), Some(287.5)); +} + +// The default width + +#[test] +fn columns_that_were_never_given_a_width_follow_the_default() { + let mut space = strip(options(0.25), 4); + space.set_width(&1, ColumnWidth::Fixed(500)); + space.add_column(9, Some(4), Some(ColumnWidth::Proportion(0.4)), false); + + space.set_options(options(0.5)); + + // 1 was sized by hand and 9 was opened with a width of its own, so neither follows + assert_eq!( + widths(&space), + [(0, 600), (1, 500), (2, 600), (3, 600), (9, 480)] + ); +} + +#[test] +fn a_column_that_is_sized_stops_following_the_default() { + let mut space = strip(options(0.25), 5); + space.cycle_preset_width(&1, true); + space.set_width(&2, ColumnWidth::Proportion(0.25)); + space.focus(&3); + space.expand_to_available_width(); + let before = widths(&space); + + space.set_options(options(0.5)); + + for id in [1, 2, 3] { + let id_width = |widths: &[(u32, i32)]| widths.iter().find(|w| w.0 == id).unwrap().1; + assert_eq!(id_width(&widths(&space)), id_width(&before), "column {id}"); + } + // and 0 and 4 do + assert_eq!(width_of(&space, 0), 600); + assert_eq!(width_of(&space, 4), 600); +} + +#[test] +fn a_column_that_is_toggled_to_full_width_and_back_follows_the_default_again() { + let mut space = strip(options(0.25), 2); + + space.toggle_full_width(&1); + space.set_options(options(0.5)); + space.toggle_full_width(&1); + + assert_eq!(width_of(&space, 1), 600); +} + +#[test] +fn opening_a_column_never_resizes_the_others_even_though_they_follow_the_default() { + let mut space = strip(options(0.25), 3); + + // the default is not touched by opening a column, so nothing follows anything anywhere + space.add_column(9, None, None, true); + + assert!(widths(&space).iter().all(|(_, width)| *width == 300)); +} + +#[test] +fn a_strip_equals_one_that_was_made_the_same_way() { + let mut one = strip(options(0.25), 4); + let two = strip(options(0.25), 4); + assert_eq!(one, two); + + one.focus(&0); + assert_ne!(one, two, "the viewport is part of what is compared"); + + let mut three = strip(options(0.25), 4); + three.focus(&0); + assert_eq!(one, three); +} diff --git a/komorebi-scrolling/tests/tiles.rs b/komorebi-scrolling/tests/tiles.rs new file mode 100644 index 00000000..39513a31 --- /dev/null +++ b/komorebi-scrolling/tests/tiles.rs @@ -0,0 +1,721 @@ +//! Columns of more than one tile, which sit one above the other and share the height. +//! +//! The viewport is 1200 by 800, there are no gaps unless a test says so, and columns are a quarter +//! of the viewport wide (300). A strip of four is keyed 0, 1, 2 and 3 in reading order. + +mod common; + +use common::*; +use komorebi_scrolling::ColumnWidth; +use komorebi_scrolling::Options; +use komorebi_scrolling::Rect; +use komorebi_scrolling::ScrollingSpace; + +fn ids(space: &ScrollingSpace) -> Vec { + space.ids() +} + +/// The columns, each as the keys of its tiles from the top. +fn columns(space: &ScrollingSpace) -> Vec> { + let mut seen = vec![]; + let mut columns = vec![]; + + for id in space.ids() { + if seen.contains(&id) { + continue; + } + + let column = space.column_of(&id).unwrap(); + seen.extend(column.iter().copied()); + columns.push(column); + } + + columns +} + +// Making columns of tiles + +#[test] +fn consuming_to_the_right_takes_the_next_columns_tile_to_the_bottom_of_this_one() { + let mut space = strip(options(0.25), 4); + space.focus(&0); + + assert!(space.consume_right()); + + assert_eq!(columns(&space), [vec![0, 1], vec![2], vec![3]]); + // the order of the tiles is the order that they were in + assert_eq!(ids(&space), [0, 1, 2, 3]); + assert_eq!(space.len(), 3); + assert_eq!(space.tile_count(), 4); + + // one above the other, in the column that was 0's, sharing the height + assert_eq!( + rect(&space, 0), + Rect { + x: 0, + y: 0, + width: 300, + height: 400 + } + ); + assert_eq!( + rect(&space, 1), + Rect { + x: 0, + y: 400, + width: 300, + height: 400 + } + ); + + // and the columns after it have moved up to where the one that went was + assert_eq!(x_of(&space, 2), 300); + assert_eq!(x_of(&space, 3), 600); +} + +#[test] +fn consuming_takes_only_the_first_tile_of_a_column_that_has_others() { + let mut space = strip(options(0.25), 4); + space.focus(&1); + space.consume_right(); + space.set_width(&1, ColumnWidth::Fixed(500)); + assert_eq!(columns(&space), [vec![0], vec![1, 2], vec![3]]); + + space.focus(&0); + assert!(space.consume_right()); + + // 1 left its column, and 2 is a column of its own that has the width that the column had + assert_eq!(columns(&space), [vec![0, 1], vec![2], vec![3]]); + assert_eq!(width_of(&space, 2), 500); + assert_eq!(width_of(&space, 1), 300); +} + +#[test] +fn there_is_nothing_to_consume_after_the_last_column_or_before_the_first() { + let mut space = strip(options(0.25), 3); + space.focus(&2); + assert!(!space.consume_right()); + + space.focus(&0); + assert!(!space.consume_left()); + + assert_eq!(columns(&space), [vec![0], vec![1], vec![2]]); +} + +#[test] +fn consuming_to_the_left_takes_the_last_tile_of_the_column_before_to_the_top_of_this_one() { + let mut space = strip(options(0.25), 4); + space.focus(&1); + + assert!(space.consume_left()); + + assert_eq!(columns(&space), [vec![0, 1], vec![2], vec![3]]); + assert_eq!(ids(&space), [0, 1, 2, 3]); + assert_eq!(space.active_id(), Some(&1), "focus stays on the same tile"); + assert_eq!(space.active_tile_index(), Some(1)); +} + +#[test] +fn consuming_to_the_left_leaves_the_tiles_above_the_one_that_goes() { + let mut space = strip(options(0.25), 4); + space.focus(&0); + space.consume_right(); + space.focus(&2); + + assert!(space.consume_left()); + + assert_eq!(columns(&space), [vec![0], vec![1, 2], vec![3]]); + assert_eq!(space.active_id(), Some(&2)); +} + +#[test] +fn consuming_does_not_move_the_focused_column_on_screen() { + let mut space = strip(options(0.25), 8); + space.focus(&5); + let x = space.active_x(); + + space.consume_left(); + assert_eq!(space.active_x(), x); + space.consume_right(); + assert_eq!(space.active_x(), x); + assert_eq!(space.active_id(), Some(&5)); +} + +#[test] +fn consuming_does_not_change_the_width_of_the_focused_column() { + let mut space = strip(options(0.25), 4); + space.set_width(&1, ColumnWidth::Fixed(450)); + space.focus(&1); + + space.consume_right(); + assert_eq!(width_of(&space, 1), 450); + assert_eq!( + width_of(&space, 2), + 450, + "the tile has the width of its column" + ); + space.consume_left(); + assert_eq!(width_of(&space, 1), 450); +} + +// Taking tiles out of a column + +#[test] +fn expelling_the_last_tile_puts_it_in_a_column_of_its_own_on_the_right() { + let mut space = strip(options(0.25), 3); + space.focus(&0); + space.consume_right(); + space.set_width(&0, ColumnWidth::Fixed(500)); + space.focus(&1); + + assert!(space.expel()); + + assert_eq!(columns(&space), [vec![0], vec![1], vec![2]]); + assert_eq!( + ids(&space), + [0, 1, 2], + "it was last, so it did not move in the order" + ); + // 0 keeps what it was given and the new column has the default + assert_eq!(width_of(&space, 0), 500); + assert_eq!(width_of(&space, 1), 300); + // and focus goes with it + assert_eq!(space.active_id(), Some(&1)); + assert_eq!(space.active_index(), Some(1)); +} + +#[test] +fn expelling_a_tile_that_has_others_below_it_moves_it_past_them() { + let mut space = strip(options(0.25), 3); + space.focus(&0); + space.consume_right(); + space.consume_right(); + assert_eq!(columns(&space), [vec![0, 1, 2]]); + + space.focus_down(); + assert_eq!(space.active_id(), Some(&1)); + assert!(space.expel()); + + assert_eq!(columns(&space), [vec![0, 2], vec![1]]); + assert_eq!( + ids(&space), + [0, 2, 1], + "the host has to change its list to match" + ); + assert_eq!(space.active_id(), Some(&1)); +} + +#[test] +fn a_tile_that_is_alone_cannot_be_expelled() { + let mut space = strip(options(0.25), 3); + + assert!(!space.expel()); + assert_eq!(columns(&space), [vec![0], vec![1], vec![2]]); +} + +#[test] +fn expelling_scrolls_only_if_the_new_column_is_out_of_view() { + let mut space = strip(options(0.25), 4); + space.focus(&3); + space.consume_left(); + space.set_width(&3, ColumnWidth::Fixed(900)); + let before = x_of(&space, 3); + + space.expel(); + + // 3 was 900 wide and had the right of the screen. The new column is 300 wide and fits + assert_eq!(space.active_id(), Some(&3)); + assert!(placement(&space, 3).rect.right() <= 1200); + assert!(x_of(&space, 3) >= 0, "{before}"); +} + +// Moving around + +#[test] +fn focus_moves_between_the_tiles_of_a_column_and_stops_at_the_ends() { + let mut space = strip(options(0.25), 3); + space.focus(&0); + space.consume_right(); + space.consume_right(); + assert_eq!(space.active_id(), Some(&0)); + + assert!(!space.focus_up()); + assert!(space.focus_down()); + assert_eq!(space.active_id(), Some(&1)); + assert!(space.focus_down()); + assert_eq!(space.active_id(), Some(&2)); + assert!(!space.focus_down(), "and it does not go round"); + assert!(space.focus_up()); + assert_eq!(space.active_id(), Some(&1)); + + // and nothing in the strip moved + assert_eq!(x_of(&space, 0), 0); + assert!(placement(&space, 1).active); +} + +#[test] +fn a_column_remembers_which_of_its_tiles_had_focus() { + let mut space = strip(options(0.25), 4); + space.focus(&0); + space.consume_right(); + space.focus(&1); + + space.focus(&2); + assert_eq!(space.active_id(), Some(&2)); + + space.focus_left(); + assert_eq!(space.active_id(), Some(&1), "it was the one that had it"); + + space.focus_right(); + space.focus_right(); + space.focus_left(); + space.focus_left(); + assert_eq!(space.active_id(), Some(&1)); +} + +#[test] +fn focusing_a_tile_in_another_column_goes_to_that_column_and_that_tile() { + let mut space = strip(options(0.25), 6); + space.focus(&3); + space.consume_right(); + space.focus(&0); + + space.focus(&4); + + assert_eq!(space.active_id(), Some(&4)); + assert_eq!(space.active_index(), Some(3), "columns, not tiles"); + assert_eq!( + space.active_tile_index(), + Some(4), + "tiles, in reading order" + ); +} + +#[test] +fn moving_a_tile_swaps_it_with_its_neighbour_and_keeps_focus_on_it() { + let mut space = strip(options(0.25), 3); + space.focus(&0); + space.consume_right(); + space.consume_right(); + + assert!(!space.move_tile_up(), "it is at the top"); + assert!(space.move_tile_down()); + assert_eq!(ids(&space), [1, 0, 2]); + assert_eq!(space.active_id(), Some(&0)); + assert_eq!(columns(&space), [vec![1, 0, 2]]); + + assert!(space.move_tile_down()); + assert!(!space.move_tile_down(), "it is at the bottom"); + assert_eq!(ids(&space), [1, 2, 0]); + + assert!(space.move_tile_up()); + assert_eq!(ids(&space), [1, 0, 2]); + assert_eq!(space.active_id(), Some(&0)); +} + +#[test] +fn moving_a_column_moves_all_of_its_tiles() { + let mut space = strip(options(0.25), 4); + space.focus(&1); + space.consume_right(); + assert_eq!(columns(&space), [vec![0], vec![1, 2], vec![3]]); + + space.move_column(&1, 2); + + assert_eq!(columns(&space), [vec![0], vec![3], vec![1, 2]]); + assert_eq!(ids(&space), [0, 3, 1, 2]); +} + +// What a column of tiles looks like + +#[test] +fn tiles_share_the_height_of_the_column_with_a_gap_between_them() { + let options = Options { + gaps: 10, + ..options(0.25) + }; + let mut space = strip(options, 3); + space.focus(&0); + space.consume_right(); + space.consume_right(); + + // 800 less a gap above, a gap below and a gap between each of the three: 800 - 40 = 760 + let tiles: Vec<_> = [0, 1, 2].iter().map(|id| rect(&space, *id)).collect(); + let heights: Vec<_> = tiles.iter().map(|tile| tile.height).collect(); + assert_eq!(tiles[0].y, 10); + assert_eq!(tiles[2].bottom(), 790); + for pair in tiles.windows(2) { + assert_eq!(pair[1].y - pair[0].bottom(), 10); + } + assert!(heights.iter().all(|h| (*h - 253).abs() <= 1), "{heights:?}"); +} + +#[test] +fn only_one_tile_in_the_strip_is_active() { + let mut space = strip(options(0.25), 3); + space.focus(&0); + space.consume_right(); + space.focus(&1); + + let active: Vec<_> = space + .layout() + .into_iter() + .filter(|placement| placement.active) + .map(|placement| placement.id) + .collect(); + assert_eq!(active, [1]); +} + +#[test] +fn a_fullscreen_column_gives_its_tiles_the_whole_height() { + let options = Options { + gaps: 10, + ..options(0.25) + }; + let mut space = strip(options, 2); + space.focus(&0); + space.consume_right(); + + space.set_fullscreen(&0, true); + + assert_eq!(rect(&space, 0).y, 0); + assert_eq!(rect(&space, 1).bottom(), 800); + assert_eq!(rect(&space, 1).y, rect(&space, 0).bottom()); +} + +// Closing tiles + +#[test] +fn closing_a_tile_leaves_the_column_with_its_width() { + let mut space = strip(options(0.25), 3); + space.focus(&0); + space.consume_right(); + space.set_width(&0, ColumnWidth::Fixed(500)); + + assert!(space.remove_column(&1)); + + assert_eq!(columns(&space), [vec![0], vec![2]]); + assert_eq!(width_of(&space, 0), 500); + assert_eq!(rect(&space, 0).height, 800, "and it has the height again"); +} + +#[test] +fn closing_the_first_tile_of_a_column_leaves_the_others_as_the_column() { + let mut space = strip(options(0.25), 3); + space.focus(&0); + space.consume_right(); + space.consume_right(); + space.set_width(&0, ColumnWidth::Fixed(500)); + + space.remove_column(&0); + + assert_eq!(columns(&space), [vec![1, 2]]); + assert_eq!(width_of(&space, 1), 500); + assert_eq!(width_of(&space, 2), 500); +} + +#[test] +fn closing_the_focused_tile_gives_focus_to_the_one_that_takes_its_place() { + let mut space = strip(options(0.25), 3); + space.focus(&0); + space.consume_right(); + space.consume_right(); + space.focus(&1); + + space.remove_column(&1); + assert_eq!(space.active_id(), Some(&2), "the one below"); + + space.remove_column(&2); + assert_eq!( + space.active_id(), + Some(&0), + "and the one above if there is none" + ); +} + +#[test] +fn closing_a_tile_that_does_not_have_focus_leaves_focus_where_it_is() { + let mut space = strip(options(0.25), 3); + space.focus(&0); + space.consume_right(); + space.consume_right(); + space.focus(&2); + + space.remove_column(&0); + assert_eq!(space.active_id(), Some(&2)); + + let mut space = strip(options(0.25), 3); + space.focus(&0); + space.consume_right(); + space.consume_right(); + space.focus(&1); + space.remove_column(&2); + assert_eq!(space.active_id(), Some(&1)); +} + +// A host that keeps a list + +#[test] +fn saying_what_is_there_changes_nothing_with_columns_of_tiles() { + let mut space = strip(options(0.25), 6); + space.focus(&1); + space.consume_right(); + space.focus(&4); + space.consume_left(); + space.set_width(&1, ColumnWidth::Fixed(450)); + let before = space.clone(); + + let all = space.ids(); + let focused = space.active_id().copied(); + space.reconcile(&all, focused.as_ref()); + + assert_eq!(space, before); +} + +#[test] +fn a_tile_that_is_new_starts_a_column_of_its_own() { + let mut space = strip(options(0.25), 3); + space.focus(&0); + space.consume_right(); + + // after the last tile of the column, which is where the host puts a window that is opened + space.reconcile(&[0, 1, 9, 2], Some(&9)); + + assert_eq!(columns(&space), [vec![0, 1], vec![9], vec![2]]); + assert_eq!(width_of(&space, 9), 300); +} + +#[test] +fn a_tile_that_is_put_in_the_middle_of_a_column_splits_it() { + // the list wins over what the model had: this is the reason that a host puts a window that + // is opened after the last tile of the column that has focus, and not after the focused tile + let mut space = strip(options(0.25), 3); + space.focus(&0); + space.consume_right(); + space.consume_right(); + + space.reconcile(&[0, 9, 1, 2], Some(&9)); + + assert_eq!(columns(&space), [vec![0], vec![9, 1, 2]]); +} + +#[test] +fn tiles_that_go_are_taken_out_of_their_columns() { + let mut space = strip(options(0.25), 4); + space.focus(&0); + space.consume_right(); + space.consume_right(); + space.set_width(&0, ColumnWidth::Fixed(500)); + + space.reconcile(&[0, 2, 3], Some(&0)); + + assert_eq!(columns(&space), [vec![0, 2], vec![3]]); + assert_eq!(width_of(&space, 0), 500); +} + +#[test] +fn a_list_that_swaps_two_columns_moves_the_tiles_with_them() { + let mut space = strip(options(0.25), 4); + space.focus(&1); + space.consume_right(); + space.set_width(&1, ColumnWidth::Fixed(450)); + + space.reconcile(&[3, 1, 2, 0], Some(&1)); + + assert_eq!(columns(&space), [vec![3], vec![1, 2], vec![0]]); + assert_eq!(width_of(&space, 1), 450); +} + +#[test] +fn a_list_that_moves_a_tile_into_another_column_is_followed() { + let mut space = strip(options(0.25), 3); + space.focus(&0); + space.consume_right(); + + // the tile that was below 0 is now below 2, which is in a column of its own + space.reconcile(&[0, 2, 1], Some(&0)); + + assert_eq!(columns(&space), [vec![0], vec![2, 1]]); +} + +#[test] +fn a_column_that_does_not_have_focus_keeps_its_focused_tile_when_the_list_reorders_its_tiles() { + let mut space = strip(options(0.25), 4); + space.focus(&1); + space.consume_right(); + space.consume_right(); + assert_eq!(columns(&space), [vec![0], vec![1, 2, 3]]); + space.focus(&3); + space.focus(&0); + + // the host swapped the last two tiles of the column on the right + space.reconcile(&[0, 1, 3, 2], Some(&0)); + space.focus_right(); + + assert_eq!( + space.active_id(), + Some(&3), + "it is still the one that had focus" + ); + assert_eq!(space.active_tile_index(), Some(2)); +} + +#[test] +fn a_column_that_loses_its_first_tile_to_consuming_keeps_its_focused_tile() { + let mut space = strip(options(0.25), 4); + space.focus(&1); + space.consume_right(); + space.consume_right(); + space.focus(&3); + space.focus(&0); + assert_eq!(columns(&space), [vec![0], vec![1, 2, 3]]); + + assert!(space.consume_right()); + space.focus_right(); + + assert_eq!(columns(&space), [vec![0, 1], vec![2, 3]]); + assert_eq!(space.active_id(), Some(&3)); +} + +#[test] +fn consuming_to_the_left_forgets_which_column_a_new_window_was_opened_next_to() { + let mut space = strip(options(0.25), 3); + space.focus(&1); + // 9 is opened next to 1, so closing it would go back to 1 + space.add_column(9, None, None, true); + assert_eq!(ids(&space), [0, 1, 9, 2]); + + // but 1 is now in a column with it, and 1 is not what is to the left of 9 any more + space.consume_left(); + assert_eq!(columns(&space), [vec![0], vec![1, 9], vec![2]]); + space.remove_column(&9); + space.remove_column(&1); + + // so the column that takes its place is the one on the right, as when anything else is closed + assert_eq!(space.active_id(), Some(&2)); +} + +// Going to another strip + +#[test] +fn a_tile_that_comes_from_another_strip_is_as_wide_as_it_was_there() { + let mut there = strip(options(0.25), 3); + there.set_width(&1, ColumnWidth::Fixed(555)); + there.set_width_limits(&1, Some(500), None); + let record = there.column_record(&1).unwrap(); + + let mut here = strip(options(0.25), 2); + here.arrive(9, record); + here.reconcile(&[0, 1, 9], Some(&9)); + + assert_eq!(width_of(&here, 9), 555); + assert_eq!(here.width_limits(&9), Some((Some(500), None))); + assert_eq!(width_of(&here, 0), 300, "and nothing else changed"); +} + +#[test] +fn a_tile_that_comes_from_a_column_with_a_preset_carries_on_from_it() { + let mut there = strip(options(0.25), 2); + there.cycle_preset_width(&0, true); + there.cycle_preset_width(&0, true); + assert_eq!(width_of(&there, 0), 600); + let record = there.column_record(&0).unwrap(); + + let mut here = strip(options(0.25), 1); + here.arrive(9, record); + here.reconcile(&[0, 9], Some(&9)); + here.cycle_preset_width(&9, true); + + // 600 is the second preset, so the next is the third + assert_eq!(width_of(&here, 9), 800); +} + +#[test] +fn the_width_of_a_full_width_column_is_carried_with_it() { + let mut there = strip(options(0.25), 2); + there.toggle_full_width(&0); + let record = there.column_record(&0).unwrap(); + + let mut here = strip(options(0.25), 1); + here.arrive(9, record); + here.reconcile(&[0, 9], Some(&9)); + + assert_eq!(width_of(&here, 9), 1200); +} + +#[test] +fn a_tile_that_was_in_a_column_of_several_arrives_as_a_column_of_its_own() { + let mut there = strip(options(0.25), 3); + there.focus(&0); + there.consume_right(); + there.set_width(&0, ColumnWidth::Fixed(450)); + there.set_tile_height(&1, komorebi_scrolling::TileHeight::Fixed(600)); + let record = there.column_record(&1).unwrap(); + + let mut here = strip(options(0.25), 1); + here.arrive(1, record); + here.reconcile(&[0, 1], Some(&1)); + + // it has the width of the column that it left, and the height of a column of its own + assert_eq!(width_of(&here, 1), 450); + assert_eq!(rect(&here, 1).height, 800); + assert_eq!(here.column_of(&1), Some(vec![1])); +} + +#[test] +fn what_arrives_is_used_once_and_only_by_a_tile_that_is_added() { + let mut there = strip(options(0.25), 2); + there.set_width(&0, ColumnWidth::Fixed(555)); + let record = there.column_record(&0).unwrap(); + + let mut here = strip(options(0.25), 2); + // 1 is there already, so there is nothing to be told about it + here.arrive(1, record.clone()); + here.arrive(9, record); + + here.reconcile(&[0, 1, 9], Some(&9)); + assert_eq!(width_of(&here, 1), 300); + assert_eq!(width_of(&here, 9), 555); + + // and it is not remembered: a tile that goes and comes back is the default + here.remove_column(&9); + here.reconcile(&[0, 1, 9], Some(&9)); + assert_eq!(width_of(&here, 9), 300); +} + +#[test] +fn a_tile_that_is_added_with_a_width_of_its_own_has_it() { + let mut there = strip(options(0.25), 2); + there.set_width(&0, ColumnWidth::Fixed(555)); + let record = there.column_record(&0).unwrap(); + + let mut here = strip(options(0.25), 1); + here.arrive(9, record); + here.add_column(9, None, Some(ColumnWidth::Fixed(222)), true); + + assert_eq!(width_of(&here, 9), 222); +} + +#[test] +fn a_strip_does_not_wait_for_ever_for_tiles_that_never_come() { + let mut there = strip(options(0.25), 2); + there.set_width(&0, ColumnWidth::Fixed(555)); + let record = there.column_record(&0).unwrap(); + + let mut here = strip(options(0.25), 1); + for id in 100..140 { + here.arrive(id, record.clone()); + } + + // the oldest were forgotten, and the newest are there + here.reconcile(&[0, 100, 139], Some(&0)); + assert_eq!(width_of(&here, 100), 300); + assert_eq!(width_of(&here, 139), 555); +} + +#[test] +fn nothing_is_known_about_a_tile_that_is_not_there() { + let space = strip(options(0.25), 2); + + assert_eq!(space.column_record(&9), None); +} diff --git a/komorebi/Cargo.toml b/komorebi/Cargo.toml index 27c3e953..ae09be10 100644 --- a/komorebi/Cargo.toml +++ b/komorebi/Cargo.toml @@ -9,6 +9,7 @@ edition = "2024" [dependencies] komorebi-layouts = { path = "../komorebi-layouts", features = ["win32"] } +komorebi-scrolling = { path = "../komorebi-scrolling" } komorebi-themes = { path = "../komorebi-themes" } base64 = "0.22"