Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
Update components and system settings
src/borders.rs | 6 ++-
src/clearctl.rs | 60 +++++++++++++----------
src/config.rs | 48 +++++++++++++++++++
src/decorations.rs | 16 ++++++-
src/restart.rs | 4 ++
src/state.rs | 1 +
src/status.rs | 1 +
src/types.rs | 7 +++
src/wayland.rs | 136 ++++++++++++++++++++++++++++++++++++++++-------------
src/wm.rs | 23 ++++++++-
10 files changed, 241 insertions(+), 61 deletions(-)
diff --git a/src/borders.rs b/src/borders.rs
index 8c7c69b..984d138 100644
--- a/src/borders.rs
+++ b/src/borders.rs
@@ -84,7 +84,10 @@ pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
state.layout.border_b,
);
- let (r, g, b, a) = if is_focused {
+ let (r, g, b, a) = if win.tiling_mode == TilingMode::Popup {
+ // Popup windows have a transparent border
+ (0, 0, 0, 0)
+ } else if is_focused {
// Focused window: pure border color
(
state.layout.border_r,
@@ -119,6 +122,7 @@ pub fn compute_border_colors(state: &WindowManager) -> Vec<WindowBorders> {
TilingMode::Vsplit => state.layout.vsplit_border_width,
TilingMode::Hsplit => state.layout.hsplit_border_width,
TilingMode::Floating => state.layout.floating_border_width,
+ TilingMode::Popup => state.layout.border_width,
};
if win.app_id.as_deref() == Some("clear-status-interface") {
diff --git a/src/clearctl.rs b/src/clearctl.rs
index eb58a30..3a6d64f 100644
--- a/src/clearctl.rs
+++ b/src/clearctl.rs
@@ -8,38 +8,50 @@ use std::process;
const SOCKET_PATH: &str = "/tmp/clearwm.sock";
-fn usage(name: &str) {
- eprintln!("usage: {} <command> [args...]", name);
- eprintln!();
- eprintln!("commands:");
- eprintln!(" layout <gap|gap_top|gap_left|gap_right|gap_bottom|offset|bar_height|border_width|fullscreen_border_width|border_color> <value>");
- eprintln!(" view <1-4>");
- eprintln!(" toggle <1-4>");
- eprintln!(" close");
- eprintln!(" focus-next");
- eprintln!(" windows");
- eprintln!(" exit");
- eprintln!(" restart");
- eprintln!(" reload");
- 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");
- eprintln!(" set-tag <1-4>");
- eprintln!(" mode <cascade|grid|vsplit|hsplit|fullscreen|floating> <app_id> [title]");
- eprintln!(" tag-layout <1-4> <cascade|grid|vsplit|hsplit|fullscreen|floating>");
+fn usage(name: &str, to_stderr: bool) {
+ let print = |s: &str| {
+ if to_stderr {
+ eprintln!("{}", s);
+ } else {
+ println!("{}", s);
+ }
+ };
+ print(&format!("usage: {} <command> [args...]", name));
+ print("");
+ print("commands:");
+ print(" layout <gap|gap_top|gap_left|gap_right|gap_bottom|offset|bar_height|border_width|fullscreen_border_width|border_color> <value>");
+ print(" view <1-4>");
+ print(" toggle <1-4>");
+ print(" close");
+ print(" focus-next");
+ print(" windows");
+ print(" exit");
+ print(" restart");
+ print(" reload");
+ print(" repeat <rate> <delay>");
+ print(" config-done");
+ print(" spawn <command>");
+ print(" notify <title> [body]");
+ print(" bind <mods> <keysym> <action> [args...]");
+ print(" pbind <mods> <button> <action>");
+ print(" retile");
+ print(" set-tag <1-4>");
+ print(" mode <cascade|grid|vsplit|hsplit|fullscreen|floating|popup> <app_id> [title]");
+ print(" tag-layout <1-4> <cascade|grid|vsplit|hsplit|fullscreen|floating|popup>");
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
- usage(&args[0]);
+ usage(&args[0], true);
process::exit(1);
}
+ if args[1] == "--help" || args[1] == "-h" || args[1] == "help" {
+ usage(&args[0], false);
+ return;
+ }
+
// Special case: "windows" reads the status file directly
if args[1] == "windows" {
match fs::read_to_string("/tmp/clearwm-windows") {
diff --git a/src/config.rs b/src/config.rs
index a060828..e6d13e6 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -33,6 +33,13 @@ pub struct Config {
pub tag_layout: Vec<TagLayoutConfig>,
#[serde(default)]
pub notifications: NotificationsConfig,
+ #[serde(default)]
+ pub reload: Vec<ReloadEntryConfig>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct ReloadEntryConfig {
+ pub exec: String,
}
#[derive(Debug, Deserialize)]
@@ -380,6 +387,12 @@ pub fn parse_config(path: &str, cold_start: bool, state: &mut WindowManager) ->
// [notifications]
state.notifications_enable = config.notifications.enable;
+ // [[reload]] array
+ state.reload_commands.clear();
+ for entry in &config.reload {
+ state.reload_commands.push(entry.exec.clone());
+ }
+
// Signal config-done
state.config_done = true;
Ok(())
@@ -596,6 +609,12 @@ enable = false
let config_empty: Config = toml::from_str(toml_str_empty).unwrap();
assert!(config_empty.notifications.enable);
}
+
+ #[test]
+ fn test_parse_keysym_favorites() {
+ let sym = parse_keysym("XF86Favorites");
+ assert!(sym != 0, "XF86Favorites keysym must be resolved successfully");
+ }
}
#[cfg(test)]
@@ -623,4 +642,33 @@ once = true
assert!(config.startup[1].once);
assert_eq!(config.env.get("XDG_CURRENT_DESKTOP").unwrap(), "river");
}
+
+ #[test]
+ fn test_reload_entry_format() {
+ let toml_str = r#"
+[[reload]]
+exec = "pkill clear-input-manager"
+
+[[reload]]
+exec = "echo reloaded"
+"#;
+ let config: Config = toml::from_str(toml_str).expect("TOML parse failed");
+ assert_eq!(config.reload.len(), 2);
+ assert_eq!(config.reload[0].exec, "pkill clear-input-manager");
+ assert_eq!(config.reload[1].exec, "echo reloaded");
+
+ // Also test integration via parse_config (we can write to a temporary file in /tmp or mock it,
+ // but wait! we can write to a temporary file inside the workspace)
+ let temp_path = "/home/lsgalante/Dropbox/Clear/clear-window-manager/scratch_config_test.toml";
+ std::fs::write(temp_path, toml_str).unwrap();
+
+ let mut wm = WindowManager::default();
+ let parse_res = parse_config(temp_path, false, &mut wm);
+ let _ = std::fs::remove_file(temp_path);
+
+ parse_res.unwrap();
+ assert_eq!(wm.reload_commands.len(), 2);
+ assert_eq!(wm.reload_commands[0], "pkill clear-input-manager");
+ assert_eq!(wm.reload_commands[1], "echo reloaded");
+ }
}
diff --git a/src/decorations.rs b/src/decorations.rs
index a73219a..be8932d 100644
--- a/src/decorations.rs
+++ b/src/decorations.rs
@@ -189,6 +189,18 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
_ => return,
};
+ // Clean up decorations for windows that should not be decorated
+ for (wid, wp) in &mut state.window_proxies {
+ if let Some(w) = state.wm.windows.iter().find(|win| win.id == *wid) {
+ if w.closed || w.app_id.as_deref() == Some("clear-status-interface") || w.tiling_mode == crate::types::TilingMode::Popup {
+ if let Some(dec) = wp.decoration.take() {
+ dec.decoration.destroy();
+ dec.surface.destroy();
+ }
+ }
+ }
+ }
+
// Calculate dynamic border colors
let border_colors = crate::borders::compute_border_colors(&state.wm);
let text_color = 0xFFE0E0E0u32;
@@ -199,7 +211,7 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
.windows
.iter()
.enumerate()
- .filter(|(_, w)| !w.closed && w.app_id.as_deref() != Some("clear-status-interface"))
+ .filter(|(_, w)| !w.closed && w.app_id.as_deref() != Some("clear-status-interface") && w.tiling_mode != crate::types::TilingMode::Popup)
.filter(|(_, w)| (w.tags & active_tags) != 0)
.map(|(idx, w)| {
let title = w.title.clone().unwrap_or_else(|| {
@@ -217,6 +229,7 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
crate::types::TilingMode::Vsplit => "V",
crate::types::TilingMode::Hsplit => "H",
crate::types::TilingMode::Fullscreen => "S",
+ crate::types::TilingMode::Popup => "P",
};
let title_with_idx = format!("[{}{}] {}", indicator, mode_idx, title);
@@ -246,6 +259,7 @@ pub fn update_decorations(state: &mut AppState, qhandle: &QueueHandle<AppState>)
crate::types::TilingMode::Vsplit => state.wm.layout.vsplit_border_width,
crate::types::TilingMode::Hsplit => state.wm.layout.hsplit_border_width,
crate::types::TilingMode::Floating => state.wm.layout.floating_border_width,
+ crate::types::TilingMode::Popup => 0,
};
(w.id, w.width, border_w, title_with_idx, bg_color)
})
diff --git a/src/restart.rs b/src/restart.rs
index dd3a542..3465266 100644
--- a/src/restart.rs
+++ b/src/restart.rs
@@ -217,6 +217,10 @@ pub fn wm_reload(state: &mut WindowManager) {
if std::path::Path::new(&config_path).exists() {
match parse_config(&config_path, false, state) {
Ok(_) => {
+ for cmd in &state.reload_commands {
+ eprintln!("[reload] executing reload command: {}", cmd);
+ spawn_command_bg(cmd);
+ }
if state.notifications_enable {
crate::config::show_notification("clearwm", "Configuration reloaded successfully");
}
diff --git a/src/state.rs b/src/state.rs
index e8ccf2a..3188328 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -228,6 +228,7 @@ fn parse_tiling_mode_str(s: &str) -> TilingMode {
"Hsplit" => TilingMode::Hsplit,
"Fullscreen" => TilingMode::Fullscreen,
"Floating" => TilingMode::Floating,
+ "Popup" => TilingMode::Popup,
_ => TilingMode::Cascade,
}
}
diff --git a/src/status.rs b/src/status.rs
index 914b1a9..467d27b 100644
--- a/src/status.rs
+++ b/src/status.rs
@@ -90,5 +90,6 @@ fn tiling_mode_str(mode: TilingMode) -> &'static str {
TilingMode::Vsplit => "Vsplit",
TilingMode::Hsplit => "Hsplit",
TilingMode::Fullscreen => "Fullscreen",
+ TilingMode::Popup => "Popup",
}
}
diff --git a/src/types.rs b/src/types.rs
index 8ca8617..a67427b 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -13,6 +13,7 @@ pub enum TilingMode {
Vsplit,
Hsplit,
Fullscreen,
+ Popup,
}
impl TilingMode {
@@ -24,6 +25,7 @@ impl TilingMode {
TilingMode::Vsplit => "Vsplit",
TilingMode::Hsplit => "Hsplit",
TilingMode::Fullscreen => "Fullscreen",
+ TilingMode::Popup => "Popup",
}
}
}
@@ -330,6 +332,8 @@ pub struct WindowManager {
pub tap_config_applied: bool,
/// Whether system notifications are enabled
pub notifications_enable: bool,
+ /// Reload commands to execute on configuration reload
+ pub reload_commands: Vec<String>,
}
impl Default for WindowManager {
@@ -364,6 +368,7 @@ impl Default for WindowManager {
tap_to_click: false,
tap_config_applied: false,
notifications_enable: true,
+ reload_commands: Vec::new(),
}
}
}
@@ -453,6 +458,7 @@ pub fn parse_tiling_mode(s: &str) -> TilingMode {
"hsplit" => TilingMode::Hsplit,
"fullscreen" => TilingMode::Fullscreen,
"floating" => TilingMode::Floating,
+ "popup" => TilingMode::Popup,
_ => TilingMode::Floating,
}
}
@@ -607,6 +613,7 @@ mod tests {
assert_eq!(parse_tiling_mode("hsplit"), TilingMode::Hsplit);
assert_eq!(parse_tiling_mode("fullscreen"), TilingMode::Fullscreen);
assert_eq!(parse_tiling_mode("floating"), TilingMode::Floating);
+ assert_eq!(parse_tiling_mode("popup"), TilingMode::Popup);
assert_eq!(parse_tiling_mode("unknown"), TilingMode::Floating);
}
diff --git a/src/wayland.rs b/src/wayland.rs
index 75bb5dc..2b2eb7e 100644
--- a/src/wayland.rs
+++ b/src/wayland.rs
@@ -150,6 +150,8 @@ pub struct AppState {
pub libinput_config: Option<RiverLibinputConfigV1>,
/// Tracked libinput devices with their tap state
pub libinput_devices: Vec<LibinputDeviceInfo>,
+ /// The surface the pointer is currently hovering over
+ pub pointer_hovered_surface: Option<wl_surface::WlSurface>,
}
/// Info tracked for each libinput device discovered via river_libinput_config_v1
@@ -202,6 +204,7 @@ impl AppState {
status_sender: None,
libinput_config: None,
libinput_devices: Vec::new(),
+ pointer_hovered_surface: None,
}
}
@@ -808,41 +811,60 @@ impl Dispatch<RiverWindowManagerV1, ()> for AppState {
// Update and render window title decorations on borders
crate::decorations::update_decorations(state, qhandle);
- // Raise the focused window to the top of the visual stack.
- // place_top() modifies rendering state and must be called
- // during a render sequence.
- if let Some(seat) = state.wm.seats.iter().find(|s| !s.removed) {
- if let Some(focused_id) = seat.focused_window_id {
- if let Some(node) = state
- .window_nodes
- .iter()
- .find(|(id, _)| *id == focused_id)
- .map(|(_, n)| n)
- {
- let app_id = state
- .wm
- .get_window(focused_id)
- .and_then(|w| w.app_id.clone());
- if app_id.as_deref() != Some("clear-status-interface") {
- eprintln!(
- "[render] place_top for focused window id={} (app_id={:?})",
- focused_id, app_id
- );
- node.place_top();
- }
+ // Raise and stack windows according to z-axis logic:
+ // 1. clear-status-interface at the absolute bottom (score = 0)
+ // 2. Unfocused tiled/fullscreen windows (score = 1)
+ // 3. Focused tiled/fullscreen window (score = 2)
+ // 4. Unfocused floating windows (score = 3)
+ // 5. Focused floating window (score = 4)
+ let active_tags = state.wm.active_tags;
+ let focused_id = state.wm.seats.iter().find(|s| !s.removed).and_then(|s| s.focused_window_id);
+
+ let get_window_score = |win: &crate::types::Window| -> i32 {
+ if win.app_id.as_deref() == Some("clear-status-interface") {
+ 0
+ } else if win.tiling_mode == crate::types::TilingMode::Popup {
+ 5
+ } else if win.tiling_mode != crate::types::TilingMode::Floating {
+ if Some(win.id) == focused_id {
+ 2
+ } else {
+ 1
+ }
+ } else {
+ if Some(win.id) == focused_id {
+ 4
+ } else {
+ 3
}
}
- }
+ };
- // Enforce that clear-status-interface is placed at the bottom
+ let mut nodes_to_place: Vec<(i32, usize, u64, Option<String>, &RiverNodeV1)> = Vec::new();
for &(wid, ref node) in &state.window_nodes {
- if let Some(win) = state.wm.get_window(wid) {
- if win.app_id.as_deref() == Some("clear-status-interface") {
- node.place_bottom();
+ if let Some((idx, win)) = state.wm.windows.iter().enumerate().find(|(_, w)| w.id == wid) {
+ if win.closed {
+ continue;
+ }
+ let visible = (win.tags & active_tags) != 0;
+ if visible {
+ let score = get_window_score(win);
+ nodes_to_place.push((score, idx, win.id, win.app_id.clone(), node));
}
}
}
+ // Sort ascending by score, then by original window list index
+ nodes_to_place.sort_by_key(|&(score, idx, _, _, _)| (score, idx));
+
+ for &(score, _, id, ref app_id, node) in &nodes_to_place {
+ eprintln!(
+ "[render] placing node id={} (app_id={:?}) at top with score {}",
+ id, app_id, score
+ );
+ node.place_top();
+ }
+
state.wm.needs_render = false;
}
@@ -1723,15 +1745,23 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
state.wm.needs_status_update = true;
}
Action::FocusNext => {
- // Focus the next visible window (wrapping) and move it to the
- // front of the cascade stack (end of windows vector).
+ // Focus the next visible window (wrapping) of the same tiling mode,
+ // and move it to the front of the cascade stack (end of windows vector).
if let Some(seat) = state.wm.seats.iter_mut().find(|s| !s.removed) {
let focused_id = seat.focused_window_id;
+ let focused_mode = focused_id.and_then(|fid| {
+ state.wm.windows.iter().find(|w| w.id == fid).map(|w| w.tiling_mode)
+ });
let visible_ids: Vec<u64> = state
.wm
.windows
.iter()
- .filter(|w| (w.tags & state.wm.active_tags) != 0 && !w.closed && w.app_id.as_deref() != Some("clear-status-interface"))
+ .filter(|w| {
+ (w.tags & state.wm.active_tags) != 0
+ && !w.closed
+ && w.app_id.as_deref() != Some("clear-status-interface")
+ && (focused_mode.is_none() || Some(w.tiling_mode) == focused_mode)
+ })
.map(|w| w.id)
.collect();
if let Some(fid) = focused_id {
@@ -1747,9 +1777,15 @@ fn execute_action(state: &mut AppState, action: &crate::types::Action, command:
state.wm.needs_focus = true;
state.wm.needs_status_update = true;
}
+ } else if !visible_ids.is_empty() {
+ let next_id = visible_ids[visible_ids.len() - 1];
+ seat.focused_window_id = Some(next_id);
+ state.wm.move_window_to_end(next_id);
+ state.wm.needs_render = true;
+ state.wm.needs_focus = true;
+ state.wm.needs_status_update = true;
}
}
- // Trigger a manage sequence so focus_window() is called
}
Action::Move => {
// TODO: pointer move
@@ -2729,13 +2765,47 @@ impl Dispatch<wl_seat::WlSeat, ()> for AppState {
impl Dispatch<wl_pointer::WlPointer, ()> for AppState {
fn event(
- _state: &mut Self,
+ state: &mut Self,
_proxy: &wl_pointer::WlPointer,
- _event: wl_pointer::Event,
+ event: wl_pointer::Event,
_data: &(),
_conn: &Connection,
_qhandle: &QueueHandle<Self>,
) {
+ match event {
+ wl_pointer::Event::Enter { surface, .. } => {
+ state.pointer_hovered_surface = Some(surface);
+ }
+ wl_pointer::Event::Leave { .. } => {
+ state.pointer_hovered_surface = None;
+ }
+ wl_pointer::Event::Button { button, state: btn_state, .. } => {
+ // Middle click is button 0x112 (BTN_MIDDLE)
+ if button == 0x112 && btn_state == wayland_client::WEnum::Value(wl_pointer::ButtonState::Pressed) {
+ if let Some(ref current_surface) = state.pointer_hovered_surface {
+ let matched_window_id = state.window_proxies.iter().find_map(|(id, proxy)| {
+ if let Some(dec) = &proxy.decoration {
+ if &dec.surface == current_surface {
+ Some(*id)
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ });
+
+ if let Some(wid) = matched_window_id {
+ eprintln!("[pointer] Middle click on window {} border, closing window", wid);
+ if let Some(wp) = state.get_window_proxy(wid) {
+ wp.river_window.close();
+ }
+ }
+ }
+ }
+ }
+ _ => {}
+ }
}
}
diff --git a/src/wm.rs b/src/wm.rs
index 8467e1f..6a3218f 100644
--- a/src/wm.rs
+++ b/src/wm.rs
@@ -361,14 +361,14 @@ fn compute_tiling(
let fbw = wm.layout.floating_border_width;
let fw = if win.width > 0 {
win.width
- } else if win.hint_min_width > 0 {
+ } else if win.hint_min_width > 32 {
win.hint_min_width
} else {
screen_w * 2 / 3
};
let fh = if win.height > 0 {
win.height
- } else if win.hint_min_height > 0 {
+ } else if win.hint_min_height > 32 {
win.hint_min_height
} else {
screen_h * 2 / 3
@@ -386,6 +386,25 @@ fn compute_tiling(
idx_floating += 1;
(fx, fy, fw, fh)
}
+ TilingMode::Popup => {
+ let fw = if win.width > 0 {
+ win.width
+ } else if win.hint_min_width > 32 {
+ win.hint_min_width
+ } else {
+ 360
+ };
+ let fh = if win.height > 0 {
+ win.height
+ } else if win.hint_min_height > 32 {
+ win.hint_min_height
+ } else {
+ 100
+ };
+ let fx = screen_w - fw - gap_right;
+ let fy = bar_height + gap_top;
+ (fx, fy, fw, fh)
+ }
};
results.push(TileResult { wid, x, y, w, h });