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

+5
View File
@@ -37,6 +37,11 @@ default_column_width = "full"
`default_column_width = 1.0` is equivalent. `Super+M` switches the active `default_column_width = 1.0` is equivalent. `Super+M` switches the active
workspace between scrolling and tree modes. workspace between scrolling and tree modes.
Scrolling controls use `Super+R` to cycle configured width presets, `Super+C`
to center the focused column, `Super+[` to consume the focused window into an
adjacent column, and `Super+]` to expel it. `Super+Ctrl+H/J/K/L` resizes in the
corresponding direction.
Text-input-v3 is available to applications by default. Input-method-v2 is 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 hidden unless the session explicitly opts in, because an input method can
request privileged keyboard access. Enable it only when launching a trusted request privileged keyboard access. Enable it only when launching a trusted
+68 -19
View File
@@ -32,6 +32,8 @@ enum LayoutModeValue {
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct ScrollingConfig { struct ScrollingConfig {
default_column_width: Option<ColumnWidthValue>, default_column_width: Option<ColumnWidthValue>,
#[serde(default)]
width_presets: Vec<ColumnWidthValue>,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
@@ -51,7 +53,10 @@ pub enum ConfigError {
path: PathBuf, path: PathBuf,
source: toml::de::Error, source: toml::de::Error,
}, },
InvalidColumnWidth(String), InvalidColumnWidth {
field: &'static str,
value: String,
},
} }
impl fmt::Display for ConfigError { impl fmt::Display for ConfigError {
@@ -63,9 +68,9 @@ impl fmt::Display for ConfigError {
Self::Parse { path, source } => { Self::Parse { path, source } => {
write!(formatter, "failed to parse {}: {source}", path.display()) write!(formatter, "failed to parse {}: {source}", path.display())
} }
Self::InvalidColumnWidth(value) => write!( Self::InvalidColumnWidth { field, value } => write!(
formatter, 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 { match self {
Self::Read { source, .. } => Some(source), Self::Read { source, .. } => Some(source),
Self::Parse { 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> { pub fn default_column_width(&self) -> Result<ColumnWidth, ConfigError> {
match self parse_column_width(
.scrolling self.scrolling
.default_column_width .default_column_width
.as_ref() .as_ref()
.unwrap_or(&ColumnWidthValue::Proportion(0.5)) .unwrap_or(&ColumnWidthValue::Proportion(0.5)),
{ "default_column_width",
ColumnWidthValue::Proportion(value) if value.is_finite() && *value > 0.0 => { )
Ok(ColumnWidth::Proportion(*value)) }
}
ColumnWidthValue::Named(value) if value.eq_ignore_ascii_case("full") => { pub fn width_presets(&self) -> Result<Vec<ColumnWidth>, ConfigError> {
Ok(ColumnWidth::Full) if self.scrolling.width_presets.is_empty() {
} return Ok(vec![
value => Err(ConfigError::InvalidColumnWidth(match value { 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::Proportion(value) => value.to_string(),
ColumnWidthValue::Named(value) => format!("{value:?}"), 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] #[test]
fn rejects_non_positive_or_unknown_column_widths() { fn rejects_non_positive_or_unknown_column_widths() {
let zero = parse("[scrolling]\ndefault_column_width = 0.0"); let zero = parse("[scrolling]\ndefault_column_width = 0.0");
+55 -11
View File
@@ -71,19 +71,31 @@ impl Ferese {
return FilterResult::Intercept(()); return FilterResult::Intercept(());
} }
let direction = if modifiers.logo && !modifiers.ctrl && !modifiers.alt { let direction = match symbol {
match symbol { keysyms::KEY_h | keysyms::KEY_H => Some(Direction::Left),
keysyms::KEY_h | keysyms::KEY_H => Some(Direction::Left), keysyms::KEY_j | keysyms::KEY_J => Some(Direction::Down),
keysyms::KEY_j | keysyms::KEY_J => Some(Direction::Down), keysyms::KEY_k | keysyms::KEY_K => Some(Direction::Up),
keysyms::KEY_k | keysyms::KEY_K => Some(Direction::Up), keysyms::KEY_l | keysyms::KEY_L => Some(Direction::Right),
keysyms::KEY_l | keysyms::KEY_L => Some(Direction::Right), _ => None,
_ => None,
}
} else {
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 { if state == KeyState::Pressed {
data.intercepted_keys.insert(keycode); data.intercepted_keys.insert(keycode);
@@ -147,6 +159,38 @@ impl Ferese {
data.toggle_layout_mode(); 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(()) FilterResult::Intercept(())
} else if modifiers.shift && symbol == keysyms::KEY_space { } else if modifiers.shift && symbol == keysyms::KEY_space {
if state == KeyState::Pressed { if state == KeyState::Pressed {
+1
View File
@@ -31,6 +31,7 @@ fn main() -> Result<(), Box<dyn Error>> {
display, display,
config.layout_mode(), config.layout_mode(),
config.default_column_width()?, config.default_column_width()?,
config.width_presets()?,
)?; )?;
backends::init(launch.backend, &mut event_loop, &mut state)?; 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_ids: HashMap<Window, WindowId>,
pub window_geometry: HashMap<WindowId, WindowGeometry>, pub window_geometry: HashMap<WindowId, WindowGeometry>,
pub focused_window: Option<WindowId>, pub focused_window: Option<WindowId>,
column_width_presets: Vec<ColumnWidth>,
pub cursor_status: CursorImageStatus, pub cursor_status: CursorImageStatus,
pub(crate) cursor_theme: xcursor::CursorTheme, pub(crate) cursor_theme: xcursor::CursorTheme,
pub(crate) named_cursors: HashMap<CursorIcon, crate::cursor::NamedCursor>, pub(crate) named_cursors: HashMap<CursorIcon, crate::cursor::NamedCursor>,
@@ -273,6 +274,7 @@ impl Ferese {
display: Display<Self>, display: Display<Self>,
default_layout_mode: LayoutMode, default_layout_mode: LayoutMode,
default_column_width: ColumnWidth, default_column_width: ColumnWidth,
column_width_presets: Vec<ColumnWidth>,
) -> Result<Self, Box<dyn Error>> { ) -> Result<Self, Box<dyn Error>> {
let display_handle = display.handle(); let display_handle = display.handle();
crate::handlers::screencopy::init_global(&display_handle); crate::handlers::screencopy::init_global(&display_handle);
@@ -328,6 +330,7 @@ impl Ferese {
window_ids: HashMap::new(), window_ids: HashMap::new(),
window_geometry: HashMap::new(), window_geometry: HashMap::new(),
focused_window: None, focused_window: None,
column_width_presets,
cursor_status: CursorImageStatus::default_named(), cursor_status: CursorImageStatus::default_named(),
cursor_theme, cursor_theme,
named_cursors, 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) { pub fn toggle_focused_floating(&mut self) {
let Some(window) = self.focused_window else { let Some(window) = self.focused_window else {
return; return;
+125
View File
@@ -183,6 +183,45 @@ impl WorkspaceLayout {
} }
} }
pub fn extract_window(&mut self, window: WindowId) -> Result<bool, LayoutError> {
match self {
Self::Scrolling(layout) => {
let grouped = layout
.columns()
.iter()
.find(|column| column.windows.contains(&window))
.is_some_and(|column| column.windows.len() > 1);
layout.extract_to_column(window)?;
Ok(grouped)
}
Self::Tree(_) => Ok(false),
}
}
pub fn cycle_column_width(
&mut self,
window: WindowId,
presets: &[ColumnWidth],
) -> Result<bool, LayoutError> {
match self {
Self::Scrolling(layout) => layout.cycle_column_width(window, presets),
Self::Tree(_) => Ok(false),
}
}
pub fn center_window(
&mut self,
window: WindowId,
bounds: Rect,
gaps: GapConfig,
constraints: &HashMap<WindowId, SizeConstraints>,
) -> Result<bool, LayoutError> {
match self {
Self::Scrolling(layout) => layout.center_window(window, bounds, gaps, constraints),
Self::Tree(_) => Ok(false),
}
}
pub fn validate(&self) -> Result<(), LayoutError> { pub fn validate(&self) -> Result<(), LayoutError> {
match self { match self {
Self::Scrolling(layout) => layout.validate(), Self::Scrolling(layout) => layout.validate(),
@@ -472,6 +511,64 @@ impl WorkspaceSet {
Ok(()) Ok(())
} }
pub fn extract_window(&mut self, window: WindowId) -> Result<bool, WorkspaceError> {
if self.workspace_for_window(window) != Some(self.active)
|| self.placement(window) != Some(WindowPlacement::Tiled)
{
return Err(WorkspaceError::InvalidState(
"extracted window is not tiled on the active workspace",
));
}
let changed = self.active_mut().layout.extract_window(window)?;
debug_assert!(self.validate().is_ok());
Ok(changed)
}
pub fn cycle_column_width(
&mut self,
window: WindowId,
presets: &[ColumnWidth],
) -> Result<bool, WorkspaceError> {
if self.workspace_for_window(window) != Some(self.active)
|| self.placement(window) != Some(WindowPlacement::Tiled)
{
return Err(WorkspaceError::InvalidState(
"resized window is not tiled on the active workspace",
));
}
let changed = self
.active_mut()
.layout
.cycle_column_width(window, presets)?;
debug_assert!(self.validate().is_ok());
Ok(changed)
}
pub fn center_window(
&mut self,
window: WindowId,
bounds: Rect,
gaps: GapConfig,
constraints: &HashMap<WindowId, SizeConstraints>,
) -> Result<bool, WorkspaceError> {
if self.workspace_for_window(window) != Some(self.active)
|| self.placement(window) != Some(WindowPlacement::Tiled)
{
return Err(WorkspaceError::InvalidState(
"centered window is not tiled on the active workspace",
));
}
let changed = self
.active_mut()
.layout
.center_window(window, bounds, gaps, constraints)?;
debug_assert!(self.validate().is_ok());
Ok(changed)
}
pub fn ensure_numeric(&mut self, index: u32) -> Result<WorkspaceId, WorkspaceError> { pub fn ensure_numeric(&mut self, index: u32) -> Result<WorkspaceId, WorkspaceError> {
if index == 0 { if index == 0 {
return Err(WorkspaceError::InvalidNumericName(index)); return Err(WorkspaceError::InvalidNumericName(index));
@@ -1179,6 +1276,34 @@ mod tests {
assert!(workspaces.validate().is_ok()); assert!(workspaces.validate().is_ok());
} }
#[test]
fn scrolling_column_commands_preserve_workspace_state() {
let mut workspaces = WorkspaceSet::default();
for id in 1..=3 {
workspaces
.insert_window(WindowId(id), Axis::Horizontal, 0.5)
.unwrap();
}
workspaces.stack_window(WindowId(3), WindowId(2)).unwrap();
assert!(workspaces.extract_window(WindowId(3)).unwrap());
assert!(
workspaces
.cycle_column_width(
WindowId(3),
&[ColumnWidth::Proportion(0.5), ColumnWidth::Full],
)
.unwrap()
);
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
let _ = workspaces
.center_window(WindowId(3), bounds, GapConfig::default(), &HashMap::new())
.unwrap();
assert_eq!(workspaces.active().last_focused, Some(WindowId(3)));
assert!(workspaces.validate().is_ok());
}
#[test] #[test]
fn randomized_workspace_sequences_preserve_all_invariants() { fn randomized_workspace_sequences_preserve_all_invariants() {
let mut workspaces = WorkspaceSet::default(); let mut workspaces = WorkspaceSet::default();
+193
View File
@@ -227,6 +227,55 @@ impl ScrollingLayout {
Ok(()) Ok(())
} }
pub fn cycle_column_width(
&mut self,
window: WindowId,
presets: &[ColumnWidth],
) -> Result<bool, LayoutError> {
let (column, _) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
let presets = presets
.iter()
.copied()
.map(normalized_width)
.collect::<Vec<_>>();
if presets.is_empty() {
return Ok(false);
}
let current = normalized_width(self.columns[column].width);
let next = presets
.iter()
.position(|preset| *preset == current)
.map(|index| presets[(index + 1) % presets.len()])
.unwrap_or(presets[0]);
self.columns[column].width = next;
Ok(next != current)
}
pub fn center_window(
&mut self,
window: WindowId,
bounds: Rect,
gaps: GapConfig,
constraints: &HashMap<WindowId, SizeConstraints>,
) -> Result<bool, LayoutError> {
let before = self.viewport_x;
let result = self.geometry_with_constraints(bounds, gaps, constraints, Some(window))?;
let rect = result
.geometry
.get(&window)
.copied()
.ok_or(LayoutError::UnknownWindow(window))?;
let viewport_center = bounds.x + bounds.width / 2.0;
let window_center = rect.x + rect.width / 2.0;
self.viewport_x += window_center - viewport_center;
self.geometry_with_constraints(bounds, gaps, constraints, Some(window))?;
Ok(self.viewport_x != before)
}
pub fn directional_neighbor( pub fn directional_neighbor(
&self, &self,
window: WindowId, window: WindowId,
@@ -472,6 +521,16 @@ impl ScrollingLayout {
} }
pub fn validate(&self) -> Result<(), LayoutError> { pub fn validate(&self) -> Result<(), LayoutError> {
if !self.viewport_x.is_finite() || self.viewport_x < 0.0 {
return Err(LayoutError::InvalidTree(
"scrolling viewport offset is invalid",
));
}
if normalized_width(self.default_width) != self.default_width {
return Err(LayoutError::InvalidTree(
"scrolling default column width is invalid",
));
}
if self.columns.is_empty() { if self.columns.is_empty() {
return if self.active_column.is_none() { return if self.active_column.is_none() {
Ok(()) Ok(())
@@ -492,6 +551,11 @@ impl ScrollingLayout {
let mut windows = HashSet::new(); let mut windows = HashSet::new();
for column in &self.columns { for column in &self.columns {
if normalized_width(column.width) != column.width {
return Err(LayoutError::InvalidTree(
"scrolling column width is invalid",
));
}
if column.windows.is_empty() { if column.windows.is_empty() {
return Err(LayoutError::InvalidTree("scrolling column is empty")); return Err(LayoutError::InvalidTree("scrolling column is empty"));
} }
@@ -742,4 +806,133 @@ mod tests {
); );
assert_eq!(layout.columns()[0].heights, vec![0.6, 0.4]); assert_eq!(layout.columns()[0].heights, vec![0.6, 0.4]);
} }
#[test]
fn width_cycle_wraps_through_configured_presets() {
let mut layout = ScrollingLayout::default();
layout.insert(window(1), None).unwrap();
let presets = [
ColumnWidth::Proportion(0.5),
ColumnWidth::Proportion(2.0 / 3.0),
ColumnWidth::Full,
];
assert!(layout.cycle_column_width(window(1), &presets).unwrap());
assert_eq!(
layout.columns()[0].width,
ColumnWidth::Proportion(2.0 / 3.0)
);
assert!(layout.cycle_column_width(window(1), &presets).unwrap());
assert_eq!(layout.columns()[0].width, ColumnWidth::Full);
assert!(layout.cycle_column_width(window(1), &presets).unwrap());
assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.5));
}
#[test]
fn explicit_center_places_a_middle_column_in_the_viewport_center() {
let mut layout = ScrollingLayout::default();
for id in 1_u64..=4 {
layout
.insert(
window(id),
id.checked_sub(1).filter(|id| *id > 0).map(window),
)
.unwrap();
}
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
let gaps = GapConfig {
inner: 0.0,
outer: 0.0,
smart: false,
};
layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(4)))
.unwrap();
assert!(
layout
.center_window(window(2), bounds, gaps, &HashMap::new())
.unwrap()
);
let result = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2)))
.unwrap();
let rect = result.geometry[&window(2)];
assert_eq!(rect.x + rect.width / 2.0, 500.0);
}
#[test]
fn randomized_scrolling_operations_preserve_invariants() {
let mut layout = ScrollingLayout::default();
let mut windows = Vec::new();
let mut next_window = 1_u64;
let mut random = 0x5c40_11ab_u64;
for _ in 0..2_000 {
random = random
.wrapping_mul(2_862_933_555_777_941_757)
.wrapping_add(3_037_000_493);
match random % 7 {
0 if windows.len() < 48 => {
let new_window = window(next_window);
next_window += 1;
let focused = windows.get(random as usize % windows.len().max(1)).copied();
layout.insert(new_window, focused).unwrap();
windows.push(new_window);
}
1 if !windows.is_empty() => {
let index = random as usize % windows.len();
layout.remove(windows.swap_remove(index)).unwrap();
}
2 if windows.len() > 1 => {
let first = random as usize % windows.len();
let mut second = random.rotate_left(17) as usize % windows.len();
if first == second {
second = (second + 1) % windows.len();
}
layout
.move_into_column(windows[first], windows[second])
.unwrap();
}
3 if !windows.is_empty() => {
let index = random as usize % windows.len();
layout.extract_to_column(windows[index]).unwrap();
}
4 if !windows.is_empty() => {
let index = random as usize % windows.len();
let direction = random_direction(random.rotate_left(9));
layout.move_window(windows[index], direction).unwrap();
}
5 if !windows.is_empty() => {
let index = random as usize % windows.len();
let direction = random_direction(random.rotate_left(23));
layout
.resize_window(windows[index], direction, 0.05)
.unwrap();
}
6 if !windows.is_empty() => {
let index = random as usize % windows.len();
layout.focus(windows[index]).unwrap();
}
_ => {}
}
layout.validate().unwrap();
assert_eq!(
layout.window_ids().collect::<HashSet<_>>().len(),
windows.len()
);
}
}
fn random_direction(random: u64) -> Direction {
match random % 4 {
0 => Direction::Left,
1 => Direction::Right,
2 => Direction::Up,
_ => Direction::Down,
}
}
} }
+5
View File
@@ -1242,10 +1242,15 @@ Super+Enter terminal
Super+Q close window Super+Q close window
Super+H/J/K/L focus left/down/up/right Super+H/J/K/L focus left/down/up/right
Super+Shift+H/J/K/L move left/down/up/right Super+Shift+H/J/K/L move left/down/up/right
Super+Ctrl+H/J/K/L resize left/down/up/right
Super+1..9 switch workspace Super+1..9 switch workspace
Super+Shift+1..9 move window to workspace Super+Shift+1..9 move window to workspace
Super+F toggle fullscreen Super+F toggle fullscreen
Super+M toggle scrolling/tree layout Super+M toggle scrolling/tree layout
Super+R cycle scrolling column width
Super+C center scrolling column
Super+[ consume window into adjacent column
Super+] expel window from column
Super+Shift+Space toggle floating Super+Shift+Space toggle floating
Super+Space launcher Super+Space launcher
Super+Tab overview Super+Tab overview