Diff
diff --git a/resources/test-config.lua b/resources/test-config.lua
index d9765f1..b8862db 100644
--- a/resources/test-config.lua
+++ b/resources/test-config.lua
@@ -75,8 +75,12 @@ oxwm.key.bind({ modkey, "Shift" }, "Space", oxwm.client.toggle_floating())
oxwm.key.bind({ modkey }, "F", oxwm.layout.set("normie"))
oxwm.key.bind({ modkey }, "C", oxwm.layout.set("tiling"))
+oxwm.key.bind({ modkey }, "G", oxwm.layout.set("scrolling"))
oxwm.key.bind({ modkey }, "N", oxwm.layout.cycle())
+oxwm.key.bind({ modkey }, "Left", oxwm.layout.scroll_left())
+oxwm.key.bind({ modkey }, "Right", oxwm.layout.scroll_right())
+
oxwm.key.bind({ modkey }, "A", oxwm.toggle_gaps())
-- Master area controls
diff --git a/src/animations/mod.rs b/src/animations/mod.rs
new file mode 100644
index 0000000..4b9ae69
--- /dev/null
+++ b/src/animations/mod.rs
@@ -0,0 +1,42 @@
+mod scroll;
+
+pub use scroll::ScrollAnimation;
+
+use std::time::Duration;
+
+#[derive(Debug, Clone, Copy)]
+pub enum Easing {
+ Linear,
+ EaseOut,
+ EaseInOut,
+}
+
+impl Easing {
+ pub fn apply(&self, t: f64) -> f64 {
+ match self {
+ Easing::Linear => t,
+ Easing::EaseOut => 1.0 - (1.0 - t).powi(3),
+ Easing::EaseInOut => {
+ if t < 0.5 {
+ 4.0 * t * t * t
+ } else {
+ 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
+ }
+ }
+ }
+ }
+}
+
+pub struct AnimationConfig {
+ pub duration: Duration,
+ pub easing: Easing,
+}
+
+impl Default for AnimationConfig {
+ fn default() -> Self {
+ Self {
+ duration: Duration::from_millis(150),
+ easing: Easing::EaseOut,
+ }
+ }
+}
diff --git a/src/animations/scroll.rs b/src/animations/scroll.rs
new file mode 100644
index 0000000..808b0eb
--- /dev/null
+++ b/src/animations/scroll.rs
@@ -0,0 +1,76 @@
+use std::time::Instant;
+use super::{AnimationConfig, Easing};
+
+pub struct ScrollAnimation {
+ start_value: i32,
+ end_value: i32,
+ start_time: Instant,
+ duration_ms: u64,
+ easing: Easing,
+ active: bool,
+}
+
+impl ScrollAnimation {
+ pub fn new() -> Self {
+ Self {
+ start_value: 0,
+ end_value: 0,
+ start_time: Instant::now(),
+ duration_ms: 150,
+ easing: Easing::EaseOut,
+ active: false,
+ }
+ }
+
+ pub fn start(&mut self, from: i32, to: i32, config: &AnimationConfig) {
+ if from == to {
+ self.active = false;
+ return;
+ }
+ self.start_value = from;
+ self.end_value = to;
+ self.start_time = Instant::now();
+ self.duration_ms = config.duration.as_millis() as u64;
+ self.easing = config.easing;
+ self.active = true;
+ }
+
+ pub fn update(&mut self) -> Option<i32> {
+ if !self.active {
+ return None;
+ }
+
+ let elapsed = self.start_time.elapsed().as_millis() as u64;
+
+ if elapsed >= self.duration_ms {
+ self.active = false;
+ return Some(self.end_value);
+ }
+
+ let t = elapsed as f64 / self.duration_ms as f64;
+ let eased_t = self.easing.apply(t);
+
+ let delta = (self.end_value - self.start_value) as f64;
+ let current = self.start_value as f64 + delta * eased_t;
+
+ Some(current.round() as i32)
+ }
+
+ pub fn is_active(&self) -> bool {
+ self.active
+ }
+
+ pub fn target(&self) -> i32 {
+ self.end_value
+ }
+
+ pub fn cancel(&mut self) {
+ self.active = false;
+ }
+}
+
+impl Default for ScrollAnimation {
+ fn default() -> Self {
+ Self::new()
+ }
+}
diff --git a/src/config/lua_api.rs b/src/config/lua_api.rs
index 52d815f..50a5e3e 100644
--- a/src/config/lua_api.rs
+++ b/src/config/lua_api.rs
@@ -298,8 +298,16 @@ fn register_layout_module(lua: &Lua, parent: &Table) -> Result<(), ConfigError>
)
})?;
+ let scroll_left =
+ lua.create_function(|lua, ()| create_action_table(lua, "ScrollLeft", Value::Nil))?;
+
+ let scroll_right =
+ lua.create_function(|lua, ()| create_action_table(lua, "ScrollRight", Value::Nil))?;
+
layout_table.set("cycle", cycle)?;
layout_table.set("set", set)?;
+ layout_table.set("scroll_left", scroll_left)?;
+ layout_table.set("scroll_right", scroll_right)?;
parent.set("layout", layout_table)?;
Ok(())
}
@@ -918,6 +926,8 @@ fn string_to_action(s: &str) -> mlua::Result<KeyAction> {
"FocusMonitor" => Ok(KeyAction::FocusMonitor),
"TagMonitor" => Ok(KeyAction::TagMonitor),
"ShowKeybindOverlay" => Ok(KeyAction::ShowKeybindOverlay),
+ "ScrollLeft" => Ok(KeyAction::ScrollLeft),
+ "ScrollRight" => Ok(KeyAction::ScrollRight),
_ => Err(mlua::Error::RuntimeError(format!(
"unknown action '{}'. this is an internal error, please report it",
s
diff --git a/src/keyboard/handlers.rs b/src/keyboard/handlers.rs
index fc52c77..8c8e88e 100644
--- a/src/keyboard/handlers.rs
+++ b/src/keyboard/handlers.rs
@@ -41,6 +41,8 @@ pub enum KeyAction {
ShowKeybindOverlay,
SetMasterFactor,
IncNumMaster,
+ ScrollLeft,
+ ScrollRight,
None,
}
diff --git a/src/layout/mod.rs b/src/layout/mod.rs
index a05ecb1..e332587 100644
--- a/src/layout/mod.rs
+++ b/src/layout/mod.rs
@@ -1,6 +1,7 @@
pub mod grid;
pub mod monocle;
pub mod normie;
+pub mod scrolling;
pub mod tabbed;
pub mod tiling;
@@ -23,6 +24,7 @@ pub enum LayoutType {
Grid,
Monocle,
Tabbed,
+ Scrolling,
}
impl LayoutType {
@@ -33,6 +35,7 @@ impl LayoutType {
Self::Grid => Box::new(grid::GridLayout),
Self::Monocle => Box::new(monocle::MonocleLayout),
Self::Tabbed => Box::new(tabbed::TabbedLayout),
+ Self::Scrolling => Box::new(scrolling::ScrollingLayout),
}
}
@@ -42,7 +45,8 @@ impl LayoutType {
Self::Normie => Self::Grid,
Self::Grid => Self::Monocle,
Self::Monocle => Self::Tabbed,
- Self::Tabbed => Self::Tiling,
+ Self::Tabbed => Self::Scrolling,
+ Self::Scrolling => Self::Tiling,
}
}
@@ -53,6 +57,7 @@ impl LayoutType {
Self::Grid => "grid",
Self::Monocle => "monocle",
Self::Tabbed => "tabbed",
+ Self::Scrolling => "scrolling",
}
}
}
@@ -67,6 +72,7 @@ impl FromStr for LayoutType {
"grid" => Ok(Self::Grid),
"monocle" => Ok(Self::Monocle),
"tabbed" => Ok(Self::Tabbed),
+ "scrolling" => Ok(Self::Scrolling),
_ => Err(format!("Invalid Layout Type: {}", s)),
}
}
diff --git a/src/layout/scrolling.rs b/src/layout/scrolling.rs
new file mode 100644
index 0000000..fed8bd6
--- /dev/null
+++ b/src/layout/scrolling.rs
@@ -0,0 +1,100 @@
+use super::{GapConfig, Layout, WindowGeometry};
+use x11rb::protocol::xproto::Window;
+
+pub struct ScrollingLayout;
+
+struct GapValues {
+ outer_horizontal: u32,
+ outer_vertical: u32,
+ inner_vertical: u32,
+}
+
+impl ScrollingLayout {
+ fn getgaps(gaps: &GapConfig, window_count: usize, smartgaps_enabled: bool) -> GapValues {
+ let outer_enabled = if smartgaps_enabled && window_count == 1 {
+ 0
+ } else {
+ 1
+ };
+
+ GapValues {
+ outer_horizontal: gaps.outer_horizontal * outer_enabled,
+ outer_vertical: gaps.outer_vertical * outer_enabled,
+ inner_vertical: gaps.inner_vertical,
+ }
+ }
+}
+
+impl Layout for ScrollingLayout {
+ fn name(&self) -> &'static str {
+ "scrolling"
+ }
+
+ fn symbol(&self) -> &'static str {
+ "[>>]"
+ }
+
+ fn arrange(
+ &self,
+ windows: &[Window],
+ screen_width: u32,
+ screen_height: u32,
+ gaps: &GapConfig,
+ _master_factor: f32,
+ num_master: i32,
+ smartgaps_enabled: bool,
+ ) -> Vec<WindowGeometry> {
+ let window_count = windows.len();
+ if window_count == 0 {
+ return Vec::new();
+ }
+
+ let gap_values = Self::getgaps(gaps, window_count, smartgaps_enabled);
+
+ let outer_horizontal = gap_values.outer_horizontal;
+ let outer_vertical = gap_values.outer_vertical;
+ let inner_vertical = gap_values.inner_vertical;
+
+ let visible_count = if num_master > 0 {
+ num_master as usize
+ } else {
+ 2
+ };
+
+ let available_width = screen_width.saturating_sub(2 * outer_vertical);
+ let available_height = screen_height.saturating_sub(2 * outer_horizontal);
+
+ let total_inner_gaps = if visible_count > 1 {
+ inner_vertical * (visible_count.min(window_count) - 1) as u32
+ } else {
+ 0
+ };
+ let window_width = if window_count <= visible_count {
+ let num_windows = window_count as u32;
+ let total_gaps = if num_windows > 1 {
+ inner_vertical * (num_windows - 1)
+ } else {
+ 0
+ };
+ (available_width.saturating_sub(total_gaps)) / num_windows
+ } else {
+ (available_width.saturating_sub(total_inner_gaps)) / visible_count as u32
+ };
+
+ let mut geometries = Vec::with_capacity(window_count);
+ let mut x = outer_vertical as i32;
+
+ for _window in windows.iter() {
+ geometries.push(WindowGeometry {
+ x_coordinate: x,
+ y_coordinate: outer_horizontal as i32,
+ width: window_width,
+ height: available_height,
+ });
+
+ x += window_width as i32 + inner_vertical as i32;
+ }
+
+ geometries
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index b52b23c..22a9c4c 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,5 +1,6 @@
use std::path::PathBuf;
+pub mod animations;
pub mod bar;
pub mod client;
pub mod config;
diff --git a/src/monitor.rs b/src/monitor.rs
index 8f55a9c..cae6f33 100644
--- a/src/monitor.rs
+++ b/src/monitor.rs
@@ -35,6 +35,7 @@ pub struct Monitor {
pub stack_head: Option<Window>,
pub bar_window: Option<Window>,
pub layout_indices: [usize; 2],
+ pub scroll_offset: i32,
}
impl Monitor {
@@ -67,6 +68,7 @@ impl Monitor {
stack_head: None,
bar_window: None,
layout_indices: [0, 1],
+ scroll_offset: 0,
}
}
diff --git a/src/overlay/keybind.rs b/src/overlay/keybind.rs
index 6786a53..278bf28 100644
--- a/src/overlay/keybind.rs
+++ b/src/overlay/keybind.rs
@@ -239,6 +239,8 @@ impl KeybindOverlay {
KeyAction::TagMonitor => "Send Window to Monitor".to_string(),
KeyAction::SetMasterFactor => "Adjust Master Area Size".to_string(),
KeyAction::IncNumMaster => "Adjust Number of Master Windows".to_string(),
+ KeyAction::ScrollLeft => "Scroll Layout Left".to_string(),
+ KeyAction::ScrollRight => "Scroll Layout Right".to_string(),
KeyAction::None => "No Action".to_string(),
}
}
diff --git a/src/window_manager.rs b/src/window_manager.rs
index 6e29e90..6c8a546 100644
--- a/src/window_manager.rs
+++ b/src/window_manager.rs
@@ -1,4 +1,5 @@
use crate::Config;
+use crate::animations::{AnimationConfig, ScrollAnimation};
use crate::bar::Bar;
use crate::client::{Client, TagMask};
use crate::errors::WmError;
@@ -144,6 +145,8 @@ pub struct WindowManager {
error_message: Option<String>,
overlay: ErrorOverlay,
keybind_overlay: KeybindOverlay,
+ scroll_animation: ScrollAnimation,
+ animation_config: AnimationConfig,
}
type WmResult<T> = Result<T, WmError>;
@@ -303,6 +306,8 @@ impl WindowManager {
error_message: None,
overlay,
keybind_overlay,
+ scroll_animation: ScrollAnimation::new(),
+ animation_config: AnimationConfig::default(),
};
for tab_bar in &window_manager.tab_bars {
@@ -528,6 +533,8 @@ impl WindowManager {
last_bar_update = std::time::Instant::now();
}
+ self.tick_animations()?;
+
self.connection.flush()?;
std::thread::sleep(std::time::Duration::from_millis(16));
}
@@ -610,8 +617,219 @@ impl WindowManager {
Ok(())
}
+ fn tick_animations(&mut self) -> WmResult<()> {
+ if self.scroll_animation.is_active() {
+ if let Some(new_offset) = self.scroll_animation.update() {
+ if let Some(m) = self.monitors.get_mut(self.selected_monitor) {
+ m.scroll_offset = new_offset;
+ }
+ self.apply_layout()?;
+ self.update_bar()?;
+ }
+ }
+ Ok(())
+ }
+
+ fn scroll_layout(&mut self, direction: i32) -> WmResult<()> {
+ if self.layout.name() != "scrolling" {
+ return Ok(());
+ }
+
+ let monitor_index = self.selected_monitor;
+ let monitor = match self.monitors.get(monitor_index) {
+ Some(m) => m.clone(),
+ None => return Ok(()),
+ };
+
+ let visible_count = if monitor.num_master > 0 {
+ monitor.num_master as usize
+ } else {
+ 2
+ };
+
+ let mut tiled_count = 0;
+ let mut current = self.next_tiled(monitor.clients_head, &monitor);
+ while let Some(window) = current {
+ tiled_count += 1;
+ if let Some(client) = self.clients.get(&window) {
+ current = self.next_tiled(client.next, &monitor);
+ } else {
+ break;
+ }
+ }
+
+ if tiled_count <= visible_count {
+ if let Some(m) = self.monitors.get_mut(monitor_index) {
+ m.scroll_offset = 0;
+ }
+ return Ok(());
+ }
+
+ let outer_gap = if self.gaps_enabled {
+ self.config.gap_outer_vertical
+ } else {
+ 0
+ };
+ let inner_gap = if self.gaps_enabled {
+ self.config.gap_inner_vertical
+ } else {
+ 0
+ };
+
+ let available_width = monitor.screen_width - 2 * outer_gap as i32;
+ let total_inner_gaps = inner_gap as i32 * (visible_count - 1) as i32;
+ let window_width = (available_width - total_inner_gaps) / visible_count as i32;
+ let scroll_amount = window_width + inner_gap as i32;
+
+ let total_width = tiled_count as i32 * window_width + (tiled_count - 1) as i32 * inner_gap as i32;
+ let max_scroll = (total_width - available_width).max(0);
+
+ let current_offset = monitor.scroll_offset;
+ let target_offset = if self.scroll_animation.is_active() {
+ self.scroll_animation.target() + direction * scroll_amount
+ } else {
+ current_offset + direction * scroll_amount
+ };
+ let target_offset = target_offset.clamp(0, max_scroll);
+
+ self.scroll_animation.start(current_offset, target_offset, &self.animation_config);
+
+ Ok(())
+ }
+
+ fn scroll_to_window(&mut self, target_window: Window, animate: bool) -> WmResult<()> {
+ if self.layout.name() != "scrolling" {
+ return Ok(());
+ }
+
+ let monitor_index = self.selected_monitor;
+ let monitor = match self.monitors.get(monitor_index) {
+ Some(m) => m.clone(),
+ None => return Ok(()),
+ };
+
+ let visible_count = if monitor.num_master > 0 {
+ monitor.num_master as usize
+ } else {
+ 2
+ };
+
+ let outer_gap = if self.gaps_enabled {
+ self.config.gap_outer_vertical
+ } else {
+ 0
+ };
+ let inner_gap = if self.gaps_enabled {
+ self.config.gap_inner_vertical
+ } else {
+ 0
+ };
+
+ let mut tiled_windows = Vec::new();
+ let mut current = self.next_tiled(monitor.clients_head, &monitor);
+ while let Some(window) = current {
+ tiled_windows.push(window);
+ if let Some(client) = self.clients.get(&window) {
+ current = self.next_tiled(client.next, &monitor);
+ } else {
+ break;
+ }
+ }
+
+ let target_idx = tiled_windows.iter().position(|&w| w == target_window);
+ let target_idx = match target_idx {
+ Some(idx) => idx,
+ None => return Ok(()),
+ };
+
+ let tiled_count = tiled_windows.len();
+ if tiled_count <= visible_count {
+ if let Some(m) = self.monitors.get_mut(monitor_index) {
+ m.scroll_offset = 0;
+ }
+ return Ok(());
+ }
+
+ let available_width = monitor.screen_width - 2 * outer_gap as i32;
+ let total_inner_gaps = inner_gap as i32 * (visible_count - 1) as i32;
+ let window_width = (available_width - total_inner_gaps) / visible_count as i32;
+ let scroll_step = window_width + inner_gap as i32;
+
+ let total_width = tiled_count as i32 * window_width + (tiled_count - 1) as i32 * inner_gap as i32;
+ let max_scroll = (total_width - available_width).max(0);
+
+ let target_scroll = (target_idx as i32) * scroll_step;
+ let new_offset = target_scroll.clamp(0, max_scroll);
+
+ let current_offset = monitor.scroll_offset;
+ if current_offset != new_offset {
+ if animate {
+ self.scroll_animation.start(current_offset, new_offset, &self.animation_config);
+ } else {
+ if let Some(m) = self.monitors.get_mut(monitor_index) {
+ m.scroll_offset = new_offset;
+ }
+ }
+ }
+
+ Ok(())
+ }
+
fn get_layout_symbol(&self) -> String {
let layout_name = self.layout.name();
+
+ if layout_name == "scrolling" {
+ if let Some(monitor) = self.monitors.get(self.selected_monitor) {
+ let visible_count = if monitor.num_master > 0 {
+ monitor.num_master as usize
+ } else {
+ 2
+ };
+
+ let mut tiled_count = 0;
+ let mut current = self.next_tiled(monitor.clients_head, monitor);
+ while let Some(window) = current {
+ tiled_count += 1;
+ if let Some(client) = self.clients.get(&window) {
+ current = self.next_tiled(client.next, monitor);
+ } else {
+ break;
+ }
+ }
+
+ if tiled_count > 0 {
+ let outer_gap = if self.gaps_enabled {
+ self.config.gap_outer_vertical
+ } else {
+ 0
+ };
+ let inner_gap = if self.gaps_enabled {
+ self.config.gap_inner_vertical
+ } else {
+ 0
+ };
+
+ let available_width = monitor.screen_width - 2 * outer_gap as i32;
+ let total_inner_gaps = inner_gap as i32 * (visible_count.min(tiled_count) - 1) as i32;
+ let window_width = if tiled_count <= visible_count {
+ (available_width - total_inner_gaps) / tiled_count as i32
+ } else {
+ (available_width - inner_gap as i32 * (visible_count - 1) as i32) / visible_count as i32
+ };
+
+ let scroll_step = window_width + inner_gap as i32;
+ let first_visible = if scroll_step > 0 {
+ (monitor.scroll_offset / scroll_step) + 1
+ } else {
+ 1
+ };
+ let last_visible = (first_visible + visible_count as i32 - 1).min(tiled_count as i32);
+
+ return format!("[{}-{}/{}]", first_visible, last_visible, tiled_count);
+ }
+ }
+ }
+
self.config
.layout_symbols
.iter()
@@ -908,6 +1126,12 @@ impl WindowManager {
self.inc_num_master(*delta)?;
}
}
+ KeyAction::ScrollLeft => {
+ self.scroll_layout(-1)?;
+ }
+ KeyAction::ScrollRight => {
+ self.scroll_layout(1)?;
+ }
KeyAction::None => {}
}
Ok(())
@@ -1953,7 +2177,15 @@ impl WindowManager {
)?;
}
- self.attach_aside(window, client_monitor);
+ if self.layout.name() == "scrolling" {
+ if let Some(selected) = self.monitors.get(client_monitor).and_then(|m| m.selected_client) {
+ self.attach_after(window, selected, client_monitor);
+ } else {
+ self.attach_aside(window, client_monitor);
+ }
+ } else {
+ self.attach_aside(window, client_monitor);
+ }
self.attach_stack(window, client_monitor);
self.windows.push(window);
@@ -1985,6 +2217,10 @@ impl WindowManager {
m.selected_client = Some(window);
}
+ if self.layout.name() == "scrolling" {
+ self.scroll_to_window(window, false)?;
+ }
+
self.apply_layout()?;
self.connection.map_window(window)?;
self.focus(Some(window))?;
@@ -2224,6 +2460,11 @@ impl WindowManager {
};
self.focus(Some(next_window))?;
+
+ if self.layout.name() == "scrolling" {
+ self.scroll_to_window(next_window, true)?;
+ }
+
self.update_tab_bars()?;
Ok(())
@@ -3434,6 +3675,7 @@ impl WindowManager {
let monitor_y = monitor.screen_y;
let monitor_width = monitor.screen_width;
let monitor_height = monitor.screen_height;
+ let scroll_offset = monitor.scroll_offset;
let mut visible: Vec<Window> = Vec::new();
let mut current = self.next_tiled(monitor.clients_head, monitor);
@@ -3487,7 +3729,12 @@ impl WindowManager {
adjusted_height = hint_height as u32;
}
- let adjusted_x = geometry.x_coordinate + monitor_x;
+ let is_scrolling = self.layout.name() == "scrolling";
+ let adjusted_x = if is_scrolling {
+ geometry.x_coordinate + monitor_x - scroll_offset
+ } else {
+ geometry.x_coordinate + monitor_x
+ };
let adjusted_y = geometry.y_coordinate + monitor_y + bar_height as i32;
if let Some(client) = self.clients.get_mut(window) {
@@ -4046,6 +4293,20 @@ impl WindowManager {
}
}
+ fn attach_after(&mut self, window: Window, after_window: Window, monitor_index: usize) {
+ if let Some(after_client) = self.clients.get(&after_window) {
+ let old_next = after_client.next;
+ if let Some(new_client) = self.clients.get_mut(&window) {
+ new_client.next = old_next;
+ }
+ if let Some(after_client_mut) = self.clients.get_mut(&after_window) {
+ after_client_mut.next = Some(window);
+ }
+ } else {
+ self.attach(window, monitor_index);
+ }
+ }
+
fn attach_aside(&mut self, window: Window, monitor_index: usize) {
let monitor = match self.monitors.get(monitor_index) {
Some(m) => m,
diff --git a/templates/oxwm.lua b/templates/oxwm.lua
index 3dc0d81..547708b 100644
--- a/templates/oxwm.lua
+++ b/templates/oxwm.lua
@@ -192,10 +192,18 @@ oxwm.layout = {}
function oxwm.layout.cycle() end
---Set specific layout
----@param name string Layout name (e.g., "tiling", "normie", "tabbed", "grid", "monocle")
+---@param name string Layout name (e.g., "tiling", "normie", "tabbed", "grid", "monocle", "scrolling")
---@return table Action table for keybinding
function oxwm.layout.set(name) end
+---Scroll layout left (for scrolling layout)
+---@return table Action table for keybinding
+function oxwm.layout.scroll_left() end
+
+---Scroll layout right (for scrolling layout)
+---@return table Action table for keybinding
+function oxwm.layout.scroll_right() end
+
---Tag/workspace management module
---@class oxwm.tag
oxwm.tag = {}