srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-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
33 files changed, 1184 insertions, 80 deletions
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);