diff --git a/Cargo.lock b/Cargo.lock index 9315c10..771d81a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -420,7 +420,9 @@ dependencies = [ "ferese-core", "ferese-layout", "libc", + "serde", "smithay", + "toml", "tracing", "tracing-subscriber", "xcursor", @@ -1393,6 +1395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -1415,6 +1418,15 @@ dependencies = [ "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]] name = "sha2" version = "0.10.9" @@ -1633,6 +1645,30 @@ dependencies = [ "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]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -1649,9 +1685,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.4", ] [[package]] @@ -1660,9 +1696,15 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" 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]] name = "tracing" version = "0.1.44" @@ -2231,6 +2273,12 @@ dependencies = [ "xkbcommon-dl", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + [[package]] name = "winnow" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 7e03daf..bc691f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,4 +28,6 @@ smithay = { version = "0.7.0", default-features = false, features = [ ] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } +serde = { version = "1.0.228", features = ["derive"] } +toml = "0.9.7" xcursor = "0.3.11" diff --git a/README.md b/README.md index 7c57ced..bafb30c 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,11 @@ # Ferese Ferese is an experimental Wayland scrolling compositor written in Rust with -Smithay. The current implementation includes the original tree layout, -workspaces, and timestamp-driven animated geometry. The specification now makes -scrolling columns the default and retains tree tiling as an optional mode. DRM -multi-output and native interoperability are being completed before that layout -transition in the -[window-manager specification](docs/ferese-window-manager-spec.md). +Smithay. Scrolling columns are the default, tree tiling remains available as an +optional per-workspace mode, and geometry changes use timestamp-driven +animation. See the +[window-manager specification](docs/ferese-window-manager-spec.md) for the +current architecture and milestones. ## Development @@ -25,6 +24,19 @@ cargo run -p ferese -- foot 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 hidden unless the session explicitly opts in, because an input method can 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 integrated GPU. -The direct path currently targets one connected output. Multi-output hotplug is -part of M5. +The direct path creates an independent render pipeline per connected output and +rescans connectors on hotplug. Physical unplug/replug acceptance testing on the +reference hardware is still pending. diff --git a/compositor/Cargo.toml b/compositor/Cargo.toml index e7cb163..e662f97 100644 --- a/compositor/Cargo.toml +++ b/compositor/Cargo.toml @@ -11,6 +11,8 @@ ferese-core = { path = "../crates/ferese-core" } ferese-layout = { path = "../crates/ferese-layout" } libc.workspace = true smithay.workspace = true +serde.workspace = true +toml.workspace = true tracing.workspace = true tracing-subscriber.workspace = true xcursor.workspace = true diff --git a/compositor/src/config.rs b/compositor/src/config.rs new file mode 100644 index 0000000..b1eaff3 --- /dev/null +++ b/compositor/src/config.rs @@ -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, +} + +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum LayoutModeValue { + Scrolling, + Tree, +} + +#[derive(Debug, Default, Deserialize)] +struct ScrollingConfig { + default_column_width: Option, +} + +#[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 { + 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 { + 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 { + 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()); + } +} diff --git a/compositor/src/input.rs b/compositor/src/input.rs index 7463b70..661247a 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -138,6 +138,15 @@ impl Ferese { 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(()) } else if modifiers.shift && symbol == keysyms::KEY_space { if state == KeyState::Pressed { @@ -313,6 +322,8 @@ impl Ferese { toplevel.send_pending_configure(); } }); + + self.relayout(); } fn clamp_pointer_position(&self, requested: Point) -> Point { diff --git a/compositor/src/main.rs b/compositor/src/main.rs index af4ec5e..432df30 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -1,6 +1,7 @@ #![allow(irrefutable_let_patterns)] mod backends; +mod config; mod cursor; mod grabs; mod handlers; @@ -15,15 +16,22 @@ pub use state::Ferese; use tracing::{info, warn}; use crate::backends::LaunchConfig; +use crate::config::Config; fn main() -> Result<(), Box> { init_logging(); let launch = LaunchConfig::from_environment() .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let config = Config::load()?; let mut event_loop = EventLoop::try_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)?; // SAFETY: the backend has already read the host display and Ferese is diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 7fc4c2b..09030c9 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -7,8 +7,12 @@ use std::{ }; use ferese_animation::{ClientSize, SpringConfig, WindowGeometry}; -use ferese_core::{OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceSet}; -use ferese_layout::{Axis, Direction, GapConfig, LayoutResult, Rect, SizeConstraints, WindowId}; +use ferese_core::{ + LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceSet, +}; +use ferese_layout::{ + Axis, ColumnWidth, Direction, GapConfig, LayoutResult, Rect, SizeConstraints, WindowId, +}; use smithay::{ backend::{ @@ -267,6 +271,8 @@ impl Ferese { pub fn new( event_loop: &mut EventLoop<'static, Self>, display: Display, + default_layout_mode: LayoutMode, + default_column_width: ColumnWidth, ) -> Result> { let display_handle = display.handle(); crate::handlers::screencopy::init_global(&display_handle); @@ -315,7 +321,7 @@ impl Ferese { display_handle, loop_signal: event_loop.get_signal(), space: Space::default(), - workspaces: WorkspaceSet::default(), + workspaces: WorkspaceSet::new(default_layout_mode, default_column_width), output_workspaces: OutputWorkspaceMap::default(), output_ids: HashMap::new(), output_identity_ids: HashMap::new(), @@ -620,6 +626,7 @@ impl Ferese { pub fn relayout(&mut self) { self.arrange_layers(); let outputs = self.space.outputs().cloned().collect::>(); + let constraints = self.window_constraints(); let mut visible = HashSet::new(); let mut placements = Vec::new(); @@ -630,23 +637,28 @@ impl Ferese { let Some(workspace_id) = self.output_workspaces.active_workspace(output_id) else { continue; }; - let Some(workspace) = self.workspaces.workspace(workspace_id) else { - continue; - }; let Some(bounds) = self.output_bounds_for(&output) else { continue; }; 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 .focused_window .filter(|window| { 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( bounds, GapConfig::default(), - &self.window_constraints(), + &constraints, focused, ) { Ok(layout) => layout, @@ -671,9 +683,9 @@ impl Ferese { continue; } - let rect = if workspace.fullscreen == Some(*id) { + let rect = if workspace_fullscreen == Some(*id) { fullscreen_bounds - } else if workspace.fullscreen.is_some() { + } else if workspace_fullscreen.is_some() { continue; } else { match self.workspaces.placement(*id) { @@ -687,7 +699,7 @@ impl Ferese { None => continue, } }; - let is_fullscreen = workspace.fullscreen == Some(*id); + let is_fullscreen = workspace_fullscreen == Some(*id); let is_floating = matches!( self.workspaces.placement(*id), Some(WindowPlacement::Floating { .. }) @@ -885,6 +897,8 @@ impl Ferese { toplevel.send_pending_configure(); } } + + self.relayout(); } 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) { let Some(window) = self.focused_window else { return; @@ -1142,23 +1172,22 @@ impl Ferese { } } - fn logical_window_rect(&self, window: WindowId, bounds: Rect) -> Option { + fn logical_window_rect(&mut self, window: WindowId, bounds: Rect) -> Option { 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); } + let constraints = self.window_constraints(); + let focused = self.focused_window; match self.workspaces.placement(window)? { - WindowPlacement::Tiled => workspace + WindowPlacement::Tiled => self + .workspaces + .workspace_mut(workspace)? .layout - .geometry_with_constraints( - bounds, - GapConfig::default(), - &self.window_constraints(), - self.focused_window, - ) + .geometry_with_constraints(bounds, GapConfig::default(), &constraints, focused) .ok()? .geometry .get(&window) @@ -1167,13 +1196,13 @@ impl Ferese { } } - fn tiled_layout(&self, bounds: Rect) -> Result { - self.workspaces.active().layout.geometry_with_constraints( - bounds, - GapConfig::default(), - &self.window_constraints(), - self.focused_window, - ) + fn tiled_layout(&mut self, bounds: Rect) -> Result { + let constraints = self.window_constraints(); + let focused = self.focused_window; + self.workspaces + .active_mut() + .layout + .geometry_with_constraints(bounds, GapConfig::default(), &constraints, focused) } fn window_constraints(&self) -> HashMap { diff --git a/crates/ferese-core/src/lib.rs b/crates/ferese-core/src/lib.rs index 583b199..486edf7 100644 --- a/crates/ferese-core/src/lib.rs +++ b/crates/ferese-core/src/lib.rs @@ -2,4 +2,7 @@ mod output; mod workspace; 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, +}; diff --git a/crates/ferese-core/src/workspace.rs b/crates/ferese-core/src/workspace.rs index cfd975e..1ab0206 100644 --- a/crates/ferese-core/src/workspace.rs +++ b/crates/ferese-core/src/workspace.rs @@ -2,7 +2,10 @@ use std::collections::{HashMap, HashSet}; use std::error::Error; 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)] pub struct WorkspaceId(pub u64); @@ -11,12 +14,237 @@ pub struct WorkspaceId(pub u64); pub struct Workspace { pub id: WorkspaceId, pub name: String, - pub layout: LayoutTree, + pub layout: WorkspaceLayout, pub floating: Vec, pub last_focused: Option, pub fullscreen: Option, } +#[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 + '_> { + 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, + 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, 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, + focused: Option, + ) -> Result { + 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, + bounds: Rect, + ) -> Result { + 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, 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 { + 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 { + 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, + default_column_width: ColumnWidth, + ) -> Result { + 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)] pub enum WindowPlacement { Tiled, @@ -70,16 +298,24 @@ pub struct WorkspaceSet { names: HashMap, window_workspaces: HashMap, placements: HashMap, + default_layout_mode: LayoutMode, + default_column_width: ColumnWidth, next_id: u64, } impl Default for WorkspaceSet { 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 workspace = Workspace { id: active, name: "1".to_owned(), - layout: LayoutTree::default(), + layout: WorkspaceLayout::new(default_layout_mode, default_column_width), floating: Vec::new(), last_focused: None, fullscreen: None, @@ -93,12 +329,12 @@ impl Default for WorkspaceSet { names, window_workspaces: HashMap::new(), placements: HashMap::new(), + default_layout_mode, + default_column_width, next_id: 2, } } -} -impl WorkspaceSet { pub fn active_id(&self) -> WorkspaceId { self.active } @@ -130,6 +366,10 @@ impl WorkspaceSet { 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 { self.window_workspaces.get(&window).copied() } @@ -138,6 +378,22 @@ impl WorkspaceSet { self.placements.get(&window).copied() } + pub fn set_active_layout_mode( + &mut self, + mode: LayoutMode, + bounds: Rect, + ) -> Result { + 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( &mut self, window: WindowId, @@ -235,7 +491,7 @@ impl WorkspaceSet { Workspace { id, name, - layout: LayoutTree::default(), + layout: WorkspaceLayout::new(self.default_layout_mode, self.default_column_width), floating: Vec::new(), last_focused: None, fullscreen: None, @@ -604,6 +860,28 @@ mod tests { 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] fn switching_restores_workspace_focus() { let mut workspaces = WorkspaceSet::default(); @@ -840,7 +1118,7 @@ mod tests { } #[test] - fn stacking_tiled_windows_preserves_workspace_membership() { + fn grouping_tiled_windows_preserves_workspace_membership() { let mut workspaces = WorkspaceSet::default(); workspaces .insert_window(WindowId(1), Axis::Horizontal, 0.5) @@ -851,6 +1129,7 @@ mod tests { workspaces.stack_window(WindowId(2), WindowId(1)).unwrap(); assert_eq!(workspaces.active().last_focused, Some(WindowId(2))); + assert_eq!(workspaces.active().layout.mode(), LayoutMode::Scrolling); assert_eq!( workspaces .active() @@ -858,11 +1137,48 @@ mod tests { .geometry(Rect::new(0.0, 0.0, 100.0, 80.0)) .unwrap() .len(), - 1 + 2 ); 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::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] fn randomized_workspace_sequences_preserve_all_invariants() { let mut workspaces = WorkspaceSet::default(); diff --git a/crates/ferese-layout/src/lib.rs b/crates/ferese-layout/src/lib.rs index f03d401..0f29912 100644 --- a/crates/ferese-layout/src/lib.rs +++ b/crates/ferese-layout/src/lib.rs @@ -170,6 +170,18 @@ impl LayoutTree { self.windows.keys().copied() } + pub fn window_ids_in_reading_order(&self, bounds: Rect) -> Result, LayoutError> { + let geometry = self.geometry(bounds)?; + let mut windows = geometry.into_iter().collect::>(); + 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( &mut self, window: WindowId, diff --git a/crates/ferese-layout/src/scrolling.rs b/crates/ferese-layout/src/scrolling.rs index 3475b2b..ea31085 100644 --- a/crates/ferese-layout/src/scrolling.rs +++ b/crates/ferese-layout/src/scrolling.rs @@ -1,11 +1,14 @@ use std::collections::{HashMap, HashSet}; use crate::{ - ConstraintKind, ConstraintWarning, GapConfig, LayoutError, LayoutResult, Rect, SizeConstraints, - WindowId, normalized_constraints, record_minimum_warnings, + ConstraintKind, ConstraintWarning, Direction, GapConfig, LayoutError, LayoutResult, Rect, + SizeConstraints, WindowId, normalized_constraints, record_minimum_warnings, }; 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)] pub enum ColumnWidth { @@ -29,11 +32,11 @@ pub struct Column { } impl Column { - fn new(window: WindowId) -> Self { + fn new(window: WindowId, width: ColumnWidth) -> Self { Self { windows: vec![window], active: 0, - width: ColumnWidth::default(), + width: normalized_width(width), heights: vec![1.0], } } @@ -63,6 +66,7 @@ pub struct ScrollingLayout { active_column: Option, viewport_x: f64, neighbor_context: f64, + default_width: ColumnWidth, } impl Default for ScrollingLayout { @@ -72,11 +76,23 @@ impl Default for ScrollingLayout { active_column: None, viewport_x: 0.0, neighbor_context: 48.0, + default_width: ColumnWidth::default(), } } } 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] { &self.columns } @@ -112,7 +128,8 @@ impl ScrollingLayout { .and_then(|focused| self.window_location(focused)) .map(|(column, _)| column + 1) .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); debug_assert!(self.validate().is_ok()); @@ -190,7 +207,8 @@ impl ScrollingLayout { self.remove(window)?; 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); debug_assert!(self.validate().is_ok()); @@ -209,6 +227,109 @@ impl ScrollingLayout { Ok(()) } + pub fn directional_neighbor( + &self, + window: WindowId, + direction: Direction, + ) -> Result, 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 { + 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 { + 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( &mut self, 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)> { self.columns.iter().enumerate().find_map(|(column, data)| { data.windows @@ -323,7 +471,7 @@ impl ScrollingLayout { }) } - fn validate(&self) -> Result<(), LayoutError> { + pub fn validate(&self) -> Result<(), LayoutError> { if self.columns.is_empty() { return if self.active_column.is_none() { 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 { match width { ColumnWidth::Proportion(value) if value.is_finite() && value > 0.0 => { @@ -441,6 +601,21 @@ mod tests { 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] fn focus_reveal_scrolls_only_as_far_as_needed() { let mut layout = ScrollingLayout::default(); @@ -505,4 +680,66 @@ mod tests { assert_eq!(first.x, second.x); 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![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]); + } } diff --git a/docs/ferese-window-manager-spec.md b/docs/ferese-window-manager-spec.md index 3d08676..1c854af 100644 --- a/docs/ferese-window-manager-spec.md +++ b/docs/ferese-window-manager-spec.md @@ -1024,6 +1024,7 @@ smart_gaps = true [scrolling] 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] neighbor_context = 48.0 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+Shift+1..9 move window to workspace Super+F toggle fullscreen +Super+M toggle scrolling/tree layout Super+Shift+Space toggle floating Super+Space launcher Super+Tab overview