mirror of
https://github.com/LGUG2Z/komorebi.git
synced 2026-11-13 12:23:38 +01:00
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:
1 parent
3b93035d48
commit
8c7327fd31
9 files changed
+917
-83
No files matched your search
Generated
+2
-1
@@ -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"
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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,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
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user