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

+2
View File
@@ -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
+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();
}
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<f64, Logical>) -> Point<f64, Logical> {
+9 -1
View File
@@ -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<dyn Error>> {
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
+57 -28
View File
@@ -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<Self>,
default_layout_mode: LayoutMode,
default_column_width: ColumnWidth,
) -> Result<Self, Box<dyn Error>> {
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::<Vec<_>>();
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<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(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<LayoutResult, ferese_layout::LayoutError> {
self.workspaces.active().layout.geometry_with_constraints(
bounds,
GapConfig::default(),
&self.window_constraints(),
self.focused_window,
)
fn tiled_layout(&mut self, bounds: Rect) -> Result<LayoutResult, ferese_layout::LayoutError> {
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<WindowId, SizeConstraints> {