feat: complete scrolling layout controls

This commit is contained in:
Samuel Onoja committed 2026-09-25 02:14:04 +01:00
1 parent 9d90415e55
commit 5e6c9c3d9d
8 files changed
+537 -30

No files matched your search

+68 -19
View File
@@ -32,6 +32,8 @@ enum LayoutModeValue {
#[derive(Debug, Default, Deserialize)]
struct ScrollingConfig {
default_column_width: Option<ColumnWidthValue>,
#[serde(default)]
width_presets: Vec<ColumnWidthValue>,
}
#[derive(Clone, Debug, Deserialize)]
@@ -51,7 +53,10 @@ pub enum ConfigError {
path: PathBuf,
source: toml::de::Error,
},
InvalidColumnWidth(String),
InvalidColumnWidth {
field: &'static str,
value: String,
},
}
impl fmt::Display for ConfigError {
@@ -63,9 +68,9 @@ impl fmt::Display for ConfigError {
Self::Parse { path, source } => {
write!(formatter, "failed to parse {}: {source}", path.display())
}
Self::InvalidColumnWidth(value) => write!(
Self::InvalidColumnWidth { field, value } => write!(
formatter,
"invalid scrolling.default_column_width {value}; expected a positive number or \"full\""
"invalid scrolling.{field} {value}; expected a positive number or \"full\""
),
}
}
@@ -76,7 +81,7 @@ impl Error for ConfigError {
match self {
Self::Read { source, .. } => Some(source),
Self::Parse { source, .. } => Some(source),
Self::InvalidColumnWidth(_) => None,
Self::InvalidColumnWidth { .. } => None,
}
}
}
@@ -103,23 +108,51 @@ impl Config {
}
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 {
parse_column_width(
self.scrolling
.default_column_width
.as_ref()
.unwrap_or(&ColumnWidthValue::Proportion(0.5)),
"default_column_width",
)
}
pub fn width_presets(&self) -> Result<Vec<ColumnWidth>, ConfigError> {
if self.scrolling.width_presets.is_empty() {
return Ok(vec![
ColumnWidth::Proportion(1.0 / 3.0),
ColumnWidth::Proportion(0.5),
ColumnWidth::Proportion(2.0 / 3.0),
ColumnWidth::Full,
]);
}
self.scrolling
.width_presets
.iter()
.map(|value| parse_column_width(value, "width_presets"))
.collect()
}
}
fn parse_column_width(
value: &ColumnWidthValue,
field: &'static str,
) -> Result<ColumnWidth, ConfigError> {
match value {
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 {
field,
value: match value {
ColumnWidthValue::Proportion(value) => value.to_string(),
ColumnWidthValue::Named(value) => format!("{value:?}"),
})),
}
},
}),
}
}
@@ -165,6 +198,22 @@ mod tests {
);
}
#[test]
fn parses_width_presets_and_supplies_defaults() {
let defaults = parse("");
let configured = parse("[scrolling]\nwidth_presets = [0.5, 1.0, \"full\"]");
assert_eq!(defaults.width_presets().unwrap().len(), 4);
assert_eq!(
configured.width_presets().unwrap(),
vec![
ColumnWidth::Proportion(0.5),
ColumnWidth::Proportion(1.0),
ColumnWidth::Full
]
);
}
#[test]
fn rejects_non_positive_or_unknown_column_widths() {
let zero = parse("[scrolling]\ndefault_column_width = 0.0");
+55 -11
View File
@@ -71,19 +71,31 @@ impl Ferese {
return FilterResult::Intercept(());
}
let direction = if modifiers.logo && !modifiers.ctrl && !modifiers.alt {
match symbol {
keysyms::KEY_h | keysyms::KEY_H => Some(Direction::Left),
keysyms::KEY_j | keysyms::KEY_J => Some(Direction::Down),
keysyms::KEY_k | keysyms::KEY_K => Some(Direction::Up),
keysyms::KEY_l | keysyms::KEY_L => Some(Direction::Right),
_ => None,
}
} else {
None
let direction = match symbol {
keysyms::KEY_h | keysyms::KEY_H => Some(Direction::Left),
keysyms::KEY_j | keysyms::KEY_J => Some(Direction::Down),
keysyms::KEY_k | keysyms::KEY_K => Some(Direction::Up),
keysyms::KEY_l | keysyms::KEY_L => Some(Direction::Right),
_ => None,
};
if let Some(direction) = direction {
if modifiers.logo
&& modifiers.ctrl
&& !modifiers.alt
&& !modifiers.shift
&& let Some(direction) = direction
{
if state == KeyState::Pressed {
data.intercepted_keys.insert(keycode);
data.resize_direction(direction);
}
FilterResult::Intercept(())
} else if modifiers.logo
&& !modifiers.ctrl
&& !modifiers.alt
&& let Some(direction) = direction
{
if state == KeyState::Pressed {
data.intercepted_keys.insert(keycode);
@@ -147,6 +159,38 @@ impl Ferese {
data.toggle_layout_mode();
}
FilterResult::Intercept(())
} else if !modifiers.shift
&& matches!(symbol, keysyms::KEY_r | keysyms::KEY_R)
{
if state == KeyState::Pressed {
data.intercepted_keys.insert(keycode);
data.cycle_focused_column_width();
}
FilterResult::Intercept(())
} else if !modifiers.shift
&& matches!(symbol, keysyms::KEY_c | keysyms::KEY_C)
{
if state == KeyState::Pressed {
data.intercepted_keys.insert(keycode);
data.center_focused_column();
}
FilterResult::Intercept(())
} else if !modifiers.shift && symbol == keysyms::KEY_bracketleft {
if state == KeyState::Pressed {
data.intercepted_keys.insert(keycode);
data.consume_focused_window();
}
FilterResult::Intercept(())
} else if !modifiers.shift && symbol == keysyms::KEY_bracketright {
if state == KeyState::Pressed {
data.intercepted_keys.insert(keycode);
data.expel_focused_window();
}
FilterResult::Intercept(())
} else if modifiers.shift && symbol == keysyms::KEY_space {
if state == KeyState::Pressed {
+1
View File
@@ -31,6 +31,7 @@ fn main() -> Result<(), Box<dyn Error>> {
display,
config.layout_mode(),
config.default_column_width()?,
config.width_presets()?,
)?;
backends::init(launch.backend, &mut event_loop, &mut state)?;
+85
View File
@@ -84,6 +84,7 @@ pub struct Ferese {
pub window_ids: HashMap<Window, WindowId>,
pub window_geometry: HashMap<WindowId, WindowGeometry>,
pub focused_window: Option<WindowId>,
column_width_presets: Vec<ColumnWidth>,
pub cursor_status: CursorImageStatus,
pub(crate) cursor_theme: xcursor::CursorTheme,
pub(crate) named_cursors: HashMap<CursorIcon, crate::cursor::NamedCursor>,
@@ -273,6 +274,7 @@ impl Ferese {
display: Display<Self>,
default_layout_mode: LayoutMode,
default_column_width: ColumnWidth,
column_width_presets: Vec<ColumnWidth>,
) -> Result<Self, Box<dyn Error>> {
let display_handle = display.handle();
crate::handlers::screencopy::init_global(&display_handle);
@@ -328,6 +330,7 @@ impl Ferese {
window_ids: HashMap::new(),
window_geometry: HashMap::new(),
focused_window: None,
column_width_presets,
cursor_status: CursorImageStatus::default_named(),
cursor_theme,
named_cursors,
@@ -956,6 +959,88 @@ impl Ferese {
}
}
pub fn consume_focused_window(&mut self) {
let Some(window) = self.focused_window else {
return;
};
let Some(bounds) = self.output_bounds() else {
return;
};
if self.workspaces.active().layout.mode() != LayoutMode::Scrolling {
return;
}
let target = self
.workspaces
.active()
.layout
.directional_neighbor(window, Direction::Left, bounds)
.ok()
.flatten()
.or_else(|| {
self.workspaces
.active()
.layout
.directional_neighbor(window, Direction::Right, bounds)
.ok()
.flatten()
});
let Some(target) = target else {
return;
};
if let Err(error) = self.workspaces.stack_window(window, target) {
tracing::error!(%error, ?window, ?target, "failed to consume window into column");
return;
}
self.relayout();
}
pub fn expel_focused_window(&mut self) {
let Some(window) = self.focused_window else {
return;
};
match self.workspaces.extract_window(window) {
Ok(true) => self.relayout(),
Ok(false) => {}
Err(error) => tracing::error!(%error, ?window, "failed to expel window from column"),
}
}
pub fn cycle_focused_column_width(&mut self) {
let Some(window) = self.focused_window else {
return;
};
match self
.workspaces
.cycle_column_width(window, &self.column_width_presets)
{
Ok(true) => self.relayout(),
Ok(false) => {}
Err(error) => tracing::error!(%error, ?window, "failed to cycle column width"),
}
}
pub fn center_focused_column(&mut self) {
let Some(window) = self.focused_window else {
return;
};
let Some(bounds) = self.output_bounds() else {
return;
};
let constraints = self.window_constraints();
match self
.workspaces
.center_window(window, bounds, GapConfig::default(), &constraints)
{
Ok(true) => self.relayout(),
Ok(false) => {}
Err(error) => tracing::error!(%error, ?window, "failed to center column"),
}
}
pub fn toggle_focused_floating(&mut self) {
let Some(window) = self.focused_window else {
return;