Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
feat: add system notifications support and notify IPC command
Cargo.toml | 2 +-
src/clearctl.rs | 1 +
src/config.rs | 56 ++++++++++--
src/ipc.rs | 78 +++++++++++++++++
src/main.rs | 4 +-
src/restart.rs | 13 ++-
src/tiling.rs | 57 +++++-------
src/types.rs | 5 ++
src/wayland.rs | 268 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--
src/wm.rs | 67 ++++++++------
start-river.sh | 3 +
11 files changed, 476 insertions(+), 78 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index 971ab15..b453b40 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,7 +14,7 @@ path = "src/clearctl.rs"
[dependencies]
wayland-client = "0.31"
wayland-backend = "0.3"
-wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
+wayland-protocols = { version = "0.32", features = ["client", "unstable", "staging"] }
wayland-scanner = "0.31"
xkbcommon = "0.7"
toml = "0.8"
diff --git a/src/clearctl.rs b/src/clearctl.rs
index 3af57e1..eb58a30 100644
--- a/src/clearctl.rs
+++ b/src/clearctl.rs
@@ -24,6 +24,7 @@ fn usage(name: &str) {
eprintln!(" repeat <rate> <delay>");
eprintln!(" config-done");
eprintln!(" spawn <command>");
+ eprintln!(" notify <title> [body]");
eprintln!(" bind <mods> <keysym> <action> [args...]");
eprintln!(" pbind <mods> <button> <action>");
eprintln!(" retile");
diff --git a/src/config.rs b/src/config.rs
index 77f8494..a060828 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -31,6 +31,8 @@ pub struct Config {
pub mode_rule: Vec<ModeRuleConfig>,
#[serde(default)]
pub tag_layout: Vec<TagLayoutConfig>,
+ #[serde(default)]
+ pub notifications: NotificationsConfig,
}
#[derive(Debug, Deserialize)]
@@ -161,6 +163,22 @@ pub struct InputConfig {
pub tap_to_click: bool,
}
+#[derive(Debug, Deserialize)]
+pub struct NotificationsConfig {
+ #[serde(default = "default_true")]
+ pub enable: bool,
+}
+
+impl Default for NotificationsConfig {
+ fn default() -> Self {
+ Self { enable: true }
+ }
+}
+
+fn default_true() -> bool {
+ true
+}
+
#[derive(Debug, Deserialize)]
pub struct StartupEntryConfig {
pub exec: String,
@@ -200,20 +218,22 @@ pub struct TagLayoutConfig {
/// Parse the TOML config file and apply it to the WindowManager state.
/// `cold_start` controls whether `once = true` startup entries are spawned.
-pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
+pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) -> Result<(), String> {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
- eprintln!("parse_config: cannot open {}: {}", path, e);
- return;
+ let err_msg = format!("cannot open {}: {}", path, e);
+ eprintln!("parse_config: {}", err_msg);
+ return Err(err_msg);
}
};
let config: Config = match toml::from_str(&content) {
Ok(c) => c,
Err(e) => {
- eprintln!("parse_config: TOML parse error: {}", e);
- return;
+ let err_msg = format!("TOML parse error: {}", e);
+ eprintln!("parse_config: {}", err_msg);
+ return Err(err_msg);
}
};
@@ -357,8 +377,12 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) {
// (in the RiverLibinputDeviceV1 TapSupport event handler).
state.tap_to_click = config.input.tap_to_click;
+ // [notifications]
+ state.notifications_enable = config.notifications.enable;
+
// Signal config-done
state.config_done = true;
+ Ok(())
}
/// Expand $VAR and ${VAR} references in a string using the current environment.
@@ -470,6 +494,14 @@ pub fn process_running(name: &str) -> bool {
}
}
+/// Show a system desktop notification via notify-send.
+pub fn show_notification(title: &str, body: &str) {
+ let title_escaped = title.replace('\'', "'\\''");
+ let body_escaped = body.replace('\'', "'\\''");
+ let cmd = format!("notify-send -a clearwm '{}' '{}'", title_escaped, body_escaped);
+ spawn_command_bg(&cmd);
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -550,6 +582,20 @@ mod tests {
assert!(expanded.starts_with(&format!("{}/.local/bin:", home)));
assert!(expanded.contains(&orig_path));
}
+
+ #[test]
+ fn test_notifications_config_parse() {
+ let toml_str = r#"
+[notifications]
+enable = false
+"#;
+ let config: Config = toml::from_str(toml_str).unwrap();
+ assert!(!config.notifications.enable);
+
+ let toml_str_empty = "";
+ let config_empty: Config = toml::from_str(toml_str_empty).unwrap();
+ assert!(config_empty.notifications.enable);
+ }
}
#[cfg(test)]
diff --git a/src/ipc.rs b/src/ipc.rs
index fdd370c..76f2771 100644
--- a/src/ipc.rs
+++ b/src/ipc.rs
@@ -161,6 +161,18 @@ pub fn handle_ipc_command(cmd: &str, state: &mut WindowManager) {
"input" => {
handle_input_command(rest, state);
}
+ "notify" => {
+ let parts: Vec<&str> = rest.splitn(2, ' ').collect();
+ if parts.len() == 2 {
+ if state.notifications_enable {
+ crate::config::show_notification(parts[0], parts[1]);
+ }
+ } else if parts.len() == 1 && !parts[0].is_empty() {
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", parts[0]);
+ }
+ }
+ }
_ => {
// Unknown command, ignore
}
@@ -195,71 +207,113 @@ fn handle_layout_command(rest: &str, state: &mut WindowManager) {
"gap" => {
if let Ok(value) = value_str.parse::<i32>() {
state.layout.gap = value;
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Gap set to {}px", value));
+ }
}
}
"gap_top" => {
if let Ok(value) = value_str.parse::<i32>() {
state.layout.gap_top = value;
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Top gap set to {}px", value));
+ }
}
}
"gap_left" => {
if let Ok(value) = value_str.parse::<i32>() {
state.layout.gap_left = value;
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Left gap set to {}px", value));
+ }
}
}
"gap_right" => {
if let Ok(value) = value_str.parse::<i32>() {
state.layout.gap_right = value;
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Right gap set to {}px", value));
+ }
}
}
"gap_bottom" => {
if let Ok(value) = value_str.parse::<i32>() {
state.layout.gap_bottom = value;
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Bottom gap set to {}px", value));
+ }
}
}
"cascade_offset" => {
if let Ok(value) = value_str.parse::<i32>() {
state.layout.cascade_offset = value;
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Cascade offset set to {}px", value));
+ }
}
}
"bar_height" => {
if let Ok(value) = value_str.parse::<i32>() {
state.layout.bar_height = value;
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Bar height set to {}px", value));
+ }
}
}
"border_width" => {
if let Ok(value) = value_str.parse::<i32>() {
state.layout.border_width = value;
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Border width set to {}px", value));
+ }
}
}
"fullscreen_border_width" => {
if let Ok(value) = value_str.parse::<i32>() {
state.layout.fullscreen_border_width = value;
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Fullscreen border width set to {}px", value));
+ }
}
}
"cascade_border_width" => {
if let Ok(value) = value_str.parse::<i32>() {
state.layout.cascade_border_width = value;
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Cascade border width set to {}px", value));
+ }
}
}
"grid_border_width" => {
if let Ok(value) = value_str.parse::<i32>() {
state.layout.grid_border_width = value;
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Grid border width set to {}px", value));
+ }
}
}
"vsplit_border_width" => {
if let Ok(value) = value_str.parse::<i32>() {
state.layout.vsplit_border_width = value;
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Vsplit border width set to {}px", value));
+ }
}
}
"hsplit_border_width" => {
if let Ok(value) = value_str.parse::<i32>() {
state.layout.hsplit_border_width = value;
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Hsplit border width set to {}px", value));
+ }
}
}
"floating_border_width" => {
if let Ok(value) = value_str.parse::<i32>() {
state.layout.floating_border_width = value;
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Floating border width set to {}px", value));
+ }
}
}
"border_color" => {
@@ -268,6 +322,9 @@ fn handle_layout_command(rest: &str, state: &mut WindowManager) {
state.layout.border_g = g;
state.layout.border_b = b;
state.layout.border_a = a;
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Border color set to {}", value_str));
+ }
}
}
"background_color" => {
@@ -276,6 +333,9 @@ fn handle_layout_command(rest: &str, state: &mut WindowManager) {
state.layout.background_g = g;
state.layout.background_b = b;
state.layout.background_a = a;
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Background color set to {}", value_str));
+ }
}
}
_ => {}
@@ -332,9 +392,14 @@ fn handle_set_mode_command(rest: &str, state: &mut WindowManager) {
return;
}
let mode = parse_tiling_mode(mode_str);
+ let notifications_enable = state.notifications_enable;
if let Some(window) = state.focused_window_mut() {
window.tiling_mode = mode;
window.mode_locked = true;
+ if notifications_enable {
+ let win_title = window.title.as_deref().unwrap_or("Window");
+ crate::config::show_notification("clearwm", &format!("Tiling mode set to {} for: {}", mode.as_str(), win_title));
+ }
}
}
@@ -407,6 +472,9 @@ fn handle_tag_layout_command(rest: &str, state: &mut WindowManager) {
let mode = parse_tiling_mode(parts[1]);
state.tag_layouts[tag as usize - 1] = mode;
state.has_tag_layout[tag as usize - 1] = true;
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Tag {} layout set to {}", tag, mode.as_str()));
+ }
}
}
}
@@ -464,6 +532,7 @@ fn handle_input_command(rest: &str, state: &mut WindowManager) {
match param {
"tap-to-click" | "tap_to_click" => {
+ let old_val = state.tap_to_click;
match value_str {
"true" | "1" | "enabled" => {
state.tap_to_click = true;
@@ -479,6 +548,15 @@ fn handle_input_command(rest: &str, state: &mut WindowManager) {
}
_ => {}
}
+ if state.tap_to_click != old_val && state.notifications_enable {
+ crate::config::show_notification(
+ "clearwm",
+ &format!(
+ "Tap-to-click {}",
+ if state.tap_to_click { "enabled" } else { "disabled" }
+ ),
+ );
+ }
}
_ => {}
}
diff --git a/src/main.rs b/src/main.rs
index 429650e..e655e8d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -115,7 +115,9 @@ fn main() {
if let Ok(home) = env::var("HOME") {
let config_path = format!("{}/.config/clearwm/config.toml", home);
if fs::metadata(&config_path).is_ok() {
- parse_config(&config_path, cold_start, &mut state.wm);
+ if let Err(e) = parse_config(&config_path, cold_start, &mut state.wm) {
+ eprintln!("[init] failed to load config: {}", e);
+ }
}
}
eprintln!(
diff --git a/src/restart.rs b/src/restart.rs
index dba8c56..dd3a542 100644
--- a/src/restart.rs
+++ b/src/restart.rs
@@ -215,7 +215,18 @@ pub fn wm_reload(state: &mut WindowManager) {
if !home.is_empty() {
let config_path = format!("{}/.config/clearwm/config.toml", home);
if std::path::Path::new(&config_path).exists() {
- parse_config(&config_path, false, state);
+ match parse_config(&config_path, false, state) {
+ Ok(_) => {
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", "Configuration reloaded successfully");
+ }
+ }
+ Err(e) => {
+ if state.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Config reload failed:\n{}", e));
+ }
+ }
+ }
}
}
}
diff --git a/src/tiling.rs b/src/tiling.rs
index df4cfee..baf262c 100644
--- a/src/tiling.rs
+++ b/src/tiling.rs
@@ -149,27 +149,18 @@ pub fn tile_hsplit(
/// Tile a window in fullscreen mode — fills the screen minus gaps, bar, and borders.
///
/// Only the focused window is visible; other fullscreen windows are skipped.
-/// width = screen_w - gap_left - gap_right - bw * 2
-/// height = screen_h - bar_height - gap_top - gap_bottom - bw * 2
-/// x = gap_left + bw
-/// y = bar_height + gap_top + bw
+/// A fullscreen window occupies the entire screen (0, 0, screen_w, screen_h).
pub fn tile_fullscreen(
screen_w: i32,
screen_h: i32,
- gap_top: i32,
- gap_left: i32,
- gap_right: i32,
- gap_bottom: i32,
- bw: i32,
- bar_height: i32,
+ _gap_top: i32,
+ _gap_left: i32,
+ _gap_right: i32,
+ _gap_bottom: i32,
+ _bw: i32,
+ _bar_height: i32,
) -> (i32, i32, i32, i32) {
- let width = screen_w - gap_left - gap_right - bw * 2;
- let height = screen_h - bar_height - gap_top - gap_bottom - bw * 2;
- let width = if width < 1 { 1 } else { width };
- let height = if height < 1 { 1 } else { height };
- let x = gap_left + bw;
- let y = bar_height + gap_top + bw;
- (x, y, width, height)
+ (0, 0, screen_w, screen_h)
}
/// Interpolate a byte-replicated 32-bit channel (0xVVVVVVVV) by factor^depth.
@@ -466,33 +457,27 @@ mod tests {
#[test]
fn test_tile_fullscreen_basic() {
let (x, y, w, h) = tile_fullscreen(1920, 1080, 18, 18, 18, 18, 6, 28);
- // x = gap_left + bw = 18 + 6 = 24
- assert_eq!(x, 24);
- // y = bar_height + gap_top + bw = 28 + 18 + 6 = 52
- assert_eq!(y, 52);
- // w = 1920 - 18 - 18 - 6*2 = 1920 - 48 = 1872
- assert_eq!(w, 1872);
- // h = 1080 - 28 - 18 - 18 - 6*2 = 1080 - 76 = 1004
- assert_eq!(h, 1004);
+ assert_eq!(x, 0);
+ assert_eq!(y, 0);
+ assert_eq!(w, 1920);
+ assert_eq!(h, 1080);
}
#[test]
fn test_tile_fullscreen_asymmetric_gaps() {
let (x, y, w, h) = tile_fullscreen(1920, 1080, 10, 20, 30, 40, 6, 28);
- // x = gap_left + bw = 20 + 6 = 26
- assert_eq!(x, 26);
- // y = bar_height + gap_top + bw = 28 + 10 + 6 = 44
- assert_eq!(y, 44);
- // w = 1920 - 20 - 30 - 6*2 = 1920 - 62 = 1858
- assert_eq!(w, 1858);
- // h = 1080 - 28 - 10 - 40 - 6*2 = 1080 - 90 = 990
- assert_eq!(h, 990);
+ assert_eq!(x, 0);
+ assert_eq!(y, 0);
+ assert_eq!(w, 1920);
+ assert_eq!(h, 1080);
}
#[test]
fn test_tile_fullscreen_minimum_size() {
- let (_, _, w, h) = tile_fullscreen(50, 50, 18, 18, 18, 18, 6, 28);
- assert!(w >= 1);
- assert!(h >= 1);
+ let (x, y, w, h) = tile_fullscreen(50, 50, 18, 18, 18, 18, 6, 28);
+ assert_eq!(x, 0);
+ assert_eq!(y, 0);
+ assert_eq!(w, 50);
+ assert_eq!(h, 50);
}
}
diff --git a/src/types.rs b/src/types.rs
index bdcea97..8ca8617 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -168,6 +168,7 @@ pub struct Output {
pub usable_y: i32,
pub usable_width: i32,
pub usable_height: i32,
+ pub wl_output_name: Option<u32>,
}
impl Default for Output {
@@ -183,6 +184,7 @@ impl Default for Output {
usable_y: 0,
usable_width: 0,
usable_height: 0,
+ wl_output_name: None,
}
}
}
@@ -326,6 +328,8 @@ pub struct WindowManager {
pub tap_to_click: bool,
/// Whether tap-to-click config has been applied to libinput devices yet
pub tap_config_applied: bool,
+ /// Whether system notifications are enabled
+ pub notifications_enable: bool,
}
impl Default for WindowManager {
@@ -359,6 +363,7 @@ impl Default for WindowManager {
state_restore_attempts: 0,
tap_to_click: false,
tap_config_applied: false,
+ notifications_enable: true,
}
}
}
diff --git a/src/wayland.rs b/src/wayland.rs
index 4874ed1..dc16c5d 100644
--- a/src/wayland.rs
+++ b/src/wayland.rs
@@ -40,9 +40,15 @@ use crate::protocol::wlr_output_management::client::{
};
use crate::types::{Action, BindingUserData, Output, Seat, TilingMode, Window, WindowManager};
+use wayland_client::protocol::{wl_pointer, wl_seat, wl_output};
+use wayland_protocols::wp::cursor_shape::v1::client::{
+ wp_cursor_shape_device_v1::{self, WpCursorShapeDeviceV1, Shape},
+ wp_cursor_shape_manager_v1::{self, WpCursorShapeManagerV1},
+};
// Interface name constants (from river protocol XML)
const IFACE_WINDOW_MANAGER: &str = "river_window_manager_v1";
+const IFACE_CURSOR_SHAPE_MANAGER: &str = "wp_cursor_shape_manager_v1";
const IFACE_XKB_BINDINGS: &str = "river_xkb_bindings_v1";
const IFACE_LAYER_SHELL: &str = "river_layer_shell_v1";
const IFACE_INPUT_MANAGER: &str = "river_input_manager_v1";
@@ -59,6 +65,9 @@ pub struct WindowProxy {
pub struct SeatProxy {
pub river_seat: RiverSeatV1,
pub xkb_bindings_seat: Option<RiverXkbBindingsSeatV1>,
+ pub wl_seat: Option<wl_seat::WlSeat>,
+ pub wl_pointer: Option<wl_pointer::WlPointer>,
+ pub cursor_shape_device: Option<WpCursorShapeDeviceV1>,
}
/// Wayland proxy objects stored alongside each Output.
@@ -67,15 +76,28 @@ pub struct OutputProxy {
pub layer_shell_output: Option<RiverLayerShellOutputV1>,
}
+/// Tracked wl_output proxy and its physical properties.
+pub struct WlOutputInfo {
+ pub name: u32,
+ pub wl_output: wl_output::WlOutput,
+ pub width: i32,
+ pub height: i32,
+ pub x: i32,
+ pub y: i32,
+}
+
/// The full app state combining logic state + protocol proxy storage.
pub struct AppState {
pub wm: WindowManager,
+ pub wl_outputs: Vec<WlOutputInfo>,
// Protocol objects (None until bound via registry)
+ pub registry: Option<wl_registry::WlRegistry>,
pub window_manager: Option<RiverWindowManagerV1>,
pub xkb_bindings: Option<RiverXkbBindingsV1>,
pub layer_shell: Option<RiverLayerShellV1>,
pub input_manager: Option<RiverInputManagerV1>,
+ pub cursor_shape_manager: Option<WpCursorShapeManagerV1>,
// Whether we got all required globals
pub has_window_manager: bool,
@@ -147,10 +169,13 @@ impl AppState {
pub fn new() -> Self {
AppState {
wm: WindowManager::new(),
+ wl_outputs: Vec::new(),
+ registry: None,
window_manager: None,
xkb_bindings: None,
layer_shell: None,
input_manager: None,
+ cursor_shape_manager: None,
has_window_manager: false,
has_xkb_bindings: false,
window_proxies: Vec::new(),
@@ -279,6 +304,23 @@ impl Dispatch<wl_registry::WlRegistry, RegistryData> for AppState {
let om: ZwlrOutputManagerV1 =
registry.bind::<ZwlrOutputManagerV1, _, _>(name, 4, qhandle, ());
state.output_manager = Some(om);
+ } else if interface == IFACE_CURSOR_SHAPE_MANAGER {
+ eprintln!("registry: binding {}", IFACE_CURSOR_SHAPE_MANAGER);
+ let csm: WpCursorShapeManagerV1 =
+ registry.bind::<WpCursorShapeManagerV1, _, _>(name, 1, qhandle, ());
+ state.cursor_shape_manager = Some(csm);
+ } else if interface == "wl_output" {
+ eprintln!("registry: binding wl_output name={}", name);
+ let wl_out: wl_output::WlOutput =
+ registry.bind::<wl_output::WlOutput, _, _>(name, 4, qhandle, ());
+ state.wl_outputs.push(WlOutputInfo {
+ name,
+ wl_output: wl_out,
+ width: 0,
+ height: 0,
+ x: 0,
+ y: 0,
+ });
}
}
wl_registry::Event::GlobalRemove { name: _ } => {}
@@ -892,6 +934,9 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
SeatProxy {
river_seat,
xkb_bindings_seat: None,
+ wl_seat: None,
+ wl_pointer: None,
+ cursor_shape_device: None,
},
));
eprintln!("wm_handle_seat: new seat id={}", id);
@@ -1146,7 +1191,7 @@ impl Dispatch<RiverSeatV1, ()> for AppState {
event: river_seat_v1::Event,
_data: &(),
_conn: &Connection,
- _qhandle: &QueueHandle<Self>,
+ qhandle: &QueueHandle<Self>,
) {
let sid = match state.seat_id_for_proxy(proxy) {
Some(id) => id,
@@ -1199,6 +1244,17 @@ impl Dispatch<RiverSeatV1, ()> for AppState {
}
}
+ river_seat_v1::Event::WlSeat { name } => {
+ if let Some(ref registry) = state.registry {
+ if let Some((_, seat_proxy)) = state.seat_proxies.iter_mut().find(|(_, sp)| sp.river_seat == *proxy) {
+ if seat_proxy.wl_seat.is_none() {
+ let wl_seat = registry.bind::<wl_seat::WlSeat, _, _>(name, 2, qhandle, ());
+ seat_proxy.wl_seat = Some(wl_seat);
+ }
+ }
+ }
+ }
+
river_seat_v1::Event::OpDelta { .. } => {}
river_seat_v1::Event::OpRelease => {}
@@ -1211,15 +1267,115 @@ impl Dispatch<RiverSeatV1, ()> for AppState {
impl Dispatch<RiverOutputV1, ()> for AppState {
fn event(
- _state: &mut Self,
- _proxy: &RiverOutputV1,
+ state: &mut Self,
+ proxy: &RiverOutputV1,
event: river_output_v1::Event,
_data: &(),
_conn: &Connection,
_qhandle: &QueueHandle<Self>,
) {
+ let oid = state
+ .output_proxies
+ .iter()
+ .find(|(_, op)| op.river_output.id() == proxy.id())
+ .map(|(id, _)| *id);
+
match event {
- river_output_v1::Event::WlOutput { .. } => {}
+ river_output_v1::Event::WlOutput { name } => {
+ if let Some(oid) = oid {
+ if let Some(output) = state.wm.outputs.iter_mut().find(|o| o.id == oid) {
+ output.wl_output_name = Some(name);
+ // Also try to copy dimensions from WlOutputInfo if already populated
+ if let Some(info) = state.wl_outputs.iter().find(|info| info.name == name) {
+ if info.width > 0 && info.height > 0 {
+ output.width = info.width;
+ output.height = info.height;
+ output.x = info.x;
+ output.y = info.y;
+ state.wm.needs_render = true;
+ eprintln!(
+ "river_output linked to wl_output name={} dimensions (copied): {}x{} at ({},{})",
+ name, info.width, info.height, info.x, info.y
+ );
+ }
+ }
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+}
+
+// --- wl_output::WlOutput events ---
+
+impl Dispatch<wl_output::WlOutput, ()> for AppState {
+ fn event(
+ state: &mut Self,
+ proxy: &wl_output::WlOutput,
+ event: wl_output::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qhandle: &QueueHandle<Self>,
+ ) {
+ // Find registry name
+ let name = state
+ .wl_outputs
+ .iter()
+ .find(|info| info.wl_output.id() == proxy.id())
+ .map(|info| info.name);
+
+ let Some(name) = name else { return };
+
+ match event {
+ wl_output::Event::Geometry {
+ x,
+ y,
+ ..
+ } => {
+ if let Some(info) = state.wl_outputs.iter_mut().find(|info| info.name == name) {
+ info.x = x;
+ info.y = y;
+ }
+ // Also update matched Output in WindowManager
+ if let Some(output) = state.wm.outputs.iter_mut().find(|o| o.wl_output_name == Some(name)) {
+ if output.x != x || output.y != y {
+ output.x = x;
+ output.y = y;
+ state.wm.needs_render = true;
+ }
+ }
+ }
+ wl_output::Event::Mode {
+ flags,
+ width,
+ height,
+ ..
+ } => {
+ let is_current = match flags {
+ wayland_client::WEnum::Value(mode) => mode.contains(wl_output::Mode::Current),
+ _ => false,
+ };
+
+ if is_current {
+ if let Some(info) = state.wl_outputs.iter_mut().find(|info| info.name == name) {
+ info.width = width;
+ info.height = height;
+ }
+ // Also update matched Output in WindowManager
+ if let Some(output) = state.wm.outputs.iter_mut().find(|o| o.wl_output_name == Some(name)) {
+ if output.width != width || output.height != height {
+ output.width = width;
+ output.height = height;
+ state.wm.needs_render = true;
+ eprintln!(
+ "wl_output name={} updated current mode dimensions: {}x{}",
+ name, width, height
+ );
+ }
+ }
+ }
+ }
_ => {}
}
}
@@ -1676,6 +1832,10 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
active_tags
);
+ if state.wm.notifications_enable {
+ crate::config::show_notification("clearwm", &format!("Layout set to {} for active tags", next.as_str()));
+ }
+
// Unlock windows that got their mode from the layout (not from mode_rules
// or manual set-mode) so assign_window_modes will reassign them.
// Windows with mode_locked=true were explicitly set by the user and stay.
@@ -1701,6 +1861,7 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
.find(|s| !s.removed)
.and_then(|s| s.focused_window_id);
if let Some(fid) = focused_id {
+ let notifications_enable = state.wm.notifications_enable;
if let Some(win) = state.wm.get_window_mut(fid) {
let next = cycle
.iter()
@@ -1716,14 +1877,17 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
);
win.tiling_mode = next;
win.mode_locked = true;
+ if notifications_enable {
+ let win_title = win.title.as_deref().unwrap_or("Window");
+ crate::config::show_notification("clearwm", &format!("Tiling mode set to {} for: {}", next.as_str(), win_title));
+ }
state.wm.needs_render = true;
state.wm.needs_status_update = true;
}
}
}
Action::Reload => {
- // TODO: implement reload (re-run config)
- eprintln!("reload: not yet implemented");
+ crate::restart::wm_reload(&mut state.wm);
}
Action::Restart => {
crate::restart::wm_restart();
@@ -2021,7 +2185,7 @@ impl Dispatch<ZwlrOutputHeadV1, ()> for AppState {
event: zwlr_output_head_v1::Event,
_data: &(),
_conn: &Connection,
- qhandle: &QueueHandle<Self>,
+ _qhandle: &QueueHandle<Self>,
) {
// Find or create the head entry by proxy ID
let pid = proxy.id().protocol_id();
@@ -2291,10 +2455,11 @@ pub fn wayland_init() -> Result<(Connection, EventQueue<AppState>, AppState), St
let mut event_queue = conn.new_event_queue::<AppState>();
let qh = event_queue.handle();
- let _registry = conn.display().get_registry(&qh, RegistryData);
+ let registry = conn.display().get_registry(&qh, RegistryData);
// Do initial roundtrip to receive global events and bind protocols
let mut state = AppState::new();
+ state.registry = Some(registry);
eprintln!("[init] first roundtrip starting...");
let rt1 = std::time::Instant::now();
event_queue
@@ -2371,7 +2536,7 @@ impl Dispatch<RiverLibinputDeviceV1, ()> for AppState {
event: river_libinput_device_v1::Event,
_data: &(),
_conn: &Connection,
- qhandle: &QueueHandle<Self>,
+ _qhandle: &QueueHandle<Self>,
) {
match event {
river_libinput_device_v1::Event::TapSupport { finger_count } => {
@@ -2476,3 +2641,88 @@ pub fn apply_tap_config(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
eprintln!("[libinput] tap config applied to all devices");
}
}
+
+// --- wl_seat events ---
+
+impl Dispatch<wl_seat::WlSeat, ()> for AppState {
+ fn event(
+ state: &mut Self,
+ proxy: &wl_seat::WlSeat,
+ event: wl_seat::Event,
+ _data: &(),
+ _conn: &Connection,
+ qhandle: &QueueHandle<Self>,
+ ) {
+ match event {
+ wl_seat::Event::Capabilities { capabilities } => {
+ let has_pointer = match capabilities {
+ wayland_client::WEnum::Value(caps) => caps.contains(wl_seat::Capability::Pointer),
+ _ => false,
+ };
+ if let Some((_, seat_proxy)) = state.seat_proxies.iter_mut().find(|(_, sp)| {
+ sp.wl_seat.as_ref() == Some(proxy)
+ }) {
+ if has_pointer && seat_proxy.wl_pointer.is_none() {
+ let wl_pointer = proxy.get_pointer(qhandle, ());
+
+ if let Some(ref csm) = state.cursor_shape_manager {
+ let device = csm.get_pointer(&wl_pointer, qhandle, ());
+ device.set_shape(0, Shape::Crosshair);
+ seat_proxy.cursor_shape_device = Some(device);
+ }
+
+ seat_proxy.wl_pointer = Some(wl_pointer);
+ } else if !has_pointer && seat_proxy.wl_pointer.is_some() {
+ seat_proxy.cursor_shape_device = None;
+ if let Some(pointer) = seat_proxy.wl_pointer.take() {
+ pointer.release();
+ }
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+}
+
+// --- wl_pointer events ---
+
+impl Dispatch<wl_pointer::WlPointer, ()> for AppState {
+ fn event(
+ _state: &mut Self,
+ _proxy: &wl_pointer::WlPointer,
+ _event: wl_pointer::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qhandle: &QueueHandle<Self>,
+ ) {
+ }
+}
+
+// --- wp_cursor_shape_manager_v1 events ---
+
+impl Dispatch<WpCursorShapeManagerV1, ()> for AppState {
+ fn event(
+ _state: &mut Self,
+ _proxy: &WpCursorShapeManagerV1,
+ _event: wp_cursor_shape_manager_v1::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qhandle: &QueueHandle<Self>,
+ ) {
+ }
+}
+
+// --- wp_cursor_shape_device_v1 events ---
+
+impl Dispatch<WpCursorShapeDeviceV1, ()> for AppState {
+ fn event(
+ _state: &mut Self,
+ _proxy: &WpCursorShapeDeviceV1,
+ _event: wp_cursor_shape_device_v1::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qhandle: &QueueHandle<Self>,
+ ) {
+ }
+}
diff --git a/src/wm.rs b/src/wm.rs
index 7d4241a..2f97f50 100644
--- a/src/wm.rs
+++ b/src/wm.rs
@@ -108,22 +108,25 @@ struct TileResult {
/// These modify window management state and can ONLY be called during
/// a manage sequence (between ManageStart and ManageFinish).
pub fn manage_windows(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
- let screen_dims = get_screen_dimensions(&state.wm);
- let (screen_w, screen_h) = screen_dims;
+ let (screen_w, screen_h, phys_w, phys_h, phys_x, phys_y) = get_screen_geometry(&state.wm);
eprintln!(
- "[manage] windows={} outputs={} screen={}x{}",
+ "[manage] windows={} outputs={} screen={}x{} (phys={}x{} at {},{})",
state.wm.windows.len(),
state.wm.outputs.len(),
screen_w,
- screen_h
+ screen_h,
+ phys_w,
+ phys_h,
+ phys_x,
+ phys_y
);
// Ensure each window has a river_node_v1 proxy for positioning
ensure_window_nodes(state, qhandle);
// Compute tiling
- let tile_results = compute_tiling(&state.wm, screen_w, screen_h);
+ let tile_results = compute_tiling(&state.wm, screen_w, screen_h, phys_w, phys_h, phys_x, phys_y);
// Apply: set_position + propose_dimensions + update internal state
apply_tiling(state, &tile_results);
@@ -136,32 +139,37 @@ pub fn render_borders(state: &mut AppState) {
set_borders(state);
}
-/// Get screen dimensions from the first output, with fallbacks.
-fn get_screen_dimensions(wm: &WindowManager) -> (i32, i32) {
+/// Get screen geometry (usable_w, usable_h, phys_w, phys_h, phys_x, phys_y) from the first output, with fallbacks.
+fn get_screen_geometry(wm: &WindowManager) -> (i32, i32, i32, i32, i32, i32) {
let output = match wm.outputs.first() {
Some(o) if !o.removed => o,
- _ => return (800, 600),
+ _ => return (800, 600, 800, 600, 0, 0),
};
- let mut w = output.usable_width;
- let mut h = output.usable_height;
+ let mut uw = output.usable_width;
+ let mut uh = output.usable_height;
- // Fall back to raw output dimensions if usable area not yet set
- if w <= 0 && output.width > 0 {
- w = output.width;
+ // Fall back to physical output dimensions if usable area not yet set
+ if uw <= 0 && output.width > 0 {
+ uw = output.width;
}
- if h <= 0 && output.height > 0 {
- h = output.height;
+ if uh <= 0 && output.height > 0 {
+ uh = output.height;
}
- if w <= 0 {
- w = 800;
+ if uw <= 0 {
+ uw = 800;
}
- if h <= 0 {
- h = 600;
+ if uh <= 0 {
+ uh = 600;
}
- (w, h)
+ let pw = if output.width > 0 { output.width } else { uw };
+ let ph = if output.height > 0 { output.height } else { uh };
+ let px = output.x;
+ let py = output.y;
+
+ (uw, uh, pw, ph, px, py)
}
/// Ensure each window has a river_node_v1 proxy for positioning.
@@ -189,7 +197,15 @@ fn ensure_window_nodes(state: &mut AppState, qhandle: &QueueHandle<AppState>) {
}
/// Compute tiling for all visible windows (read-only, returns results).
-fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileResult> {
+fn compute_tiling(
+ wm: &WindowManager,
+ screen_w: i32,
+ screen_h: i32,
+ phys_w: i32,
+ phys_h: i32,
+ phys_x: i32,
+ phys_y: i32,
+) -> Vec<TileResult> {
let gap = wm.layout.gap;
let gap_top = wm.layout.gap_top;
let gap_left = wm.layout.gap_left;
@@ -253,16 +269,17 @@ fn compute_tiling(wm: &WindowManager, screen_w: i32, screen_h: i32) -> Vec<TileR
let (x, y, w, h) = match mode {
TilingMode::Fullscreen => {
if fullscreen_id == Some(wid) {
- tiling::tile_fullscreen(
- screen_w,
- screen_h,
+ let (tx, ty, tw, th) = tiling::tile_fullscreen(
+ phys_w,
+ phys_h,
gap_top,
gap_left,
gap_right,
gap_bottom,
wm.layout.fullscreen_border_width,
bar_height,
- )
+ );
+ (tx + phys_x, ty + phys_y, tw, th)
} else {
continue;
}
diff --git a/start-river.sh b/start-river.sh
index f21b21f..1e725ea 100755
--- a/start-river.sh
+++ b/start-river.sh
@@ -11,6 +11,9 @@ done
export XDG_RUNTIME_DIR=/run/user/$(id -u)
export WAYLAND_DISPLAY=wayland-1
+export XCURSOR_THEME="crosshair-theme"
+export XCURSOR_SIZE=24
+export XCURSOR_PATH="/home/lsgalante/.local/share/icons:/home/lsgalante/.icons:/usr/share/icons"
# Create the River init executable (clearwm launch script)
# This must exist before River starts, and /tmp is cleared on reboot.