srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-04-27 00:18:00 +0200
committersrdusr <[email protected]>2026-04-27 00:18:00 +0200
commitc4dc99cc3211c4dc1a461f228402be1593b883c9 (patch)
tree10a85d60acb863ca98b7e70118dec5cda357efa6
parent436d42da6ef61a5ea20d5102c4baed7bf0993606 (diff)
downloadsrdwm-c4dc99cc3211c4dc1a461f228402be1593b883c9.tar.gz
srdwm-c4dc99cc3211c4dc1a461f228402be1593b883c9.zip
Build the eight asks recovered from the previous session's transcript
The punch list was not the whole record. These are the owner's own typed requests, read back out of the previous session's transcript rather than guessed at, then each checked against the code before being treated as open. Three they suspected were already done really were: dialogs already had a Close-only titlebar, inactive dimming already existed, and the corner resize hitbox had already been tuned. Snap layouts on drag, asked for twice. Edge snapping worked but committed silently on release with nothing shown first, so there was no way to know it would happen or where. A translucent drop-target preview now follows the drag, and throwing the pointer at a monitor's top edge drops down the existing six-cell grid to aim at. The preview calls the same snap_zone that end_drag does, so the two cannot disagree. Two defects found by screenshot before landing: moving down onto the flyout closed it, and its labels overflowed at a fixed cell width - the same "text goes out of view" fault already fixed once for the context menu. New File now offers real types, chosen by extension, with the de-duplication counter placed before the extension so the file stays what it says it is. Refresh re-reads init.lua and fires a new srd.on("refresh") handler instead of only re-scanning the icon grid. What refresh means beyond srdwm's own config stays the config's decision. general.config_reload_on_write (default on) applies an edited config on save, via an mtime sweep rather than an inotify watch: no new dependency, same behaviour on every target, and unaffected by editors that write through a temp file. A real bug behind "what happens when our config fails": you lost every keybinding. do_reload cleared the binding, handler and repeat tables before re-executing and never restored them, so a syntax error left neither the old config nor the new one, and the only key still working was the reload combo nobody thinks to press. The tables are now restored on any failure and config errors reach notify-send, not just the log. srd.lock() and a default Mod4+Ctrl+l binding: the built-in lock screen could not be reached from Lua at all. Native rather than shelling out, because a lock key that shells out fails silently when the binary is not on PATH. Default bindings added for srd.window.move and a dynamic/tiling toggle, both of which existed with no way to reach them, plus srd.layout.get() so the toggle reads the live workspace rather than the configured default. Dialogs open centred, and are excluded from remembered geometry in both directions - that table is keyed by app_id, which a dialog shares with the window that spawned it, so dialogs inherited an unrelated position and size and then overwrote it with their own. theme.decorations.title_bar.button_mode (dynamic by default, or fixed) drops the Maximize button on a window whose client pinned min == max size, where pressing it can do nothing. Maximize is removed from the slot list rather than skipped in place on both the render and hit-test sides, so the remaining buttons close the gap identically; three tests pin that agreement, which is what fails silently when it drifts. Also: the nested backend's screencopy pass now draws both menus, the flyout and the drag preview. Four investigations in one day started from a screenshot missing a tier, so that pass carries an explicit list of what it still omits and the on-screen loop points at it. 512 tests pass, clippy clean.
-rw-r--r--config/srd/keybindings.lua25
-rw-r--r--crates/config/src/engine/general.rs25
-rw-r--r--crates/config/src/engine/layout.rs19
-rw-r--r--crates/config/src/engine/register.rs2
-rw-r--r--crates/config/src/engine/support.rs58
-rw-r--r--crates/config/src/engine/tests.rs78
-rw-r--r--crates/core/src/lib.rs2
-rw-r--r--crates/core/src/manager/dragresize.rs64
-rw-r--r--crates/core/src/manager/hittest.rs14
-rw-r--r--crates/core/src/manager/lock.rs43
-rw-r--r--crates/core/src/manager/mod.rs17
-rw-r--r--crates/core/src/manager/tests.rs154
-rw-r--r--crates/core/src/manager/windows.rs31
-rw-r--r--crates/core/src/placement.rs25
-rw-r--r--crates/core/src/theme.rs19
-rw-r--r--crates/core/src/window.rs135
-rw-r--r--crates/ctl/src/main.rs7
-rw-r--r--crates/platform/src/ipc/dispatch.rs13
-rw-r--r--crates/platform/src/ipc/types.rs4
-rw-r--r--crates/srdwm/src/main.rs113
-rw-r--r--crates/wayland/src/decoration/tests.rs36
-rw-r--r--crates/wayland/src/decoration/titlebar.rs24
-rw-r--r--crates/wayland/src/desktop_menu.rs82
-rw-r--r--crates/wayland/src/elements.rs57
-rw-r--r--crates/wayland/src/input/pointer.rs82
-rw-r--r--crates/wayland/src/state/desktop_icons.rs25
-rw-r--r--crates/wayland/src/state/lifecycle.rs29
-rw-r--r--crates/wayland/src/state/menu.rs14
-rw-r--r--crates/wayland/src/state/mod.rs7
-rw-r--r--crates/wayland/src/udev/platform.rs1
-rw-r--r--crates/wayland/src/udev/render.rs15
-rw-r--r--crates/wayland/src/winit/capture.rs48
-rw-r--r--crates/wayland/src/winit/connect.rs1
-rw-r--r--crates/wayland/src/winit/render.rs20
-rw-r--r--docs/DEFAULTS.md42
-rw-r--r--docs/TODO.md113
36 files changed, 1364 insertions, 80 deletions
diff --git a/config/srd/keybindings.lua b/config/srd/keybindings.lua
index 5c05b84..aa3e425 100644
--- a/config/srd/keybindings.lua
+++ b/config/srd/keybindings.lua
@@ -32,6 +32,31 @@ srd.bind("Mod4+j", function() srd.window.focus("down") end)
srd.bind("Mod4+k", function() srd.window.focus("up") end)
srd.bind("Mod4+l", function() srd.window.focus("right") end)
+- Move the focused window in a direction, mirroring the focus keys above.
+- `srd.window.move` has existed for a long time and simply had no default
+- binding, so "move window to absolute directions" was unreachable without
+- writing your own config.
+srd.bind("Mod4+Shift+h", function() srd.window.move("left") end)
+srd.bind("Mod4+Shift+j", function() srd.window.move("down") end)
+srd.bind("Mod4+Shift+k", function() srd.window.move("up") end)
+srd.bind("Mod4+Shift+l", function() srd.window.move("right") end)
+
+- Layout switching. srdwm is dynamic-first: "dynamic" is free placement,
+- the default, and tiling is one opt-in layout among several. Mod4+s
+- toggles between the two, which is the pair worth a single key.
+srd.bind("Mod4+s", function()
+ if srd.layout.get() == "tiling" then
+ srd.layout.set("dynamic")
+ else
+ srd.layout.set("tiling")
+ end
+end)
+
+- Lock the session using srdwm's own built-in lock screen. `srd.lock()`
+- talks to the compositor directly rather than shelling out to the control
+- CLI, so it still works when that binary is not on PATH.
+srd.bind("Mod4+Ctrl+l", function() srd.lock() end)
+
- Workspace management
srd.bind("Mod4+Tab", function() srd.workspace.next() end)
srd.bind("Mod4+Shift+Tab", function() srd.workspace.prev() end)
diff --git a/crates/config/src/engine/general.rs b/crates/config/src/engine/general.rs
index 6276854..c10ecf0 100644
--- a/crates/config/src/engine/general.rs
+++ b/crates/config/src/engine/general.rs
@@ -79,7 +79,7 @@ impl Engine {
pub(super) fn fn_on(&self) -> Result<mlua::Function<'_>> {
let state = self.state.clone();
Ok(self.lua.create_function(move |lua, (name, f): (String, mlua::Function)| {
- const KNOWN: [&str; 3] = ["lid_closed", "lid_open", "ready"];
+ const KNOWN: [&str; 4] = ["lid_closed", "lid_open", "ready", "refresh"];
if !KNOWN.contains(&name.as_str()) {
return Err(mlua::Error::RuntimeError(format!(
"srd.on: unknown event '{name}' (known: {})",
@@ -327,6 +327,29 @@ impl Engine {
})?)
}
+ /// `srd.lock()` - locks the session, the same request the control
+ /// CLI's own lock dispatch sends over IPC.
+ ///
+ /// Native rather than left to a `srd.spawn(...)` shelling out to that
+ /// CLI: a lock binding that shells out fails silently when the binary
+ /// is not on the config's own `PATH`, which is exactly the situation a
+ /// user is least able to diagnose - the screen simply does not lock.
+ /// This goes straight to `WindowManager::request_lock`, the same flag
+ /// the IPC dispatch sets, so the backend's existing drain handles it
+ /// with no second code path.
+ ///
+ /// There was no way to bind the built-in lock screen from Lua at all
+ /// before this; the shipped config's only lock key ran an external
+ /// script. Asked directly: "do we have a lockscreen binding?".
+ pub(super) fn fn_lock(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |_, ()| {
+ let wm = state.borrow().wm.clone();
+ wm.borrow_mut().request_lock();
+ Ok(())
+ })?)
+ }
+
pub(super) fn fn_reload(&self) -> Result<mlua::Function<'_>> {
let state = self.state.clone();
// `create_function`'s closure is handed the `&Lua` it's being
diff --git a/crates/config/src/engine/layout.rs b/crates/config/src/engine/layout.rs
index ebdb317..c0d923b 100644
--- a/crates/config/src/engine/layout.rs
+++ b/crates/config/src/engine/layout.rs
@@ -16,6 +16,25 @@ impl Engine {
})?)
}
+ /// `srd.layout.get()` - the name of the layout the *current workspace*
+ /// is using right now.
+ ///
+ /// Deliberately not `srd.get("workspace.layout")`, which reads the
+ /// config values table: that holds the configured default layout for
+ /// new workspaces, not what the workspace in front of you switched to
+ /// since. A toggle written against that key works exactly once and
+ /// then sticks, because the value it reads never changes.
+ pub(super) fn fn_layout_get(&self) -> Result<mlua::Function<'_>> {
+ let state = self.state.clone();
+ Ok(self.lua.create_function(move |lua, ()| {
+ let wm = state.borrow().wm.clone();
+ let wm = wm.borrow();
+ let ws = wm.current_workspace();
+ let name = wm.workspace(ws).map(|w| w.layout.clone()).unwrap_or_default();
+ Ok(Value::String(lua.create_string(&name)?))
+ })?)
+ }
+
pub(super) fn fn_layout_configure(&self) -> Result<mlua::Function<'_>> {
let state = self.state.clone();
Ok(self.lua.create_function(move |_, (name, table): (String, Table)| {
diff --git a/crates/config/src/engine/register.rs b/crates/config/src/engine/register.rs
index 4ef6844..77aede9 100644
--- a/crates/config/src/engine/register.rs
+++ b/crates/config/src/engine/register.rs
@@ -20,6 +20,7 @@ impl Engine {
srd.set("setenv", self.fn_setenv()?)?;
srd.set("notify", self.fn_notify()?)?;
srd.set("quit", self.fn_quit()?)?;
+ srd.set("lock", self.fn_lock()?)?;
srd.set("reload", self.fn_reload()?)?;
srd.set("validate_config", self.fn_validate_config()?)?;
@@ -65,6 +66,7 @@ impl Engine {
let layout = lua.create_table()?;
layout.set("set", self.fn_layout_set()?)?;
+ layout.set("get", self.fn_layout_get()?)?;
layout.set("configure", self.fn_layout_configure()?)?;
srd.set("layout", layout)?;
diff --git a/crates/config/src/engine/support.rs b/crates/config/src/engine/support.rs
index 5574bee..a2423d2 100644
--- a/crates/config/src/engine/support.rs
+++ b/crates/config/src/engine/support.rs
@@ -4,17 +4,58 @@ use super::*;
/// the closure can't capture `&Engine` itself (it isn't `Clone`/`Rc`, and
/// `mlua::Lua::create_function` needs a `'static` closure), so both go
/// through cloned `Lua`/state handles instead of one calling the other.
+/// Re-executes `init.lua`, and puts the previous config back if that fails.
+///
+/// The clear-then-execute order is required: a binding or handler deleted
+/// from the edited file has to actually disappear, which only clearing
+/// first achieves. The bug was that nothing ever undid the clear. A Lua
+/// syntax error - the single most likely thing to go wrong with a
+/// programmable config, and the thing a user is most likely to do by
+/// accident - left the compositor with **no keybindings at all**: not the
+/// old ones, not the new ones. The only key still working was the hardcoded
+/// reload combo `main.rs` handles before consulting Lua, which is the one
+/// key nobody thinks to press when their config has just stopped working,
+/// because nothing tells them that is the situation.
+///
+/// Now the three maps are moved out rather than cleared, and moved back on
+/// any failure, so a broken edit leaves the last *working* config running.
+/// That is the behaviour every mainstream programmable config has (tmux,
+/// Neovim, Hyprland): a bad reload is a no-op with an error, not a
+/// half-applied state.
+///
+/// Answers "what happens when our config fails/user does something wrong
+/// which can be expected since lua programmable config" - asked directly,
+/// and previously answered by the code with "you lose every keybinding".
pub(super) fn do_reload(lua: &Lua, state: &Rc<RefCell<SharedState>>) -> Result<()> {
- let config_dir = {
+ let (config_dir, previous) = {
let mut s = state.borrow_mut();
- s.key_bindings.clear();
- s.event_handlers.clear();
- s.repeat_keys.clear();
- s.config_dir.clone()
+ let previous = (
+ std::mem::take(&mut s.key_bindings),
+ std::mem::take(&mut s.event_handlers),
+ std::mem::take(&mut s.repeat_keys),
+ );
+ (s.config_dir.clone(), previous)
+ };
+ let restore = |state: &Rc<RefCell<SharedState>>, previous: (_, _, _)| {
+ let mut s = state.borrow_mut();
+ // Whatever the failed run managed to register before erroring is
+ // discarded, not merged: half of a broken config is not a config.
+ s.key_bindings = previous.0;
+ s.event_handlers = previous.1;
+ s.repeat_keys = previous.2;
};
let path = config_dir.join("init.lua");
- let src = std::fs::read_to_string(&path).map_err(|source| ConfigError::Io { path: path.clone(), source })?;
- lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec()?;
+ let src = match std::fs::read_to_string(&path) {
+ Ok(src) => src,
+ Err(source) => {
+ restore(state, previous);
+ return Err(ConfigError::Io { path, source });
+ }
+ };
+ if let Err(e) = lua.load(&src).set_name(path.to_string_lossy().as_ref()).exec() {
+ restore(state, previous);
+ return Err(e.into());
+ }
Ok(())
}
@@ -138,6 +179,9 @@ pub(super) fn default_config() -> HashMap<String, ConfigValue> {
m.insert(k.to_string(), v);
};
set("general.default_layout", String("dynamic".into()));
+ // Re-read `init.lua` when it changes on disk, no reload key needed.
+ // See `main.rs`'s `config_mtime`/`CONFIG_POLL_INTERVAL`.
+ set("general.config_reload_on_write", Bool(true));
set("general.smart_placement", Bool(true));
set("general.window_gap", Number(8.0));
set("general.animations", Bool(true));
diff --git a/crates/config/src/engine/tests.rs b/crates/config/src/engine/tests.rs
index f4f011b..591c4c0 100644
--- a/crates/config/src/engine/tests.rs
+++ b/crates/config/src/engine/tests.rs
@@ -101,6 +101,84 @@
}
#[test]
+ fn a_reload_that_fails_leaves_the_previous_config_running() {
+ // The single most likely thing to go wrong with a programmable
+ // config: the user saves a syntax error. Before this, the clear
+ // that precedes re-execution was never undone, so a bad edit left
+ // the compositor with no keybindings at all.
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::write(dir.path().join("init.lua"), r#"srd.bind("Mod4+t", function() end)"#).unwrap();
+ let engine = engine_in(dir.path());
+ engine.load_init().unwrap();
+ assert_eq!(engine.bound_keys().len(), 1, "the good config bound one key");
+
+ std::fs::write(dir.path().join("init.lua"), "this is not lua ((").unwrap();
+ assert!(engine.reload().is_err(), "a syntax error must be reported");
+ assert_eq!(engine.bound_keys().len(), 1, "the working config must survive a failed reload");
+ assert!(engine.dispatch_keybinding(&srdwm_core::canonicalize_key_combo("Mod4+t")), "and must still actually run");
+ }
+
+ #[test]
+ fn a_reload_whose_file_has_vanished_also_keeps_the_previous_config() {
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::write(dir.path().join("init.lua"), r#"srd.bind("Mod4+t", function() end)"#).unwrap();
+ let engine = engine_in(dir.path());
+ engine.load_init().unwrap();
+ std::fs::remove_file(dir.path().join("init.lua")).unwrap();
+ assert!(engine.reload().is_err());
+ assert_eq!(engine.bound_keys().len(), 1);
+ }
+
+ #[test]
+ fn a_reload_that_succeeds_still_drops_bindings_removed_from_the_file() {
+ // The restore must not turn into "bindings are never cleared".
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::write(dir.path().join("init.lua"), r#"srd.bind("Mod4+t", function() end) srd.bind("Mod4+y", function() end)"#).unwrap();
+ let engine = engine_in(dir.path());
+ engine.load_init().unwrap();
+ assert_eq!(engine.bound_keys().len(), 2);
+
+ std::fs::write(dir.path().join("init.lua"), r#"srd.bind("Mod4+t", function() end)"#).unwrap();
+ engine.reload().unwrap();
+ assert_eq!(engine.bound_keys().len(), 1, "a deleted binding must really go away");
+ }
+
+ #[test]
+ fn a_half_applied_broken_config_is_discarded_not_merged() {
+ // The failing file registers a binding and *then* errors. Keeping
+ // it would leave a config that is neither the old one nor the new.
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::write(dir.path().join("init.lua"), r#"srd.bind("Mod4+t", function() end)"#).unwrap();
+ let engine = engine_in(dir.path());
+ engine.load_init().unwrap();
+
+ std::fs::write(dir.path().join("init.lua"), "srd.bind(\"Mod4+z\", function() end)\nerror(\"boom\")").unwrap();
+ assert!(engine.reload().is_err());
+ let keys = engine.bound_keys();
+ assert_eq!(keys.len(), 1);
+ assert!(!keys.iter().any(|k| k.contains('z')), "the broken run's own binding must not survive: {keys:?}");
+ }
+
+ #[test]
+ fn refresh_is_a_known_event_name() {
+ let dir = tempfile::tempdir().unwrap();
+ let engine = engine_in(dir.path());
+ engine.lua.load(r#"srd.on("refresh", function() srd.set("refreshed", true) end)"#).exec().unwrap();
+ assert!(engine.dispatch_event("refresh"), "a registered refresh handler must run");
+ assert_eq!(engine.get("refreshed"), Some(ConfigValue::Bool(true)));
+ }
+
+ #[test]
+ fn srd_lock_queues_a_lock_request_on_the_window_manager() {
+ let dir = tempfile::tempdir().unwrap();
+ let wm = Rc::new(RefCell::new(WindowManager::new()));
+ let engine = Engine::new(wm.clone(), dir.path()).unwrap();
+ assert!(!wm.borrow_mut().drain_lock_request(), "nothing queued yet");
+ engine.lua.load("srd.lock()").exec().unwrap();
+ assert!(wm.borrow_mut().drain_lock_request(), "srd.lock() must queue a real request");
+ }
+
+ #[test]
fn srd_load_executes_module_relative_to_config_dir() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("extra.lua"), r#"srd.set("from.extra", "yes")"#).unwrap();
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index ed24e78..30fc020 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -19,7 +19,7 @@ pub use layout::{Layout, MasterStackLayout, NoOpLayout, TilingConfig};
pub use lock_config::LockConfig;
pub use manager::{CaptureRequest, ColorFilter, Direction, WindowManager};
pub use monitor::{Monitor, MonitorId};
-pub use placement::{PlacementConfig, SmartPlacement, SnapZoneKind};
+pub use placement::{centered_in, PlacementConfig, SmartPlacement, SnapZoneKind, SNAP_FLYOUT_EDGE};
pub use regex::Regex;
pub use rules::{WindowMatch, WindowRule, WindowRuleActions};
pub use theme::{format_hex_color, parse_hex_color, ThemeConfig};
diff --git a/crates/core/src/manager/dragresize.rs b/crates/core/src/manager/dragresize.rs
index 7181bb3..2efca7c 100644
--- a/crates/core/src/manager/dragresize.rs
+++ b/crates/core/src/manager/dragresize.rs
@@ -10,12 +10,16 @@ impl WindowManager {
pub fn start_drag(&mut self, id: WindowId, x: i32, y: i32) {
if let Some(w) = self.windows.get(&id) {
- self.drag = Some(DragState { window: id, start_x: x, start_y: y, orig: w.geometry });
+ self.drag = Some(DragState { window: id, start_x: x, start_y: y, orig: w.geometry, last_x: x, last_y: y });
self.focus_window(id);
}
}
pub fn update_drag(&mut self, x: i32, y: i32) {
+ if let Some(drag) = &mut self.drag {
+ drag.last_x = x;
+ drag.last_y = y;
+ }
let Some(drag) = &self.drag else { return };
let (dx, dy) = (x - drag.start_x, y - drag.start_y);
let mut new_geom = drag.orig;
@@ -108,10 +112,68 @@ impl WindowManager {
}
}
+ /// Where the currently-dragged window would land if the button came up
+ /// right now, or `None` when the drag is not in a snap zone.
+ ///
+ /// Deliberately calls the very same `SmartPlacement::snap_zone` that
+ /// `end_drag` does, on the same inputs, rather than re-deriving the
+ /// zones: a preview that can disagree with what release actually does
+ /// is worse than no preview, and any future change to the zone
+ /// geometry updates both at once by construction.
+ ///
+ /// Reported twice as missing: "if you move the window to absolute
+ /// north it show you layout options" and "why do i still not see the
+ /// windows layout or windows change layout when moved to areas of
+ /// screen like in windows". Edge snapping itself already worked - it
+ /// just committed silently on release with nothing shown beforehand,
+ /// so there was no way to tell it was going to happen, or where.
+ pub fn drag_snap_preview(&self) -> Option<Rect> {
+ let drag = self.drag.as_ref()?;
+ let w = self.windows.get(&drag.window)?;
+ let m = self.monitor_for(w.monitor)?;
+ SmartPlacement::snap_zone(w.geometry, m, &self.placement)
+ }
+
+ /// The monitor whose top edge the drag pointer is currently within
+ /// [`SNAP_FLYOUT_EDGE`] of, or `None`.
+ ///
+ /// Measured against `full_geometry`, not `geometry`: the trigger band
+ /// is the physical top of the screen, which is exactly where a bar
+ /// usually sits. Using the exclusive-zone-shrunk rect would put the
+ /// band *below* the bar, so on a machine with a top bar the gesture
+ /// would only fire after the pointer had already travelled past it.
+ ///
+ /// The pointer, not the window's own top edge, because the two differ
+ /// by however far down the titlebar the drag grabbed - and it is the
+ /// pointer the user is actually aiming.
+ ///
+ /// Not `Rect::contains_point`: that would also reject a pointer *above*
+ /// the monitor's top edge, which is the one direction this gesture is
+ /// aimed in. A real seat clamps the cursor to the output, so `y < 0`
+ /// should not arise on hardware - but `update_drag` clamps only the
+ /// window, so nothing in this type's own API guarantees it, and
+ /// "thrown past the edge" is the strongest possible form of the intent
+ /// this is trying to detect. Horizontal containment is still required,
+ /// as is being above the monitor's bottom, so a pointer on a different
+ /// output never matches.
+ pub fn drag_top_edge_monitor(&self) -> Option<&Monitor> {
+ let drag = self.drag.as_ref()?;
+ let (x, y) = (drag.last_x, drag.last_y);
+ self.monitors.iter().find(|m| {
+ let g = m.full_geometry;
+ x >= g.x && x < g.right() && y < g.bottom() && y - g.y <= SNAP_FLYOUT_EDGE
+ })
+ }
+
pub fn is_dragging(&self) -> bool {
self.drag.is_some()
}
+ /// The window the current drag is moving, if any.
+ pub fn dragged_window(&self) -> Option<WindowId> {
+ self.drag.as_ref().map(|d| d.window)
+ }
+
pub fn start_resize(&mut self, id: WindowId, edge: ResizeEdge, x: i32, y: i32) {
if let Some(w) = self.windows.get(&id) {
// Decided *before* `focus_window` below re-stacks `id` --
diff --git a/crates/core/src/manager/hittest.rs b/crates/core/src/manager/hittest.rs
index ab19a5c..9e0a8e1 100644
--- a/crates/core/src/manager/hittest.rs
+++ b/crates/core/src/manager/hittest.rs
@@ -6,6 +6,18 @@
use super::*;
impl WindowManager {
+ /// Whether `w` gets a Maximize button, resolving `theme.dynamic_buttons`
+ /// against the window's own declared resizability.
+ ///
+ /// Lives here, next to the hit-test that consumes it, so the renderer
+ /// and the hit-test are reading one shared answer rather than each
+ /// deriving their own - these two have drifted apart before, and the
+ /// failure mode (buttons drawn in one set of slots, clicks resolved
+ /// against another) is silent.
+ pub fn show_maximize(&self, w: &Window) -> bool {
+ !self.theme.dynamic_buttons || w.resizable
+ }
+
// ---- Hit testing ------------------------------------------------------
/// Topmost window whose frame contains `(x, y)`, along with what part of
@@ -59,7 +71,7 @@ impl WindowManager {
}
let margin = w.resize_margin.unwrap_or(self.resize_margin);
let geometry = geometry_for(w.id, w.geometry);
- if let Some(hit) = ResizeEdge::hit_test(geometry, x, y, w.decorated, w.border_width, margin, self.theme.buttons_left, self.theme.button_order, w.is_dialog) {
+ if let Some(hit) = ResizeEdge::hit_test(geometry, x, y, w.decorated, w.border_width, margin, self.theme.buttons_left, self.theme.button_order, w.is_dialog, self.show_maximize(w)) {
return Some((w.id, hit));
}
// Not a titlebar/border/resize-margin hit on `w` - but if the
diff --git a/crates/core/src/manager/lock.rs b/crates/core/src/manager/lock.rs
index 07735c6..0ffe0ca 100644
--- a/crates/core/src/manager/lock.rs
+++ b/crates/core/src/manager/lock.rs
@@ -25,6 +25,32 @@ impl WindowManager {
pub fn drain_lock_request(&mut self) -> bool {
std::mem::take(&mut self.lock_requested)
}
+
+ /// Queues a request for the config layer to re-read `init.lua` and fire
+ /// the `srd.on("refresh", ...)` handler.
+ ///
+ /// Same core/backend split as `request_lock` above, for the same
+ /// reason: core owns no Lua state, so it cannot reload a config or run
+ /// a handler itself. The desktop menu's own "Refresh" row is the
+ /// caller.
+ ///
+ /// Asked for as "does refresh refresh configs in a function list in the
+ /// config ie refresh os, etc, ags/aegis/polybar/waybar". Refresh used
+ /// to re-scan the desktop icon grid and nothing else, so there was no
+ /// way to make it reload anything the user actually cared about. What
+ /// "refresh" *means* beyond srdwm's own config is deliberately the
+ /// config's decision, not a hardcoded list of other people's tools --
+ /// this compositor has no business knowing whether the user runs
+ /// waybar or AGS.
+ pub fn request_refresh(&mut self) {
+ self.refresh_requested = true;
+ }
+
+ /// Takes the current refresh request, if any. Drained once per poll,
+ /// same as `drain_lock_request`.
+ pub fn drain_refresh_request(&mut self) -> bool {
+ std::mem::take(&mut self.refresh_requested)
+ }
}
#[cfg(test)]
@@ -41,6 +67,23 @@ mod tests {
}
#[test]
+ fn drain_refresh_request_is_true_once_then_false() {
+ let mut wm = WindowManager::new();
+ assert!(!wm.drain_refresh_request(), "nothing requested yet");
+ wm.request_refresh();
+ assert!(wm.drain_refresh_request(), "must report the pending request");
+ assert!(!wm.drain_refresh_request(), "must not report the same request twice");
+ }
+
+ #[test]
+ fn a_refresh_request_is_independent_of_a_lock_request() {
+ let mut wm = WindowManager::new();
+ wm.request_refresh();
+ assert!(!wm.drain_lock_request(), "refresh must not look like a lock");
+ assert!(wm.drain_refresh_request());
+ }
+
+ #[test]
fn requesting_lock_twice_before_a_drain_is_still_just_one_pending_request() {
let mut wm = WindowManager::new();
wm.request_lock();
diff --git a/crates/core/src/manager/mod.rs b/crates/core/src/manager/mod.rs
index 9265885..7454ce2 100644
--- a/crates/core/src/manager/mod.rs
+++ b/crates/core/src/manager/mod.rs
@@ -1,7 +1,7 @@
use crate::geometry::Rect;
use crate::layout::{Layout, MasterStackLayout, NoOpLayout, TilingConfig};
use crate::monitor::{DisabledMonitor, Monitor, MonitorId, MonitorSplit};
-use crate::placement::{PlacementConfig, SmartPlacement, SnapZoneKind, MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH};
+use crate::placement::{centered_in, PlacementConfig, SmartPlacement, SnapZoneKind, MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, SNAP_FLYOUT_EDGE};
use crate::rules::WindowRule;
#[cfg(test)]
use crate::rules::{WindowMatch, WindowRuleActions};
@@ -43,6 +43,17 @@ struct DragState {
start_x: i32,
start_y: i32,
orig: Rect,
+ /// Where the pointer was on the last `update_drag` tick, global space.
+ /// Seeded from the drag's own start point so it is never meaningless,
+ /// even for a drag that ends before a single motion event arrives.
+ ///
+ /// Only the *pointer* can answer "is the user reaching for the top of
+ /// the screen right now" - `orig`/`Window::geometry` answer "where is
+ /// the window", which is a different question during a drag, because
+ /// the window hangs below the grab point by however far down its
+ /// titlebar the user took hold of it. See `drag_top_edge_monitor`.
+ last_x: i32,
+ last_y: i32,
}
struct ResizeState {
@@ -423,6 +434,9 @@ pub struct WindowManager {
/// Read from `theme.lock.*`. See `LockConfig`'s own doc comment for
/// why this isn't just folded into `theme` above.
pub lock: LockConfig,
+ /// Set by `request_refresh`, drained by the main loop. Same
+ /// cross-boundary queued-request shape as `lock_requested`.
+ refresh_requested: bool,
drag: Option<DragState>,
resize: Option<ResizeState>,
rules: Vec<WindowRule>,
@@ -586,6 +600,7 @@ impl WindowManager {
auto_raise: false,
theme: ThemeConfig::default(),
lock: LockConfig::default(),
+ refresh_requested: false,
drag: None,
resize: None,
rules: Vec::new(),
diff --git a/crates/core/src/manager/tests.rs b/crates/core/src/manager/tests.rs
index 6a3ba3f..fade3aa 100644
--- a/crates/core/src/manager/tests.rs
+++ b/crates/core/src/manager/tests.rs
@@ -700,6 +700,160 @@
assert_eq!(wm.window(a).unwrap().geometry, original);
}
+ /// Adds a window and drags it by exactly `(dx, dy)`, leaving the drag
+ /// open. Deltas rather than absolute targets because `add_window` runs
+ /// `SmartPlacement`, so a window's real starting rect is chosen by the
+ /// placement policy, not by whatever the test set before adding it.
+ fn drag_by(wm: &mut WindowManager, dx: i32, dy: i32) -> (WindowId, Rect) {
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "a"));
+ let start = wm.window(a).unwrap().geometry;
+ // Grab well down the window so the pointer and the window's own
+ // top edge are never accidentally the same measurement.
+ let (px, py) = (start.x + 40, start.y + 40);
+ wm.start_drag(a, px, py);
+ wm.update_drag(px + dx, py + dy);
+ (a, start)
+ }
+
+ #[test]
+ fn dragging_to_the_left_edge_previews_the_left_half_before_release() {
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "a"));
+ let start = wm.window(a).unwrap().geometry;
+ assert_eq!(wm.drag_snap_preview(), None, "no drag in progress");
+
+ let (px, py) = (start.x + 40, start.y + 40);
+ wm.start_drag(a, px, py);
+ assert_eq!(wm.drag_snap_preview(), None, "still where it started: nothing to preview");
+
+ // Exactly enough to put the window's own left edge on x=0.
+ wm.update_drag(px - start.x, py);
+ assert_eq!(wm.drag_snap_preview(), Some(Rect::new(0, 0, 960, 1080)), "left half");
+ }
+
+ #[test]
+ fn the_drag_preview_is_exactly_what_release_then_commits() {
+ // The preview and the commit must never be able to disagree --
+ // both go through SmartPlacement::snap_zone on the same inputs.
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "a"));
+ let start = wm.window(a).unwrap().geometry;
+ let (px, py) = (start.x + 40, start.y + 40);
+ wm.start_drag(a, px, py);
+ wm.update_drag(px - start.x, py);
+ let previewed = wm.drag_snap_preview().expect("a zone was previewed");
+ wm.end_drag();
+ assert_eq!(wm.window(a).unwrap().geometry, previewed);
+ }
+
+ #[test]
+ fn a_drag_that_is_not_near_any_edge_previews_nothing() {
+ let mut wm = wm_with_monitor();
+ let (_, _) = drag_by(&mut wm, 300, 300);
+ assert_eq!(wm.drag_snap_preview(), None);
+ }
+
+ #[test]
+ fn the_flyout_trigger_follows_the_pointer_not_the_windows_own_top_edge() {
+ // A drag grabbed 40px down its titlebar holds the window's top
+ // edge 40px below the pointer, and `update_drag` clamps the window
+ // to the monitor while the pointer is free to reach y=0. Aiming
+ // the cursor at the top of the screen has to be enough on its own.
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "a"));
+ let start = wm.window(a).unwrap().geometry;
+ let (px, py) = (start.x + 40, start.y + 40);
+ wm.start_drag(a, px, py);
+
+ wm.update_drag(px, py + 200);
+ assert!(wm.drag_top_edge_monitor().is_none(), "mid-screen");
+
+ wm.update_drag(px, 2);
+ assert_eq!(wm.drag_top_edge_monitor().map(|m| m.id), Some(0), "pointer is at the top edge");
+ }
+
+ #[test]
+ fn the_flyout_trigger_needs_the_pointer_actually_at_the_edge() {
+ let mut wm = wm_with_monitor();
+ drag_by(&mut wm, 0, -10_000);
+ // Clamped hard against the top: the pointer went with it, so this
+ // one legitimately does trigger.
+ assert!(wm.drag_top_edge_monitor().is_some());
+
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ wm.add_window(Window::new(a, "a"));
+ let start = wm.window(a).unwrap().geometry;
+ wm.start_drag(a, start.x + 40, start.y + 40);
+ wm.update_drag(start.x + 40, SNAP_FLYOUT_EDGE + 1);
+ assert!(wm.drag_top_edge_monitor().is_none(), "one pixel outside the band");
+ }
+
+ #[test]
+ fn there_is_no_flyout_trigger_when_nothing_is_being_dragged() {
+ let wm = wm_with_monitor();
+ assert!(wm.drag_top_edge_monitor().is_none());
+ }
+
+ #[test]
+ fn a_dialog_opens_centered_not_cascaded_into_the_corner() {
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ let mut d = Window::new(a, "dialog");
+ d.is_dialog = true;
+ d.geometry = Rect::new(0, 0, 400, 300);
+ wm.add_window(d);
+ // 1920x1080 monitor, 400x300 dialog.
+ assert_eq!(wm.window(a).unwrap().geometry, Rect::new(760, 390, 400, 300));
+ }
+
+ #[test]
+ fn a_dialog_ignores_the_apps_remembered_position() {
+ // `remembered_geometry` is keyed by app_id, which a dialog shares
+ // with the window that spawned it - without the dialog branch
+ // running first, a dialog lands wherever that app's last main
+ // window sat.
+ let mut wm = wm_with_monitor();
+ wm.set_remembered_geometry("someapp".to_string(), (50, 60, 900, 700));
+ let a = wm.alloc_window_id();
+ let mut d = Window::new(a, "dialog");
+ d.app_id = "someapp".into();
+ d.is_dialog = true;
+ d.geometry = Rect::new(0, 0, 400, 300);
+ wm.add_window(d);
+ let g = wm.window(a).unwrap().geometry;
+ assert_eq!((g.x, g.y), (760, 390), "centred, not restored to (50,60)");
+ }
+
+ #[test]
+ fn an_ordinary_window_still_uses_its_remembered_position() {
+ // The dialog branch must not have stolen the normal path.
+ let mut wm = wm_with_monitor();
+ wm.set_remembered_geometry("someapp".to_string(), (50, 60, 900, 700));
+ let a = wm.alloc_window_id();
+ let mut w = Window::new(a, "main");
+ w.app_id = "someapp".into();
+ wm.add_window(w);
+ let g = wm.window(a).unwrap().geometry;
+ assert_eq!((g.x, g.y), (50, 60));
+ }
+
+ #[test]
+ fn a_dialog_larger_than_the_screen_still_starts_on_screen() {
+ let mut wm = wm_with_monitor();
+ let a = wm.alloc_window_id();
+ let mut d = Window::new(a, "dialog");
+ d.is_dialog = true;
+ d.geometry = Rect::new(0, 0, 4000, 3000);
+ wm.add_window(d);
+ let g = wm.window(a).unwrap().geometry;
+ assert!(g.x >= 0 && g.y >= 0, "clamped to the monitor origin, got {g:?}");
+ }
+
#[test]
fn apply_snap_zone_resizes_to_the_named_zones_rect() {
let mut wm = wm_with_monitor();
diff --git a/crates/core/src/manager/windows.rs b/crates/core/src/manager/windows.rs
index 3271dee..bd13b0c 100644
--- a/crates/core/src/manager/windows.rs
+++ b/crates/core/src/manager/windows.rs
@@ -77,8 +77,15 @@ impl WindowManager {
// question from size: a size is always safe to reapply verbatim,
// but a *position* needs checking against the monitors that
// actually exist right now before it's safe to reuse.
+ //
+ // Skipped entirely for a dialog: `remembered_geometry` is keyed by
+ // `app_id`, which a dialog shares with the ordinary window that
+ // spawned it, so reapplying that entry would resize a small
+ // confirmation prompt to the size of the app's main window. Its
+ // own requested size is the right one. See the dialog branch
+ // further down for the matching position half.
let mut remembered_position: Option<(i32, i32)> = None;
- if !window.app_id.is_empty() {
+ if !window.app_id.is_empty() && !window.is_dialog {
if let Some((x, y, w, h)) = self.remembered_geometry.get(&window.app_id).copied() {
window.geometry.width = w.max(MIN_WINDOW_WIDTH);
window.geometry.height = h.max(MIN_WINDOW_HEIGHT);
@@ -131,7 +138,27 @@ impl WindowManager {
// shrunk usable one): a remembered position under where a bar now
// sits is still "a real monitor, just partly covered", not invalid.
let remembered_monitor = remembered_position.and_then(|(x, y)| self.monitors.iter().find(|m| m.full_geometry.contains_point(x, y)));
- if let (Some((x, y)), Some(monitor)) = (remembered_position, remembered_monitor) {
+ // A dialog is centred, and neither cascaded nor restored to a
+ // remembered spot. Reported as "even dialog/starter windows spawn
+ // that side, most times should be centered".
+ //
+ // Ahead of the remembered-position branch on purpose: `remembered_
+ // geometry` is keyed by `app_id`, which a dialog shares with the
+ // ordinary window that spawned it, so a dialog would otherwise be
+ // dropped at wherever that app's last *main* window happened to
+ // sit - and would then overwrite that memory with its own small
+ // rect on close. Centring is also what every mainstream desktop
+ // does with a transient: the user's attention is already at the
+ // middle of the screen, not at a cascade origin.
+ //
+ // `geometry`, not `full_geometry`: a dialog centred in the usable
+ // area sits clear of a bar or dock, which is where a modal belongs.
+ if window.is_dialog {
+ if let Some(monitor) = target_monitor {
+ window.monitor = monitor.id;
+ window.geometry = centered_in(monitor.geometry, window.geometry.width, window.geometry.height);
+ }
+ } else if let (Some((x, y)), Some(monitor)) = (remembered_position, remembered_monitor) {
window.monitor = monitor.id;
window.geometry.x = x;
window.geometry.y = y;
diff --git a/crates/core/src/placement.rs b/crates/core/src/placement.rs
index e2b3d04..91c317c 100644
--- a/crates/core/src/placement.rs
+++ b/crates/core/src/placement.rs
@@ -51,6 +51,19 @@ impl Default for PlacementConfig {
}
}
+/// How close to a monitor's top edge the drag pointer has to get before
+/// the Snap-Layouts flyout drops down, in logical pixels.
+///
+/// Much larger than [`PlacementConfig::snap_threshold`] (8) on purpose, and
+/// they measure different things: `snap_threshold` measures the dragged
+/// *window's* edge against the screen edge and decides whether to commit a
+/// snap, so it has to be tight or an ordinary reposition near the top
+/// silently maximizes. This measures the *pointer* and only decides whether
+/// to offer a menu, which costs nothing if ignored - the user throws the
+/// cursor at the top of the screen, the way Windows 11's own gesture works,
+/// and a tight band would just make it feel unreliable.
+pub const SNAP_FLYOUT_EDGE: i32 = 12;
+
/// The six fixed screen positions offered by the Snap-Layouts flyout
/// (`crates/wayland/src/snap_flyout.rs`, opened by right-clicking a
/// titlebar's maximize button) - the click-driven equivalent of dragging a
@@ -117,6 +130,18 @@ impl SnapZoneKind {
}
}
+/// A `width` x `height` rect centred in `area`, clamped so it never starts
+/// outside `area` even when it is larger than it.
+///
+/// Used for dialogs (see `WindowManager::add_window`). Integer division
+/// biases a one-pixel remainder toward the top-left, which is the standard
+/// convention and invisible in practice.
+pub fn centered_in(area: Rect, width: u32, height: u32) -> Rect {
+ let x = area.x + (area.width as i32 - width as i32) / 2;
+ let y = area.y + (area.height as i32 - height as i32) / 2;
+ Rect::new(x.max(area.x), y.max(area.y), width, height)
+}
+
pub struct SmartPlacement;
impl SmartPlacement {
diff --git a/crates/core/src/theme.rs b/crates/core/src/theme.rs
index 4dbf96d..026688a 100644
--- a/crates/core/src/theme.rs
+++ b/crates/core/src/theme.rs
@@ -157,6 +157,24 @@ pub struct ThemeConfig {
/// this convention's own (Windows/GNOME) maximize icon rather than
/// borrowing the other convention's.
pub traffic_light_buttons: bool,
+ /// Show only the titlebar buttons the window can actually use, rather
+ /// than a fixed three.
+ ///
+ /// Today that means one rule: a window whose client pinned its minimum
+ /// and maximum size to the same value (`Window::resizable` is `false`)
+ /// does not get a Maximize button, because pressing it can do nothing.
+ /// A dialog's Close-only titlebar is a separate, older rule that
+ /// applies regardless of this setting.
+ ///
+ /// On by default. A button that visibly does nothing when pressed is a
+ /// defect rather than a preference, and GNOME, KDE and Windows all
+ /// hide or disable maximize in exactly this case. `theme.button_mode =
+ /// "fixed"` restores the always-three behaviour for anyone who wants
+ /// the titlebar to look identical on every window.
+ ///
+ /// Asked for as titlebars able to use "decorations/buttons of the
+ /// program/dynamic".
+ pub dynamic_buttons: bool,
}
impl Default for ThemeConfig {
@@ -175,6 +193,7 @@ impl Default for ThemeConfig {
button_order: None,
button_glyph_always: false,
traffic_light_buttons: true,
+ dynamic_buttons: true,
}
}
}
diff --git a/crates/core/src/window.rs b/crates/core/src/window.rs
index a3d48a2..ee9fd23 100644
--- a/crates/core/src/window.rs
+++ b/crates/core/src/window.rs
@@ -182,6 +182,21 @@ pub struct Window {
/// lights - see `hit_test`'s and `decoration::render_titlebar`'s own
/// use of this for what actually changes.
pub is_dialog: bool,
+ /// Whether the client says it can actually be resized - `false` when
+ /// it pinned its minimum and maximum size to the same value (a native
+ /// `xdg_toplevel`'s `set_min_size`/`set_max_size`, or an XWayland
+ /// window's ICCCM size hints). Backend-set on every decoration redraw,
+ /// same as `is_dialog` above, for the same reason: `core` has no
+ /// protocol of its own to read it from. Defaults to `true`, so a
+ /// client that never declares limits - the common case - is treated
+ /// as resizable.
+ ///
+ /// Consumed by the `dynamic` titlebar button mode: a window that
+ /// cannot be resized cannot meaningfully be maximized either, so
+ /// offering the button is offering a no-op. Every mainstream desktop
+ /// (GNOME, KDE, Windows) hides or disables it in exactly this case.
+ /// Asked for as titlebars with "buttons of the program/dynamic".
+ pub resizable: bool,
/// `decorated`'s value from just before entering fullscreen, restored
/// on exit - see `WindowManager::toggle_fullscreen`'s doc comment on
/// why this can't just hardcode `true` back.
@@ -286,6 +301,7 @@ impl Window {
restore_geometry: None,
decorated: true,
is_dialog: false,
+ resizable: true,
restore_decorated: None,
floating: false,
minimized: false,
@@ -503,6 +519,12 @@ impl ResizeEdge {
// other" trap every other button-geometry value here already has
// to avoid.
is_dialog: bool,
+ // Whether a Maximize button is shown at all - `theme.
+ // dynamic_buttons && !window.resizable` resolves to `false`. Same
+ // "must stay in exact agreement with `decoration::render_titlebar`"
+ // contract as `is_dialog` directly above, and for the same reason:
+ // the two sides compute button slots independently.
+ show_maximize: bool,
) -> Option<TitlebarHit> {
// Border strips render *outside* `frame` (`decoration::
// border_strips`, `border_width` pixels past each edge) - without
@@ -579,10 +601,20 @@ impl ResizeEdge {
[TitlebarButton::Close, TitlebarButton::Maximize, TitlebarButton::Minimize]
})
};
+ // Maximize dropped from the slot list entirely rather than
+ // left in place and ignored: leaving a hole would put a dead
+ // gap between the two remaining buttons, and the renderer
+ // closes the gap, so hit-testing has to close it identically
+ // or every button after it is offset by one slot.
+ let order: Vec<TitlebarButton> = if show_maximize {
+ order.to_vec()
+ } else {
+ order.iter().copied().filter(|b| *b != TitlebarButton::Maximize).collect()
+ };
// A dialog only ever recognizes the first slot, matching
// `decoration::render_titlebar` only ever drawing the one
// button there too.
- let button_count = if is_dialog { 1 } else { 3 };
+ let button_count = if is_dialog { 1 } else { order.len() };
if buttons_left {
let left = frame.x + BUTTON_CLUSTER_MARGIN as i32;
// `x >= left` excludes the dead `BUTTON_CLUSTER_MARGIN`
@@ -1000,14 +1032,14 @@ mod tests {
// margin is a real dead strip now (see its own doc comment) - a
// point only `5` in from the raw edge landed inside it, not on the
// button.
- let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, None, false);
+ let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, None, false, true);
assert_eq!(hit, Some(TitlebarHit::Close));
}
#[test]
fn maximize_is_left_of_close() {
let f = frame();
- let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - BUTTON_PITCH as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, None, false);
+ let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - BUTTON_PITCH as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, None, false, true);
assert_eq!(hit, Some(TitlebarHit::Maximize));
}
@@ -1023,12 +1055,12 @@ mod tests {
// undecorated windows.
let f = frame();
let maximize_spot = f.right() - BUTTON_CLUSTER_MARGIN as i32 - BUTTON_PITCH as i32 - 5;
- let hit = ResizeEdge::hit_test(f, maximize_spot, f.y + 5, true, 0, RESIZE_MARGIN, false, None, true);
+ let hit = ResizeEdge::hit_test(f, maximize_spot, f.y + 5, true, 0, RESIZE_MARGIN, false, None, true, true);
assert_ne!(hit, Some(TitlebarHit::Maximize), "a dialog must not have a Maximize hit zone at all");
assert_ne!(hit, Some(TitlebarHit::Minimize), "a dialog must not have a Minimize hit zone at all");
// The one real button (Close) must still be exactly where it always
// is - `is_dialog` removes the other two, not shifts this one.
- let close_hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, None, true);
+ let close_hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, None, true, true);
assert_eq!(close_hit, Some(TitlebarHit::Close));
}
@@ -1041,7 +1073,7 @@ mod tests {
// it get used.
let f = frame();
let order = [TitlebarButton::Maximize, TitlebarButton::Minimize, TitlebarButton::Close];
- let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, Some(order), true);
+ let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, Some(order), true, true);
assert_eq!(hit, Some(TitlebarHit::Close));
}
@@ -1053,7 +1085,7 @@ mod tests {
// not just what gets drawn.
let f = frame();
let order = [TitlebarButton::Close, TitlebarButton::Minimize, TitlebarButton::Maximize];
- let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - BUTTON_PITCH as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, Some(order), false);
+ let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - BUTTON_PITCH as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, Some(order), false, true);
assert_eq!(hit, Some(TitlebarHit::Minimize));
}
@@ -1066,8 +1098,8 @@ mod tests {
// on.
let f = frame();
let order = [TitlebarButton::Maximize, TitlebarButton::Minimize, TitlebarButton::Close];
- let left_hit = ResizeEdge::hit_test(f, f.x + BUTTON_CLUSTER_MARGIN as i32 + 5, f.y + 5, true, 0, RESIZE_MARGIN, true, Some(order), false);
- let right_hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, Some(order), false);
+ let left_hit = ResizeEdge::hit_test(f, f.x + BUTTON_CLUSTER_MARGIN as i32 + 5, f.y + 5, true, 0, RESIZE_MARGIN, true, Some(order), false, true);
+ let right_hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 5, f.y + 5, true, 0, RESIZE_MARGIN, false, Some(order), false, true);
assert_eq!(left_hit, Some(TitlebarHit::Maximize));
assert_eq!(right_hit, Some(TitlebarHit::Maximize));
}
@@ -1081,17 +1113,54 @@ mod tests {
// of_titlebar_resizes_not_drags` for that zone's own coverage.
let f = frame();
let (cx, _) = f.center();
- let hit = ResizeEdge::hit_test(f, cx, f.y + DECORATED_TOP_RESIZE_MARGIN + 5, true, 0, RESIZE_MARGIN, false, None, false);
+ let hit = ResizeEdge::hit_test(f, cx, f.y + DECORATED_TOP_RESIZE_MARGIN + 5, true, 0, RESIZE_MARGIN, false, None, false, true);
assert_eq!(hit, Some(TitlebarHit::Drag));
}
#[test]
fn bottom_right_corner_is_resize() {
let f = frame();
- let hit = ResizeEdge::hit_test(f, f.right() - 1, f.bottom() - 1, true, 0, RESIZE_MARGIN, false, None, false);
+ let hit = ResizeEdge::hit_test(f, f.right() - 1, f.bottom() - 1, true, 0, RESIZE_MARGIN, false, None, false, true);
assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::BottomRight)));
}
+ #[test]
+ fn a_non_resizable_window_has_no_maximize_button_to_click() {
+ // `show_maximize = false` - the dynamic button mode's one rule.
+ let f = Rect::new(0, 0, 400, 300);
+ // Buttons on the right, default order Close, Maximize, Minimize.
+ // Slot 0 is Close either way.
+ let slot = |i: i32| f.right() - BUTTON_CLUSTER_MARGIN as i32 - BUTTON_PITCH as i32 * i - 1;
+ let hit = |x: i32, show_max: bool| ResizeEdge::hit_test(f, x, f.y + TITLEBAR_HEIGHT as i32 / 2, true, 0, RESIZE_MARGIN, false, None, false, show_max);
+
+ assert_eq!(hit(slot(1), true), Some(TitlebarHit::Maximize), "resizable: slot 1 is Maximize");
+ // With Maximize dropped, Minimize moves up into slot 1 - it must
+ // not leave a dead gap there.
+ assert_eq!(hit(slot(1), false), Some(TitlebarHit::Minimize), "non-resizable: Minimize closes the gap");
+ assert_eq!(hit(slot(0), false), Some(TitlebarHit::Close), "Close stays put");
+ }
+
+ #[test]
+ fn a_non_resizable_window_still_has_exactly_two_buttons() {
+ let f = Rect::new(0, 0, 400, 300);
+ let slot = |i: i32| f.right() - BUTTON_CLUSTER_MARGIN as i32 - BUTTON_PITCH as i32 * i - 1;
+ let hit = |x: i32| ResizeEdge::hit_test(f, x, f.y + TITLEBAR_HEIGHT as i32 / 2, true, 0, RESIZE_MARGIN, false, None, false, false);
+ // Slot 2 held Minimize when there were three; with two it is past
+ // the cluster and must be a drag, not a phantom third button.
+ assert_eq!(hit(slot(2)), Some(TitlebarHit::Drag), "no third button exists any more");
+ }
+
+ #[test]
+ fn a_dialog_is_close_only_regardless_of_the_dynamic_button_mode() {
+ let f = Rect::new(0, 0, 400, 300);
+ let slot = |i: i32| f.right() - BUTTON_CLUSTER_MARGIN as i32 - BUTTON_PITCH as i32 * i - 1;
+ for show_max in [true, false] {
+ let hit = |x: i32| ResizeEdge::hit_test(f, x, f.y + TITLEBAR_HEIGHT as i32 / 2, true, 0, RESIZE_MARGIN, false, None, true, show_max);
+ assert_eq!(hit(slot(0)), Some(TitlebarHit::Close), "show_maximize={show_max}");
+ assert_eq!(hit(slot(1)), Some(TitlebarHit::Drag), "a dialog has no second button, show_maximize={show_max}");
+ }
+ }
+
/// The bug this guards against: an undecorated window's own content in
/// its top `TITLEBAR_HEIGHT` band (Firefox's tab strip/URL bar, once
/// `decorated = false` actually applies to it) was silently swallowed
@@ -1105,7 +1174,7 @@ mod tests {
// outside RESIZE_MARGIN, so a real resize edge can't also explain a
// `None` here - undecorated, this must not be treated as
// decoration (or a resize edge) at all, just plain content.
- let hit = ResizeEdge::hit_test(f, cx, f.y + 20, false, 0, RESIZE_MARGIN, false, None, false);
+ let hit = ResizeEdge::hit_test(f, cx, f.y + 20, false, 0, RESIZE_MARGIN, false, None, false, true);
assert_eq!(hit, None);
}
@@ -1113,7 +1182,7 @@ mod tests {
fn undecorated_window_still_resizes_from_every_edge_including_top() {
let f = frame();
let (cx, _) = f.center();
- let hit = ResizeEdge::hit_test(f, cx, f.y + 1, false, 0, RESIZE_MARGIN, false, None, false);
+ let hit = ResizeEdge::hit_test(f, cx, f.y + 1, false, 0, RESIZE_MARGIN, false, None, false, true);
assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::Top)));
}
@@ -1133,9 +1202,9 @@ mod tests {
// resize zone at all, which is no longer true.
let f = frame();
let (cx, _) = f.center();
- assert_eq!(ResizeEdge::hit_test(f, cx, f.y + 5, false, 0, RESIZE_MARGIN, false, None, false), None, "5px in: past the narrow undecorated band, must reach the client");
+ assert_eq!(ResizeEdge::hit_test(f, cx, f.y + 5, false, 0, RESIZE_MARGIN, false, None, false, true), None, "5px in: past the narrow undecorated band, must reach the client");
assert_eq!(
- ResizeEdge::hit_test(f, cx, f.y + DECORATED_TOP_RESIZE_MARGIN + 5, true, 0, RESIZE_MARGIN, false, None, false),
+ ResizeEdge::hit_test(f, cx, f.y + DECORATED_TOP_RESIZE_MARGIN + 5, true, 0, RESIZE_MARGIN, false, None, false, true),
Some(TitlebarHit::Drag),
"decorated: past its own (wider) top resize margin, still plain drag"
);
@@ -1151,7 +1220,7 @@ mod tests {
// within the old, decorated-window-sized corner zone
// (`CORNER_MARGIN * RESIZE_MARGIN` = 18px) before this fix.
let f = frame();
- let hit = ResizeEdge::hit_test(f, f.right() - 10, f.y + 10, false, 0, RESIZE_MARGIN, false, None, false);
+ let hit = ResizeEdge::hit_test(f, f.right() - 10, f.y + 10, false, 0, RESIZE_MARGIN, false, None, false, true);
assert_eq!(hit, None, "past the narrow undecorated corner margin on both axes, must reach the client");
}
@@ -1161,7 +1230,7 @@ mod tests {
// content near - nothing about `CORNER_MARGIN`'s widening should
// survive for an undecorated window at any corner.
let f = frame();
- let hit = ResizeEdge::hit_test(f, f.right() - 10, f.bottom() - 10, false, 0, RESIZE_MARGIN, false, None, false);
+ let hit = ResizeEdge::hit_test(f, f.right() - 10, f.bottom() - 10, false, 0, RESIZE_MARGIN, false, None, false, true);
assert_eq!(hit, None, "past the narrow undecorated corner margin on both axes, must reach the client");
}
@@ -1174,14 +1243,14 @@ mod tests {
let f = frame();
let (_, cy) = f.center();
let (cx, _) = f.center();
- assert_eq!(ResizeEdge::hit_test(f, f.right() - 1, cy, false, 0, RESIZE_MARGIN, false, None, false), Some(TitlebarHit::Resize(ResizeEdge::Right)));
- assert_eq!(ResizeEdge::hit_test(f, cx, f.bottom() - 1, false, 0, RESIZE_MARGIN, false, None, false), Some(TitlebarHit::Resize(ResizeEdge::Bottom)));
+ assert_eq!(ResizeEdge::hit_test(f, f.right() - 1, cy, false, 0, RESIZE_MARGIN, false, None, false, true), Some(TitlebarHit::Resize(ResizeEdge::Right)));
+ assert_eq!(ResizeEdge::hit_test(f, cx, f.bottom() - 1, false, 0, RESIZE_MARGIN, false, None, false, true), Some(TitlebarHit::Resize(ResizeEdge::Bottom)));
}
#[test]
fn outside_frame_is_none() {
let f = frame();
- assert_eq!(ResizeEdge::hit_test(f, 0, 0, true, 0, RESIZE_MARGIN, false, None, false), None);
+ assert_eq!(ResizeEdge::hit_test(f, 0, 0, true, 0, RESIZE_MARGIN, false, None, false, true), None);
}
#[test]
@@ -1196,16 +1265,16 @@ mod tests {
let border_width = 2;
// One pixel into the border strip, past the left edge.
let x = f.x - 1;
- assert_eq!(ResizeEdge::hit_test(f, x, cy, true, 0, RESIZE_MARGIN, false, None, false), None, "sanity check: with no border, this point really is outside the window");
+ assert_eq!(ResizeEdge::hit_test(f, x, cy, true, 0, RESIZE_MARGIN, false, None, false, true), None, "sanity check: with no border, this point really is outside the window");
assert_eq!(
- ResizeEdge::hit_test(f, x, cy, true, border_width, RESIZE_MARGIN, false, None, false),
+ ResizeEdge::hit_test(f, x, cy, true, border_width, RESIZE_MARGIN, false, None, false, true),
Some(TitlebarHit::Resize(ResizeEdge::Left)),
"one pixel into the actual drawn border must still register as the left edge"
);
// Just past the border entirely (border_width + 1 outside frame) is
// still nothing - the fix widens the dead zone's boundary, it
// doesn't remove it.
- assert_eq!(ResizeEdge::hit_test(f, f.x - border_width as i32 - 1, cy, true, border_width, RESIZE_MARGIN, false, None, false), None);
+ assert_eq!(ResizeEdge::hit_test(f, f.x - border_width as i32 - 1, cy, true, border_width, RESIZE_MARGIN, false, None, false, true), None);
}
#[test]
@@ -1222,7 +1291,7 @@ mod tests {
assert!(corner_reach > RESIZE_MARGIN, "the whole point of this test is that corner reach exceeds a plain edge's");
let x = f.x + corner_reach - 1;
let y = f.bottom() - corner_reach + 1;
- assert_eq!(ResizeEdge::hit_test(f, x, y, true, 0, RESIZE_MARGIN, false, None, false), Some(TitlebarHit::Resize(ResizeEdge::BottomLeft)));
+ assert_eq!(ResizeEdge::hit_test(f, x, y, true, 0, RESIZE_MARGIN, false, None, false, true), Some(TitlebarHit::Resize(ResizeEdge::BottomLeft)));
}
#[test]
@@ -1233,7 +1302,7 @@ mod tests {
// - must read as a plain bottom edge, not a corner.
let x = f.x + corner_reach + 5;
let y = f.bottom() - 1;
- assert_eq!(ResizeEdge::hit_test(f, x, y, true, 0, RESIZE_MARGIN, false, None, false), Some(TitlebarHit::Resize(ResizeEdge::Bottom)));
+ assert_eq!(ResizeEdge::hit_test(f, x, y, true, 0, RESIZE_MARGIN, false, None, false, true), Some(TitlebarHit::Resize(ResizeEdge::Bottom)));
}
#[test]
@@ -1244,7 +1313,7 @@ mod tests {
// `resize_edge_at` never even ran for a y inside the titlebar.
let f = frame();
let corner_reach = CORNER_MARGIN * RESIZE_MARGIN;
- let hit = ResizeEdge::hit_test(f, f.x + corner_reach - 1, f.y + corner_reach - 1, true, 0, RESIZE_MARGIN, false, None, false);
+ let hit = ResizeEdge::hit_test(f, f.x + corner_reach - 1, f.y + corner_reach - 1, true, 0, RESIZE_MARGIN, false, None, false, true);
assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::TopLeft)));
}
@@ -1259,7 +1328,7 @@ mod tests {
// the raw corner itself now sits in that real dead strip (see its
// own doc comment), which correctly falls through to drag/resize,
// not Close.
- let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 1, f.y + 1, true, 0, RESIZE_MARGIN, false, None, false);
+ let hit = ResizeEdge::hit_test(f, f.right() - BUTTON_CLUSTER_MARGIN as i32 - 1, f.y + 1, true, 0, RESIZE_MARGIN, false, None, false, true);
assert_eq!(hit, Some(TitlebarHit::Close));
}
@@ -1276,7 +1345,7 @@ mod tests {
// frame's own edge, nowhere near where Close's hitbox starts) and
// within `DECORATED_TOP_RESIZE_MARGIN` vertically.
let f = frame();
- let hit = ResizeEdge::hit_test(f, f.right() - 1, f.y + 1, true, 0, RESIZE_MARGIN, false, None, false);
+ let hit = ResizeEdge::hit_test(f, f.right() - 1, f.y + 1, true, 0, RESIZE_MARGIN, false, None, false, true);
assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::TopRight)));
}
@@ -1289,7 +1358,7 @@ mod tests {
// this only ever claims the true corner, not the whole column
// beside Close.
let f = frame();
- let hit = ResizeEdge::hit_test(f, f.right() - 1, f.y + DECORATED_TOP_RESIZE_MARGIN + 5, true, 0, RESIZE_MARGIN, false, None, false);
+ let hit = ResizeEdge::hit_test(f, f.right() - 1, f.y + DECORATED_TOP_RESIZE_MARGIN + 5, true, 0, RESIZE_MARGIN, false, None, false, true);
assert_eq!(hit, Some(TitlebarHit::Drag));
}
@@ -1300,7 +1369,7 @@ mod tests {
// corner, so the dead strip and its own corner-resize target move
// to the frame's left edge instead.
let f = frame();
- let hit = ResizeEdge::hit_test(f, f.x + 1, f.y + 1, true, 0, RESIZE_MARGIN, true, None, false);
+ let hit = ResizeEdge::hit_test(f, f.x + 1, f.y + 1, true, 0, RESIZE_MARGIN, true, None, false, true);
assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::TopLeft)));
}
@@ -1316,7 +1385,7 @@ mod tests {
// boxes, so this is testing the plain top edge specifically.
let f = frame();
let x = f.x + f.width as i32 / 2;
- let hit = ResizeEdge::hit_test(f, x, f.y + DECORATED_TOP_RESIZE_MARGIN - 1, true, 0, RESIZE_MARGIN, false, None, false);
+ let hit = ResizeEdge::hit_test(f, x, f.y + DECORATED_TOP_RESIZE_MARGIN - 1, true, 0, RESIZE_MARGIN, false, None, false, true);
assert_eq!(hit, Some(TitlebarHit::Resize(ResizeEdge::Top)));
}
@@ -1328,7 +1397,7 @@ mod tests {
// silently grown a resize zone everywhere.
let f = frame();
let x = f.x + f.width as i32 / 2;
- let hit = ResizeEdge::hit_test(f, x, f.y + DECORATED_TOP_RESIZE_MARGIN + 5, true, 0, RESIZE_MARGIN, false, None, false);
+ let hit = ResizeEdge::hit_test(f, x, f.y + DECORATED_TOP_RESIZE_MARGIN + 5, true, 0, RESIZE_MARGIN, false, None, false, true);
assert_eq!(hit, Some(TitlebarHit::Drag));
}
@@ -1341,7 +1410,7 @@ mod tests {
// avoid. Right-aligned close button's box starts at `right - 30`;
// well inside it, at the very top row.
let f = frame();
- let hit = ResizeEdge::hit_test(f, f.right() - 15, f.y + 1, true, 0, RESIZE_MARGIN, false, None, false);
+ let hit = ResizeEdge::hit_test(f, f.right() - 15, f.y + 1, true, 0, RESIZE_MARGIN, false, None, false, true);
assert_eq!(hit, Some(TitlebarHit::Close));
}
diff --git a/crates/ctl/src/main.rs b/crates/ctl/src/main.rs
index 1751483..4689660 100644
--- a/crates/ctl/src/main.rs
+++ b/crates/ctl/src/main.rs
@@ -186,7 +186,7 @@ fn build_request(args: &[String]) -> Result<String, String> {
// as booleans at all, not a string it then has to reject.
Some("set") => {
let key = args.get(1).ok_or(
- "set needs a key (border_width/border_color/corner_radius/gap_inner/gap_outer/master_ratio/master_count/shadows/rounded_corners/animations/night_light/reading_mode/phone_mode/multi_cursor/per_monitor/decoration_mode/button_style/button_side/button_order/title_centered/button_glyph_always/desktop_icons/desktop_icons_all_monitors)",
+ "set needs a key (border_width/border_color/corner_radius/gap_inner/gap_outer/master_ratio/master_count/shadows/rounded_corners/animations/night_light/reading_mode/phone_mode/multi_cursor/per_monitor/decoration_mode/button_style/button_side/button_mode/button_order/title_centered/button_glyph_always/desktop_icons/desktop_icons_all_monitors)",
)?;
let raw = args.get(2).ok_or("set needs a value")?;
let value = match key.as_str() {
@@ -211,6 +211,10 @@ fn build_request(args: &[String]) -> Result<String, String> {
"left" | "right" => format!("{:?}", raw),
_ => return Err(format!("{key} needs 'left' or 'right'")),
},
+ "button_mode" => match raw.as_str() {
+ "dynamic" | "fixed" => format!("{:?}", raw),
+ _ => return Err(format!("{key} needs 'dynamic' or 'fixed'")),
+ },
"border_color" | "button_order" => format!("{:?}", raw),
_ => return Err(format!("unknown set key '{key}'")),
};
@@ -446,6 +450,7 @@ fn print_usage() {
eprintln!(" srd set master_count <n>");
eprintln!(" srd set decoration_mode <server|client>");
eprintln!(" srd set button_style <traffic_lights|traditional>");
+ eprintln!(" srd set button_mode <dynamic|fixed>");
eprintln!(" srd set button_side <left|right>");
eprintln!(" srd set button_order <close,minimize,maximize order>");
eprintln!(" srd set title_centered <true|false>");
diff --git a/crates/platform/src/ipc/dispatch.rs b/crates/platform/src/ipc/dispatch.rs
index 5668d14..d2232c8 100644
--- a/crates/platform/src/ipc/dispatch.rs
+++ b/crates/platform/src/ipc/dispatch.rs
@@ -39,6 +39,7 @@ pub(crate) fn handle_request(line: &[u8], wm: &std::rc::Rc<std::cell::RefCell<Wi
master_count: wm.tiling.master_count,
per_monitor: wm.per_monitor_workspaces,
button_style: if wm.theme.traffic_light_buttons { "traffic_lights" } else { "traditional" }.to_string(),
+ button_mode: if wm.theme.dynamic_buttons { "dynamic" } else { "fixed" }.to_string(),
button_side: if wm.theme.buttons_left { "left" } else { "right" }.to_string(),
button_order: wm.theme.button_order.map(srdwm_core::format_button_order),
title_centered: wm.theme.title_centered,
@@ -523,6 +524,18 @@ fn handle_set(req: &serde_json::Value, wm: &std::rc::Rc<std::cell::RefCell<Windo
wm.borrow_mut().theme.traffic_light_buttons = v == "traffic_lights";
(ok(), true)
}
+ // `srd set button_mode <dynamic|fixed>` - live equivalent of
+ // `theme.decorations.title_bar.button_mode`
+ // (`ThemeConfig::dynamic_buttons`). Same scope note as
+ // `button_style` above.
+ "button_mode" => {
+ let Some(v) = value.and_then(|v| v.as_str()) else { return (err("button_mode needs \"dynamic\" or \"fixed\""), false) };
+ if v != "dynamic" && v != "fixed" {
+ return (err("button_mode needs \"dynamic\" or \"fixed\""), false);
+ }
+ wm.borrow_mut().theme.dynamic_buttons = v == "dynamic";
+ (ok(), true)
+ }
// `srd set button_side <left|right>` - live equivalent of `theme.
// decorations.title_bar.button_side`. Same scope note as
// `button_style` above.
diff --git a/crates/platform/src/ipc/types.rs b/crates/platform/src/ipc/types.rs
index 095dc3d..f9af26c 100644
--- a/crates/platform/src/ipc/types.rs
+++ b/crates/platform/src/ipc/types.rs
@@ -206,6 +206,10 @@ pub(crate) struct SettingsResponse {
/// `"traffic_lights"`/`"traditional"` string `srd set button_style`
/// itself accepts.
pub(crate) button_style: String,
+ /// `"dynamic"`/`"fixed"` string `srd set button_mode` accepts --
+ /// `ThemeConfig::dynamic_buttons`'s readback, same shape and reason as
+ /// `button_style` directly above.
+ pub(crate) button_mode: String,
/// `ThemeConfig::buttons_left`'s readback, as `"left"`/`"right"`.
pub(crate) button_side: String,
/// `ThemeConfig::button_order`'s readback - `null` when unset (the
diff --git a/crates/srdwm/src/main.rs b/crates/srdwm/src/main.rs
index edb87d6..e21e585 100644
--- a/crates/srdwm/src/main.rs
+++ b/crates/srdwm/src/main.rs
@@ -100,6 +100,60 @@ fn install_signal_handlers() {}
/// rewriting the literal, so it stays legible as "what you'd type in Lua".
const RELOAD_COMBO_LITERAL: &str = "Mod4+Ctrl+r";
+/// How often the config directory is checked for edits when
+/// `general.config_reload_on_write` is on. See `config_mtime`.
+const CONFIG_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
+
+/// Puts a config error in front of the user instead of only in a log they
+/// have no reason to be reading.
+///
+/// A Lua config is a program, so a user breaking it is an ordinary event,
+/// not an exceptional one - and the failure is close to silent from the
+/// user's side: the compositor keeps running, their edit simply does
+/// nothing. Nothing on screen said why. `notify-send` is the same
+/// best-effort mechanism `srd.notify` already uses (see `fn_notify`); when
+/// no notification daemon is running it falls back to the log, which is no
+/// worse than the previous behaviour and never fatal.
+///
+/// Asked as "what happens when our config fails/user does something wrong
+/// which can be expected since lua programmable config".
+fn report_config_error(message: &str) {
+ log::error!("{message}");
+ #[cfg(unix)]
+ {
+ let _ = std::process::Command::new("notify-send")
+ .arg("--urgency=critical")
+ .arg("srdwm")
+ .arg(message)
+ .status();
+ }
+}
+
+/// The newest modification time across the config directory's own `.lua`
+/// files, used to notice an edit and reload without being asked.
+///
+/// A plain `stat` sweep rather than an inotify/`notify`-crate watch: it
+/// needs no new dependency, behaves identically on every platform this
+/// project targets (the standing rule is that everything must work
+/// everywhere, Windows and macOS included), and cannot leak watch
+/// descriptors on a directory that is edited and replaced by an editor
+/// writing through a temp file - the common case, and the one inotify
+/// watches on individual files famously miss. One directory read of a
+/// handful of files, at most once a second, is not a measurable cost next
+/// to a compositor frame.
+///
+/// Non-recursive on purpose: `srd.load("module")` resolves relative to this
+/// same directory, so a flat sweep already covers every file a config can
+/// pull in without walking arbitrary user directories.
+fn config_mtime(dir: &std::path::Path) -> Option<std::time::SystemTime> {
+ let entries = std::fs::read_dir(dir).ok()?;
+ entries
+ .filter_map(|e| e.ok())
+ .filter(|e| e.path().extension().is_some_and(|x| x == "lua"))
+ .filter_map(|e| e.metadata().ok()?.modified().ok())
+ .max()
+}
+
/// Where the Lua config lives: `$SRDWM_CONFIG_PATH`, else
/// `$XDG_CONFIG_HOME/srd`, else `~/.config/srd`.
///
@@ -243,6 +297,9 @@ fn apply_general_settings(engine: &Engine, wm: &Rc<RefCell<WindowManager>>) {
// traffic-light default, same fallback shape as every other string
// switch above.
theme.traffic_light_buttons = engine.get_string("theme.decorations.title_bar.button_style", "traffic_lights") != "traditional";
+ // "dynamic" (default: only the buttons the window can actually use) or
+ // "fixed" (always the full set) - see `ThemeConfig::dynamic_buttons`.
+ theme.dynamic_buttons = engine.get_string("theme.decorations.title_bar.button_mode", "dynamic") != "fixed";
let border_width = engine.get_f64("theme.decorations.border.width", 2.0).max(0.0) as u32;
theme.default_border_width = border_width;
// 12, not the original 6: matches real macOS's own ~0.36 radius-to-
@@ -631,12 +688,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
engine.set_string("platform.os", std::env::consts::OS);
match engine.load_init() {
Ok(()) => log::info!("loaded config from {}", dir.display()),
- Err(e) => log::warn!("no usable config at {} ({e}); running with built-in defaults", dir.display()),
+ Err(e) => {
+ log::warn!("no usable config at {} ({e}); running with built-in defaults", dir.display());
+ report_config_error(&format!("Config failed to load, using built-in defaults.\n{e}"));
+ }
}
apply_workspace_count(&engine, &wm);
apply_general_settings(&engine, &wm);
apply_default_layout(&engine, &wm);
let running = engine.running_flag();
+ // `general.config_reload_on_write` - on by default. A programmable
+ // config is edited far more often than it is reloaded deliberately, and
+ // a failed reload can no longer leave the session in a broken state
+ // (`do_reload` restores the previous working config), so the safe
+ // default is the convenient one. Set it false for a config that
+ // deliberately does expensive work at load time.
+ let reload_on_write = engine.get_bool("general.config_reload_on_write", true);
+ let mut last_config_mtime = config_mtime(&dir);
+ let mut last_config_poll = std::time::Instant::now();
let mut platform: Box<dyn Platform> = match kind {
#[cfg(all(unix, not(target_os = "macos")))]
@@ -706,6 +775,46 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
};
+ // `general.config_reload_on_write`: notice an edited config and
+ // apply it without the user having to press the reload combo.
+ //
+ // Polled at most once a second (see `config_mtime`), and only when
+ // the newest `.lua` mtime has actually moved - so the steady-state
+ // cost is one directory read per second and nothing else. A failed
+ // reload here is not fatal and does not spam: `do_reload` puts the
+ // previous working config back, and the mtime has already been
+ // recorded, so a file that stays broken is reported once, not on
+ // every tick. Saving a fixed version moves the mtime again and
+ // reloads for real.
+ if reload_on_write {
+ let now = std::time::Instant::now();
+ if now.duration_since(last_config_poll) >= CONFIG_POLL_INTERVAL {
+ last_config_poll = now;
+ let current = config_mtime(&dir);
+ if current.is_some() && current != last_config_mtime {
+ last_config_mtime = current;
+ match engine.reload() {
+ Ok(()) => log::info!("config reloaded (file changed on disk)"),
+ Err(e) => report_config_error(&format!("Config edit not applied, keeping the last working one.\n{e}")),
+ }
+ apply_general_settings(&engine, &wm);
+ }
+ }
+ }
+ // The desktop menu's "Refresh" row, drained here rather than in a
+ // backend: reloading Lua and firing a Lua handler both need the
+ // `Engine`, which only this loop owns. See
+ // `WindowManager::request_refresh`.
+ if wm.borrow_mut().drain_refresh_request() {
+ match engine.reload() {
+ Ok(()) => log::info!("config reloaded (desktop refresh)"),
+ Err(e) => report_config_error(&format!("Config reload failed, keeping the last working one.\n{e}")),
+ }
+ // After the reload, so a handler edited in the config since
+ // startup is the one that runs.
+ engine.dispatch_event("refresh");
+ }
+
let mut dirty = false;
for event in events {
match event {
@@ -714,7 +823,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if combo == srdwm_core::canonicalize_key_combo(RELOAD_COMBO_LITERAL) {
match engine.reload() {
Ok(()) => log::info!("config reloaded"),
- Err(e) => log::error!("config reload failed: {e}"),
+ Err(e) => report_config_error(&format!("Config reload failed, keeping the last working one.\n{e}")),
}
} else if !engine.dispatch_keybinding(&combo) {
log::debug!("no binding for '{combo}'");
diff --git a/crates/wayland/src/decoration/tests.rs b/crates/wayland/src/decoration/tests.rs
index f2a9ded..0d6609f 100644
--- a/crates/wayland/src/decoration/tests.rs
+++ b/crates/wayland/src/decoration/tests.rs
@@ -152,7 +152,7 @@ fn shadow_bitmap_corner_is_softer_than_a_square_windows_when_rounded() {
#[test]
fn fills_background_when_no_text() {
- let buf = render_titlebar(40, 20, "", (0x2e, 0x34, 0x40), (0xec, 0xef, 0xf4), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let buf = render_titlebar(40, 20, "", (0x2e, 0x34, 0x40), (0xec, 0xef, 0xf4), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true);
assert_eq!(buf.len(), 40 * 20 * 4);
// Center, not (0,0): the top-left pixel is inside the rounded
// corner `round_top_corners` clips away, so it's transparent by
@@ -175,7 +175,7 @@ fn button_icons_are_drawn_in_the_squares_hit_test_assigns_them() {
let (width, height) = (300u32, srdwm_core::TITLEBAR_HEIGHT);
let bg = (0x2e, 0x34, 0x40);
let fg = (0xec, 0xef, 0xf4);
- let buf = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let buf = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true);
let frame = srdwm_core::Rect::new(0, 0, width, height);
let (width, height) = (width as usize, height as usize);
@@ -189,7 +189,7 @@ fn button_icons_are_drawn_in_the_squares_hit_test_assigns_them() {
let cx = (x0 + x1) / 2;
let cy = (y0 + y1) / 2;
assert_eq!(
- srdwm_core::ResizeEdge::hit_test(frame, cx, cy, true, 0, srdwm_core::RESIZE_MARGIN, false, None, false),
+ srdwm_core::ResizeEdge::hit_test(frame, cx, cy, true, 0, srdwm_core::RESIZE_MARGIN, false, None, false, true),
Some(expected),
"icon drawn at right_offset={right_offset} does not land in the square hit_test assigns to {expected:?}"
);
@@ -208,8 +208,8 @@ fn a_dialog_draws_only_close_and_never_a_coloured_traffic_light() {
// must override it regardless of what the active theme otherwise
// uses everywhere else. `glyph_always = true` so Close's own X is
// visible without needing a live hover to check it landed.
- let dialog = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, true, None, true, true);
- let normal = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, true, None, true, false);
+ let dialog = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, true, None, true, true, true);
+ let normal = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, true, None, true, false, true);
let (w, h) = (width as usize, height as usize);
// Where a normal (non-dialog) titlebar draws Maximize (offset
@@ -251,7 +251,7 @@ fn drawing_title_changes_some_pixels_when_font_available() {
}
let bg = (0x2e, 0x34, 0x40);
let fg = (0xec, 0xef, 0xf4);
- let buf = render_titlebar(200, 30, "Terminal", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let buf = render_titlebar(200, 30, "Terminal", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true);
let bg_bytes = rgb_to_bgra(bg, 255);
let changed = buf.chunks_exact(4).any(|px| px != bg_bytes);
assert!(changed, "expected at least one pixel to differ from the background once text is drawn");
@@ -282,8 +282,8 @@ fn centered_title_starts_further_right_than_left_aligned() {
let leftmost_ink_column = |buf: &[u8]| -> Option<usize> {
(0..width as usize).find(|&x| scan_rows.clone().any(|y| buf[(y * width as usize + x) * 4..(y * width as usize + x) * 4 + 4] != bg_bytes))
};
- let left = render_titlebar(width, height, "Hi", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
- let centered = render_titlebar(width, height, "Hi", bg, fg, true, CORNER_RADIUS, 0, true, None, true, false, false, None, true, false);
+ let left = render_titlebar(width, height, "Hi", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true);
+ let centered = render_titlebar(width, height, "Hi", bg, fg, true, CORNER_RADIUS, 0, true, None, true, false, false, None, true, false, true);
let left_start = leftmost_ink_column(&left).expect("left-aligned title must draw some ink");
let centered_start = leftmost_ink_column(&centered).expect("centered title must draw some ink");
assert!(centered_start > left_start, "a short title centered in a wide titlebar must start well to the right of the left-aligned version (left starts at {left_start}, centered at {centered_start})");
@@ -315,7 +315,7 @@ fn centered_title_ignores_the_button_reservation_and_centers_on_the_whole_width(
let ink_columns = |buf: &[u8]| -> Vec<usize> {
(button_reservation..width as usize).filter(|&x| scan_rows.clone().any(|y| buf[(y * width as usize + x) * 4..(y * width as usize + x) * 4 + 4] != bg_bytes)).collect()
};
- let buf = render_titlebar(width, height, "Hi", bg, fg, true, CORNER_RADIUS, 0, true, None, true, true, false, None, true, false);
+ let buf = render_titlebar(width, height, "Hi", bg, fg, true, CORNER_RADIUS, 0, true, None, true, true, false, None, true, false, true);
let columns = ink_columns(&buf);
let (first, last) = (*columns.first().expect("centered title must draw some ink"), *columns.last().unwrap());
let midpoint = (first + last) as f32 / 2.0;
@@ -327,7 +327,7 @@ fn centered_title_ignores_the_button_reservation_and_centers_on_the_whole_width(
fn empty_title_leaves_buffer_all_background_outside_the_rounded_corners() {
let bg = (0x10, 0x20, 0x30);
let (width, height) = (50, 24);
- let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true);
let bg_bytes = rgb_to_bgra(bg, 255);
for (i, px) in buf.chunks_exact(4).enumerate() {
let (x, y) = (i % width as usize, i / width as usize);
@@ -343,7 +343,7 @@ fn empty_title_leaves_buffer_all_background_outside_the_rounded_corners() {
fn corners_are_clipped_but_the_middle_is_not() {
let bg = (0x10, 0x20, 0x30);
let (width, height) = (50, 24);
- let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true);
let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
// The very corner pixel is well outside the quarter-circle at any
// sane radius - fully clipped.
@@ -374,7 +374,7 @@ fn clipped_corner_pixels_are_fully_premultiplied_zero_not_just_alpha() {
// is `(0, 0, 0, 0)` in every channel, not just alpha.
let bg = (0x10, 0x20, 0x30);
let (width, height) = (50, 24);
- let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true);
let px_at = |x: usize, y: usize| &buf[(y * width as usize + x) * 4..(y * width as usize + x) * 4 + 4];
assert_eq!(px_at(0, 0), [0, 0, 0, 0], "top-left corner pixel must be fully zeroed (premultiplied transparent), not just alpha");
assert_eq!(px_at(width as usize - 1, 0), [0, 0, 0, 0], "top-right corner pixel must be fully zeroed (premultiplied transparent), not just alpha");
@@ -384,7 +384,7 @@ fn clipped_corner_pixels_are_fully_premultiplied_zero_not_just_alpha() {
fn round_corners_false_leaves_the_top_corners_square() {
let bg = (0x10, 0x20, 0x30);
let (width, height) = (50, 24);
- let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), false, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let buf = render_titlebar(width, height, "", bg, (0xff, 0xff, 0xff), false, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true);
let alpha_at = |x: usize, y: usize| buf[(y * width as usize + x) * 4 + 3];
assert_eq!(alpha_at(0, 0), 255, "top-left corner should stay square when round_corners is false");
assert_eq!(alpha_at(width as usize - 1, 0), 255, "top-right corner should stay square when round_corners is false");
@@ -406,8 +406,8 @@ fn hovering_the_close_button_brightens_only_that_dot() {
let (width, height) = (200u32, srdwm_core::TITLEBAR_HEIGHT);
let bg = (0x2e, 0x34, 0x40);
let fg = (0xec, 0xef, 0xf4);
- let plain = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
- let close_hovered = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, Some((srdwm_core::TitlebarHit::Close, 255)), false, false, false, None, true, false);
+ let plain = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true);
+ let close_hovered = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, Some((srdwm_core::TitlebarHit::Close, 255)), false, false, false, None, true, false, true);
let frame = srdwm_core::Rect::new(0, 0, width, height);
let (w, h) = (width as usize, height as usize);
let margin = srdwm_core::BUTTON_CLUSTER_MARGIN as usize;
@@ -424,7 +424,7 @@ fn hovering_the_close_button_brightens_only_that_dot() {
// point to Close, or this test would be checking a hover state
// that a real pointer could never reach in the first place.
let (cx, cy) = ((close_box.0 + close_box.2) / 2, (close_box.1 + close_box.3) / 2);
- assert_eq!(srdwm_core::ResizeEdge::hit_test(frame, cx, cy, true, 0, srdwm_core::RESIZE_MARGIN, false, None, false), Some(srdwm_core::TitlebarHit::Close));
+ assert_eq!(srdwm_core::ResizeEdge::hit_test(frame, cx, cy, true, 0, srdwm_core::RESIZE_MARGIN, false, None, false, true), Some(srdwm_core::TitlebarHit::Close));
}
#[test]
@@ -439,7 +439,7 @@ fn button_dot_has_a_glossy_highlight_toward_the_upper_left_and_shadow_toward_the
let (width, height) = (200u32, srdwm_core::TITLEBAR_HEIGHT);
let bg = (0x2e, 0x34, 0x40);
let fg = (0xec, 0xef, 0xf4);
- let buf = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false);
+ let buf = render_titlebar(width, height, "", bg, fg, true, CORNER_RADIUS, 0, true, None, false, false, false, None, true, false, true);
let (w, h) = (width as usize, height as usize);
let close_box = button_box(w, h, srdwm_core::BUTTON_CLUSTER_MARGIN as usize, false, BUTTON_MARGIN);
let (cx, cy) = ((close_box.0 + close_box.2) / 2, (close_box.1 + close_box.3) / 2);
@@ -501,7 +501,7 @@ fn border_top_and_titlebar_corners_meet_without_a_seam() {
let color = (0x40, 0x50, 0x60);
let (width, thickness, radius) = (60, 4, 6);
let border = render_border_top(width, thickness, color, radius, true);
- let titlebar = render_titlebar(width, 24, "", color, (0xff, 0xff, 0xff), true, radius, thickness, true, None, false, false, false, None, true, false);
+ let titlebar = render_titlebar(width, 24, "", color, (0xff, 0xff, 0xff), true, radius, thickness, true, None, false, false, false, None, true, false, true);
let border_alpha_at = |x: usize| border[((thickness as usize - 1) * width as usize + x) * 4 + 3];
let titlebar_alpha_at = |xt: usize| titlebar[xt * 4 + 3];
// `x` below is the shared *global* column - distance from the true
diff --git a/crates/wayland/src/decoration/titlebar.rs b/crates/wayland/src/decoration/titlebar.rs
index 03a614a..846bd41 100644
--- a/crates/wayland/src/decoration/titlebar.rs
+++ b/crates/wayland/src/decoration/titlebar.rs
@@ -76,6 +76,13 @@ pub fn render_titlebar(
// the same "renders on one side, hit-tests on the other" trap every
// other button-geometry value here already has to avoid.
is_dialog: bool,
+ // Whether a Maximize button is drawn at all - `WindowManager::
+ // show_maximize`'s answer, which resolves `theme.dynamic_buttons`
+ // against the window's own `resizable`. Must stay in exact agreement
+ // with `ResizeEdge::hit_test`'s own `show_maximize` parameter, the
+ // same "renders on one side, hit-tests on the other" contract
+ // `is_dialog` directly above already carries.
+ show_maximize: bool,
) -> Vec<u8> {
let (width, height) = (width.max(1) as usize, height.max(1) as usize);
// Forced off, not just defaulted - a dialog never gets coloured
@@ -98,7 +105,13 @@ pub fn render_titlebar(
let cluster_margin = srdwm_core::BUTTON_CLUSTER_MARGIN as usize;
// A dialog only ever gets one button (Close) - see this function's own
// `is_dialog` doc comment.
- let wanted_buttons = if is_dialog { 1 } else { 3 };
+ let wanted_buttons = if is_dialog {
+ 1
+ } else if show_maximize {
+ 3
+ } else {
+ 2
+ };
let button_count = if width >= cluster_margin + pitch * wanted_buttons { wanted_buttons } else { 0 };
// `BUTTON_CLUSTER_MARGIN` included, not just the buttons' own `pitch *
// button_count` span - the cluster's own leading gap needs reserving
@@ -245,6 +258,15 @@ pub fn render_titlebar(
[srdwm_core::TitlebarButton::Close, srdwm_core::TitlebarButton::Maximize, srdwm_core::TitlebarButton::Minimize]
})
};
+ // Maximize removed from the list rather than skipped in the loop:
+ // skipping would leave an empty slot where it used to be, while
+ // `hit_test` closes the gap - so every later button would be drawn
+ // one pitch away from where its clicks actually land.
+ let order: Vec<srdwm_core::TitlebarButton> = if show_maximize {
+ order.to_vec()
+ } else {
+ order.iter().copied().filter(|b| *b != srdwm_core::TitlebarButton::Maximize).collect()
+ };
// `BUTTON_CLUSTER_MARGIN` first, then each button's own `pitch * i`
// spacing after it - must stay in agreement with `ResizeEdge::
// hit_test`'s matching `left`/`right` base, the same "renders on
diff --git a/crates/wayland/src/desktop_menu.rs b/crates/wayland/src/desktop_menu.rs
index dbbf957..dfc3a6e 100644
--- a/crates/wayland/src/desktop_menu.rs
+++ b/crates/wayland/src/desktop_menu.rs
@@ -23,6 +23,21 @@ pub(crate) enum DesktopMenuAction {
/// "not even new file" - see `CompState::new_desktop_text_file`'s own
/// doc comment.
NewTextFile,
+ /// Creates an empty file with this extension (no leading dot), named
+ /// "New <label>". The type is chosen by extension, which is what
+ /// actually decides how a desktop or file manager treats a file:
+ /// asked for as "in context menu say new file, user can choose what
+ /// file type is obviously by extension".
+ ///
+ /// A fixed, deliberately short list (see `NEW_FILE_TYPES`) rather than
+ /// a scan of the system's MIME database: the point is a couple of
+ /// clicks for the handful of files people actually create from a
+ /// desktop, not a type browser - a real file manager, one row further
+ /// down this same menu, is the right tool for anything beyond that.
+ NewFileOfType {
+ label: &'static str,
+ extension: &'static str,
+ },
/// Spawns a terminal with `~/Desktop` as its working directory --
/// `general.terminal`, or a common-binary fallback list if unset.
OpenTerminalHere,
@@ -52,6 +67,20 @@ pub(crate) struct DesktopMenu {
const MENU_WIDTH: u32 = 170;
const ROW_HEIGHT: u32 = 28;
+/// The file types the bare-desktop menu offers to create, in order.
+///
+/// Plain text stays its own separate `NewTextFile` action rather than
+/// appearing here, because it is the overwhelmingly common one and was
+/// already the menu's own established top-level entry - demoting it into
+/// a list would make the ordinary case worse to reach.
+const NEW_FILE_TYPES: [(&str, &str); 5] = [
+ ("Markdown Document", "md"),
+ ("Shell Script", "sh"),
+ ("Python Script", "py"),
+ ("JSON File", "json"),
+ ("CSV Spreadsheet", "csv"),
+];
+
impl DesktopMenu {
/// Right-click on `icon` itself - the action set depends on what kind
/// of icon it is, not one fixed list: a real file/folder gets Open/
@@ -76,16 +105,21 @@ impl DesktopMenu {
/// Right-click on bare desktop (no icon under the pointer).
pub(crate) fn open_for_desktop(pos: (i32, i32)) -> Self {
- let items = vec![
+ let mut items = vec![
("New Folder", DesktopMenuAction::NewFolder),
("New Text Document", DesktopMenuAction::NewTextFile),
+ ];
+ for (label, extension) in NEW_FILE_TYPES {
+ items.push((label, DesktopMenuAction::NewFileOfType { label, extension }));
+ }
+ items.extend([
("\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}", DesktopMenuAction::Separator),
("Open Terminal Here", DesktopMenuAction::OpenTerminalHere),
("Open in File Manager", DesktopMenuAction::OpenInFileManager),
("\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}", DesktopMenuAction::Separator),
("Select All", DesktopMenuAction::SelectAll),
("Refresh", DesktopMenuAction::Refresh),
- ];
+ ]);
Self { pos, width: MENU_WIDTH, row_height: ROW_HEIGHT, items }
}
@@ -150,7 +184,49 @@ mod tests {
let menu = DesktopMenu::open_for_desktop((10, 10));
let real_actions: Vec<&str> =
menu.items.iter().filter(|(_, a)| !matches!(a, DesktopMenuAction::Separator)).map(|(l, _)| *l).collect();
- assert_eq!(real_actions, vec!["New Folder", "New Text Document", "Open Terminal Here", "Open in File Manager", "Select All", "Refresh"]);
+ assert_eq!(
+ real_actions,
+ vec![
+ "New Folder",
+ "New Text Document",
+ "Markdown Document",
+ "Shell Script",
+ "Python Script",
+ "JSON File",
+ "CSV Spreadsheet",
+ "Open Terminal Here",
+ "Open in File Manager",
+ "Select All",
+ "Refresh",
+ ]
+ );
+ }
+
+ #[test]
+ fn every_offered_file_type_carries_a_real_extension() {
+ let menu = DesktopMenu::open_for_desktop((0, 0));
+ let types: Vec<(&str, &str)> = menu
+ .items
+ .iter()
+ .filter_map(|(_, a)| match a {
+ DesktopMenuAction::NewFileOfType { label, extension } => Some((*label, *extension)),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(types.len(), NEW_FILE_TYPES.len());
+ for (label, extension) in types {
+ assert!(!extension.is_empty(), "{label} has no extension");
+ assert!(!extension.starts_with('.'), "{label}: the dot is added when building the name, not stored");
+ }
+ }
+
+ #[test]
+ fn no_two_offered_file_types_share_an_extension() {
+ let mut seen: Vec<&str> = Vec::new();
+ for (label, extension) in NEW_FILE_TYPES {
+ assert!(!seen.contains(&extension), "{label} repeats extension {extension}");
+ seen.push(extension);
+ }
}
#[test]
diff --git a/crates/wayland/src/elements.rs b/crates/wayland/src/elements.rs
index b5be5a8..1fafcae 100644
--- a/crates/wayland/src/elements.rs
+++ b/crates/wayland/src/elements.rs
@@ -77,6 +77,63 @@ pub(crate) fn border_side_render_element(buf: &mut SolidColorBuffer, strip: srdw
SolidColorRenderElement::from_buffer(buf, loc, 1.0, 1.0, Kind::Unspecified)
}
+/// How thick the snap preview's outline is, in logical pixels.
+const SNAP_PREVIEW_OUTLINE: i32 = 3;
+
+/// Opacity of the snap preview's translucent interior fill.
+///
+/// Low on purpose: this sits on top of whatever is already on screen, and
+/// its job is to say "the window lands *here*" without hiding what is
+/// underneath. The outline carries the shape; the fill only tints it.
+const SNAP_PREVIEW_FILL_ALPHA: f32 = 0.22;
+
+/// The drop-target overlay drawn while a window is being dragged into a
+/// snap zone - a translucent fill plus a solid outline over the rect the
+/// window will occupy when the button comes up.
+///
+/// `buffers` is a persistent pool (five entries: fill, then top/bottom/
+/// left/right outline) for exactly the reason [`border_side_render_element`]
+/// documents at length - a fresh `SolidColorBuffer` per frame gets a fresh
+/// `Id`, which makes smithay's damage tracker mark the element damaged on
+/// every frame forever and stops the output ever going idle.
+///
+/// Solid fills, not a rasterised bitmap: the preview rect is up to a whole
+/// monitor in size and changes as the drag moves, so building an ARGB
+/// buffer for it would mean allocating and filling megabytes per zone
+/// change. `Frame::draw_solid` needs no texture at all.
+pub(crate) fn snap_preview_elements(buffers: &mut Vec<SolidColorBuffer>, rect: srdwm_core::Rect, color: (u8, u8, u8), origin: (i32, i32)) -> Vec<SolidColorRenderElement> {
+ if rect.width == 0 || rect.height == 0 {
+ return Vec::new();
+ }
+ let (r, g, b) = (color.0 as f32 / 255.0, color.1 as f32 / 255.0, color.2 as f32 / 255.0);
+ let t = SNAP_PREVIEW_OUTLINE.min(rect.width as i32 / 2).min(rect.height as i32 / 2).max(1);
+ let (w, h) = (rect.width as i32, rect.height as i32);
+ // Fill first so the outline draws over it, then the four edges.
+ let parts: [(srdwm_core::Rect, f32); 5] = [
+ (rect, SNAP_PREVIEW_FILL_ALPHA),
+ (srdwm_core::Rect::new(rect.x, rect.y, rect.width, t as u32), 1.0),
+ (srdwm_core::Rect::new(rect.x, rect.y + h - t, rect.width, t as u32), 1.0),
+ (srdwm_core::Rect::new(rect.x, rect.y + t, t as u32, (h - 2 * t).max(0) as u32), 1.0),
+ (srdwm_core::Rect::new(rect.x + w - t, rect.y + t, t as u32, (h - 2 * t).max(0) as u32), 1.0),
+ ];
+ let mut out = Vec::with_capacity(parts.len());
+ for (index, (part, alpha)) in parts.into_iter().enumerate() {
+ if part.width == 0 || part.height == 0 {
+ continue;
+ }
+ let buf = border_fragment_buffer(buffers, index);
+ // Alpha lives in the element, not the buffer colour: `SolidColorBuffer::
+ // update` only bumps its commit counter when size or colour change,
+ // so keeping the colour opaque here means a preview that merely
+ // moves does not also invalidate on a colour it never actually
+ // changed.
+ buf.update((part.width as i32, part.height as i32), Color32F::new(r, g, b, 1.0));
+ let loc = Point::from((part.x - origin.0, part.y - origin.1));
+ out.push(SolidColorRenderElement::from_buffer(buf, loc, 1.0, alpha, Kind::Unspecified));
+ }
+ out
+}
+
/// Splits a border strip into the sub-rectangles still visible after
/// subtracting every window rect stacked in front of it.
///
diff --git a/crates/wayland/src/input/pointer.rs b/crates/wayland/src/input/pointer.rs
index c6ed5ba..e82b25f 100644
--- a/crates/wayland/src/input/pointer.rs
+++ b/crates/wayland/src/input/pointer.rs
@@ -23,6 +23,26 @@ use super::{notify_idle_activity, DRAG_MODIFIER};
/// mouse or touchpad can produce.
pub(crate) const RESIZE_REDRAW_INTERVAL: std::time::Duration = std::time::Duration::from_millis(1000 / 60);
+/// How far below a monitor's top edge the drag-triggered Snap-Layouts
+/// flyout hangs. Enough to clear a typical bar, so the grid is not opening
+/// underneath one.
+const SNAP_FLYOUT_DROP: i32 = 36;
+
+/// What one pointer-motion tick decided about the drag-triggered
+/// Snap-Layouts flyout, decided while `WindowManager` is borrowed and acted
+/// on once that borrow is released (opening the flyout rasterises a buffer,
+/// which needs `&mut CompState` as a whole).
+///
+/// `NotDragging` is distinct from `Close` on purpose: a flyout opened the
+/// other way - by right-clicking a maximize button - must survive an
+/// unrelated pointer move, so a tick with no drag in progress has to leave
+/// it alone rather than close it.
+enum DragFlyout {
+ Open(WindowId, (i32, i32)),
+ Close,
+ NotDragging,
+}
+
/// `WindowManager::hit_test`, but substituting each window's currently
/// *animated* rect (if it has one active in `state.window_anims`) for its
/// final `geometry` - see `hit_test_with`'s own doc comment in
@@ -311,6 +331,9 @@ pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Log
update_cursor_shape(state, hit, over_layer_surface, over_content);
+ let mut drag_flyout = DragFlyout::NotDragging;
+ // Resolved before `wm` is borrowed below, since it reads `state`.
+ let over_open_flyout = state.snap_flyout.as_ref().is_some_and(|f| f.zone_at(pos.x as i32, pos.y as i32).is_some());
let mut wm = state.wm.borrow_mut();
let dragging_or_resizing = wm.is_dragging() || wm.is_resizing();
// Captured now, while `wm` is already borrowed, and acted on further
@@ -320,6 +343,26 @@ pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Log
let resizing_id = wm.resizing_window();
if wm.is_dragging() {
wm.update_drag(pos.x as i32, pos.y as i32);
+ // Windows-11-style: throw the window at the top of the screen and
+ // the Snap-Layouts grid drops down to be aimed at. Captured here
+ // while `wm` is borrowed and acted on after `drop(wm)` below --
+ // opening the flyout rasterises a buffer, which needs `&mut state`
+ // as a whole.
+ drag_flyout = match (wm.drag_top_edge_monitor(), wm.dragged_window()) {
+ (Some(m), Some(id)) => {
+ let g = m.full_geometry;
+ DragFlyout::Open(id, (g.x + g.width as i32 / 2, g.y + SNAP_FLYOUT_DROP))
+ }
+ // Leaving the trigger band does not dismiss an already-open
+ // flyout while the pointer is still on it - aiming at a cell
+ // means moving down, away from the band, which would otherwise
+ // close the grid on the way to using it. Confirmed by
+ // screenshot before this check existed: the flyout appeared at
+ // the top edge and had vanished by the time the pointer
+ // reached its first cell.
+ _ if over_open_flyout => DragFlyout::NotDragging,
+ _ => DragFlyout::Close,
+ };
} else if wm.is_resizing() {
wm.update_resize(pos.x as i32, pos.y as i32);
}
@@ -348,6 +391,26 @@ pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Log
}
}
drop(wm);
+ // Open/close the drag-triggered Snap-Layouts flyout, now that `wm`'s
+ // borrow is released - see the capture site above.
+ match drag_flyout {
+ DragFlyout::Open(id, anchor) => {
+ let already_there = state.snap_flyout.as_ref().is_some_and(|f| f.window == id);
+ if !already_there {
+ state.open_snap_flyout(id, anchor);
+ // Centre it on the anchor, which is the monitor's own
+ // horizontal midpoint - `open_snap_flyout` places its
+ // top-left corner at whatever it is given (the maximize
+ // button's position, for the click-driven caller), and
+ // only this caller wants it centred.
+ if let Some(flyout) = state.snap_flyout.as_mut() {
+ flyout.pos.0 -= flyout.width() as i32 / 2;
+ }
+ }
+ }
+ DragFlyout::Close => state.close_snap_flyout(),
+ DragFlyout::NotDragging => {}
+ }
if let Some(id) = focus_follow_target {
focus_window(state, id);
}
@@ -745,6 +808,15 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic
// below, just for a desktop icon instead of a window.
state.end_desktop_icon_drag();
state.end_desktop_marquee();
+ // Releasing a drag over a cell of the drag-triggered Snap-Layouts
+ // flyout picks that cell's zone, in place of whatever edge snap
+ // `end_drag` would otherwise have computed from the drop point.
+ // Resolved before `end_drag` runs, because that consumes the drag.
+ let flyout_pick = state
+ .snap_flyout
+ .as_ref()
+ .filter(|f| state.wm.borrow().dragged_window() == Some(f.window))
+ .and_then(|f| f.zone_at(pos.x as i32, pos.y as i32).map(|zone| (f.window, zone)));
let mut wm = state.wm.borrow_mut();
let was_dragging = wm.is_dragging();
let was_resizing = wm.is_resizing();
@@ -757,6 +829,12 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic
let id = wm.focused_id();
if was_dragging {
wm.end_drag();
+ // After `end_drag`, so the flyout's own choice is the last
+ // word rather than being overwritten by the edge snap that
+ // dropping at the top of the screen would otherwise apply.
+ if let Some((window, zone)) = flyout_pick {
+ wm.apply_snap_zone(window, zone);
+ }
} else if was_resizing {
wm.end_resize();
}
@@ -769,6 +847,10 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic
crate::window_memory::save_all(wm.all_remembered_geometry());
}
drop(wm);
+ if was_dragging {
+ // Whether or not it was used, a drag ending closes it.
+ state.close_snap_flyout();
+ }
// `end_drag` can snap the geometry one more time (edge/top-of-
// screen snapping, `SmartPlacement::snap_zone`) *after* the last
// `update_drag` already moved the window - without this, that
diff --git a/crates/wayland/src/state/desktop_icons.rs b/crates/wayland/src/state/desktop_icons.rs
index 05bd85a..ad7796f 100644
--- a/crates/wayland/src/state/desktop_icons.rs
+++ b/crates/wayland/src/state/desktop_icons.rs
@@ -564,12 +564,23 @@ impl CompState {
/// against this menu next to Windows'/macOS' own. Same collision-
/// avoidance and refresh as `new_desktop_folder` just above.
pub(crate) fn new_desktop_text_file(&mut self) {
+ self.new_desktop_file("New Text Document", "txt");
+ }
+
+ /// Creates an empty `~/Desktop/New <stem>.<extension>`, adding ` (2)`,
+ /// ` (3)` ... until the name is free, then refreshes the icon grid.
+ ///
+ /// The de-duplication counter goes before the extension, not after the
+ /// whole filename: `New Shell Script (2).sh` is still a shell script,
+ /// `New Shell Script.sh (2)` is not - and the extension is the entire
+ /// point of letting the type be chosen here.
+ pub(crate) fn new_desktop_file(&mut self, stem: &str, extension: &str) {
let Ok(home) = std::env::var("HOME") else { return };
let desktop = std::path::PathBuf::from(home).join("Desktop");
- let mut name = "New Text Document.txt".to_string();
+ let mut name = format!("{stem}.{extension}");
let mut n = 2;
while desktop.join(&name).exists() {
- name = format!("New Text Document ({n}).txt");
+ name = format!("{stem} ({n}).{extension}");
n += 1;
}
if let Err(e) = std::fs::write(desktop.join(&name), "") {
@@ -639,7 +650,15 @@ impl CompState {
DesktopMenuAction::OpenTerminalHere => self.open_terminal_here(),
DesktopMenuAction::OpenInFileManager => self.open_desktop_in_file_manager(),
DesktopMenuAction::SelectAll => self.select_all_desktop_icons(),
- DesktopMenuAction::Refresh => self.refresh_desktop_icons(),
+ DesktopMenuAction::NewFileOfType { label, extension } => self.new_desktop_file(&format!("New {label}"), extension),
+ DesktopMenuAction::Refresh => {
+ self.refresh_desktop_icons();
+ // Re-reads the Lua config and fires `srd.on("refresh")`,
+ // so a user's own "restart my bar / reload my shell" list
+ // runs from the same menu row - see
+ // `WindowManager::request_refresh`.
+ self.wm.borrow_mut().request_refresh();
+ }
// Never actually reached - the click-dispatch site intercepts
// `Separator` first, same as `context_menu::MenuAction::
// Separator`'s own dispatch. Handled here too so this match
diff --git a/crates/wayland/src/state/lifecycle.rs b/crates/wayland/src/state/lifecycle.rs
index 51297bb..92f7d9d 100644
--- a/crates/wayland/src/state/lifecycle.rs
+++ b/crates/wayland/src/state/lifecycle.rs
@@ -108,9 +108,37 @@ impl CompState {
let is_dialog = self.id_to_window.get(&id).is_some_and(|dw| {
dw.toplevel().is_some_and(|t| t.parent().is_some()) || dw.x11_surface().is_some_and(|x| x.is_transient_for().is_some())
});
+ // Resolved the same way and at the same time as `is_dialog` above,
+ // for the same reason: this is protocol state, which `core` cannot
+ // read for itself. A client that pinned min == max on both axes is
+ // telling us it cannot be resized - and therefore cannot be
+ // maximized, so the Maximize button would do nothing. `0` means
+ // "unconstrained" on that axis in both protocols, so a zero on
+ // either side is never a match. See `Window::resizable`.
+ let resizable = self.id_to_window.get(&id).is_none_or(|dw| {
+ if let Some(toplevel) = dw.toplevel() {
+ let (min, max) = smithay::wayland::compositor::with_states(toplevel.wl_surface(), |states| {
+ let mut cached = states.cached_state.get::<smithay::wayland::shell::xdg::SurfaceCachedState>();
+ let current = cached.current();
+ (current.min_size, current.max_size)
+ });
+ return !(min.w > 0 && min.h > 0 && min == max);
+ }
+ if let Some(x11) = dw.x11_surface() {
+ if let (Some(min), Some(max)) = (x11.min_size(), x11.max_size()) {
+ return min != max;
+ }
+ }
+ true
+ });
if let Some(win) = self.wm.borrow_mut().window_mut(id) {
win.is_dialog = is_dialog;
+ win.resizable = resizable;
}
+ let show_maximize = {
+ let wm = self.wm.borrow();
+ wm.window(id).is_none_or(|win| wm.show_maximize(win))
+ };
// Corrects `w.geometry`'s far edge to match what the client's
// surface really committed, when that's known - see
// `effective_frame`'s own doc comment. Every bitmap this method
@@ -206,6 +234,7 @@ impl CompState {
theme.button_order,
theme.traffic_light_buttons,
is_dialog,
+ show_maximize,
);
let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (width as i32, TITLEBAR_HEIGHT as i32), 1, Transform::Normal, None);
self.decorations.insert(id, buffer);
diff --git a/crates/wayland/src/state/menu.rs b/crates/wayland/src/state/menu.rs
index 82e4434..d5e91cb 100644
--- a/crates/wayland/src/state/menu.rs
+++ b/crates/wayland/src/state/menu.rs
@@ -143,9 +143,21 @@ impl CompState {
/// (global space, by convention the maximize button's own titlebar
/// position). Same build-once-on-open pattern as `open_context_menu`.
pub(crate) fn open_snap_flyout(&mut self, window: WindowId, pos: (i32, i32)) {
- let flyout = crate::snap_flyout::SnapFlyout::open(window, pos);
+ let mut flyout = crate::snap_flyout::SnapFlyout::open(window, pos);
let theme = self.wm.borrow().theme;
let labels: Vec<&str> = flyout.cells().iter().map(|z| z.label()).collect();
+ // Grow each cell to fit the widest label, exactly as
+ // `open_context_menu` above already does for menu rows - never
+ // shrinking below the built-in minimum `SnapFlyout::open` picked.
+ //
+ // The flyout was the one labelled surface in this compositor that
+ // never got this treatment: at the fixed 90px cell width "Bottom
+ // Right" was cut off mid-word and "Top Left" ran into its
+ // neighbour, the same "text goes out of view" fault already
+ // reported and fixed for the context menu.
+ let font = decoration::find_system_font();
+ let widest = labels.iter().map(|l| decoration::measure_text_width(&font, l, decoration::FONT_PIXELS)).fold(0.0_f32, f32::max);
+ flyout.cell_width = flyout.cell_width.max((widest + decoration::TEXT_LEFT_PADDING * 2.0).ceil() as u32);
let data = decoration::render_snap_flyout(3, flyout.cell_width, flyout.cell_height, &labels, theme.titlebar_bg, theme.titlebar_fg_focused, theme.default_border_color);
let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (flyout.width() as i32, flyout.height() as i32), 1, Transform::Normal, None);
self.snap_flyout_buffer = Some(buffer);
diff --git a/crates/wayland/src/state/mod.rs b/crates/wayland/src/state/mod.rs
index 6396692..fee7af3 100644
--- a/crates/wayland/src/state/mod.rs
+++ b/crates/wayland/src/state/mod.rs
@@ -582,6 +582,13 @@ pub(crate) struct CompState {
/// dropping them would lose the damage-tracking stability the whole
/// scheme exists for the moment fragment counts fluctuate back up.
pub(crate) border_side_buffers: HashMap<WindowId, Vec<SolidColorBuffer>>,
+ /// Persistent solid-colour buffers backing the drag snap-preview
+ /// overlay (fill plus four outline strips) - same "reuse the buffer so
+ /// its `Id` stays stable across frames" reasoning as
+ /// `border_side_buffers` above. One pool, not one per window: only ever
+ /// one drag is in progress at a time. See
+ /// `elements::snap_preview_elements`.
+ pub(crate) snap_preview_buffers: Vec<SolidColorBuffer>,
/// Persistent solid-colour buffer backing the whole-output night-light/
/// reading-mode overlay, one per output name - same "reuse the buffer
/// so its `Id` stays stable across frames" reasoning as `border_side_
diff --git a/crates/wayland/src/udev/platform.rs b/crates/wayland/src/udev/platform.rs
index 732fbc2..0ae65f7 100644
--- a/crates/wayland/src/udev/platform.rs
+++ b/crates/wayland/src/udev/platform.rs
@@ -267,6 +267,7 @@ impl UdevPlatform {
content_epoch: HashMap::new(),
rounded_content_buffers: HashMap::new(),
border_side_buffers: HashMap::new(),
+ snap_preview_buffers: Vec::new(),
color_filter_buffers: HashMap::new(),
last_synced_size: HashMap::new(),
provisional_size: HashSet::new(),
diff --git a/crates/wayland/src/udev/render.rs b/crates/wayland/src/udev/render.rs
index ed62290..4170f47 100644
--- a/crates/wayland/src/udev/render.rs
+++ b/crates/wayland/src/udev/render.rs
@@ -45,6 +45,11 @@ impl CompState {
// per-window shadow push below to keep a shadow off any monitor its
// own window does not occupy (`decoration::shadow_rect_clipped`).
let monitor_bounds: Vec<srdwm_core::Rect> = self.wm.borrow().monitors().iter().map(|m| m.full_geometry).collect();
+ // Same "gather immutable state before `self.udev` is borrowed"
+ // reason again - both are read inside the per-head loop below,
+ // which holds that borrow for its whole body.
+ let drag_snap_preview = self.wm.borrow().drag_snap_preview();
+ let accent_color = self.wm.borrow().theme.default_border_color;
// Captured-and-blurred backgrounds collected during the per-head
// loop below, applied via `self.capture_output` only after it
// ends - `self.udev`'s mutable borrow is held for the whole loop
@@ -450,6 +455,16 @@ impl CompState {
Err(e) => log::warn!("udev: failed to import context menu buffer: {e}"),
}
}
+ // The drag snap preview - below the flyout (which the
+ // pointer is actively aiming at) but above every window,
+ // since it is showing where one of them is about to go.
+ if let Some(rect) = drag_snap_preview {
+ custom_elements.extend(
+ crate::elements::snap_preview_elements(&mut self.snap_preview_buffers, rect, accent_color, (origin.x, origin.y))
+ .into_iter()
+ .map(crate::elements::OverlayElement::Solid),
+ );
+ }
// The Snap-Layouts flyout, if open - same "topmost but
// never hides the cursor" placement as the context menu.
if let (Some(flyout), Some(buffer)) = (self.snap_flyout.as_ref(), self.snap_flyout_buffer.as_ref()) {
diff --git a/crates/wayland/src/winit/capture.rs b/crates/wayland/src/winit/capture.rs
index d21ed72..c382190 100644
--- a/crates/wayland/src/winit/capture.rs
+++ b/crates/wayland/src/winit/capture.rs
@@ -17,10 +17,18 @@ impl WaylandPlatform {
renderer.create_buffer(Fourcc::Abgr8888, (size.w, size.h).into()).map_err(err)?;
let mut framebuffer = renderer.bind(&mut target).map_err(err)?;
- // Not full parity with the on-screen render loop above (no border/
- // shadow strips here, same as before this function's content/opacity
- // fix) - a real, pre-existing gap in what a screenshot shows on
- // this backend, flagged rather than grown further in this pass.
+ // WHAT THIS PASS STILL DOES NOT DRAW: border strips, and the
+ // desktop icon grid. Everything else the on-screen loop draws is
+ // covered - content, titlebars, shadows, layer-shell surfaces,
+ // popups, both menus, the Snap-Layouts flyout and the drag snap
+ // preview.
+ //
+ // Keep this list honest. The whole reason the nested backend exists
+ // is checking behaviour with `grim`, and a tier missing here makes
+ // a working feature photograph as broken. That has now cost four
+ // separate investigations in one day (popups, shadows, the drag
+ // snap preview, the desktop menu), every one of them starting from
+ // a screenshot that was quietly lying.
// Content (with each window's own `opacity`, unlike the
// `self.state.space`-based single-alpha call this replaced) and the
// bar/dock now render into the capture, at least: a screenshot used
@@ -48,6 +56,38 @@ impl WaylandPlatform {
// like the client never opened one.
let popup_targets = crate::elements::popup_targets(&self.state);
custom_elements.extend(crate::elements::popup_render_elements(&popup_targets, renderer, (0, 0)));
+ // The titlebar context menu and the desktop/desktop-icon menu, both
+ // topmost, in the same order the on-screen loop draws them.
+ for (menu_pos, buffer) in [
+ (self.state.context_menu.as_ref().map(|m| m.pos), self.state.context_menu_buffer.as_ref()),
+ (self.state.desktop_menu.as_ref().map(|m| m.pos), self.state.desktop_menu_buffer.as_ref()),
+ ] {
+ let (Some(pos), Some(buffer)) = (menu_pos, buffer) else { continue };
+ match MemoryRenderBufferRenderElement::from_buffer(renderer, (pos.0 as f64, pos.1 as f64), buffer, None, None, None, Kind::Unspecified) {
+ Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
+ Err(e) => log::warn!("screencopy: failed to import menu buffer: {e}"),
+ }
+ }
+ // The Snap-Layouts flyout and the drag snap preview, in the same
+ // order the on-screen loop draws them. Both are drag-time overlays,
+ // and a screenshot that omits them cannot be used to check either
+ // one - which is exactly how this pass's missing popup tier wasted
+ // a session already.
+ if let (Some(flyout), Some(buffer)) = (self.state.snap_flyout.as_ref(), self.state.snap_flyout_buffer.as_ref()) {
+ let pos = (flyout.pos.0 as f64, flyout.pos.1 as f64);
+ match MemoryRenderBufferRenderElement::from_buffer(renderer, pos, buffer, None, None, None, Kind::Unspecified) {
+ Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
+ Err(e) => log::warn!("screencopy: failed to import snap flyout buffer: {e}"),
+ }
+ }
+ if let Some(rect) = self.wm.borrow().drag_snap_preview() {
+ let accent = self.wm.borrow().theme.default_border_color;
+ custom_elements.extend(
+ crate::elements::snap_preview_elements(&mut self.state.snap_preview_buffers, rect, accent, (0, 0))
+ .into_iter()
+ .map(crate::elements::OverlayElement::Solid),
+ );
+ }
if !hide_top_layers {
custom_elements.extend(crate::elements::output_layer_elements(renderer, &self.output, |layer| matches!(layer, Layer::Top | Layer::Overlay)));
}
diff --git a/crates/wayland/src/winit/connect.rs b/crates/wayland/src/winit/connect.rs
index 4c6954b..1648826 100644
--- a/crates/wayland/src/winit/connect.rs
+++ b/crates/wayland/src/winit/connect.rs
@@ -179,6 +179,7 @@ impl WaylandPlatform {
content_epoch: HashMap::new(),
rounded_content_buffers: HashMap::new(),
border_side_buffers: HashMap::new(),
+ snap_preview_buffers: Vec::new(),
color_filter_buffers: HashMap::new(),
last_synced_size: HashMap::new(),
provisional_size: HashSet::new(),
diff --git a/crates/wayland/src/winit/render.rs b/crates/wayland/src/winit/render.rs
index dff425f..ccc64a2 100644
--- a/crates/wayland/src/winit/render.rs
+++ b/crates/wayland/src/winit/render.rs
@@ -135,6 +135,26 @@ impl WaylandPlatform {
Err(e) => log::warn!("failed to import context menu buffer: {e}"),
}
}
+ // ADDING A TIER HERE? THIS BACKEND RENDERS THE SCENE TWICE. What a
+ // screenshot shows comes from `winit/capture.rs`'s own separate
+ // offscreen pass, not from this loop, so anything pushed here and
+ // not there is on screen but invisible to `grim` - which reads as
+ // the feature being broken. That gap has now cost two separate
+ // investigations in one day (popups, then this very preview). The
+ // DRM backend has no equivalent trap: it serves screencopy out of
+ // the frame it just drew.
+ //
+ // The drag snap preview - below the flyout (which the pointer is
+ // actively aiming at) but above every window, since it is showing
+ // where one of them is about to go.
+ if let Some(rect) = self.wm.borrow().drag_snap_preview() {
+ let accent = self.wm.borrow().theme.default_border_color;
+ custom_elements.extend(
+ crate::elements::snap_preview_elements(&mut self.state.snap_preview_buffers, rect, accent, (0, 0))
+ .into_iter()
+ .map(|e| crate::rounded_corners::WinitElement::Base(crate::elements::OverlayElement::Solid(e))),
+ );
+ }
// The Snap-Layouts flyout, if open - same topmost placement.
if let (Some(flyout), Some(buffer)) = (self.state.snap_flyout.as_ref(), self.state.snap_flyout_buffer.as_ref()) {
let pos = (flyout.pos.0 as f64, flyout.pos.1 as f64);
diff --git a/docs/DEFAULTS.md b/docs/DEFAULTS.md
index 3d1a863..a130434 100644
--- a/docs/DEFAULTS.md
+++ b/docs/DEFAULTS.md
@@ -19,6 +19,7 @@ srd.set("general.auto_raise", false) -- Default: false - also
srd.set("general.gpu", false) -- Default: false - udev backend only, see "GPU rendering" below
srd.set("general.desktop_icons", true) -- Default: true - see "Desktop icons" below
srd.set("general.desktop_icons_all_monitors", true) -- Default: true - mirror icons onto every monitor, not just primary
+srd.set("general.config_reload_on_write", true) -- Default: true - re-read init.lua when it changes on disk
srd.set("general.reserve_top", 0) -- Default: 0 - static space reserved before any bar/dock connects
srd.set("general.reserve_bottom", 0) -- Default: 0 - see "Startup space reservation" below
srd.set("general.reserve_left", 0) -- Default: 0
@@ -453,8 +454,18 @@ srd.set("theme.decorations.title_bar.button_side", "right") -- Default: "right
srd.set("theme.decorations.title_bar.button_order", "") -- Default: "" (unset)
srd.set("theme.decorations.title_bar.button_glyph", "hover") -- Default: "hover"
srd.set("theme.decorations.title_bar.button_style", "traffic_lights") -- Default: "traffic_lights"
+srd.set("theme.decorations.title_bar.button_mode", "dynamic") -- Default: "dynamic"
```
+`button_mode` sets `"dynamic"` (the default) to show only the buttons a
+window can actually use, or `"fixed"` to always show the full set. Today
+dynamic mode has one rule: a window whose client pinned its minimum and
+maximum size to the same value gets no Maximize button, because pressing it
+can do nothing. GNOME, KDE and Windows all hide or disable maximize in the
+same case. A dialog's Close-only titlebar is a separate rule and applies in
+both modes. Live-settable with `srd set button_mode <dynamic|fixed>`, and
+readable back from `srd settings`.
+
`text_align` sets `"center"` for the macOS convention (title centered on
the whole titlebar width, ignoring the button cluster the way real macOS
does) or `"left"` (default) for the Windows/GTK convention. Any value
@@ -832,3 +843,34 @@ local elapsed = srd.debug.profile_stop()
This documentation provides a comprehensive reference for all default values and configuration options in SRDWM.
+
+## Config reloading and what happens when a config breaks
+
+A Lua config is a program, so breaking it is an ordinary event rather than
+an exceptional one. Three things make that safe.
+
+**A failed reload changes nothing.** Reloading clears the keybinding, event
+handler and repeat-key tables before re-running `init.lua`, so that a
+binding deleted from the file really disappears. If the new file fails to
+parse or errors while running, all three tables are put back exactly as they
+were. The last working config keeps running. Whatever the broken run managed
+to register before it failed is discarded rather than merged, because half a
+config is not a config.
+
+**The error is shown, not just logged.** Config failures go to
+`notify-send` as well as the log. Without that the failure is close to
+silent from the user's side: the compositor keeps running and the edit simply
+does nothing.
+
+**Edits apply on save.** `general.config_reload_on_write` (default `true`)
+checks the config directory's `.lua` modification times once a second and
+reloads when one changes. Set it to `false` for a config that does expensive
+work at load time. `Mod4+Ctrl+r` still reloads on demand in either case.
+
+Two limits to know. A reload does not re-register key *grabs* with the
+backend, so a brand new key combination needs a restart before the
+compositor sees that key at all; an existing combination picks up its new
+action immediately. And a reload rebuilds the theme from the config file, so
+it discards live `srd set` theme changes - including titlebar settings
+changed through the right-click menu. That is the correct precedence, but
+with reload-on-write it now happens every time the config is saved.
diff --git a/docs/TODO.md b/docs/TODO.md
index 72b3d20..49f7312 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -1,5 +1,118 @@
# TODO / planned features - master checklist
+## Eight asks recovered from the previous session's transcript, all built (2026-08-28)
+
+Reported as "there was more stuff from previous agent", then "please do all of
+them ... i also stated many issues ie titlebar inconsistencies etc". The list
+was rebuilt by reading the owner's own typed messages out of the previous
+session's transcript rather than by guessing, then each item was checked
+against the code before being called open. Three things they suspected were
+already done really were done - dialogs already got a Close-only titlebar,
+inactive-window dimming already existed, and the corner resize hitbox had
+already been tuned. The eight below had not been built.
+
+**1. Windows-style snap layouts on drag.** Asked twice ("if you move the
+window to absolute north it show you layout options", then "why do i still
+not see the windows layout ... when moved to areas of screen like in
+windows"). Edge snapping already worked but committed silently on release
+with nothing shown beforehand, so there was no way to know it was about to
+happen or where. Now: `WindowManager::drag_snap_preview` returns the rect the
+window will land in, drawn as a translucent accent fill with a solid outline
+(`elements::snap_preview_elements`, native solid fills - a preview can be a
+whole monitor in size and changes as the drag moves, so rasterising a bitmap
+per zone change would allocate megabytes); and throwing the pointer at a
+monitor's top edge drops down the existing six-cell Snap-Layouts grid to aim
+at. The preview calls the very same `SmartPlacement::snap_zone` that
+`end_drag` does, so preview and commit cannot disagree.
+
+Two defects found by screenshot and fixed before landing: moving down onto
+the flyout closed it (the pointer had left the trigger band), and its cell
+labels overflowed at the fixed 90px width - "Bottom Right" was cut off
+mid-word, the same "text goes out of view" fault already reported and fixed
+for the context menu. The flyout now grows to fit its widest label exactly as
+`open_context_menu` already did.
+
+**2. New File with a real type choice.** "in context menu say new file, user
+can choose what file type is obviously by extension". The desktop menu had
+`New Folder` and `New Text Document` only. Five more types now sit under
+them, each creating an empty file with the right extension. The de-duplication
+counter goes before the extension (`New Shell Script (2).sh`), since a
+suffixed extension would stop being one.
+
+**3. Refresh actually refreshes something.** "does refresh refresh configs in
+a function list in the config ie refresh os, etc, ags/aegis/polybar/waybar".
+Refresh re-scanned the desktop icon grid and nothing else. It now also
+re-reads `init.lua` and fires a new `srd.on("refresh", ...)` handler, so the
+config decides what else to reload. What "refresh" means beyond srdwm's own
+config is deliberately the user's decision - this compositor has no business
+hardcoding whether they run waybar or AGS.
+
+**4. Config reload on write.** "do we suppport update config on write" - no,
+it did not. `general.config_reload_on_write` (default on) polls the config
+directory's `.lua` mtimes once a second and reloads on a change. A `stat`
+sweep rather than an inotify watch: no new dependency, identical behaviour on
+every platform this project targets, and immune to the editor-writes-a-temp-
+file-and-renames pattern that defeats watches on individual files.
+
+**5. What happens when a config fails - a real bug, fixed.** Asked as "what
+happens when our config fails/user does something wrong which can be expected
+since lua programmable config". The answer the code gave was: **you lose every
+keybinding.** `do_reload` cleared `key_bindings`/`event_handlers`/
+`repeat_keys` before re-executing and never restored them, so a Lua syntax
+error - the most likely thing to go wrong with a programmable config - left
+neither the old bindings nor the new ones. The only key still working was the
+hardcoded reload combo, which is the one key nobody thinks to press, because
+nothing said that was the situation. The three maps are now moved out and put
+back on any failure, so a broken edit leaves the last working config running,
+and config errors go to `notify-send` rather than only to a log. Verified live
+in a nested compositor: breaking the config logged "Config edit not applied,
+keeping the last working one", the compositor kept serving IPC, and fixing the
+file reloaded for real.
+
+**6. A lock-screen keybinding.** "do we have a lockscreen binding?" - there
+was no way to reach the built-in lock screen from Lua at all, and the shipped
+config's only lock key ran an external script. New native `srd.lock()`, bound
+to `Mod4+Ctrl+l` by default. Native rather than shelling out to the control
+CLI, because a lock binding that shells out fails silently when that binary is
+not on `PATH`.
+
+**7. Dialogs open centred.** "even dialog/starter windows spawn that side,
+most times should be centered". Dialogs now centre on the target monitor's
+usable area, and are excluded from the remembered-geometry path in both
+directions: `remembered_geometry` is keyed by `app_id`, which a dialog shares
+with the window that spawned it, so a dialog was being given that app's last
+main-window position *and size* - and would then overwrite that memory with
+its own small rect.
+
+**8. Titlebar buttons that follow the program.** "ideally we can also set
+titlebar to have inhouse decorations/buttons of the program/dynamic". New
+`theme.decorations.title_bar.button_mode` (`dynamic`, the default, or
+`fixed`), live-settable via `srd set button_mode` and readable back through
+`srd settings`. In dynamic mode a window whose client pinned min == max size
+gets no Maximize button, because pressing it can do nothing - what GNOME, KDE
+and Windows all do. Maximize is removed from the slot list rather than skipped
+in place, in both the renderer and the hit-test, so the remaining buttons
+close the gap identically on both sides; three tests pin that agreement, which
+is the part that fails silently when it drifts.
+
+Also fixed, and the reason several of these took two attempts: the nested
+backend's screencopy pass was still missing tiers. It now draws the titlebar
+context menu, the desktop menu, the Snap-Layouts flyout and the drag snap
+preview as well as the popups and shadows added earlier today. Four separate
+investigations in one day started from a screenshot that was quietly lying --
+`winit/capture.rs` now carries an explicit list of what it still does not
+draw (border strips and the desktop icon grid), and `winit/render.rs` carries
+a pointer to it at the place a new tier gets added.
+
+**One interaction worth knowing.** A config reload rebuilds `ThemeConfig` from
+the config file, so it discards live `srd set` theme changes - correct
+precedence, and pre-existing, but auto-reload makes it happen on every save
+rather than only when the reload key is pressed. A titlebar customised live
+through the right-click menu reverts the next time `init.lua` is saved.
+
+Full workspace build/test/clippy clean: 512 tests (262 core / 160 wayland /
+43 platform / 34 config / 13 ctl), 0 failed, 0 clippy warnings.
+
## Nemo's right-click menu: confirmed working, and two real bugs found doing it (2026-08-28)
The last open punch-list item. It is closed on a real end-to-end repro, not