mirror of
https://github.com/LGUG2Z/komorebi.git
synced 2026-11-13 12:23:38 +01:00
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:
1 parent
a4e03e3d76
commit
6c4eb621d3
17 files changed
+5643
No files matched your search
Generated
+5
@@ -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"
|
||||
|
||||
@@ -6,6 +6,7 @@ members = [
|
||||
"komorebi-client",
|
||||
"komorebi-gui",
|
||||
"komorebi-layouts",
|
||||
"komorebi-scrolling",
|
||||
"komorebic",
|
||||
"komorebic-no-console",
|
||||
"komorebi-bar",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user