From 95c810c855a27a28f4dfa7dc6b949fef0901c7b2 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Sun, 13 Aug 2023 12:46:53 +0400 Subject: Refactor everything, add initial tiling code --- Cargo.toml | 2 +- README.md | 65 ++- src/backend.rs | 2 + src/grabs/move_grab.rs | 12 +- src/grabs/resize_grab.rs | 82 ++-- src/handlers/compositor.rs | 103 ++++- src/handlers/xdg_shell.rs | 187 ++++---- src/input.rs | 278 +++++++---- src/layout.rs | 1099 ++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 3 +- src/niri.rs | 247 ++++++++-- src/tty.rs | 313 +++++++------ src/winit.rs | 19 +- 13 files changed, 1974 insertions(+), 438 deletions(-) create mode 100644 src/layout.rs diff --git a/Cargo.toml b/Cargo.toml index d5e599dc..ffcc93ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "niri" version = "0.1.0" -description = "A Wayland compositor" +description = "A scrollable-tiling Wayland compositor" authors = ["Ivan Molodetskikh "] license = "GPL-3.0-or-later" edition = "2021" diff --git a/README.md b/README.md index 668a9398..0111ba62 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,71 @@ # niri -The beginnings of a Wayland compositor. +The beginnings of a scrollable-tiling Wayland compositor. + +## Status + +Heavily work in progress. +The occasional thing works, but likely is in a half-broken state. + +## Idea + +This section describes the goals I'm working towards. +Many things don't work as written yet. + +Niri implements scrollable tiling, heavily inspired by [PaperWM]. +Windows are arranged in columns on an infinite strip going to the right. +Every column takes up as much height as possible, spread between its windows. + +![](https://github.com/YaLTeR/niri/assets/1794388/b734da07-301a-452b-b201-d4789a3eca60) + +With multiple monitors, every monitor has its own separate window strip. +Windows can never "overflow" to an adjacent monitor. + +This is one of the reasons that prompted me to try writing my own compositor. +PaperWM is a solid implementation that I use every day, but, being a GNOME Shell extension, it has to work around Shell's global window coordinate space to prevent windows from overflowing. + +Niri also has dynamic workspaces which work similar to GNOME Shell. +Since windows go left-to-right horizontally, workspaces are arranged vertically. +Every monitor has an independent set of workspaces, and there's always one empty workspace present all the way down. + +Niri tries to preserve the workspace arrangement as much as possible upon disconnecting and connecting monitors. +When a monitor disconnects, its workspaces will move to another monitor, but upon reconnection they will move back to the original monitor where it makes sense. ## Running `cargo run -- -- alacritty` -Inside a desktop session, it will run in a window. On a TTY, it will run natively. +Inside a desktop session, it will run in a window. +On a TTY, it will run natively. + +To exit when running on a TTY, press SuperShiftE. + +## Hotkeys + +When running on a TTY, the Mod key is Super. +When running in a window, the Mod key is Alt. + +The general system is: if a hotkey switches somewhere, then adding Ctrl will move the focused window or column there. + +| Hotkey | Description | +| ------ | ----------- | +| ModT | Spawn `alacritty` | +| ModQ | Close the focused window | +| ModH or Mod | Focus the window to the left | +| ModL or Mod | Focus the window to the right | +| ModJ or Mod | Focus the window below in a column | +| ModK or Mod | Focus the window above in a column | +| ModCtrlH or ModCtrl | Move the focused column to the left | +| ModCtrlL or ModCtrl | Move the focused column to the right | +| ModCtrlJ or ModCtrl | Move the focused window below in a column | +| ModCtrlK or ModCtrl | Move the focused window above in a column | +| ModU | Switch to the workspace below | +| ModI | Switch to the workspace above | +| ModCtrlU | Move the focused window to the workspace below | +| ModCtrlI | Move the focused window to the workspace above | +| Mod, | Consume the window to the right into the focused column | +| Mod. | Expel the focused window into its own column | +| ModShiftE | Exit niri | -To exit when running on a TTY, press Super+Shift+e. +[PaperWM]: https://github.com/paperwm/PaperWM diff --git a/src/backend.rs b/src/backend.rs index 7dcc0121..55ddfaff 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -1,5 +1,6 @@ use smithay::backend::renderer::element::surface::WaylandSurfaceRenderElement; use smithay::backend::renderer::gles::GlesRenderer; +use smithay::output::Output; use crate::niri::OutputRenderElements; use crate::Niri; @@ -10,6 +11,7 @@ pub trait Backend { fn render( &mut self, niri: &mut Niri, + output: &Output, elements: &[OutputRenderElements< GlesRenderer, WaylandSurfaceRenderElement, diff --git a/src/grabs/move_grab.rs b/src/grabs/move_grab.rs index 699921d1..67a1f285 100644 --- a/src/grabs/move_grab.rs +++ b/src/grabs/move_grab.rs @@ -5,6 +5,7 @@ use smithay::input::pointer::{ }; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::utils::{Logical, Point}; +use smithay::wayland::seat::WaylandFocus; use crate::Niri; @@ -25,10 +26,13 @@ impl PointerGrab for MoveSurfaceGrab { // While the grab is active, no client has pointer focus handle.motion(data, None, event); - let delta = event.location - self.start_data.location; - let new_location = self.initial_window_location.to_f64() + delta; - data.space - .map_element(self.window.clone(), new_location.to_i32_round(), true); + // let delta = event.location - self.start_data.location; + // let new_location = self.initial_window_location.to_f64() + delta; + // let (window, space) = data + // .monitor_set + // .find_window_and_space(self.window.wl_surface().as_ref().unwrap()) + // .unwrap(); + // space.map_element(window.clone(), new_location.to_i32_round(), true); } fn relative_motion( diff --git a/src/grabs/resize_grab.rs b/src/grabs/resize_grab.rs index 2bc2f194..819e0b6a 100644 --- a/src/grabs/resize_grab.rs +++ b/src/grabs/resize_grab.rs @@ -1,6 +1,6 @@ use std::cell::RefCell; -use smithay::desktop::{Space, Window}; +use smithay::desktop::Window; use smithay::input::pointer::{ AxisFrame, ButtonEvent, GrabStartData as PointerGrabStartData, MotionEvent, PointerGrab, PointerInnerHandle, RelativeMotionEvent, @@ -241,48 +241,48 @@ impl ResizeSurfaceState { } } -/// Should be called on `WlSurface::commit` -pub fn handle_commit(space: &mut Space, surface: &WlSurface) -> Option<()> { - let window = space - .elements() - .find(|w| w.toplevel().wl_surface() == surface) - .cloned()?; - - let mut window_loc = space.element_location(&window)?; - let geometry = window.geometry(); - - let new_loc: Point, Logical> = ResizeSurfaceState::with(surface, |state| { - state - .commit() - .and_then(|(edges, initial_rect)| { - // If the window is being resized by top or left, its location must be adjusted - // accordingly. - edges.intersects(ResizeEdge::TOP_LEFT).then(|| { - let new_x = edges - .intersects(ResizeEdge::LEFT) - .then_some(initial_rect.loc.x + (initial_rect.size.w - geometry.size.w)); - - let new_y = edges - .intersects(ResizeEdge::TOP) - .then_some(initial_rect.loc.y + (initial_rect.size.h - geometry.size.h)); - - (new_x, new_y).into() - }) - }) - .unwrap_or_default() +pub fn handle_commit(window: &Window) -> Option<()> { + // FIXME + let surface = window.toplevel().wl_surface(); + ResizeSurfaceState::with(surface, |state| { + state.commit(); }); - if let Some(new_x) = new_loc.x { - window_loc.x = new_x; - } - if let Some(new_y) = new_loc.y { - window_loc.y = new_y; - } - - if new_loc.x.is_some() || new_loc.y.is_some() { - // If TOP or LEFT side of the window got resized, we have to move it - space.map_element(window, window_loc, false); - } + // let mut window_loc = space.element_location(&window)?; + // let geometry = window.geometry(); + + // let new_loc: Point, Logical> = ResizeSurfaceState::with(surface, |state| { + // state + // .commit() + // .and_then(|(edges, initial_rect)| { + // // If the window is being resized by top or left, its location must be adjusted + // // accordingly. + // edges.intersects(ResizeEdge::TOP_LEFT).then(|| { + // let new_x = edges + // .intersects(ResizeEdge::LEFT) + // .then_some(initial_rect.loc.x + (initial_rect.size.w - geometry.size.w)); + + // let new_y = edges + // .intersects(ResizeEdge::TOP) + // .then_some(initial_rect.loc.y + (initial_rect.size.h - geometry.size.h)); + + // (new_x, new_y).into() + // }) + // }) + // .unwrap_or_default() + // }); + + // if let Some(new_x) = new_loc.x { + // window_loc.x = new_x; + // } + // if let Some(new_y) = new_loc.y { + // window_loc.y = new_y; + // } + + // if new_loc.x.is_some() || new_loc.y.is_some() { + // // If TOP or LEFT side of the window got resized, we have to move it + // space.map_element(window, window_loc, false); + // } Some(()) } diff --git a/src/handlers/compositor.rs b/src/handlers/compositor.rs index f5a955e1..d9e25b8f 100644 --- a/src/handlers/compositor.rs +++ b/src/handlers/compositor.rs @@ -1,4 +1,7 @@ -use smithay::backend::renderer::utils::on_commit_buffer_handler; +use std::collections::hash_map::Entry; + +use smithay::backend::renderer::utils::{on_commit_buffer_handler, with_renderer_surface_state}; +use smithay::desktop::find_popup_root_surface; use smithay::reexports::wayland_server::protocol::wl_buffer; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::reexports::wayland_server::Client; @@ -9,6 +12,7 @@ use smithay::wayland::compositor::{ use smithay::wayland::shm::{ShmHandler, ShmState}; use smithay::{delegate_compositor, delegate_shm}; +use super::xdg_shell; use crate::grabs::resize_grab; use crate::niri::ClientState; use crate::Niri; @@ -28,24 +32,95 @@ impl CompositorHandler for Niri { .message("client commit", 0); on_commit_buffer_handler::(surface); - if !is_sync_subsurface(surface) { - let mut root = surface.clone(); - while let Some(parent) = get_parent(&root) { - root = parent; + + if is_sync_subsurface(surface) { + return; + } + + let mut root_surface = surface.clone(); + while let Some(parent) = get_parent(&root_surface) { + root_surface = parent; + } + + if surface == &root_surface { + // This is a root surface commit. It might have mapped a previously-unmapped toplevel. + if let Entry::Occupied(entry) = self.unmapped_windows.entry(surface.clone()) { + let is_mapped = + with_renderer_surface_state(surface, |state| state.buffer().is_some()); + + if is_mapped { + // The toplevel got mapped. + let window = entry.remove(); + window.on_commit(); + + let output = self.monitor_set.active_output().unwrap().clone(); + self.monitor_set.add_window_to_output(&output, window, true); + self.update_focus(); + + self.queue_redraw(output); + return; + } + + // The toplevel remains unmapped. + let window = entry.get(); + xdg_shell::send_initial_configure_if_needed(window); + return; } - if let Some(window) = self - .space - .elements() - .find(|w| w.toplevel().wl_surface() == &root) - { + + // This is a commit of a previously-mapped root or a non-toplevel root. + if let Some((window, space)) = self.monitor_set.find_window_and_space(surface) { + // This is a commit of a previously-mapped toplevel. + let output = space.outputs().next().unwrap().clone(); + window.on_commit(); + + // This is a commit of a previously-mapped toplevel. + let is_mapped = + with_renderer_surface_state(surface, |state| state.buffer().is_some()); + + if !is_mapped { + // The toplevel got unmapped. + let window = window.clone(); + self.monitor_set.remove_window(&window); + self.unmapped_windows.insert(surface.clone(), window); + self.update_focus(); + + self.queue_redraw(output); + return; + } + + // The toplevel remains mapped. + resize_grab::handle_commit(&window); + self.monitor_set.update_window(&window); + + self.queue_redraw(output); + return; } - }; - self.xdg_handle_commit(surface); - resize_grab::handle_commit(&mut self.space, surface); + // This is a commit of a non-toplevel root. + } - self.queue_redraw(); + // This is a commit of a non-root or a non-toplevel root. + let root_window_space = self.monitor_set.find_window_and_space(&root_surface); + if let Some((window, space)) = root_window_space { + let output = space.outputs().next().unwrap().clone(); + window.on_commit(); + self.monitor_set.update_window(&window); + self.queue_redraw(output); + return; + } + + // This might be a popup. + self.popups_handle_commit(surface); + if let Some(popup) = self.popups.find_popup(surface) { + if let Ok(root) = find_popup_root_surface(&popup) { + let root_window_space = self.monitor_set.find_window_and_space(&root); + if let Some((_window, space)) = root_window_space { + let output = space.outputs().next().unwrap().clone(); + self.queue_redraw(output); + } + } + } } } diff --git a/src/handlers/xdg_shell.rs b/src/handlers/xdg_shell.rs index 0e1ad760..c7df18c7 100644 --- a/src/handlers/xdg_shell.rs +++ b/src/handlers/xdg_shell.rs @@ -2,20 +2,19 @@ use smithay::delegate_xdg_shell; use smithay::desktop::{PopupKind, Window}; use smithay::input::pointer::{Focus, GrabStartData as PointerGrabStartData}; use smithay::input::Seat; -use smithay::output::Output; use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::reexports::wayland_server::protocol::{wl_output, wl_seat}; use smithay::reexports::wayland_server::Resource; use smithay::utils::{Rectangle, Serial}; use smithay::wayland::compositor::with_states; -use smithay::wayland::seat::WaylandFocus; use smithay::wayland::shell::xdg::{ PopupSurface, PositionerState, ToplevelSurface, XdgPopupSurfaceData, XdgShellHandler, XdgShellState, XdgToplevelSurfaceData, }; use crate::grabs::{MoveSurfaceGrab, ResizeSurfaceGrab}; +use crate::layout::MonitorSet; use crate::Niri; impl XdgShellHandler for Niri { @@ -24,8 +23,16 @@ impl XdgShellHandler for Niri { } fn new_toplevel(&mut self, surface: ToplevelSurface) { + let wl_surface = surface.wl_surface().clone(); let window = Window::new(surface); - self.space.map_element(window, (0, 0), false); + + // Tell the surface the preferred size and bounds for its likely output. + let output = self.monitor_set.active_output().unwrap(); + MonitorSet::configure_new_window(output, &window); + + // At the moment of creation, xdg toplevels must have no buffer. + let existing = self.unmapped_windows.insert(wl_surface, window); + assert!(existing.is_none()); } fn new_popup(&mut self, surface: PopupSurface, positioner: PositionerState) { @@ -49,17 +56,12 @@ impl XdgShellHandler for Niri { if let Some(start_data) = check_grab(&seat, wl_surface, serial) { let pointer = seat.get_pointer().unwrap(); - let window = self - .space - .elements() - .find(|w| w.toplevel().wl_surface() == wl_surface) - .unwrap() - .clone(); - let initial_window_location = self.space.element_location(&window).unwrap(); + let (window, space) = self.monitor_set.find_window_and_space(wl_surface).unwrap(); + let initial_window_location = space.element_location(&window).unwrap(); let grab = MoveSurfaceGrab { start_data, - window, + window: window.clone(), initial_window_location, }; @@ -81,13 +83,8 @@ impl XdgShellHandler for Niri { if let Some(start_data) = check_grab(&seat, wl_surface, serial) { let pointer = seat.get_pointer().unwrap(); - let window = self - .space - .elements() - .find(|w| w.toplevel().wl_surface() == wl_surface) - .unwrap() - .clone(); - let initial_window_location = self.space.element_location(&window).unwrap(); + let (window, space) = self.monitor_set.find_window_and_space(wl_surface).unwrap(); + let initial_window_location = space.element_location(&window).unwrap(); let initial_window_size = window.geometry().size; surface.with_pending_state(|state| { @@ -98,7 +95,7 @@ impl XdgShellHandler for Niri { let grab = ResizeSurfaceGrab::start( start_data, - window, + window.clone(), edges.into(), Rectangle::from_loc_and_size(initial_window_location, initial_window_size), ); @@ -126,7 +123,7 @@ impl XdgShellHandler for Niri { } fn grab(&mut self, _surface: PopupSurface, _seat: wl_seat::WlSeat, _serial: Serial) { - // TODO popup grabs + // FIXME popup grabs } fn maximize_request(&mut self, surface: ToplevelSurface) { @@ -135,23 +132,17 @@ impl XdgShellHandler for Niri { .capabilities .contains(xdg_toplevel::WmCapabilities::Maximize) { - let wl_surface = surface.wl_surface(); - let window = self - .space - .elements() - .find(|w| w.toplevel().wl_surface() == wl_surface) - .unwrap() - .clone(); - let geometry = self - .space - .output_geometry(self.output.as_ref().unwrap()) - .unwrap(); - - surface.with_pending_state(|state| { - state.states.set(xdg_toplevel::State::Maximized); - state.size = Some(geometry.size); - }); - self.space.map_element(window, geometry.loc, true); + // let wl_surface = surface.wl_surface(); + // let (window, space) = self.monitor_set.find_window_and_space(wl_surface).unwrap(); + // let geometry = space + // .output_geometry(space.outputs().next().unwrap()) + // .unwrap(); + + // surface.with_pending_state(|state| { + // state.states.set(xdg_toplevel::State::Maximized); + // state.size = Some(geometry.size); + // }); + // space.map_element(window.clone(), geometry.loc, true); } // The protocol demands us to always reply with a configure, @@ -185,44 +176,32 @@ impl XdgShellHandler for Niri { .capabilities .contains(xdg_toplevel::WmCapabilities::Fullscreen) { - // NOTE: This is only one part of the solution. We can set the - // location and configure size here, but the surface should be rendered fullscreen - // independently from its buffer size - let wl_surface = surface.wl_surface(); - - let output = wl_output - .as_ref() - .and_then(Output::from_resource) - .or_else(|| { - let w = self - .space - .elements() - .find(|window| { - window - .wl_surface() - .map(|s| s == *wl_surface) - .unwrap_or(false) - }) - .cloned(); - w.and_then(|w| self.space.outputs_for_element(&w).get(0).cloned()) - }); - - if let Some(output) = output { - let geometry = self.space.output_geometry(&output).unwrap(); - - surface.with_pending_state(|state| { - state.states.set(xdg_toplevel::State::Fullscreen); - state.size = Some(geometry.size); - }); - - let window = self - .space - .elements() - .find(|w| w.toplevel().wl_surface() == wl_surface) - .unwrap() - .clone(); - self.space.map_element(window, geometry.loc, true); - } + // // NOTE: This is only one part of the solution. We can set the + // // location and configure size here, but the surface should be rendered fullscreen + // // independently from its buffer size + // let wl_surface = surface.wl_surface(); + + // let output = wl_output + // .as_ref() + // .and_then(Output::from_resource) + // .or_else(|| { + // self.monitor_set + // .find_window_and_space(wl_surface) + // .and_then(|(_window, space)| space.outputs().next().cloned()) + // }); + + // if let Some(output) = output { + // let (window, space) = + // self.monitor_set.find_window_and_space(wl_surface).unwrap(); + // let geometry = space.output_geometry(&output).unwrap(); + + // surface.with_pending_state(|state| { + // state.states.set(xdg_toplevel::State::Fullscreen); + // state.size = Some(geometry.size); + // }); + + // space.map_element(window.clone(), geometry.loc, true); + // } } // The protocol demands us to always reply with a configure, @@ -247,12 +226,25 @@ impl XdgShellHandler for Niri { surface.send_pending_configure(); } - fn toplevel_destroyed(&mut self, _surface: ToplevelSurface) { - self.queue_redraw(); + fn toplevel_destroyed(&mut self, surface: ToplevelSurface) { + if self.unmapped_windows.remove(surface.wl_surface()).is_some() { + // An unmapped toplevel got destroyed. + return; + } + + let (window, space) = self + .monitor_set + .find_window_and_space(surface.wl_surface()) + .unwrap(); + let output = space.outputs().next().unwrap().clone(); + self.monitor_set.remove_window(&window); + self.update_focus(); + self.queue_redraw(output); } fn popup_destroyed(&mut self, _surface: PopupSurface) { - self.queue_redraw(); + // FIXME granular + self.queue_redraw_all(); } } @@ -282,32 +274,27 @@ fn check_grab( Some(start_data) } +pub fn send_initial_configure_if_needed(window: &Window) { + let initial_configure_sent = with_states(window.toplevel().wl_surface(), |states| { + states + .data_map + .get::() + .unwrap() + .lock() + .unwrap() + .initial_configure_sent + }); + + if !initial_configure_sent { + window.toplevel().send_configure(); + } +} + impl Niri { /// Should be called on `WlSurface::commit` - pub fn xdg_handle_commit(&mut self, surface: &WlSurface) { + pub fn popups_handle_commit(&mut self, surface: &WlSurface) { self.popups.commit(surface); - if let Some(window) = self - .space - .elements() - .find(|w| w.toplevel().wl_surface() == surface) - .cloned() - { - let initial_configure_sent = with_states(surface, |states| { - states - .data_map - .get::() - .unwrap() - .lock() - .unwrap() - .initial_configure_sent - }); - - if !initial_configure_sent { - window.toplevel().send_configure(); - } - } - if let Some(popup) = self.popups.find_popup(surface) { let PopupKind::Xdg(ref popup) = popup; let initial_configure_sent = with_states(surface, |states| { diff --git a/src/input.rs b/src/input.rs index 1b7c65ee..0c7c3e62 100644 --- a/src/input.rs +++ b/src/input.rs @@ -5,21 +5,35 @@ use smithay::backend::input::{ AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, InputBackend, InputEvent, KeyState, KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, }; -use smithay::input::keyboard::{keysyms, FilterResult}; +use smithay::input::keyboard::{keysyms, FilterResult, KeysymHandle, ModifiersState}; use smithay::input::pointer::{AxisFrame, ButtonEvent, MotionEvent, RelativeMotionEvent}; use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel; -use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::utils::SERIAL_COUNTER; use smithay::wayland::shell::xdg::XdgShellHandler; use crate::niri::Niri; -enum InputAction { +enum Action { + None, Quit, ChangeVt(i32), SpawnTerminal, CloseWindow, ToggleFullscreen, + FocusLeft, + FocusRight, + FocusDown, + FocusUp, + MoveLeft, + MoveRight, + MoveDown, + MoveUp, + ConsumeIntoColumn, + ExpelFromColumn, + SwitchWorkspaceDown, + SwitchWorkspaceUp, + MoveToWorkspaceDown, + MoveToWorkspaceUp, } pub enum CompositorMod { @@ -27,11 +41,64 @@ pub enum CompositorMod { Alt, } +impl From for FilterResult { + fn from(value: Action) -> Self { + match value { + Action::None => FilterResult::Forward, + action => FilterResult::Intercept(action), + } + } +} + +fn action(comp_mod: CompositorMod, keysym: KeysymHandle, mods: ModifiersState) -> Action { + use keysyms::*; + + let modified = keysym.modified_sym(); + if matches!(modified, KEY_XF86Switch_VT_1..=KEY_XF86Switch_VT_12) { + let vt = (modified - KEY_XF86Switch_VT_1 + 1) as i32; + return Action::ChangeVt(vt); + } + + let mod_down = match comp_mod { + CompositorMod::Super => mods.logo, + CompositorMod::Alt => mods.alt, + }; + + if !mod_down { + return Action::None; + } + + // FIXME: these don't work in the Russian layout. I guess I'll need to + // find a US keymap, then map keys somehow. + #[allow(non_upper_case_globals)] // wat + match modified { + KEY_E => Action::Quit, + KEY_t => Action::SpawnTerminal, + KEY_q => Action::CloseWindow, + KEY_f => Action::ToggleFullscreen, + KEY_h | KEY_Left if mods.ctrl => Action::MoveLeft, + KEY_l | KEY_Right if mods.ctrl => Action::MoveRight, + KEY_j | KEY_Down if mods.ctrl => Action::MoveDown, + KEY_k | KEY_Up if mods.ctrl => Action::MoveUp, + KEY_h | KEY_Left => Action::FocusLeft, + KEY_l | KEY_Right => Action::FocusRight, + KEY_j | KEY_Down => Action::FocusDown, + KEY_k | KEY_Up => Action::FocusUp, + KEY_u if mods.ctrl => Action::MoveToWorkspaceDown, + KEY_i if mods.ctrl => Action::MoveToWorkspaceUp, + KEY_u => Action::SwitchWorkspaceDown, + KEY_i => Action::SwitchWorkspaceUp, + KEY_comma => Action::ConsumeIntoColumn, + KEY_period => Action::ExpelFromColumn, + _ => Action::None, + } +} + impl Niri { pub fn process_input_event( &mut self, change_vt: &mut dyn FnMut(i32), - compositor_mod: CompositorMod, + comp_mod: CompositorMod, event: InputEvent, ) { let _span = tracy_client::span!("process_input_event"); @@ -50,33 +117,7 @@ impl Niri { time, |_, mods, keysym| { if event.state() == KeyState::Pressed { - let mod_down = match compositor_mod { - CompositorMod::Super => mods.logo, - CompositorMod::Alt => mods.alt, - }; - - // FIXME: these don't work in the Russian layout. I guess I'll need to - // find a US keymap, then map keys somehow. - match keysym.modified_sym() { - keysyms::KEY_E if mod_down => { - FilterResult::Intercept(InputAction::Quit) - } - keysym @ keysyms::KEY_XF86Switch_VT_1 - ..=keysyms::KEY_XF86Switch_VT_12 => { - let vt = (keysym - keysyms::KEY_XF86Switch_VT_1 + 1) as i32; - FilterResult::Intercept(InputAction::ChangeVt(vt)) - } - keysyms::KEY_t if mod_down => { - FilterResult::Intercept(InputAction::SpawnTerminal) - } - keysyms::KEY_q if mod_down => { - FilterResult::Intercept(InputAction::CloseWindow) - } - keysyms::KEY_f if mod_down => { - FilterResult::Intercept(InputAction::ToggleFullscreen) - } - _ => FilterResult::Forward, - } + action(comp_mod, keysym, *mods).into() } else { FilterResult::Forward } @@ -85,22 +126,27 @@ impl Niri { if let Some(action) = action { match action { - InputAction::Quit => { + Action::None => unreachable!(), + Action::Quit => { info!("quitting because quit bind was pressed"); self.stop_signal.stop() } - InputAction::ChangeVt(vt) => { + Action::ChangeVt(vt) => { (*change_vt)(vt); } - InputAction::SpawnTerminal => { + Action::SpawnTerminal => { if let Err(err) = Command::new("alacritty").spawn() { warn!("error spawning alacritty: {err}"); } } - InputAction::CloseWindow => { + Action::CloseWindow => { if let Some(focus) = self.seat.get_keyboard().unwrap().current_focus() { // FIXME: is there a better way of doing this? - for window in self.space.elements() { + for window in self + .monitor_set + .workspaces() + .flat_map(|workspace| workspace.space.elements()) + { let found = Cell::new(false); window.with_surfaces(|surface, _| { if surface == &focus { @@ -114,18 +160,22 @@ impl Niri { } } } - InputAction::ToggleFullscreen => { + Action::ToggleFullscreen => { if let Some(focus) = self.seat.get_keyboard().unwrap().current_focus() { // FIXME: is there a better way of doing this? - let window = self.space.elements().find(|window| { - let found = Cell::new(false); - window.with_surfaces(|surface, _| { - if surface == &focus { - found.set(true); - } + let window = self + .monitor_set + .workspaces() + .flat_map(|workspace| workspace.space.elements()) + .find(|window| { + let found = Cell::new(false); + window.with_surfaces(|surface, _| { + if surface == &focus { + found.set(true); + } + }); + found.get() }); - found.get() - }); if let Some(window) = window { let toplevel = window.toplevel().clone(); if toplevel @@ -140,6 +190,78 @@ impl Niri { } } } + Action::MoveLeft => { + self.monitor_set.move_left(); + // FIXME: granular + self.queue_redraw_all(); + } + Action::MoveRight => { + self.monitor_set.move_right(); + // FIXME: granular + self.queue_redraw_all(); + } + Action::MoveDown => { + self.monitor_set.move_down(); + // FIXME: granular + self.queue_redraw_all(); + } + Action::MoveUp => { + self.monitor_set.move_up(); + // FIXME: granular + self.queue_redraw_all(); + } + Action::FocusLeft => { + self.monitor_set.focus_left(); + self.update_focus(); + } + Action::FocusRight => { + self.monitor_set.focus_right(); + self.update_focus(); + } + Action::FocusDown => { + self.monitor_set.focus_down(); + self.update_focus(); + } + Action::FocusUp => { + self.monitor_set.focus_up(); + self.update_focus(); + } + Action::MoveToWorkspaceDown => { + self.monitor_set.move_to_workspace_down(); + self.update_focus(); + // FIXME: granular + self.queue_redraw_all(); + } + Action::MoveToWorkspaceUp => { + self.monitor_set.move_to_workspace_up(); + self.update_focus(); + // FIXME: granular + self.queue_redraw_all(); + } + Action::SwitchWorkspaceDown => { + self.monitor_set.switch_workspace_down(); + self.update_focus(); + // FIXME: granular + self.queue_redraw_all(); + } + Action::SwitchWorkspaceUp => { + self.monitor_set.switch_workspace_up(); + self.update_focus(); + // FIXME: granular + self.queue_redraw_all(); + } + Action::ConsumeIntoColumn => { + self.monitor_set.consume_into_column(); + self.update_focus(); + // FIXME: granular + self.queue_redraw_all(); + } + Action::ExpelFromColumn => { + self.monitor_set.expel_from_column(); + self.update_focus(); + // FIXME: granular + self.queue_redraw_all(); + } } } } @@ -147,22 +269,33 @@ impl Niri { let serial = SERIAL_COUNTER.next_serial(); let pointer = self.seat.get_pointer().unwrap(); - let mut pointer_location = pointer.current_location(); + let mut pos = pointer.current_location(); + + pos += event.delta(); - pointer_location += event.delta(); + let mut min_x = i32::MAX; + let mut min_y = i32::MAX; + let mut max_x = 0; + let mut max_y = 0; + for output in self.global_space.outputs() { + // FIXME: smarter clamping. + let geom = self.global_space.output_geometry(output).unwrap(); + min_x = min_x.min(geom.loc.x); + min_y = min_y.min(geom.loc.y); + max_x = max_x.max(geom.loc.x + geom.size.w); + max_y = max_y.max(geom.loc.y + geom.size.h); + } - let output = self.space.outputs().next().unwrap(); - let output_geo = self.space.output_geometry(output).unwrap(); + pos.x = pos.x.clamp(min_x as f64, max_x as f64); + pos.y = pos.y.clamp(min_y as f64, max_y as f64); - pointer_location.x = pointer_location.x.clamp(0., output_geo.size.w as f64); - pointer_location.y = pointer_location.y.clamp(0., output_geo.size.h as f64); + let under = self.surface_under_and_global_space(pos); - let under = self.surface_under(pointer_location); pointer.motion( self, under.clone(), &MotionEvent { - location: pointer_location, + location: pos, serial, time: event.time_msec(), }, @@ -179,12 +312,14 @@ impl Niri { ); // Redraw to update the cursor position. - self.queue_redraw(); + // FIXME: redraw only outputs overlapping the cursor. + self.queue_redraw_all(); } InputEvent::PointerMotionAbsolute { event, .. } => { - let output = self.space.outputs().next().unwrap(); + // FIXME: allow mapping tablet to different outputs. + let output = self.global_space.outputs().next().unwrap(); - let output_geo = self.space.output_geometry(output).unwrap(); + let output_geo = self.global_space.output_geometry(output).unwrap(); let pos = event.position_transformed(output_geo.size) + output_geo.loc.to_f64(); @@ -192,7 +327,7 @@ impl Niri { let pointer = self.seat.get_pointer().unwrap(); - let under = self.surface_under(pos); + let under = self.surface_under_and_global_space(pos); pointer.motion( self, @@ -205,11 +340,11 @@ impl Niri { ); // Redraw to update the cursor position. - self.queue_redraw(); + // FIXME: redraw only outputs overlapping the cursor. + self.queue_redraw_all(); } InputEvent::PointerButton { event, .. } => { let pointer = self.seat.get_pointer().unwrap(); - let keyboard = self.seat.get_keyboard().unwrap(); let serial = SERIAL_COUNTER.next_serial(); @@ -218,27 +353,16 @@ impl Niri { let button_state = event.state(); if ButtonState::Pressed == button_state && !pointer.is_grabbed() { - if let Some((window, _loc)) = self - .space - .element_under(pointer.current_location()) - .map(|(w, l)| (w.clone(), l)) + if let Some((_space, window, _loc)) = + self.window_under(pointer.current_location()) { - self.space.raise_element(&window, true); - keyboard.set_focus( - self, - Some(window.toplevel().wl_surface().clone()), - serial, - ); - self.space.elements().for_each(|window| { - window.toplevel().send_pending_configure(); - }); + self.monitor_set.activate_window(&window); } else { - self.space.elements().for_each(|window| { - window.set_activated(false); - window.toplevel().send_pending_configure(); - }); - keyboard.set_focus(self, Option::::None, serial); + let output = self.output_under_cursor().unwrap(); + self.monitor_set.activate_output(&output); } + + self.update_focus(); }; pointer.button( diff --git a/src/layout.rs b/src/layout.rs new file mode 100644 index 00000000..62a0423d --- /dev/null +++ b/src/layout.rs @@ -0,0 +1,1099 @@ +//! Window layout logic. +//! +//! Niri implements scrollable tiling with workspaces. There's one primary output, and potentially +//! multiple other outputs. +//! +//! Our layout has the following invariants: +//! +//! 1. Disconnecting and reconnecting the same output must not change the layout. +//! * This includes both secondary outputs and the primary output. +//! 2. Connecting an output must not change the layout for any workspaces that were never on that +//! output. +//! +//! Therefore, we implement the following logic: every workspace keeps track of which output it +//! originated on. When an output disconnects, its workspace (or workspaces, in case of the primary +//! output disconnecting) are appended to the (potentially new) primary output, but remember their +//! original output. Then, if the original output connects again, all workspaces originally from +//! there move back to that output. +//! +//! In order to avoid surprising behavior, if the user creates or moves any new windows onto a +//! workspace, it forgets its original output, and its current output becomes its original output. +//! Imagine a scenario: the user works with a laptop and a monitor at home, then takes their laptop +//! with them, disconnecting the monitor, and keeps working as normal, using the second monitor's +//! workspace just like any other. Then they come back, reconnect the second monitor, and now we +//! don't want an unassuming workspace to end up on it. +//! +//! ## Workspaces-only-on-primary considerations +//! +//! If this logic results in more than one workspace present on a secondary output, then as a +//! compromise we only keep the first workspace there, and move the rest to the primary output, +//! making the primary output their original output. + +use std::cmp::{max, min}; +use std::mem; + +use smithay::desktop::{Space, Window}; +use smithay::output::Output; +use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; +use smithay::utils::{Logical, Size}; + +const PADDING: i32 = 16; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OutputId(String); + +#[derive(Debug)] +pub enum MonitorSet { + /// At least one output is connected. + Normal { + monitors: Vec, + /// Index of the primary monitor. + primary_idx: usize, + /// Index of the active monitor. + active_monitor_idx: usize, + }, + /// No outputs are connected, and these are the workspaces. + // FIXME: preserve active output id? + NoOutputs(Vec), +} + +#[derive(Debug)] +pub struct Monitor { + output: Output, + // Must always contain at least one. + workspaces: Vec, + /// Index of the currently active workspace. + active_workspace_idx: usize, +} + +#[derive(Debug)] +pub struct Workspace { + /// The original output of this workspace. + /// + /// Most of the time this will be the workspace's current output, however, after an output + /// disconnection, it may remain pointing to the disconnected output. + original_output: OutputId, + + layout: Layout, + + // The actual Space with windows in this workspace. Should be synchronized to the layout except + // for a brief period during surface commit handling. + pub space: Space, +} + +#[derive(Debug)] +pub struct Layout { + columns: Vec, + /// Index of the currently active column, if any. + active_column_idx: usize, +} + +#[derive(Debug)] +pub struct Column { + // Must be non-empty. + windows: Vec, + /// Index of the currently active window. + active_window_idx: usize, +} + +impl OutputId { + pub fn new(output: &Output) -> Self { + Self(output.name()) + } +} + +impl MonitorSet { + pub fn new() -> Self { + Self::NoOutputs(vec![]) + } + + pub fn add_output(&mut self, output: Output) { + let id = OutputId::new(&output); + + *self = match mem::take(self) { + MonitorSet::Normal { + mut monitors, + primary_idx, + active_monitor_idx, + } => { + let primary = &mut monitors[primary_idx]; + + let mut workspaces = vec![]; + for i in (0..primary.workspaces.len()).rev() { + if primary.workspaces[i].original_output == id { + let mut ws = primary.workspaces.remove(i); + ws.space.unmap_output(&primary.output); + workspaces.push(ws); + } + } + workspaces.reverse(); + if workspaces + .iter() + .all(|ws| ws.space.elements().next().is_some()) + { + // Make sure there's always an empty workspace. + workspaces.push(Workspace { + original_output: id, + layout: Layout::new(), + space: Space::default(), + }); + } + + for ws in &mut workspaces { + ws.space.map_output(&output, (0, 0)); + } + + monitors.push(Monitor { + output, + workspaces, + active_workspace_idx: 0, + }); + MonitorSet::Normal { + monitors, + primary_idx, + active_monitor_idx, + } + } + MonitorSet::NoOutputs(mut workspaces) => { + if workspaces.iter().all(|ws| ws.original_output != id) { + workspaces.insert( + 0, + Workspace { + original_output: id.clone(), + layout: Layout::new(), + space: Space::default(), + }, + ); + } + + for workspace in &mut workspaces { + workspace.space.map_output(&output, (0, 0)); + } + + let monitor = Monitor { + output, + workspaces, + active_workspace_idx: 0, + }; + MonitorSet::Normal { + monitors: vec![monitor], + primary_idx: 0, + active_monitor_idx: 0, + } + } + } + } + + pub fn remove_output(&mut self, output: &Output) { + *self = match mem::take(self) { + MonitorSet::Normal { + mut monitors, + mut primary_idx, + mut active_monitor_idx, + } => { + let idx = monitors + .iter() + .position(|mon| &mon.output == output) + .expect("trying to remove non-existing output"); + let monitor = monitors.remove(idx); + let mut workspaces = monitor.workspaces; + + for ws in &mut workspaces { + ws.space.unmap_output(output); + } + + // Get rid of empty workspaces. + workspaces.retain(|ws| ws.space.elements().next().is_some()); + + if monitors.is_empty() { + // Removed the last monitor. + MonitorSet::NoOutputs(workspaces) + } else { + if primary_idx >= idx { + // Update primary_idx to either still point at the same monitor, or at some + // other monitor if the primary has been removed. + primary_idx = primary_idx.saturating_sub(1); + } + if active_monitor_idx >= idx { + // Update active_monitor_idx to either still point at the same monitor, or + // at some other monitor if the active monitor has + // been removed. + active_monitor_idx = active_monitor_idx.saturating_sub(1); + } + + let primary = &mut monitors[primary_idx]; + for ws in &mut workspaces { + ws.space.map_output(&primary.output, (0, 0)); + } + primary.workspaces.extend(workspaces); + + MonitorSet::Normal { + monitors, + primary_idx, + active_monitor_idx, + } + } + } + MonitorSet::NoOutputs(_) => { + panic!("tried to remove output when there were already none") + } + } + } + + pub fn configure_new_window(output: &Output, window: &Window) { + let output_size = output_size(output); + let size = Size::from(( + (output_size.w - PADDING * 3) / 2, + output_size.h - PADDING * 2, + )); + let bounds = Size::from((output_size.w - PADDING * 2, output_size.h - PADDING * 2)); + + window.toplevel().with_pending_state(|state| { + state.size = Some(size); + state.bounds = Some(bounds); + }); + } + + pub fn add_window( + &mut self, + monitor_idx: usize, + workspace_idx: usize, + window: Window, + activate: bool, + ) { + let MonitorSet::Normal { + monitors, + active_monitor_idx, + .. + } = self + else { + panic!() + }; + + let monitor = &mut monitors[monitor_idx]; + let workspace = &mut monitor.workspaces[workspace_idx]; + workspace.layout.add_window(window.clone(), activate); + workspace.space.map_element(window.clone(), (0, 0), false); + workspace.layout.sync_space(&mut workspace.space); + + MonitorSet::configure_new_window(&monitor.output, &window); + window.toplevel().send_pending_configure(); + + if activate { + *active_monitor_idx = monitor_idx; + monitor.active_workspace_idx = workspace_idx; + } + + if workspace_idx == monitor.workspaces.len() - 1 { + // Insert a new empty workspace. + let mut ws = Workspace { + original_output: OutputId::new(&monitor.output), + layout: Layout::new(), + space: Space::default(), + }; + ws.space.map_output(&monitor.output, (0, 0)); + monitor.workspaces.push(ws); + } + } + + pub fn add_window_to_output(&mut self, output: &Output, window: Window, activate: bool) { + let MonitorSet::Normal { monitors, .. } = self else { + panic!() + }; + + let (monitor_idx, monitor) = monitors + .iter() + .enumerate() + .find(|(_, mon)| &mon.output == output) + .unwrap(); + let workspace_idx = monitor.active_workspace_idx; + + self.add_window(monitor_idx, workspace_idx, window, activate) + } + + pub fn remove_window(&mut self, window: &Window) { + let MonitorSet::Normal { monitors, .. } = self else { + panic!() + }; + + let (output, workspace) = monitors + .iter_mut() + .flat_map(|mon| mon.workspaces.iter_mut().map(|ws| (&mon.output, ws))) + .find(|(_, ws)| ws.space.elements().any(|win| win == window)) + .unwrap(); + + workspace + .layout + .remove_window(window, output_size(output).h); + workspace.space.unmap_elem(window); + workspace.layout.sync_space(&mut workspace.space); + + // FIXME: remove empty unfocused workspaces. + } + + pub fn update_window(&mut self, window: &Window) { + let workspace = self + .workspaces() + .find(|ws| ws.space.elements().any(|w| w == window)) + .unwrap(); + workspace.layout.sync_space(&mut workspace.space); + } + + pub fn activate_window(&mut self, window: &Window) { + let MonitorSet::Normal { + monitors, + active_monitor_idx, + .. + } = self + else { + todo!() + }; + + for (monitor_idx, mon) in monitors.iter_mut().enumerate() { + for (workspace_idx, ws) in mon.workspaces.iter_mut().enumerate() { + if ws.space.elements().any(|win| win == window) { + *active_monitor_idx = monitor_idx; + mon.active_workspace_idx = workspace_idx; + + let changed = ws.layout.activate_window(window); + if changed { + ws.layout.sync_space(&mut ws.space); + } + + break; + } + } + } + } + + pub fn activate_output(&mut self, output: &Output) { + let MonitorSet::Normal { + monitors, + active_monitor_idx, + .. + } = self + else { + return; + }; + + let idx = monitors + .iter() + .position(|mon| &mon.output == output) + .unwrap(); + *active_monitor_idx = idx; + } + + pub fn active_output(&self) -> Option<&Output> { + let MonitorSet::Normal { + monitors, + active_monitor_idx, + .. + } = self + else { + return None; + }; + + Some(&monitors[*active_monitor_idx].output) + } + + fn active_workspace(&mut self) -> Option<&mut Workspace> { + let monitor = self.active_monitor()?; + Some(&mut monitor.workspaces[monitor.active_workspace_idx]) + } + + fn active_monitor(&mut self) -> Option<&mut Monitor> { + let MonitorSet::Normal { + monitors, + active_monitor_idx, + .. + } = self + else { + return None; + }; + + Some(&mut monitors[*active_monitor_idx]) + } + + pub fn move_left(&mut self) { + let Some(workspace) = self.active_workspace() else { + return; + }; + let changed = workspace.layout.move_left(); + if changed { + workspace.layout.sync_space(&mut workspace.space); + } + } + + pub fn move_right(&mut self) { + let Some(workspace) = self.active_workspace() else { + return; + }; + let changed = workspace.layout.move_right(); + if changed { + workspace.layout.sync_space(&mut workspace.space); + } + } + + pub fn move_down(&mut self) { + let Some(workspace) = self.active_workspace() else { + return; + }; + let changed = workspace.layout.move_down(); + if changed { + workspace.layout.sync_space(&mut workspace.space); + } + } + + pub fn move_up(&mut self) { + let Some(workspace) = self.active_workspace() else { + return; + }; + let changed = workspace.layout.move_up(); + if changed { + workspace.layout.sync_space(&mut workspace.space); + } + } + + pub fn focus_left(&mut self) { + let Some(workspace) = self.active_workspace() else { + return; + }; + workspace.layout.focus_left(); + } + + pub fn focus_right(&mut self) { + let Some(workspace) = self.active_workspace() else { + return; + }; + workspace.layout.focus_right(); + } + + pub fn focus_down(&mut self) { + let Some(workspace) = self.active_workspace() else { + return; + }; + workspace.layout.focus_down(); + } + + pub fn focus_up(&mut self) { + let Some(workspace) = self.active_workspace() else { + return; + }; + workspace.layout.focus_up(); + } + + pub fn move_to_workspace_up(&mut self) { + let MonitorSet::Normal { + monitors, + ref active_monitor_idx, + .. + } = self + else { + return; + }; + + let monitor = &mut monitors[*active_monitor_idx]; + + let new_idx = monitor.active_workspace_idx.saturating_sub(1); + if new_idx == monitor.active_workspace_idx { + return; + } + + let workspace = &mut monitor.workspaces[monitor.active_workspace_idx]; + if workspace.layout.columns.is_empty() { + return; + } + + let column = &mut workspace.layout.columns[workspace.layout.active_column_idx]; + let window = column.windows[column.active_window_idx].clone(); + workspace + .layout + .remove_window(&window, output_size(&monitor.output).h); + workspace.space.unmap_elem(&window); + workspace.layout.sync_space(&mut workspace.space); + + self.add_window(*active_monitor_idx, new_idx, window, true); + + // FIXME: remove empty unfocused workspaces. + } + + pub fn move_to_workspace_down(&mut self) { + let MonitorSet::Normal { + monitors, + ref active_monitor_idx, + .. + } = self + else { + return; + }; + + let monitor = &mut monitors[*active_monitor_idx]; + + let new_idx = min( + monitor.active_workspace_idx + 1, + monitor.workspaces.len() - 1, + ); + + if new_idx == monitor.active_workspace_idx { + return; + } + + let workspace = &mut monitor.workspaces[monitor.active_workspace_idx]; + if workspace.layout.columns.is_empty() { + return; + } + + let column = &mut workspace.layout.columns[workspace.layout.active_column_idx]; + let window = column.windows[column.active_window_idx].clone(); + workspace + .layout + .remove_window(&window, output_size(&monitor.output).h); + workspace.space.unmap_elem(&window); + workspace.layout.sync_space(&mut workspace.space); + + self.add_window(*active_monitor_idx, new_idx, window, true); + + // FIXME: remove empty unfocused workspaces. + } + + pub fn switch_workspace_up(&mut self) { + let Some(monitor) = self.active_monitor() else { + return; + }; + + monitor.active_workspace_idx = monitor.active_workspace_idx.saturating_sub(1); + + // FIXME: remove empty unfocused workspaces. + } + + pub fn switch_workspace_down(&mut self) { + let Some(monitor) = self.active_monitor() else { + return; + }; + monitor.active_workspace_idx = min( + monitor.active_workspace_idx + 1, + monitor.workspaces.len() - 1, + ); + + // FIXME: remove empty unfocused workspaces. + } + + pub fn consume_into_column(&mut self) { + let MonitorSet::Normal { + monitors, + active_monitor_idx, + .. + } = self + else { + return; + }; + + let monitor = &mut monitors[*active_monitor_idx]; + + let workspace = &mut monitor.workspaces[monitor.active_workspace_idx]; + let changed = workspace + .layout + .consume_into_column(output_size(&monitor.output).h); + if changed { + workspace.layout.sync_space(&mut workspace.space); + } + } + + pub fn expel_from_column(&mut self) { + let MonitorSet::Normal { + monitors, + active_monitor_idx, + .. + } = self + else { + return; + }; + + let monitor =