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

+4 -1
View File
@@ -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,
};
+324 -8
View File
@@ -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<WindowId>,
pub last_focused: 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)]
pub enum WindowPlacement {
Tiled,
@@ -70,16 +298,24 @@ pub struct WorkspaceSet {
names: HashMap<String, WorkspaceId>,
window_workspaces: HashMap<WindowId, WorkspaceId>,
placements: HashMap<WindowId, WindowPlacement>,
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<WorkspaceId> {
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<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(
&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<_>>(),
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();
+12
View File
@@ -170,6 +170,18 @@ impl LayoutTree {
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(
&mut self,
window: WindowId,
+244 -7
View File
@@ -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<usize>,
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<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(
&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<_>>(),
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]);
}
}