feat(layouts): share the platform-free half of the scrolling glue

komorebi-layouts now carries what a host needs of the Scrolling layout
without touching an operating system: the scrolling config section and its
checked settings, the layout options for the model, placement of containers
in a work area, resizing a column or tile, and the hand-over of column
records between workspaces. It re-exports the model as
komorebi_layouts::scrolling::model, so komorebi-for-mac can pick all of it up
through its existing komorebi-layouts git dependency.

Also add the Workspace methods for columns, consume/expel/stack, focus and
ordering, which sit on top of it.
This commit is contained in:
LGUG2Z committed 2026-10-30 15:45:37 -07:00
1 parent 3b93035d48
commit 8c7327fd31
9 files changed
+917 -83

No files matched your search

Generated
+2 -1
View File
@@ -3446,6 +3446,7 @@ version = "0.1.42"
dependencies = [ dependencies = [
"clap", "clap",
"color-eyre", "color-eyre",
"komorebi-scrolling",
"objc2-core-foundation", "objc2-core-foundation",
"schemars 1.2.3", "schemars 1.2.3",
"serde", "serde",
@@ -3458,7 +3459,7 @@ dependencies = [
[[package]] [[package]]
name = "komorebi-scrolling" name = "komorebi-scrolling"
version = "0.1.0" version = "0.1.42"
[[package]] [[package]]
name = "komorebi-shortcuts" name = "komorebi-shortcuts"
+1
View File
@@ -4,6 +4,7 @@ version = "0.1.42"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
komorebi-scrolling = { path = "../komorebi-scrolling" }
clap = { workspace = true } clap = { workspace = true }
color-eyre = { workspace = true } color-eyre = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
+4 -2
View File
@@ -78,8 +78,10 @@ impl Arrangement for DefaultLayout {
let keep_centered = layout_options let keep_centered = layout_options
.as_ref() .as_ref()
.and_then(|o| { .and_then(|o| {
o.scrolling o.scrolling.map(|s| {
.map(|s| s.center_focused_column.unwrap_or_default()) s.center_focused_column.unwrap_or_default()
== crate::scrolling::ScrollingCentering::Always
})
}) })
.unwrap_or(false); .unwrap_or(false);
+21 -2
View File
@@ -234,14 +234,33 @@ pub struct LayoutOptions {
pub row_ratios: Option<[Option<f32>; MAX_RATIOS]>, pub row_ratios: Option<[Option<f32>; MAX_RATIOS]>,
} }
/// A centring is one of its names, or a boolean, which is what it used to be
#[cfg(feature = "schemars")]
fn centering_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"description": "When to centre the focused column: `Never` (the default), `Always`, or
`OnOverflow`, which is when it does not fit on the screen together with the column that
focus came from. A boolean is also accepted, where `true` is `Always` and `false` is `Never`",
"anyOf": [
{ "type": "boolean" },
generator.subschema_for::<crate::scrolling::ScrollingCentering>(),
{ "type": "null" }
]
})
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// Options for the Scrolling layout /// Options for the Scrolling layout
pub struct ScrollingLayoutOptions { pub struct ScrollingLayoutOptions {
/// Desired number of visible columns (default: 3) /// Desired number of visible columns (default: 3)
pub columns: usize, pub columns: usize,
/// With an odd number of visible columns, keep the focused window column centered /// When to centre the focused column: `Never` (the default), `Always`, or `OnOverflow`, which
pub center_focused_column: Option<bool>, /// is when it does not fit on the screen together with the column that focus came from. A
/// boolean is also accepted, where `true` is `Always` and `false` is `Never`
#[serde(default)]
#[cfg_attr(feature = "schemars", schemars(schema_with = "centering_schema"))]
pub center_focused_column: Option<crate::scrolling::ScrollingCentering>,
} }
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)]
+8 -2
View File
@@ -419,7 +419,10 @@ mod layout_options_rules_tests {
let row = opts.row_ratios.unwrap(); let row = opts.row_ratios.unwrap();
assert_eq!(row[0], Some(0.5)); assert_eq!(row[0], Some(0.5));
assert_eq!(opts.scrolling.unwrap().columns, 4); assert_eq!(opts.scrolling.unwrap().columns, 4);
assert_eq!(opts.scrolling.unwrap().center_focused_column, Some(true)); assert_eq!(
opts.scrolling.unwrap().center_focused_column,
Some(crate::scrolling::ScrollingCentering::Always)
);
assert_eq!(opts.grid.unwrap().rows, 2); assert_eq!(opts.grid.unwrap().rows, 2);
} }
@@ -614,7 +617,10 @@ mod layout_default_entry_tests {
let rules = entry.layout_options_rules.unwrap(); let rules = entry.layout_options_rules.unwrap();
let r4 = &rules[&4]; let r4 = &rules[&4];
assert_eq!(r4.scrolling.unwrap().columns, 5); assert_eq!(r4.scrolling.unwrap().columns, 5);
assert_eq!(r4.scrolling.unwrap().center_focused_column, Some(true)); assert_eq!(
r4.scrolling.unwrap().center_focused_column,
Some(crate::scrolling::ScrollingCentering::Always)
);
// Rule doesn't inherit base fields - full replacement // Rule doesn't inherit base fields - full replacement
assert!(r4.column_ratios.is_none()); assert!(r4.column_ratios.is_none());
assert!(r4.grid.is_none()); assert!(r4.grid.is_none());
+1
View File
@@ -16,6 +16,7 @@ pub mod direction;
pub mod layout; pub mod layout;
pub mod operation_direction; pub mod operation_direction;
pub mod rect; pub mod rect;
pub mod scrolling;
pub mod sizing; pub mod sizing;
pub use arrangement::*; pub use arrangement::*;
+409
View File
@@ -0,0 +1,409 @@
//! The platform-free half of komorebi's Scrolling layout, shared by every komorebi.
//!
//! The strip of columns itself is the `komorebi-scrolling` crate, which has no idea what a window
//! is, and this module is what is left of the glue between it and a window manager once everything
//! that touches an operating system is taken out: what the configuration and the layout options
//! come to for the strip, where the strip says that every container goes, and how much of a
//! resize becomes how much of a column.
//!
//! A host owns a [`ScrollingSpace`] per workspace, keyed by the id of each container, and asks
//! this module the questions that do not depend on the platform. How it carries out the answers
//! (animating, hiding, parking at an edge) is its own business.
use crate::LayoutOptions;
use crate::OperationDirection;
use crate::Rect;
use crate::Sizing;
use komorebi_scrolling::CenterFocusedColumn;
use komorebi_scrolling::ColumnRecord;
use komorebi_scrolling::ColumnWidth;
use komorebi_scrolling::Options;
use komorebi_scrolling::ScrollingSpace;
use komorebi_scrolling::Viewport;
use komorebi_scrolling::Visibility;
use serde::Deserialize;
use serde::Serialize;
use std::sync::LazyLock;
use std::sync::Mutex;
/// The model of a strip of columns, for hosts that depend on this crate and not on the model's.
pub use komorebi_scrolling as model;
/// How many columns fit in the viewport when nothing says otherwise.
pub const DEFAULT_COLUMNS: usize = 3;
/// The narrowest that a column can be made to be by resizing it.
pub const MINIMUM_COLUMN_WIDTH: i32 = 100;
/// How short a window in a column of several can be made.
pub const MINIMUM_WINDOW_HEIGHT: i32 = 100;
/// When the Scrolling layout centres the focused column
///
/// A boolean is also accepted when reading, for the layout option that used to be one: `true` is
/// `Always` and `false` is `Never`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum ScrollingCentering {
/// Scroll just far enough to bring the focused column into view, and no further
#[default]
Never,
/// Keep the focused column in the middle of the screen
Always,
/// Centre the focused column when it does not fit on the screen together with the column that
/// focus came from
OnOverflow,
}
impl<'de> Deserialize<'de> for ScrollingCentering {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Repr {
Flag(bool),
Name(String),
}
match Repr::deserialize(deserializer)? {
Repr::Flag(true) => Ok(Self::Always),
Repr::Flag(false) => Ok(Self::Never),
Repr::Name(name) => match name.as_str() {
"Never" => Ok(Self::Never),
"Always" => Ok(Self::Always),
"OnOverflow" => Ok(Self::OnOverflow),
_ => Err(serde::de::Error::unknown_variant(
&name,
&["Never", "Always", "OnOverflow"],
)),
},
}
}
}
impl From<ScrollingCentering> for CenterFocusedColumn {
fn from(centering: ScrollingCentering) -> Self {
match centering {
ScrollingCentering::Never => Self::Never,
ScrollingCentering::Always => Self::Always,
ScrollingCentering::OnOverflow => Self::OnOverflow,
}
}
}
/// What the layout options of a workspace come to for the model.
///
/// `columns` says how many columns fit in the viewport, which is to say that a new column is a
/// `columns`th of its width. It does not say how many columns there are, and it does not stretch
/// a lone column to fill the viewport.
#[must_use]
pub fn scrolling_options(layout_options: Option<LayoutOptions>) -> Options {
let scrolling = layout_options.and_then(|options| options.scrolling);
let columns = scrolling
.map_or(DEFAULT_COLUMNS, |scrolling| scrolling.columns)
.max(1);
let centering = scrolling
.and_then(|scrolling| scrolling.center_focused_column)
.unwrap_or_default();
Options {
default_width: ColumnWidth::Proportion(1.0 / columns as f64),
center_focused_column: centering.into(),
minimum_tile_height: MINIMUM_WINDOW_HEIGHT,
..Options::default()
}
}
/// Where a container goes, and how much of it is in view.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScrollingPlacement {
/// Where the container is in the strip, in the coordinates of the screen. A container that is
/// partly or wholly out of view has the rectangle that it really has, which reaches past the
/// work area.
pub rect: Rect,
/// How much of the container is in view.
pub visibility: Visibility,
}
/// Make a space match the containers and the focus as they are now, creating it if there is none,
/// and with the options and the viewport that it should have.
pub fn sync(
space: &mut Option<ScrollingSpace<String>>,
options: Options,
viewport: Viewport,
ids: &[String],
focused: Option<&String>,
) {
let space = space.get_or_insert_with(|| ScrollingSpace::new(options.clone(), viewport));
if *space.options() != options {
space.set_options(options);
}
if space.viewport() != viewport {
space.set_viewport(viewport);
}
tell_of_arrivals(space, ids);
space.reconcile(ids, focused);
}
/// Where every container goes in `work_area`, from left to right, with `container_padding`
/// taken off every side of each.
#[must_use]
pub fn place(
space: &ScrollingSpace<String>,
work_area: &Rect,
container_padding: i32,
) -> Vec<ScrollingPlacement> {
space
.layout()
.into_iter()
.map(|placement| {
let mut rect = Rect {
left: work_area.left + placement.rect.x,
top: work_area.top + placement.rect.y,
right: placement.rect.width,
bottom: placement.rect.height,
};
rect.add_padding(container_padding);
ScrollingPlacement {
rect,
visibility: placement.visibility,
}
})
.collect()
}
/// The viewport that is the size of a work area.
#[must_use]
pub const fn viewport_of(work_area: &Rect) -> Viewport {
Viewport {
width: work_area.right,
height: work_area.bottom,
}
}
/// Make the focused column wider or narrower with the horizontal edges, and the focused window
/// taller or shorter in its column with the vertical ones. A window that is alone in its column
/// is as tall as the screen, so resizing it vertically does nothing.
///
/// The layouts that are made of splits move one edge of a container by half of `delta`, and
/// callers that want to move both, or to follow a mouse, rely on that, so a column changes
/// width, or a window height, by half of `delta` per edge too.
pub fn resize(
space: &mut ScrollingSpace<String>,
id: &String,
direction: OperationDirection,
sizing: Sizing,
delta: i32,
) {
let change = f64::from(delta) / 2.0;
let change = match sizing {
Sizing::Increase => change,
Sizing::Decrease => -change,
};
if matches!(direction, OperationDirection::Up | OperationDirection::Down) {
space.resize_tile_height(id, change);
return;
}
let Some(width) = space.column_width(id) else {
return;
};
let width = ((width + change).round() as i32).max(MINIMUM_COLUMN_WIDTH);
space.set_width(id, ColumnWidth::Fixed(width));
}
/// How many containers that were taken out of a workspace are waited for at another one.
const MAXIMUM_DEPARTED: usize = 32;
/// What the columns of the containers that were taken out of a workspace were given, until they
/// are put into another, whose strip has never heard of them.
///
/// A container goes from one workspace to another by being taken out of one and put into the
/// other, with nothing in between that both could be asked, so this is where it leaves what it
/// was given for the other to find.
static DEPARTED: LazyLock<Mutex<Vec<(String, ColumnRecord)>>> =
LazyLock::new(|| Mutex::new(Vec::new()));
/// Say what a container that is leaving a workspace was given.
pub fn note_departure(id: &str, record: ColumnRecord) {
let Ok(mut departed) = DEPARTED.lock() else {
return;
};
departed.retain(|(container, _)| container != id);
departed.push((id.to_string(), record));
if departed.len() > MAXIMUM_DEPARTED {
departed.remove(0);
}
}
/// What a container that arrives was given where it was, if it came from a strip.
#[must_use]
pub fn take_departed(id: &str) -> Option<ColumnRecord> {
let mut departed = DEPARTED.lock().ok()?;
let at = departed.iter().position(|(container, _)| container == id)?;
Some(departed.remove(at).1)
}
/// Tell a strip what the containers that it has not seen before were given where they came from,
/// before it is made to match the list of containers.
pub fn tell_of_arrivals(space: &mut ScrollingSpace<String>, ids: &[String]) {
for id in ids {
if space.position(id).is_none()
&& let Some(record) = take_departed(id)
{
space.arrive(id.clone(), record);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ScrollingLayoutOptions;
fn options(
columns: usize,
center_focused_column: Option<ScrollingCentering>,
) -> Option<LayoutOptions> {
Some(LayoutOptions {
scrolling: Some(ScrollingLayoutOptions {
columns,
center_focused_column,
}),
grid: None,
column_ratios: None,
row_ratios: None,
})
}
#[test]
fn a_column_is_a_share_of_the_viewport_as_wide_as_the_columns_option_says() {
for columns in [1, 2, 3, 5] {
assert_eq!(
scrolling_options(options(columns, None)).default_width,
ColumnWidth::Proportion(1.0 / columns as f64)
);
}
assert_eq!(
scrolling_options(None).default_width,
ColumnWidth::Proportion(1.0 / DEFAULT_COLUMNS as f64)
);
}
#[test]
fn centring_is_what_the_layout_option_says_and_never_by_default() {
for (given, expected) in [
(None, CenterFocusedColumn::Never),
(Some(ScrollingCentering::Never), CenterFocusedColumn::Never),
(
Some(ScrollingCentering::Always),
CenterFocusedColumn::Always,
),
(
Some(ScrollingCentering::OnOverflow),
CenterFocusedColumn::OnOverflow,
),
] {
assert_eq!(
scrolling_options(options(3, given)).center_focused_column,
expected
);
}
}
#[test]
fn centring_reads_the_booleans_that_it_used_to_be() {
let read = |json: &str| serde_json::from_str::<ScrollingCentering>(json).unwrap();
assert_eq!(read("true"), ScrollingCentering::Always);
assert_eq!(read("false"), ScrollingCentering::Never);
assert_eq!(read("\"Never\""), ScrollingCentering::Never);
assert_eq!(read("\"Always\""), ScrollingCentering::Always);
assert_eq!(read("\"OnOverflow\""), ScrollingCentering::OnOverflow);
assert!(serde_json::from_str::<ScrollingCentering>("\"Sometimes\"").is_err());
assert!(serde_json::from_str::<ScrollingCentering>("3").is_err());
}
#[test]
fn centring_is_written_as_its_name() {
assert_eq!(
serde_json::to_string(&ScrollingCentering::OnOverflow).unwrap(),
"\"OnOverflow\""
);
}
#[test]
fn containers_are_placed_inside_the_work_area_with_their_padding() {
let work_area = Rect {
left: 100,
top: 50,
right: 1200,
bottom: 800,
};
let ids: Vec<String> = ["a", "b"].iter().map(ToString::to_string).collect();
let mut space = None;
sync(
&mut space,
scrolling_options(options(3, None)),
viewport_of(&work_area),
&ids,
ids.first(),
);
let placements = place(space.as_ref().unwrap(), &work_area, 10);
assert_eq!(placements.len(), 2);
assert_eq!(placements[0].rect.left, 110);
assert_eq!(placements[0].rect.top, 60);
assert_eq!(placements[0].rect.right, 400 - 20);
assert_eq!(placements[0].rect.bottom, 800 - 20);
}
#[test]
fn resizing_changes_the_width_by_half_of_the_delta_and_never_below_the_minimum() {
let work_area = Rect {
left: 0,
top: 0,
right: 1200,
bottom: 800,
};
let ids = vec!["a".to_string()];
let mut space = None;
sync(
&mut space,
scrolling_options(options(3, None)),
viewport_of(&work_area),
&ids,
ids.first(),
);
let space = space.as_mut().unwrap();
let id = ids[0].clone();
resize(space, &id, OperationDirection::Right, Sizing::Increase, 100);
assert_eq!(space.column_width(&id), Some(450.0));
resize(
space,
&id,
OperationDirection::Left,
Sizing::Decrease,
10_000,
);
assert_eq!(
space.column_width(&id),
Some(f64::from(MINIMUM_COLUMN_WIDTH))
);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "komorebi-scrolling" name = "komorebi-scrolling"
version = "0.1.0" version = "0.1.42"
description = "A platform-free model of a scrollable strip of columns" description = "A platform-free model of a scrollable strip of columns"
edition = "2024" edition = "2024"
+470 -75
View File
@@ -1,54 +1,28 @@
//! The glue between a [`Workspace`] and the platform-free model of a strip of columns in //! The glue between a [`Workspace`] and the strip of columns of the Scrolling layout.
//! `komorebi-scrolling`.
//! //!
//! A container is a tile in the strip, and is known to it by the id of the container. //! Everything that does not touch Windows lives in [`komorebi_layouts::scrolling`] and the
//! `komorebi-scrolling` crate, so that every komorebi shares it. What is here is what needs a
//! [`Workspace`]: a container is a tile in the strip, and is known to it by its id.
use crate::container::Container;
use crate::core::DefaultLayout; use crate::core::DefaultLayout;
use crate::core::Layout; use crate::core::Layout;
use crate::core::LayoutOptions; use crate::core::LayoutOptions;
use crate::core::OperationDirection;
use crate::core::Rect; use crate::core::Rect;
use crate::core::ScrollingLayoutOptions;
use crate::core::Sizing;
use crate::workspace::Workspace; use crate::workspace::Workspace;
use komorebi_scrolling::CenterFocusedColumn; use color_eyre::eyre;
use komorebi_scrolling::ColumnWidth; use komorebi_layouts::scrolling;
use komorebi_layouts::scrolling::ScrollingPlacement;
use komorebi_scrolling::Options; use komorebi_scrolling::Options;
use komorebi_scrolling::ScrollingSpace;
use komorebi_scrolling::Viewport;
use komorebi_scrolling::Visibility; use komorebi_scrolling::Visibility;
use std::sync::atomic::Ordering;
/// How many columns fit in the viewport when nothing says otherwise.
const DEFAULT_COLUMNS: usize = 3;
/// Where a container goes, and how much of it is in view.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct ScrollingPlacement {
/// Where the container is in the strip, in the coordinates of the screen. A container that is
/// partly or wholly out of view has the rectangle that it really has, which reaches past the
/// work area.
pub rect: Rect,
#[allow(dead_code)]
pub visibility: Visibility,
}
/// What the layout options of a workspace come to for the model. /// What the layout options of a workspace come to for the model.
///
/// `columns` says how many columns fit in the viewport, which is to say that a new column is a
/// `columns`th of its width. It does not say how many columns there are, and it does not stretch
/// a lone column to fill the viewport.
pub(crate) fn scrolling_options(layout_options: Option<LayoutOptions>) -> Options { pub(crate) fn scrolling_options(layout_options: Option<LayoutOptions>) -> Options {
let scrolling = layout_options.and_then(|options| options.scrolling); scrolling::scrolling_options(layout_options)
let columns = scrolling
.map_or(DEFAULT_COLUMNS, |scrolling| scrolling.columns)
.max(1);
let centering = match scrolling.and_then(|scrolling| scrolling.center_focused_column) {
Some(true) => CenterFocusedColumn::Always,
_ => CenterFocusedColumn::Never,
};
Options {
default_width: ColumnWidth::Proportion(1.0 / columns as f64),
center_focused_column: centering,
..Options::default()
}
} }
impl Workspace { impl Workspace {
@@ -57,31 +31,104 @@ impl Workspace {
matches!(self.layout, Layout::Default(DefaultLayout::Scrolling)) matches!(self.layout, Layout::Default(DefaultLayout::Scrolling))
} }
/// Make the space match the containers, and the focus, as they are now. fn scrolling_ids(&self) -> Vec<String> {
fn sync_scrolling(&mut self, viewport: Viewport) { self.containers()
let options = scrolling_options(self.effective_layout_options());
let ids: Vec<String> = self
.containers()
.iter() .iter()
.map(|container| container.id.clone()) .map(|container| container.id.clone())
.collect(); .collect()
}
/// Set how many columns the Scrolling layout shows at once, keeping every other layout option.
pub fn set_scrolling_columns(&mut self, columns: usize) {
let mut options = self.layout_options.unwrap_or(LayoutOptions {
scrolling: None,
grid: None,
column_ratios: None,
row_ratios: None,
});
match &mut options.scrolling {
Some(scrolling) => scrolling.columns = columns,
None => {
options.scrolling = Some(ScrollingLayoutOptions {
columns,
center_focused_column: None,
});
}
}
self.layout_options = Some(options);
}
/// Make the space match the containers, and the focus, as they are now.
fn sync_scrolling(&mut self, viewport: komorebi_scrolling::Viewport) {
let options = scrolling_options(self.effective_layout_options());
let ids = self.scrolling_ids();
let focused = self let focused = self
.focused_container() .focused_container()
.map(|container| container.id.clone()); .map(|container| container.id.clone());
let space = self scrolling::sync(
.scrolling &mut self.scrolling,
.get_or_insert_with(|| ScrollingSpace::new(options.clone(), viewport)); options,
viewport,
&ids,
focused.as_ref(),
);
}
if *space.options() != options { /// Say what the column of the container at this index was given, as it is about to be taken
space.set_options(options); /// out of the workspace, for the workspace that it is put in to find.
pub(crate) fn note_scrolling_departure(&self, idx: usize) {
if let Some(space) = &self.scrolling
&& let Some(container) = self.containers().get(idx)
&& let Some(record) = space.column_record(&container.id)
{
scrolling::note_departure(&container.id, record);
}
}
/// Make the space match the containers and the focus as they are now, for a space that is
/// there to be made to match.
///
/// This is for a change that is about to be made to the containers, and that the space is
/// going to be told about directly. The space has to know what there is before it is told
/// what goes.
pub(crate) fn sync_scrolling_containers(&mut self) {
let ids = self.scrolling_ids();
let focused = self
.focused_container()
.map(|container| container.id.clone());
if let Some(space) = self.scrolling.as_mut() {
scrolling::tell_of_arrivals(space, &ids);
space.reconcile(&ids, focused.as_ref());
}
}
/// Give focus to what the strip says should have it after the container `removed` has gone,
/// and tell whether the strip had anything to say.
///
/// Focus goes back to the column that the closed one was opened next to, if nothing has
/// moved focus since, and otherwise to the column that takes its place on the right. Focus that
/// is not on the closed column stays where it is. This is not what the other layouts do, which
/// always move focus to the left.
pub(crate) fn focus_after_scrolling_removal(&mut self, removed: &str) -> bool {
let Some(space) = self.scrolling.as_mut() else {
return false;
};
if !space.remove_column(&removed.to_string()) {
return false;
} }
if space.viewport() != viewport { // a container is a tile, and the strip says where in reading order the one that has focus
space.set_viewport(viewport); // is, which is the index of the container
if let Some(idx) = space.active_tile_index() {
self.focus_container(idx);
} }
space.reconcile(&ids, focused.as_ref()); true
} }
/// Where every container goes in `work_area`, from left to right, with `container_padding` /// Where every container goes in `work_area`, from left to right, with `container_padding`
@@ -91,32 +138,380 @@ impl Workspace {
work_area: &Rect, work_area: &Rect,
container_padding: i32, container_padding: i32,
) -> Vec<ScrollingPlacement> { ) -> Vec<ScrollingPlacement> {
self.sync_scrolling(Viewport { self.sync_scrolling(scrolling::viewport_of(work_area));
width: work_area.right,
height: work_area.bottom,
});
let Some(space) = &self.scrolling else { self.scrolling
return vec![]; .as_ref()
.map(|space| scrolling::place(space, work_area, container_padding))
.unwrap_or_default()
}
/// Where the container that a new window gets goes, if the strip has a say: after the last
/// tile of the column that has focus.
///
/// A new window is a column of its own beside the focused one, and not a tile in the middle of
/// it, which is what the container after the focused one would be if the column has other
/// tiles below.
pub(crate) fn scrolling_insertion_idx(&self) -> Option<usize> {
if !self.is_scrolling() {
return None;
}
let space = self.scrolling.as_ref()?;
let focused = self.focused_container()?.id.clone();
let last = space.column_of(&focused)?.pop()?;
self.containers()
.iter()
.position(|container| container.id == last)
.map(|idx| idx + 1)
}
/// Where focus would go from the focused container in this direction, in the strip as it is
/// now, or `None` if it would not go anywhere.
///
/// Left and right are the neighbouring columns, and focus goes to the tile that had it last
/// in the one it arrives at. Up and down are the tiles of the focused column, and stop at
/// the ends.
pub(crate) fn scrolling_idx_for_direction(
&self,
direction: OperationDirection,
) -> Option<usize> {
// the strip may not have heard about everything that happened since the last layout, so
// this goes by a copy of it that has
let mut probe = self.scrolling.clone()?;
let ids = self.scrolling_ids();
let focused = self
.focused_container()
.map(|container| container.id.clone());
probe.reconcile(&ids, focused.as_ref());
let moved = match direction {
OperationDirection::Left => probe.focus_left(),
OperationDirection::Right => probe.focus_right(),
OperationDirection::Up => probe.focus_up(),
OperationDirection::Down => probe.focus_down(),
}; };
space if moved {
.layout() probe.active_tile_index()
.into_iter() } else {
.map(|placement| { None
let mut rect = Rect { }
left: work_area.left + placement.rect.x, }
top: work_area.top + placement.rect.y,
right: placement.rect.width,
bottom: placement.rect.height,
};
rect.add_padding(container_padding);
ScrollingPlacement { /// Make the order of the containers the order of the tiles of the strip, and focus the tile
rect, /// that the strip says has it.
visibility: placement.visibility, ///
/// Most of what the strip does leaves the order alone. What moves a tile past another does
/// not, and the list of containers is what is true, so it is made to match.
fn apply_scrolling_order(&mut self) {
let Some(space) = self.scrolling.as_ref() else {
return;
};
let order = space.ids();
let focused = space.active_tile_index();
let containers = self.containers_mut();
let mut containers_in_order: Vec<_> = containers.drain(..).collect();
containers_in_order.sort_by_key(|container| {
order
.iter()
.position(|id| *id == container.id)
.unwrap_or(usize::MAX)
});
containers.extend(containers_in_order);
if let Some(idx) = focused {
self.focus_container(idx);
}
// resize adjustments are not used in this layout, and must not follow a container
// to where it no longer is
self.resize_dimensions = vec![None; self.containers().len()];
}
/// Make the space agree with which container has focus, and give back the id of that
/// container, for a command that is about the focused column.
///
/// There is nothing to do until a layout has made the space, and in a layout that is not
/// Scrolling.
fn focused_scrolling_column(&mut self) -> Option<String> {
if !self.is_scrolling() || self.scrolling.is_none() {
return None;
}
self.sync_scrolling_containers();
self.focused_container()
.map(|container| container.id.clone())
}
/// Move the focused column one place to the left or right, or the focused tile one place up
/// or down in its column, and tell whether anything moved.
///
/// A column moves with all of its tiles.
pub(crate) fn move_scrolling(&mut self, direction: OperationDirection) -> bool {
let Some(id) = self.focused_scrolling_column() else {
return false;
};
let Some(space) = self.scrolling.as_mut() else {
return false;
};
let moved = match direction {
OperationDirection::Up => space.move_tile_up(),
OperationDirection::Down => space.move_tile_down(),
OperationDirection::Left | OperationDirection::Right => match space.position(&id) {
Some(at) => {
let to = match direction {
OperationDirection::Left => at.checked_sub(1),
_ => Some(at + 1).filter(|to| *to < space.len()),
};
match to {
Some(to) => {
space.move_column(&id, to);
true
}
None => false,
}
} }
}) None => false,
.collect() },
};
if moved {
self.apply_scrolling_order();
}
moved
}
/// Take the neighbouring tile on one side into the focused column: the first tile of the
/// column on the right to the bottom of it, or the last tile of the column on the left to the
/// top of it. Up and down mean nothing here.
pub(crate) fn consume_scrolling(&mut self, direction: OperationDirection) {
if self.focused_scrolling_column().is_none() {
return;
}
let Some(space) = self.scrolling.as_mut() else {
return;
};
let changed = match direction {
OperationDirection::Left => space.consume_left(),
OperationDirection::Right => space.consume_right(),
OperationDirection::Up | OperationDirection::Down => false,
};
if changed {
self.apply_scrolling_order();
}
}
/// Take the focused tile out of its column and put it in a column of its own on the right.
pub(crate) fn expel_scrolling(&mut self) {
if self.focused_scrolling_column().is_none() {
return;
}
let Some(space) = self.scrolling.as_mut() else {
return;
};
if space.expel() {
self.apply_scrolling_order();
}
}
/// Stack the windows of the focused column in one tile, or spread the stack in the focused tile
/// out into the tiles of a column, whichever it is, and tell whether there was anything to do.
///
/// A column of several tiles becomes one tile that is a stack of all of their windows, in the
/// order that they were in, with the window that had focus still showing. A tile that is a stack
/// becomes a column of a tile for each of its windows, in the order of its tabs, with focus on
/// the window that had it. Either way the column keeps its width, but the heights that its tiles
/// were given are not remembered for when it is toggled back.
pub(crate) fn toggle_scrolling_column_stack(&mut self) -> eyre::Result<bool> {
let Some(id) = self.focused_scrolling_column() else {
return Ok(false);
};
let Some(column) = self
.scrolling
.as_ref()
.and_then(|space| space.column_of(&id))
else {
return Ok(false);
};
if column.len() > 1 {
self.stack_scrolling_column(&column);
return Ok(true);
}
Ok(self.unstack_scrolling_column())
}
/// Put the windows of all of the containers with these ids in the first of them, in order.
fn stack_scrolling_column(&mut self, column: &[String]) {
let focused_window = self
.focused_container()
.and_then(Container::focused_window)
.map(|window| window.hwnd);
let mut windows = vec![];
for id in &column[1..] {
if let Some(idx) = self.containers().iter().position(|c| c.id == *id)
&& let Some(container) = self.remove_container_by_idx(idx)
{
windows.extend(container.windows().iter().copied());
}
}
let Some(idx) = self.containers().iter().position(|c| c.id == column[0]) else {
return;
};
if let Some(container) = self.containers_mut().get_mut(idx) {
for window in windows {
container.add_window(window);
}
// the window that had focus is the one that is showing
if let Some(focused) = focused_window
&& let Some(at) = container.idx_for_window(focused)
{
container.focus_window(at);
}
if !cfg!(test) {
container.load_focused_window();
}
}
self.focus_container(idx);
}
/// Put each of the windows of the focused container in a container of its own, in the same
/// column, and tell whether it had more than one.
fn unstack_scrolling_column(&mut self) -> bool {
let idx = self.focused_container_idx();
let Some(container) = self.containers_mut().get_mut(idx) else {
return false;
};
if container.windows().len() < 2 {
return false;
}
let focused = container.focused_window().map(|window| window.hwnd);
let first_id = container.id.clone();
let mut windows: Vec<_> = container.windows_mut().drain(..).collect();
let rest = windows.split_off(1);
container.windows_mut().extend(windows);
container.focus_window(0);
// the strip is about to be told that there are more containers than it has columns for,
// and so each of them is a column, until they are put in this one
let mut count = 0;
for (offset, window) in rest.into_iter().enumerate() {
let mut container = Container::default();
container.add_window(window);
// it was one of the tabs that were out of sight, and has to be put where it is shown
if !cfg!(test) {
container.restore();
}
self.insert_container_at_idx(idx + 1 + offset, container);
count += 1;
}
self.sync_scrolling_containers();
if let Some(space) = self.scrolling.as_mut() {
space.focus(&first_id);
for _ in 0..count {
space.consume_right();
}
}
// focus is on the tile that had the window that had it
let focused_idx = focused
.and_then(|focused| self.container_idx_for_window(focused))
.unwrap_or(idx);
self.focus_container(focused_idx);
true
}
/// Put every column back to the width that the `columns` option gives, which forgets every
/// width that was set by resizing, by a preset, or by taking the full width, and every window
/// back to an equal share of the height of its column.
///
/// The focused column stays where it is on screen unless it has to scroll to stay in view.
pub(crate) fn reset_scrolling_sizes(&mut self) {
if let Some(space) = self.scrolling.as_mut() {
space.reset_widths();
space.reset_heights();
}
}
/// Make the focused column as wide as the screen, or give it back the width that it had.
pub(crate) fn toggle_scrolling_full_width(&mut self) {
if let Some(id) = self.focused_scrolling_column()
&& let Some(space) = self.scrolling.as_mut()
{
space.toggle_full_width(&id);
}
}
/// Make the focused column as wide as it can be without scrolling anything else out of view,
/// which is to fill the room that the columns in view leave. Only the Scrolling layout has
/// columns to expand.
pub(crate) fn expand_scrolling_column(&mut self) {
if self.is_scrolling()
&& self.focused_scrolling_column().is_some()
&& let Some(space) = self.scrolling.as_mut()
{
space.expand_to_available_width();
}
}
/// Centre the focused column on the screen, this once.
pub(crate) fn center_scrolling_column(&mut self) {
if self.focused_scrolling_column().is_some()
&& let Some(space) = self.scrolling.as_mut()
{
space.center_column();
}
}
/// Make the focused column wider or narrower with the horizontal edges, and the focused window
/// taller or shorter in its column with the vertical ones.
pub(crate) fn resize_scrolling_column(
&mut self,
direction: OperationDirection,
sizing: Sizing,
delta: i32,
) {
let Some(id) = self
.focused_container()
.map(|container| container.id.clone())
else {
return;
};
if let Some(space) = self.scrolling.as_mut() {
scrolling::resize(space, &id, direction, sizing, delta);
}
} }
} }