feat: make scrolling the active default layout

This commit is contained in:
Samuel Onoja committed 2026-09-25 02:08:04 +01:00
1 parent 8e4283bf16
commit 9d90415e55
13 files changed
+915 -56

No files matched your search

Generated
+51 -3
View File
@@ -420,7 +420,9 @@ dependencies = [
"ferese-core", "ferese-core",
"ferese-layout", "ferese-layout",
"libc", "libc",
"serde",
"smithay", "smithay",
"toml",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"xcursor", "xcursor",
@@ -1393,6 +1395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [ dependencies = [
"serde_core", "serde_core",
"serde_derive",
] ]
[[package]] [[package]]
@@ -1415,6 +1418,15 @@ dependencies = [
"syn 3.0.6", "syn 3.0.6",
] ]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -1633,6 +1645,30 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
dependencies = [
"serde_core",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.1.1+spec-1.1.0" version = "1.1.1+spec-1.1.0"
@@ -1649,9 +1685,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"toml_datetime", "toml_datetime 1.1.1+spec-1.1.0",
"toml_parser", "toml_parser",
"winnow", "winnow 1.0.4",
] ]
[[package]] [[package]]
@@ -1660,9 +1696,15 @@ version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [ dependencies = [
"winnow", "winnow 1.0.4",
] ]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]] [[package]]
name = "tracing" name = "tracing"
version = "0.1.44" version = "0.1.44"
@@ -2231,6 +2273,12 @@ dependencies = [
"xkbcommon-dl", "xkbcommon-dl",
] ]
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "1.0.4" version = "1.0.4"
+2
View File
@@ -28,4 +28,6 @@ smithay = { version = "0.7.0", default-features = false, features = [
] } ] }
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
serde = { version = "1.0.228", features = ["derive"] }
toml = "0.9.7"
xcursor = "0.3.11" xcursor = "0.3.11"
+21 -8
View File
@@ -1,12 +1,11 @@
# Ferese # Ferese
Ferese is an experimental Wayland scrolling compositor written in Rust with Ferese is an experimental Wayland scrolling compositor written in Rust with
Smithay. The current implementation includes the original tree layout, Smithay. Scrolling columns are the default, tree tiling remains available as an
workspaces, and timestamp-driven animated geometry. The specification now makes optional per-workspace mode, and geometry changes use timestamp-driven
scrolling columns the default and retains tree tiling as an optional mode. DRM animation. See the
multi-output and native interoperability are being completed before that layout [window-manager specification](docs/ferese-window-manager-spec.md) for the
transition in the current architecture and milestones.
[window-manager specification](docs/ferese-window-manager-spec.md).
## Development ## Development
@@ -25,6 +24,19 @@ cargo run -p ferese -- foot
Set `RUST_LOG=ferese=debug` for detailed compositor logging. Set `RUST_LOG=ferese=debug` for detailed compositor logging.
Ferese reads `$XDG_CONFIG_HOME/ferese/config.toml`, or
`~/.config/ferese/config.toml` when `XDG_CONFIG_HOME` is unset. New scrolling
columns use half the viewport by default. To make every newly opened column use
the full viewport width:
```toml
[scrolling]
default_column_width = "full"
```
`default_column_width = 1.0` is equivalent. `Super+M` switches the active
workspace between scrolling and tree modes.
Text-input-v3 is available to applications by default. Input-method-v2 is Text-input-v3 is available to applications by default. Input-method-v2 is
hidden unless the session explicitly opts in, because an input method can hidden unless the session explicitly opts in, because an input method can
request privileged keyboard access. Enable it only when launching a trusted request privileged keyboard access. Enable it only when launching a trusted
@@ -68,5 +80,6 @@ M4 validation requires the direct session to:
- log page-flip timing and any missed presentation deadlines on the reference - log page-flip timing and any missed presentation deadlines on the reference
integrated GPU. integrated GPU.
The direct path currently targets one connected output. Multi-output hotplug is The direct path creates an independent render pipeline per connected output and
part of M5. rescans connectors on hotplug. Physical unplug/replug acceptance testing on the
reference hardware is still pending.
+2
View File
@@ -11,6 +11,8 @@ ferese-core = { path = "../crates/ferese-core" }
ferese-layout = { path = "../crates/ferese-layout" } ferese-layout = { path = "../crates/ferese-layout" }
libc.workspace = true libc.workspace = true
smithay.workspace = true smithay.workspace = true
serde.workspace = true
toml.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
xcursor.workspace = true xcursor.workspace = true
+176
View File
@@ -0,0 +1,176 @@
use std::env;
use std::error::Error;
use std::fmt;
use std::fs;
use std::io;
use std::path::PathBuf;
use ferese_core::LayoutMode;
use ferese_layout::ColumnWidth;
use serde::Deserialize;
#[derive(Debug, Default, Deserialize)]
pub struct Config {
#[serde(default)]
layout: LayoutConfig,
#[serde(default)]
scrolling: ScrollingConfig,
}
#[derive(Debug, Default, Deserialize)]
struct LayoutConfig {
mode: Option<LayoutModeValue>,
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
enum LayoutModeValue {
Scrolling,
Tree,
}
#[derive(Debug, Default, Deserialize)]
struct ScrollingConfig {
default_column_width: Option<ColumnWidthValue>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
enum ColumnWidthValue {
Proportion(f64),
Named(String),
}
#[derive(Debug)]
pub enum ConfigError {
Read {
path: PathBuf,
source: io::Error,
},
Parse {
path: PathBuf,
source: toml::de::Error,
},
InvalidColumnWidth(String),
}
impl fmt::Display for ConfigError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Read { path, source } => {
write!(formatter, "failed to read {}: {source}", path.display())
}
Self::Parse { path, source } => {
write!(formatter, "failed to parse {}: {source}", path.display())
}
Self::InvalidColumnWidth(value) => write!(
formatter,
"invalid scrolling.default_column_width {value}; expected a positive number or \"full\""
),
}
}
}
impl Error for ConfigError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Read { source, .. } => Some(source),
Self::Parse { source, .. } => Some(source),
Self::InvalidColumnWidth(_) => None,
}
}
}
impl Config {
pub fn load() -> Result<Self, ConfigError> {
let Some(path) = config_path() else {
return Ok(Self::default());
};
let source = match fs::read_to_string(&path) {
Ok(source) => source,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
Err(source) => return Err(ConfigError::Read { path, source }),
};
toml::from_str(&source).map_err(|source| ConfigError::Parse { path, source })
}
pub fn layout_mode(&self) -> LayoutMode {
match self.layout.mode.unwrap_or(LayoutModeValue::Scrolling) {
LayoutModeValue::Scrolling => LayoutMode::Scrolling,
LayoutModeValue::Tree => LayoutMode::Tree,
}
}
pub fn default_column_width(&self) -> Result<ColumnWidth, ConfigError> {
match self
.scrolling
.default_column_width
.as_ref()
.unwrap_or(&ColumnWidthValue::Proportion(0.5))
{
ColumnWidthValue::Proportion(value) if value.is_finite() && *value > 0.0 => {
Ok(ColumnWidth::Proportion(*value))
}
ColumnWidthValue::Named(value) if value.eq_ignore_ascii_case("full") => {
Ok(ColumnWidth::Full)
}
value => Err(ConfigError::InvalidColumnWidth(match value {
ColumnWidthValue::Proportion(value) => value.to_string(),
ColumnWidthValue::Named(value) => format!("{value:?}"),
})),
}
}
}
fn config_path() -> Option<PathBuf> {
if let Some(directory) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
return Some(PathBuf::from(directory).join("ferese/config.toml"));
}
env::var_os("HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.map(|home| home.join(".config/ferese/config.toml"))
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(source: &str) -> Config {
toml::from_str(source).unwrap()
}
#[test]
fn defaults_to_half_width_scrolling_columns() {
let config = parse("");
assert_eq!(config.layout_mode(), LayoutMode::Scrolling);
assert_eq!(
config.default_column_width().unwrap(),
ColumnWidth::Proportion(0.5)
);
}
#[test]
fn accepts_full_and_numeric_column_widths() {
let full = parse("[scrolling]\ndefault_column_width = \"full\"");
let numeric = parse("[scrolling]\ndefault_column_width = 1.0");
assert_eq!(full.default_column_width().unwrap(), ColumnWidth::Full);
assert_eq!(
numeric.default_column_width().unwrap(),
ColumnWidth::Proportion(1.0)
);
}
#[test]
fn rejects_non_positive_or_unknown_column_widths() {
let zero = parse("[scrolling]\ndefault_column_width = 0.0");
let unknown = parse("[scrolling]\ndefault_column_width = \"wide\"");
assert!(zero.default_column_width().is_err());
assert!(unknown.default_column_width().is_err());
}
}
+11
View File
@@ -138,6 +138,15 @@ impl Ferese {
data.toggle_focused_fullscreen(); data.toggle_focused_fullscreen();
} }
FilterResult::Intercept(())
} else if !modifiers.shift
&& matches!(symbol, keysyms::KEY_m | keysyms::KEY_M)
{
if state == KeyState::Pressed {
data.intercepted_keys.insert(keycode);
data.toggle_layout_mode();
}
FilterResult::Intercept(()) FilterResult::Intercept(())
} else if modifiers.shift && symbol == keysyms::KEY_space { } else if modifiers.shift && symbol == keysyms::KEY_space {
if state == KeyState::Pressed { if state == KeyState::Pressed {
@@ -313,6 +322,8 @@ impl Ferese {
toplevel.send_pending_configure(); toplevel.send_pending_configure();
} }
}); });
self.relayout();
} }
fn clamp_pointer_position(&self, requested: Point<f64, Logical>) -> Point<f64, Logical> { fn clamp_pointer_position(&self, requested: Point<f64, Logical>) -> Point<f64, Logical> {
+9 -1
View File
@@ -1,6 +1,7 @@
#![allow(irrefutable_let_patterns)] #![allow(irrefutable_let_patterns)]
mod backends; mod backends;
mod config;
mod cursor; mod cursor;
mod grabs; mod grabs;
mod handlers; mod handlers;
@@ -15,15 +16,22 @@ pub use state::Ferese;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::backends::LaunchConfig; use crate::backends::LaunchConfig;
use crate::config::Config;
fn main() -> Result<(), Box<dyn Error>> { fn main() -> Result<(), Box<dyn Error>> {
init_logging(); init_logging();
let launch = LaunchConfig::from_environment() let launch = LaunchConfig::from_environment()
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
let config = Config::load()?;
let mut event_loop = EventLoop::try_new()?; let mut event_loop = EventLoop::try_new()?;
let display = Display::new()?; let display = Display::new()?;
let mut state = Ferese::new(&mut event_loop, display)?; let mut state = Ferese::new(
&mut event_loop,
display,
config.layout_mode(),
config.default_column_width()?,
)?;
backends::init(launch.backend, &mut event_loop, &mut state)?; backends::init(launch.backend, &mut event_loop, &mut state)?;
// SAFETY: the backend has already read the host display and Ferese is // SAFETY: the backend has already read the host display and Ferese is
+57 -28
View File
@@ -7,8 +7,12 @@ use std::{
}; };
use ferese_animation::{ClientSize, SpringConfig, WindowGeometry}; use ferese_animation::{ClientSize, SpringConfig, WindowGeometry};
use ferese_core::{OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceSet}; use ferese_core::{
use ferese_layout::{Axis, Direction, GapConfig, LayoutResult, Rect, SizeConstraints, WindowId}; LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceSet,
};
use ferese_layout::{
Axis, ColumnWidth, Direction, GapConfig, LayoutResult, Rect, SizeConstraints, WindowId,
};
use smithay::{ use smithay::{
backend::{ backend::{
@@ -267,6 +271,8 @@ impl Ferese {
pub fn new( pub fn new(
event_loop: &mut EventLoop<'static, Self>, event_loop: &mut EventLoop<'static, Self>,
display: Display<Self>, display: Display<Self>,
default_layout_mode: LayoutMode,
default_column_width: ColumnWidth,
) -> Result<Self, Box<dyn Error>> { ) -> Result<Self, Box<dyn Error>> {
let display_handle = display.handle(); let display_handle = display.handle();
crate::handlers::screencopy::init_global(&display_handle); crate::handlers::screencopy::init_global(&display_handle);
@@ -315,7 +321,7 @@ impl Ferese {
display_handle, display_handle,
loop_signal: event_loop.get_signal(), loop_signal: event_loop.get_signal(),
space: Space::default(), space: Space::default(),
workspaces: WorkspaceSet::default(), workspaces: WorkspaceSet::new(default_layout_mode, default_column_width),
output_workspaces: OutputWorkspaceMap::default(), output_workspaces: OutputWorkspaceMap::default(),
output_ids: HashMap::new(), output_ids: HashMap::new(),
output_identity_ids: HashMap::new(), output_identity_ids: HashMap::new(),
@@ -620,6 +626,7 @@ impl Ferese {
pub fn relayout(&mut self) { pub fn relayout(&mut self) {
self.arrange_layers(); self.arrange_layers();
let outputs = self.space.outputs().cloned().collect::<Vec<_>>(); let outputs = self.space.outputs().cloned().collect::<Vec<_>>();
let constraints = self.window_constraints();
let mut visible = HashSet::new(); let mut visible = HashSet::new();
let mut placements = Vec::new(); let mut placements = Vec::new();
@@ -630,23 +637,28 @@ impl Ferese {
let Some(workspace_id) = self.output_workspaces.active_workspace(output_id) else { let Some(workspace_id) = self.output_workspaces.active_workspace(output_id) else {
continue; continue;
}; };
let Some(workspace) = self.workspaces.workspace(workspace_id) else {
continue;
};
let Some(bounds) = self.output_bounds_for(&output) else { let Some(bounds) = self.output_bounds_for(&output) else {
continue; continue;
}; };
let fullscreen_bounds = self.full_output_bounds_for(&output).unwrap_or(bounds); let fullscreen_bounds = self.full_output_bounds_for(&output).unwrap_or(bounds);
let Some(workspace) = self.workspaces.workspace(workspace_id) else {
continue;
};
let workspace_fullscreen = workspace.fullscreen;
let workspace_focus = workspace.last_focused;
let focused = self let focused = self
.focused_window .focused_window
.filter(|window| { .filter(|window| {
self.workspaces.workspace_for_window(*window) == Some(workspace_id) self.workspaces.workspace_for_window(*window) == Some(workspace_id)
}) })
.or(workspace.last_focused); .or(workspace_focus);
let Some(workspace) = self.workspaces.workspace_mut(workspace_id) else {
continue;
};
let layout = match workspace.layout.geometry_with_constraints( let layout = match workspace.layout.geometry_with_constraints(
bounds, bounds,
GapConfig::default(), GapConfig::default(),
&self.window_constraints(), &constraints,
focused, focused,
) { ) {
Ok(layout) => layout, Ok(layout) => layout,
@@ -671,9 +683,9 @@ impl Ferese {
continue; continue;
} }
let rect = if workspace.fullscreen == Some(*id) { let rect = if workspace_fullscreen == Some(*id) {
fullscreen_bounds fullscreen_bounds
} else if workspace.fullscreen.is_some() { } else if workspace_fullscreen.is_some() {
continue; continue;
} else { } else {
match self.workspaces.placement(*id) { match self.workspaces.placement(*id) {
@@ -687,7 +699,7 @@ impl Ferese {
None => continue, None => continue,
} }
}; };
let is_fullscreen = workspace.fullscreen == Some(*id); let is_fullscreen = workspace_fullscreen == Some(*id);
let is_floating = matches!( let is_floating = matches!(
self.workspaces.placement(*id), self.workspaces.placement(*id),
Some(WindowPlacement::Floating { .. }) Some(WindowPlacement::Floating { .. })
@@ -885,6 +897,8 @@ impl Ferese {
toplevel.send_pending_configure(); toplevel.send_pending_configure();
} }
} }
self.relayout();
} }
pub fn move_direction(&mut self, direction: Direction) { pub fn move_direction(&mut self, direction: Direction) {
@@ -926,6 +940,22 @@ impl Ferese {
} }
} }
pub fn toggle_layout_mode(&mut self) {
let Some(bounds) = self.output_bounds() else {
return;
};
let mode = match self.workspaces.active().layout.mode() {
LayoutMode::Scrolling => LayoutMode::Tree,
LayoutMode::Tree => LayoutMode::Scrolling,
};
match self.workspaces.set_active_layout_mode(mode, bounds) {
Ok(true) => self.relayout(),
Ok(false) => {}
Err(error) => tracing::error!(%error, ?mode, "failed to change layout mode"),
}
}
pub fn toggle_focused_floating(&mut self) { pub fn toggle_focused_floating(&mut self) {
let Some(window) = self.focused_window else { let Some(window) = self.focused_window else {
return; return;
@@ -1142,23 +1172,22 @@ impl Ferese {
} }
} }
fn logical_window_rect(&self, window: WindowId, bounds: Rect) -> Option<Rect> { fn logical_window_rect(&mut self, window: WindowId, bounds: Rect) -> Option<Rect> {
let workspace = self.workspaces.workspace_for_window(window)?; let workspace = self.workspaces.workspace_for_window(window)?;
let workspace = self.workspaces.workspace(workspace)?; let fullscreen = self.workspaces.workspace(workspace)?.fullscreen;
if workspace.fullscreen == Some(window) { if fullscreen == Some(window) {
return Some(bounds); return Some(bounds);
} }
let constraints = self.window_constraints();
let focused = self.focused_window;
match self.workspaces.placement(window)? { match self.workspaces.placement(window)? {
WindowPlacement::Tiled => workspace WindowPlacement::Tiled => self
.workspaces
.workspace_mut(workspace)?
.layout .layout
.geometry_with_constraints( .geometry_with_constraints(bounds, GapConfig::default(), &constraints, focused)
bounds,
GapConfig::default(),
&self.window_constraints(),
self.focused_window,
)
.ok()? .ok()?
.geometry .geometry
.get(&window) .get(&window)
@@ -1167,13 +1196,13 @@ impl Ferese {
} }
} }
fn tiled_layout(&self, bounds: Rect) -> Result<LayoutResult, ferese_layout::LayoutError> { fn tiled_layout(&mut self, bounds: Rect) -> Result<LayoutResult, ferese_layout::LayoutError> {
self.workspaces.active().layout.geometry_with_constraints( let constraints = self.window_constraints();
bounds, let focused = self.focused_window;
GapConfig::default(), self.workspaces
&self.window_constraints(), .active_mut()
self.focused_window, .layout
) .geometry_with_constraints(bounds, GapConfig::default(), &constraints, focused)
} }
fn window_constraints(&self) -> HashMap<WindowId, SizeConstraints> { fn window_constraints(&self) -> HashMap<WindowId, SizeConstraints> {
+4 -1
View File
@@ -2,4 +2,7 @@ mod output;
mod workspace; mod workspace;
pub use output::{OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch}; pub use output::{OutputError, OutputGeometry, OutputId, OutputWorkspaceMap, WorkspaceSwitch};
pub use workspace::{WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceSet}; pub use workspace::{
LayoutMode, WindowPlacement, Workspace, WorkspaceError, WorkspaceId, WorkspaceLayout,
WorkspaceSet,
};
+324 -8
View File
@@ -2,7 +2,10 @@ use std::collections::{HashMap, HashSet};
use std::error::Error; use std::error::Error;
use std::fmt; use std::fmt;
use ferese_layout::{Axis, LayoutError, LayoutTree, Rect, WindowId}; use ferese_layout::{
Axis, ColumnWidth, Direction, GapConfig, LayoutError, LayoutResult, LayoutTree, Rect,
ScrollingLayout, SizeConstraints, WindowId,
};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct WorkspaceId(pub u64); pub struct WorkspaceId(pub u64);
@@ -11,12 +14,237 @@ pub struct WorkspaceId(pub u64);
pub struct Workspace { pub struct Workspace {
pub id: WorkspaceId, pub id: WorkspaceId,
pub name: String, pub name: String,
pub layout: LayoutTree, pub layout: WorkspaceLayout,
pub floating: Vec<WindowId>, pub floating: Vec<WindowId>,
pub last_focused: Option<WindowId>, pub last_focused: Option<WindowId>,
pub fullscreen: Option<WindowId>, pub fullscreen: Option<WindowId>,
} }
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum LayoutMode {
#[default]
Scrolling,
Tree,
}
#[derive(Debug)]
pub enum WorkspaceLayout {
Scrolling(ScrollingLayout),
Tree(LayoutTree),
}
impl Default for WorkspaceLayout {
fn default() -> Self {
Self::Scrolling(ScrollingLayout::default())
}
}
impl WorkspaceLayout {
pub fn new(mode: LayoutMode, default_column_width: ColumnWidth) -> Self {
match mode {
LayoutMode::Scrolling => {
Self::Scrolling(ScrollingLayout::with_default_width(default_column_width))
}
LayoutMode::Tree => Self::Tree(LayoutTree::default()),
}
}
pub fn mode(&self) -> LayoutMode {
match self {
Self::Scrolling(_) => LayoutMode::Scrolling,
Self::Tree(_) => LayoutMode::Tree,
}
}
pub fn contains(&self, window: WindowId) -> bool {
match self {
Self::Scrolling(layout) => layout.contains(window),
Self::Tree(layout) => layout.contains(window),
}
}
pub fn window_ids(&self) -> Box<dyn Iterator<Item = WindowId> + '_> {
match self {
Self::Scrolling(layout) => Box::new(layout.window_ids()),
Self::Tree(layout) => Box::new(layout.window_ids()),
}
}
pub fn insert(
&mut self,
window: WindowId,
focused: Option<WindowId>,
axis: Axis,
ratio: f64,
) -> Result<(), LayoutError> {
match self {
Self::Scrolling(layout) => layout.insert(window, focused),
Self::Tree(layout) => layout.insert(window, focused, axis, ratio).map(drop),
}
}
pub fn remove(&mut self, window: WindowId) -> Result<(), LayoutError> {
match self {
Self::Scrolling(layout) => layout.remove(window),
Self::Tree(layout) => layout.remove(window),
}
}
pub fn stack_window(&mut self, window: WindowId, target: WindowId) -> Result<(), LayoutError> {
match self {
Self::Scrolling(layout) => layout.move_into_column(window, target),
Self::Tree(layout) => layout.stack_window(window, target),
}
}
pub fn activate_window(&mut self, window: WindowId) -> Result<(), LayoutError> {
match self {
Self::Scrolling(layout) => layout.focus(window),
Self::Tree(layout) => layout.activate_window(window),
}
}
pub fn geometry(&self, bounds: Rect) -> Result<HashMap<WindowId, Rect>, LayoutError> {
match self {
Self::Scrolling(layout) => {
let mut layout = layout.clone();
layout
.geometry_with_constraints(bounds, GapConfig::default(), &HashMap::new(), None)
.map(|result| result.geometry)
}
Self::Tree(layout) => layout.geometry(bounds),
}
}
pub fn geometry_with_constraints(
&mut self,
bounds: Rect,
gaps: GapConfig,
constraints: &HashMap<WindowId, SizeConstraints>,
focused: Option<WindowId>,
) -> Result<LayoutResult, LayoutError> {
match self {
Self::Scrolling(layout) => layout.geometry_with_constraints(
bounds,
gaps,
constraints,
focused.filter(|window| layout.contains(*window)),
),
Self::Tree(layout) => {
layout.geometry_with_constraints(bounds, gaps, constraints, focused)
}
}
}
pub fn automatic_axis(
&self,
focused: Option<WindowId>,
bounds: Rect,
) -> Result<Axis, LayoutError> {
match self {
Self::Scrolling(_) => Ok(Axis::Horizontal),
Self::Tree(layout) => layout.automatic_axis(focused, bounds),
}
}
pub fn directional_neighbor(
&self,
window: WindowId,
direction: Direction,
bounds: Rect,
) -> Result<Option<WindowId>, LayoutError> {
match self {
Self::Scrolling(layout) => layout.directional_neighbor(window, direction),
Self::Tree(layout) => layout.directional_neighbor(window, direction, bounds),
}
}
pub fn move_window(
&mut self,
window: WindowId,
direction: Direction,
bounds: Rect,
) -> Result<bool, LayoutError> {
match self {
Self::Scrolling(layout) => layout.move_window(window, direction),
Self::Tree(layout) => layout.move_window(window, direction, bounds),
}
}
pub fn resize_window(
&mut self,
window: WindowId,
direction: Direction,
amount: f64,
) -> Result<bool, LayoutError> {
match self {
Self::Scrolling(layout) => layout.resize_window(window, direction, amount),
Self::Tree(layout) => layout.resize_window(window, direction, amount),
}
}
pub fn validate(&self) -> Result<(), LayoutError> {
match self {
Self::Scrolling(layout) => layout.validate(),
Self::Tree(layout) => layout.validate(),
}
}
pub fn set_mode(
&mut self,
mode: LayoutMode,
bounds: Rect,
focused: Option<WindowId>,
default_column_width: ColumnWidth,
) -> Result<bool, LayoutError> {
if self.mode() == mode {
return Ok(false);
}
let replacement = match self {
Self::Scrolling(layout) => {
let columns = layout.columns().to_vec();
let mut tree = LayoutTree::default();
let mut previous = None;
for column in columns {
for (row, window) in column.windows.into_iter().enumerate() {
let axis = if row == 0 {
Axis::Horizontal
} else {
Axis::Vertical
};
tree.insert(window, previous, axis, 0.5)?;
previous = Some(window);
}
}
if let Some(focused) = focused.filter(|window| tree.contains(*window)) {
tree.activate_window(focused)?;
}
Self::Tree(tree)
}
Self::Tree(layout) => {
let windows = layout.window_ids_in_reading_order(bounds)?;
let mut scrolling = ScrollingLayout::with_default_width(default_column_width);
let mut previous = None;
for window in windows {
scrolling.insert(window, previous)?;
previous = Some(window);
}
if let Some(focused) = focused.filter(|window| scrolling.contains(*window)) {
scrolling.focus(focused)?;
}
Self::Scrolling(scrolling)
}
};
*self = replacement;
debug_assert!(self.validate().is_ok());
Ok(true)
}
}
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub enum WindowPlacement { pub enum WindowPlacement {
Tiled, Tiled,
@@ -70,16 +298,24 @@ pub struct WorkspaceSet {
names: HashMap<String, WorkspaceId>, names: HashMap<String, WorkspaceId>,
window_workspaces: HashMap<WindowId, WorkspaceId>, window_workspaces: HashMap<WindowId, WorkspaceId>,
placements: HashMap<WindowId, WindowPlacement>, placements: HashMap<WindowId, WindowPlacement>,
default_layout_mode: LayoutMode,
default_column_width: ColumnWidth,
next_id: u64, next_id: u64,
} }
impl Default for WorkspaceSet { impl Default for WorkspaceSet {
fn default() -> Self { fn default() -> Self {
Self::new(LayoutMode::default(), ColumnWidth::default())
}
}
impl WorkspaceSet {
pub fn new(default_layout_mode: LayoutMode, default_column_width: ColumnWidth) -> Self {
let active = WorkspaceId(1); let active = WorkspaceId(1);
let workspace = Workspace { let workspace = Workspace {
id: active, id: active,
name: "1".to_owned(), name: "1".to_owned(),
layout: LayoutTree::default(), layout: WorkspaceLayout::new(default_layout_mode, default_column_width),
floating: Vec::new(), floating: Vec::new(),
last_focused: None, last_focused: None,
fullscreen: None, fullscreen: None,
@@ -93,12 +329,12 @@ impl Default for WorkspaceSet {
names, names,
window_workspaces: HashMap::new(), window_workspaces: HashMap::new(),
placements: HashMap::new(), placements: HashMap::new(),
default_layout_mode,
default_column_width,
next_id: 2, next_id: 2,
} }
} }
}
impl WorkspaceSet {
pub fn active_id(&self) -> WorkspaceId { pub fn active_id(&self) -> WorkspaceId {
self.active self.active
} }
@@ -130,6 +366,10 @@ impl WorkspaceSet {
self.workspaces.get(&id) self.workspaces.get(&id)
} }
pub fn workspace_mut(&mut self, id: WorkspaceId) -> Option<&mut Workspace> {
self.workspaces.get_mut(&id)
}
pub fn workspace_for_window(&self, window: WindowId) -> Option<WorkspaceId> { pub fn workspace_for_window(&self, window: WindowId) -> Option<WorkspaceId> {
self.window_workspaces.get(&window).copied() self.window_workspaces.get(&window).copied()
} }
@@ -138,6 +378,22 @@ impl WorkspaceSet {
self.placements.get(&window).copied() self.placements.get(&window).copied()
} }
pub fn set_active_layout_mode(
&mut self,
mode: LayoutMode,
bounds: Rect,
) -> Result<bool, WorkspaceError> {
let default_column_width = self.default_column_width;
let workspace = self.active_mut();
let focused = tiled_focus(workspace);
let changed = workspace
.layout
.set_mode(mode, bounds, focused, default_column_width)?;
debug_assert!(self.validate().is_ok());
Ok(changed)
}
pub fn set_floating_rect( pub fn set_floating_rect(
&mut self, &mut self,
window: WindowId, window: WindowId,
@@ -235,7 +491,7 @@ impl WorkspaceSet {
Workspace { Workspace {
id, id,
name, name,
layout: LayoutTree::default(), layout: WorkspaceLayout::new(self.default_layout_mode, self.default_column_width),
floating: Vec::new(), floating: Vec::new(),
last_focused: None, last_focused: None,
fullscreen: None, fullscreen: None,
@@ -604,6 +860,28 @@ mod tests {
assert!(workspaces.validate().is_ok()); assert!(workspaces.validate().is_ok());
} }
#[test]
fn configured_layout_defaults_apply_to_new_workspaces() {
let mut workspaces = WorkspaceSet::new(LayoutMode::Scrolling, ColumnWidth::Full);
workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5)
.unwrap();
let first = workspaces
.active_mut()
.layout
.geometry_with_constraints(
Rect::new(0.0, 0.0, 1_000.0, 800.0),
GapConfig::default(),
&HashMap::new(),
Some(WindowId(1)),
)
.unwrap();
assert_eq!(first.geometry[&WindowId(1)].width, 1_000.0);
workspaces.switch_to_numeric(2).unwrap();
assert_eq!(workspaces.active().layout.mode(), LayoutMode::Scrolling);
}
#[test] #[test]
fn switching_restores_workspace_focus() { fn switching_restores_workspace_focus() {
let mut workspaces = WorkspaceSet::default(); let mut workspaces = WorkspaceSet::default();
@@ -840,7 +1118,7 @@ mod tests {
} }
#[test] #[test]
fn stacking_tiled_windows_preserves_workspace_membership() { fn grouping_tiled_windows_preserves_workspace_membership() {
let mut workspaces = WorkspaceSet::default(); let mut workspaces = WorkspaceSet::default();
workspaces workspaces
.insert_window(WindowId(1), Axis::Horizontal, 0.5) .insert_window(WindowId(1), Axis::Horizontal, 0.5)
@@ -851,6 +1129,7 @@ mod tests {
workspaces.stack_window(WindowId(2), WindowId(1)).unwrap(); workspaces.stack_window(WindowId(2), WindowId(1)).unwrap();
assert_eq!(workspaces.active().last_focused, Some(WindowId(2))); assert_eq!(workspaces.active().last_focused, Some(WindowId(2)));
assert_eq!(workspaces.active().layout.mode(), LayoutMode::Scrolling);
assert_eq!( assert_eq!(
workspaces workspaces
.active() .active()
@@ -858,11 +1137,48 @@ mod tests {
.geometry(Rect::new(0.0, 0.0, 100.0, 80.0)) .geometry(Rect::new(0.0, 0.0, 100.0, 80.0))
.unwrap() .unwrap()
.len(), .len(),
1 2
); );
assert!(workspaces.validate().is_ok()); assert!(workspaces.validate().is_ok());
} }
#[test]
fn layout_mode_conversion_preserves_membership_and_focus() {
let mut workspaces = WorkspaceSet::default();
for id in 1..=4 {
workspaces
.insert_window(WindowId(id), Axis::Horizontal, 0.5)
.unwrap();
}
workspaces.focus_window(WindowId(2)).unwrap();
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
assert!(
workspaces
.set_active_layout_mode(LayoutMode::Tree, bounds)
.unwrap()
);
assert_eq!(workspaces.active().layout.mode(), LayoutMode::Tree);
assert_eq!(workspaces.active().last_focused, Some(WindowId(2)));
assert_eq!(
workspaces
.active()
.layout
.window_ids()
.collect::<HashSet<_>>(),
HashSet::from([WindowId(1), WindowId(2), WindowId(3), WindowId(4)])
);
assert!(
workspaces
.set_active_layout_mode(LayoutMode::Scrolling, bounds)
.unwrap()
);
assert_eq!(workspaces.active().layout.mode(), LayoutMode::Scrolling);
assert_eq!(workspaces.active().last_focused, Some(WindowId(2)));
assert!(workspaces.validate().is_ok());
}
#[test] #[test]
fn randomized_workspace_sequences_preserve_all_invariants() { fn randomized_workspace_sequences_preserve_all_invariants() {
let mut workspaces = WorkspaceSet::default(); let mut workspaces = WorkspaceSet::default();
+12
View File
@@ -170,6 +170,18 @@ impl LayoutTree {
self.windows.keys().copied() self.windows.keys().copied()
} }
pub fn window_ids_in_reading_order(&self, bounds: Rect) -> Result<Vec<WindowId>, LayoutError> {
let geometry = self.geometry(bounds)?;
let mut windows = geometry.into_iter().collect::<Vec<_>>();
windows.sort_by(|(left_window, left), (right_window, right)| {
left.x
.total_cmp(&right.x)
.then_with(|| left.y.total_cmp(&right.y))
.then_with(|| left_window.0.cmp(&right_window.0))
});
Ok(windows.into_iter().map(|(window, _)| window).collect())
}
pub fn insert( pub fn insert(
&mut self, &mut self,
window: WindowId, window: WindowId,
+244 -7
View File
@@ -1,11 +1,14 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use crate::{ use crate::{
ConstraintKind, ConstraintWarning, GapConfig, LayoutError, LayoutResult, Rect, SizeConstraints, ConstraintKind, ConstraintWarning, Direction, GapConfig, LayoutError, LayoutResult, Rect,
WindowId, normalized_constraints, record_minimum_warnings, SizeConstraints, WindowId, normalized_constraints, record_minimum_warnings,
}; };
const DEFAULT_WIDTH: f64 = 0.5; const DEFAULT_WIDTH: f64 = 0.5;
const MIN_COLUMN_WIDTH: f64 = 0.1;
const MAX_COLUMN_WIDTH: f64 = 2.0;
const MIN_ROW_HEIGHT: f64 = 0.05;
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub enum ColumnWidth { pub enum ColumnWidth {
@@ -29,11 +32,11 @@ pub struct Column {
} }
impl Column { impl Column {
fn new(window: WindowId) -> Self { fn new(window: WindowId, width: ColumnWidth) -> Self {
Self { Self {
windows: vec![window], windows: vec![window],
active: 0, active: 0,
width: ColumnWidth::default(), width: normalized_width(width),
heights: vec![1.0], heights: vec![1.0],
} }
} }
@@ -63,6 +66,7 @@ pub struct ScrollingLayout {
active_column: Option<usize>, active_column: Option<usize>,
viewport_x: f64, viewport_x: f64,
neighbor_context: f64, neighbor_context: f64,
default_width: ColumnWidth,
} }
impl Default for ScrollingLayout { impl Default for ScrollingLayout {
@@ -72,11 +76,23 @@ impl Default for ScrollingLayout {
active_column: None, active_column: None,
viewport_x: 0.0, viewport_x: 0.0,
neighbor_context: 48.0, neighbor_context: 48.0,
default_width: ColumnWidth::default(),
} }
} }
} }
impl ScrollingLayout { impl ScrollingLayout {
pub fn with_default_width(default_width: ColumnWidth) -> Self {
Self {
default_width: normalized_width(default_width),
..Self::default()
}
}
pub fn default_width(&self) -> ColumnWidth {
self.default_width
}
pub fn columns(&self) -> &[Column] { pub fn columns(&self) -> &[Column] {
&self.columns &self.columns
} }
@@ -112,7 +128,8 @@ impl ScrollingLayout {
.and_then(|focused| self.window_location(focused)) .and_then(|focused| self.window_location(focused))
.map(|(column, _)| column + 1) .map(|(column, _)| column + 1)
.unwrap_or(self.columns.len()); .unwrap_or(self.columns.len());
self.columns.insert(index, Column::new(window)); self.columns
.insert(index, Column::new(window, self.default_width));
self.active_column = Some(index); self.active_column = Some(index);
debug_assert!(self.validate().is_ok()); debug_assert!(self.validate().is_ok());
@@ -190,7 +207,8 @@ impl ScrollingLayout {
self.remove(window)?; self.remove(window)?;
let insertion = (source + 1).min(self.columns.len()); let insertion = (source + 1).min(self.columns.len());
self.columns.insert(insertion, Column::new(window)); self.columns
.insert(insertion, Column::new(window, self.default_width));
self.active_column = Some(insertion); self.active_column = Some(insertion);
debug_assert!(self.validate().is_ok()); debug_assert!(self.validate().is_ok());
@@ -209,6 +227,109 @@ impl ScrollingLayout {
Ok(()) Ok(())
} }
pub fn directional_neighbor(
&self,
window: WindowId,
direction: Direction,
) -> Result<Option<WindowId>, LayoutError> {
let (column, row) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
let neighbor = match direction {
Direction::Up => row
.checked_sub(1)
.map(|row| self.columns[column].windows[row]),
Direction::Down => self.columns[column].windows.get(row + 1).copied(),
Direction::Left => column.checked_sub(1).map(|column| {
let column = &self.columns[column];
column.windows[column.active]
}),
Direction::Right => self
.columns
.get(column + 1)
.map(|column| column.windows[column.active]),
};
Ok(neighbor)
}
pub fn move_window(
&mut self,
window: WindowId,
direction: Direction,
) -> Result<bool, LayoutError> {
let (column, row) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
match direction {
Direction::Up if row > 0 => {
self.columns[column].windows.swap(row, row - 1);
self.columns[column].heights.swap(row, row - 1);
self.columns[column].active = row - 1;
}
Direction::Down if row + 1 < self.columns[column].windows.len() => {
self.columns[column].windows.swap(row, row + 1);
self.columns[column].heights.swap(row, row + 1);
self.columns[column].active = row + 1;
}
Direction::Left if column > 0 => self.move_horizontally(window, column - 1)?,
Direction::Right if column + 1 < self.columns.len() => {
self.move_horizontally(window, column + 1)?
}
_ => return Ok(false),
}
debug_assert!(self.validate().is_ok());
Ok(true)
}
pub fn resize_window(
&mut self,
window: WindowId,
direction: Direction,
amount: f64,
) -> Result<bool, LayoutError> {
let (column, row) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
let amount = if amount.is_finite() {
amount.abs()
} else {
0.0
};
if amount == 0.0 {
return Ok(false);
}
let changed = match direction {
Direction::Left | Direction::Right => {
let current = match normalized_width(self.columns[column].width) {
ColumnWidth::Proportion(value) => value,
ColumnWidth::Fixed(_) | ColumnWidth::Full => DEFAULT_WIDTH,
};
let adjustment = if direction == Direction::Right {
amount
} else {
-amount
};
let resized = (current + adjustment).clamp(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH);
self.columns[column].width = ColumnWidth::Proportion(resized);
resized != current
}
Direction::Up if row > 0 => {
resize_rows(&mut self.columns[column], row, row - 1, amount)
}
Direction::Down if row + 1 < self.columns[column].windows.len() => {
resize_rows(&mut self.columns[column], row, row + 1, amount)
}
Direction::Up | Direction::Down => false,
};
debug_assert!(self.validate().is_ok());
Ok(changed)
}
pub fn geometry_with_constraints( pub fn geometry_with_constraints(
&mut self, &mut self,
bounds: Rect, bounds: Rect,
@@ -314,6 +435,33 @@ impl ScrollingLayout {
} }
} }
fn move_horizontally(
&mut self,
window: WindowId,
destination: usize,
) -> Result<(), LayoutError> {
let (source, _) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
if self.columns[source].windows.len() == 1 {
self.columns.swap(source, destination);
self.active_column = Some(destination);
return Ok(());
}
let insertion = if destination < source {
source
} else {
source + 1
};
self.remove(window)?;
let insertion = insertion.min(self.columns.len());
self.columns
.insert(insertion, Column::new(window, self.default_width));
self.active_column = Some(insertion);
Ok(())
}
fn window_location(&self, window: WindowId) -> Option<(usize, usize)> { fn window_location(&self, window: WindowId) -> Option<(usize, usize)> {
self.columns.iter().enumerate().find_map(|(column, data)| { self.columns.iter().enumerate().find_map(|(column, data)| {
data.windows data.windows
@@ -323,7 +471,7 @@ impl ScrollingLayout {
}) })
} }
fn validate(&self) -> Result<(), LayoutError> { pub fn validate(&self) -> Result<(), LayoutError> {
if self.columns.is_empty() { if self.columns.is_empty() {
return if self.active_column.is_none() { return if self.active_column.is_none() {
Ok(()) Ok(())
@@ -370,6 +518,18 @@ impl ScrollingLayout {
} }
} }
fn resize_rows(column: &mut Column, row: usize, neighbor: usize, amount: f64) -> bool {
let transferable = (column.heights[neighbor] - MIN_ROW_HEIGHT).max(0.0);
let adjustment = amount.min(transferable);
if adjustment == 0.0 {
return false;
}
column.heights[row] += adjustment;
column.heights[neighbor] -= adjustment;
true
}
fn normalized_width(width: ColumnWidth) -> ColumnWidth { fn normalized_width(width: ColumnWidth) -> ColumnWidth {
match width { match width {
ColumnWidth::Proportion(value) if value.is_finite() && value > 0.0 => { ColumnWidth::Proportion(value) if value.is_finite() && value > 0.0 => {
@@ -441,6 +601,21 @@ mod tests {
assert_eq!(layout.active_column(), Some(1)); assert_eq!(layout.active_column(), Some(1));
} }
#[test]
fn configured_default_width_is_applied_to_new_columns() {
let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Full);
layout.insert(window(1), None).unwrap();
layout.insert(window(2), Some(window(1))).unwrap();
assert_eq!(layout.default_width(), ColumnWidth::Full);
assert!(
layout
.columns()
.iter()
.all(|column| column.width == ColumnWidth::Full)
);
}
#[test] #[test]
fn focus_reveal_scrolls_only_as_far_as_needed() { fn focus_reveal_scrolls_only_as_far_as_needed() {
let mut layout = ScrollingLayout::default(); let mut layout = ScrollingLayout::default();
@@ -505,4 +680,66 @@ mod tests {
assert_eq!(first.x, second.x); assert_eq!(first.x, second.x);
assert_eq!(first.width, second.width); assert_eq!(first.width, second.width);
} }
#[test]
fn directional_navigation_follows_columns_and_rows() {
let mut layout = ScrollingLayout::default();
layout.insert(window(1), None).unwrap();
layout.insert(window(2), Some(window(1))).unwrap();
layout.insert(window(3), Some(window(2))).unwrap();
layout.move_into_column(window(3), window(2)).unwrap();
assert_eq!(
layout.directional_neighbor(window(1), Direction::Right),
Ok(Some(window(3)))
);
assert_eq!(
layout.directional_neighbor(window(3), Direction::Up),
Ok(Some(window(2)))
);
assert_eq!(
layout.directional_neighbor(window(2), Direction::Down),
Ok(Some(window(3)))
);
}
#[test]
fn vertical_moves_reorder_rows_and_horizontal_moves_extract_them() {
let mut layout = ScrollingLayout::default();
layout.insert(window(1), None).unwrap();
layout.insert(window(2), Some(window(1))).unwrap();
layout.insert(window(3), Some(window(2))).unwrap();
layout.move_into_column(window(3), window(2)).unwrap();
assert!(layout.move_window(window(3), Direction::Up).unwrap());
assert_eq!(layout.columns()[1].windows, vec![window(3), window(2)]);
assert!(layout.move_window(window(3), Direction::Left).unwrap());
assert_eq!(layout.columns().len(), 3);
assert_eq!(
layout.window_ids().collect::<Vec<_>>(),
vec![window(1), window(3), window(2)]
);
}
#[test]
fn resize_changes_column_width_and_neighboring_row_weights() {
let mut layout = ScrollingLayout::default();
layout.insert(window(1), None).unwrap();
layout.insert(window(2), Some(window(1))).unwrap();
layout.move_into_column(window(2), window(1)).unwrap();
assert!(
layout
.resize_window(window(1), Direction::Right, 0.1)
.unwrap()
);
assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.6));
assert!(
layout
.resize_window(window(1), Direction::Down, 0.1)
.unwrap()
);
assert_eq!(layout.columns()[0].heights, vec![0.6, 0.4]);
}
} }
+2
View File
@@ -1024,6 +1024,7 @@ smart_gaps = true
[scrolling] [scrolling]
default_column_width = 0.5 default_column_width = 0.5
# Use 1.0 or "full" to open every new column at the viewport width.
width_presets = [0.333333, 0.5, 0.666667, 1.0] width_presets = [0.333333, 0.5, 0.666667, 1.0]
neighbor_context = 48.0 neighbor_context = 48.0
new_window = "column_after_focused" new_window = "column_after_focused"
@@ -1244,6 +1245,7 @@ Super+Shift+H/J/K/L move left/down/up/right
Super+1..9 switch workspace Super+1..9 switch workspace
Super+Shift+1..9 move window to workspace Super+Shift+1..9 move window to workspace
Super+F toggle fullscreen Super+F toggle fullscreen
Super+M toggle scrolling/tree layout
Super+Shift+Space toggle floating Super+Shift+Space toggle floating
Super+Space launcher Super+Space launcher
Super+Tab overview Super+Tab overview