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.
This commit is contained in:
LGUG2Z committed 2026-10-30 15:45:37 -07:00
1 parent a4e03e3d76
commit 6c4eb621d3
17 files changed
+5643

No files matched your search

Generated
+5
View File
@@ -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"
+1
View File
@@ -6,6 +6,7 @@ members = [
"komorebi-client",
"komorebi-gui",
"komorebi-layouts",
"komorebi-scrolling",
"komorebic",
"komorebic-no-console",
"komorebi-bar",
+5
View File
@@ -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"
+9
View File
@@ -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]
+44
View File
@@ -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
}
}
+150
View File
@@ -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;
+88
View File
@@ -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<ColumnWidth>,
/// 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<f64>,
/// 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,
}
}
}
File diff suppressed because it is too large. Load diff
+133
View File
@@ -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);
}
}
+143
View File
@@ -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<f64> = 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);
}
}
+67
View File
@@ -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<u32> {
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<u32>, id: u32) -> Placement<u32> {
space
.layout()
.into_iter()
.find(|placement| placement.id == id)
.unwrap_or_else(|| panic!("there is no column {id}"))
}
pub fn rect(space: &ScrollingSpace<u32>, id: u32) -> Rect {
placement(space, id).rect
}
pub fn x_of(space: &ScrollingSpace<u32>, id: u32) -> i32 {
rect(space, id).x
}
pub fn width_of(space: &ScrollingSpace<u32>, id: u32) -> i32 {
rect(space, id).width
}
pub fn widths(space: &ScrollingSpace<u32>) -> Vec<(u32, i32)> {
space
.layout()
.into_iter()
.map(|placement| (placement.id, placement.rect.width))
.collect()
}
pub fn rects(space: &ScrollingSpace<u32>) -> Vec<(u32, Rect)> {
space
.layout()
.into_iter()
.map(|placement| (placement.id, placement.rect))
.collect()
}
+280
View File
@@ -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<u32> {
let mut space = strip(options, tiles + 1);
space.focus(&0);
for _ in 1..tiles {
space.consume_right();
}
space
}
fn heights_of(space: &ScrollingSpace<u32>, tiles: u32) -> Vec<i32> {
(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::<i32>(), 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::<i32>(), 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]);
}
+290
View File
@@ -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<u32>) -> Vec<u32> {
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::<u32>::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);
}
}
+663
View File
@@ -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<T: Copy>(&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<u32>,
active_x: Option<f64>,
widths: HashMap<u32, i32>,
}
impl Before {
fn of(space: &ScrollingSpace<u32>) -> 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<Vec<u32>>,
}
impl Promise {
fn nothing_moves() -> Self {
Self {
active_stays: true,
widths_that_may_change: Some(vec![]),
}
}
}
fn step(
rng: &mut Rng,
space: &mut ScrollingSpace<u32>,
next_id: &mut u32,
log: &mut Vec<String>,
) -> 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<u32>,
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::<Vec<_>>()
!= 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<i32> = 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<String> = 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);
}
}
File diff suppressed because it is too large. Load diff
+721
View File
@@ -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<u32>) -> Vec<u32> {
space.ids()
}
/// The columns, each as the keys of its tiles from the top.
fn columns(space: &ScrollingSpace<u32>) -> Vec<Vec<u32>> {
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);
}
+1
View File
@@ -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"