From c593da7fb92bf35e6fef62bdfa694a7696fcd1ba Mon Sep 17 00:00:00 2001 From: Fi3w0 Date: Thu, 24 Sep 2026 22:54:16 +0200 Subject: [PATCH 01/13] Make automatic attention depth opt-in depth { enabled } now defaults to false in DepthConfig and in the generated first-run config, which gains an explicit depth { enabled = false } block. Unfocused windows no longer fade into the blue cool_color wash after 30 s or turn into title cards after 60 s unless a config opts in with enabled = true. depth_defaults_match_generated_config asserts the disabled default for both sources; the tier-boundary test now enables depth explicitly instead of relying on the default. DOCUMENTATION.md and CHANGELOG.md updated. --- CHANGELOG.md | 1 + DOCUMENTATION.md | 2 +- src/tide_core/config.rs | 12 ++++++++++-- src/visual/depth.rs | 1 + 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e16996b..25344ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ All notable changes to TideWM are documented here. Format loosely follows [Keep - `rule { pin_output = "" }` and the `toggle-output-pin` action persistently pin a window to a named output, closing the config-customization audit's last open `rule { }`-breadth item. Neither niri nor Hyprland actually expose this at the window level -- niri's `open-on-output` and Hyprland's `monitor:` windowrule are both spawn-time-only placement, exactly what TideWM's existing `output` field already does; niri's real persistent binding is a *named workspace's* `open-on-output`, which has nowhere to hang in TideWM's per-output-numbered-workspace model (a workspace number already exists independently on every output). This is TideWM's own adaptation, at the window instead of the workspace. `pin_output` takes priority over `output` for initial placement and is remembered in the new `Smallvil::output_pins` map (one entry per pinned window, empty and free until something pins) independent of whether the named output is connected yet. `toggle-output-pin` pins/unpins the focused window to whichever output it's currently on -- unlike the existing `toggle-pin` (workspace-sticky, floating-only by necessity), this has nothing to do with tiling state. A disconnected pinned output still falls its windows back like any other window there (physically nothing else to do), but reconnecting the same output now recalls them automatically via the new `Smallvil::recall_pinned_windows_to`/`move_window_to_output`, a single-surface counterpart to the existing bulk `migrate_output_windows` reusing the same tiled-remove/insert, floating retag-and-translate, and fullscreen/maximized `move_to_output` idioms -- called from the udev backend's output-added path alongside the existing `adopt_orphaned_output_windows` (winit's one simulated output never hotplugs, so there's nothing to wire there). `swap-workspaces-with-output` is deliberately left alone rather than given a per-window pin exemption: it's an explicit user action with no default keybind, and it already moves everything on the swapped workspace -- pins included is the honest, symmetric behavior, not a silent partial exception. Additive Wave key and action; every existing `rule { }` block keeps parsing and behaving exactly as before. Unit-tested: the parser, the `resolve_window_rules` fold, and the action string parse all have direct test coverage; `toggle_output_pin`/`move_window_to_output`/`recall_pinned_windows_to` are ordinary `Smallvil` methods with no pure seam to unit-test in isolation, the same shape `toggle_pin`/`mark_urgent` already have. ### Changed +- **Automatic attention depth is now opt-in.** `depth { enabled }` defaults to `false` in code and in the generated first-run config, which gains an explicit `depth { enabled = false }` block. Unfocused windows no longer fade into the blue `cool_color` wash after 30 s or turn into title cards after 60 s unless a config sets `enabled = true`. `depth_defaults_match_generated_config` now asserts the disabled default for both. - Backend and protocol source documentation now keeps the operational contracts for DRM surface state, output transaction ordering, lock-safe capture, XDG map sequencing, cursor scale caches, Xwayland startup, and PipeWire/zbus ownership without retaining incident narratives or comparison notes. Core-state comments likewise retain focus, workspace, migration, rendering, and physics invariants without their historical framing; the large misplaced desktop-stack comment now sits on the function it actually describes. Diagnostics also stop reading and discarding `TZ` before producing their explicitly UTC timestamp. - Visual source documentation for caustics, backdrop capture, water glass, float physics, workspace transitions, swim, compass cues, tab strips, welcome hints, and the configuration-error overlay now states the active render, damage, shader, geometry, and simulation contracts directly. Roadmap phases, implementation chronology, compositor comparisons, metaphors, and bug-story narration were removed; the safety-critical EGL ordering, texture ownership, damage identity, opacity, coordinate-space, bounds, and settling invariants remain beside the code. - High-priority source documentation now describes current ownership, coordinate, threading, merge, shader, and input contracts directly. Historical rollout stories and obsolete roadmap framing were removed from the minimap, overview, IPC, accessibility, portal, output-management, tearing-control, tiled-drag, floating-physics, output-selection, input-method, ripple-shader, and Wave-merge paths; verification history remains in this changelog and `report.md`. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 7380801..4531f9f 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -833,7 +833,7 @@ A matching `rule { depth = false }` pins that window buoyant: it stays at tier 0 | Key | Type | Default | Notes | | --- | --- | --- | --- | -| `enabled` | bool | `true` | Disables only automatic depth/buoyancy; other water effects remain active. | +| `enabled` | bool | `false` | Opt-in. Enables only automatic depth/buoyancy; other water effects are unaffected. | | `sink_after_ms` | integer, `0`–`86400000` | `30000` | Inactivity before entering tier 1. `delay_ms` is an alias. | | `tier_interval_ms` | integer, `1`–`86400000` | `30000` | Additional inactivity per deeper tier. `interval_ms` is an alias. | | `max_tier` | integer, `1`–`8` | `2` | Deepest tier a window may reach. `tiers` is an alias. | diff --git a/src/tide_core/config.rs b/src/tide_core/config.rs index 4e9ad5f..3779eb1 100644 --- a/src/tide_core/config.rs +++ b/src/tide_core/config.rs @@ -4250,7 +4250,8 @@ pub struct DepthConfig { impl Default for DepthConfig { fn default() -> Self { Self { - enabled: true, + // Opt-in: the blue wash / title cards surprise people who didn't ask for them. + enabled: false, sink_after_ms: 30_000, tier_interval_ms: 30_000, max_tier: 2, @@ -9539,6 +9540,13 @@ viscosity = 1.0 # 0 turns off drag/resize settling, higher sett # backdrop_capture_scale = 2 # 1 (default, full detail) to 4; lower the frost/water-glass # capture texture's resolution to cut VRAM on many glass windows +# Automatic attention depth: unfocused windows fade into a blue wash after +# 30 s and turn into title cards after 60 s. Off by default; set true (and +# tune sink_after_ms / cool_color in DOCUMENTATION.md's depth { }) to opt in. +depth { + enabled = false +} + # ~~~~~~~~~~~~~~~~~ the layout ~~~~~~~~~~~~~~~~~ gaps = 8 @@ -13209,7 +13217,7 @@ shader tinted-blur { parse_default_config(), ] { let depth = config.depth; - assert!(depth.enabled); + assert!(!depth.enabled); assert_eq!(depth.sink_after_ms, 30_000); assert_eq!(depth.tier_interval_ms, 30_000); assert_eq!(depth.max_tier, 2); diff --git a/src/visual/depth.rs b/src/visual/depth.rs index 8849462..35af937 100644 --- a/src/visual/depth.rs +++ b/src/visual/depth.rs @@ -434,6 +434,7 @@ mod tests { #[test] fn depth_tiers_advance_at_configured_boundaries_and_cap() { let cfg = DepthConfig { + enabled: true, sink_after_ms: 100, tier_interval_ms: 50, max_tier: 3, From 2727de9d7420c6b5c8a0c391a23f3236530bcc61 Mon Sep 17 00:00:00 2001 From: Fi3w0 Date: Fri, 25 Sep 2026 02:34:37 +0200 Subject: [PATCH 02/13] Implement client drag'n'drop WaylandDndGrabHandler was an empty impl, so Smithay's default dnd_requested cancelled every drag source immediately. Dragging files, text or links between apps did nothing, and Firefox treated every tab drag as a drop outside the window and detached the tab. Start a DnDGrab for pointer and touch drags, keep the client's icon surface, draw it at the pointer with the cursor elements on the udev backend (skipped while locked), send it frame callbacks, and clear it on drop or cancel. --- CHANGELOG.md | 1 + src/backend/udev.rs | 24 +++++++++++++++ src/backend/winit.rs | 1 + src/handlers/mod.rs | 67 ++++++++++++++++++++++++++++++++++++++++-- src/tide_core/state.rs | 14 +++++++++ 5 files changed, 104 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25344ab..219b394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ All notable changes to TideWM are documented here. Format loosely follows [Keep - High-priority source documentation now describes current ownership, coordinate, threading, merge, shader, and input contracts directly. Historical rollout stories and obsolete roadmap framing were removed from the minimap, overview, IPC, accessibility, portal, output-management, tearing-control, tiled-drag, floating-physics, output-selection, input-method, ripple-shader, and Wave-merge paths; verification history remains in this changelog and `report.md`. ### Fixed +- **Client drag'n'drop works.** `WaylandDndGrabHandler` was an empty impl, so Smithay's default `dnd_requested` cancelled every drag source the moment it started: nothing could be dragged between apps (files from Dolphin into Firefox, text, links) and Firefox treated every tab drag as a drop outside the window, detaching the tab into a new one. Drags now start a Smithay `DnDGrab` for pointer or touch; the client's drag icon is drawn at the pointer with the cursor elements on the udev backend (never over the lock screen) and receives frame callbacks; the icon is cleared on drop or cancel. Nested/winit runs get working drags but no compositor-drawn icon, since the host draws the cursor there, and screenshots do not include the icon yet. - Screenshots and screencasts of a whole output now include layer frost (`layer_rule { blur = true }`) and layer shaders. Both visible backends added the layer glass layer to the frame, but the output capture path only built window glass, so a blurred bar showed up unblurred in every capture. - A refused `spring(mass, stiffness, damping)` curve now says why in its journal warning: a coefficient outside its range, a spring that never settles within ten seconds, or not three numbers. Both refusals used to log the same generic easing warning. - Focus cycling no longer lifts a tiled window above floating ones. `cycle_focus` raised whichever window it focused, and raising a tiled window puts it over every overlapping floater, so a floating window could end up behind its tiled neighbor until something happened to retile. It now raises only floating windows, like directional focus already did. This is the likely cause of the "freshly floated window behind a tiled sibling" report from the 2026-09-12 live session, but that report's exact steps weren't recorded, so it still needs a live check. diff --git a/src/backend/udev.rs b/src/backend/udev.rs index ce08319..dd7130c 100644 --- a/src/backend/udev.rs +++ b/src/backend/udev.rs @@ -13,6 +13,7 @@ use std::{cell::RefCell, collections::HashMap, path::PathBuf, rc::Rc, time::Duration}; +use smithay::reexports::wayland_server::Resource; use smithay::{ backend::{ allocator::{ @@ -1901,6 +1902,28 @@ fn render_surface( } }; + // A client drag'n'drop icon rides with the pointer, drawn with the cursor + // elements. Never over the lock screen, same as client cursor surfaces. + let mut cursor_surface_element = cursor_surface_element; + if !locked { + if let Some(icon) = state.dnd_icon.as_ref().filter(|icon| icon.is_alive()) { + let pointer_loc = state + .seat + .get_pointer() + .map(|p| p.current_location()) + .unwrap_or_default(); + let local = (pointer_loc - output_loc.to_f64()).to_physical(scale); + cursor_surface_element.extend(render_elements_from_surface_tree( + renderer, + icon, + local.to_i32_round(), + scale, + 1.0, + Kind::Unspecified, + )); + } + } + // Locked: skip window/layer-shell rendering (and the group tab strip, // which would otherwise leak window titles over the lock screen) // entirely, rather than rendering them and relying on the lock @@ -2156,6 +2179,7 @@ fn render_surface( // sees; the lock surface gets its own frame callback instead. if locked { state.send_lock_frames(output, state.start_time.elapsed()); + state.send_dnd_icon_frames(output, state.start_time.elapsed()); } else { state.send_window_frames(output, state.start_time.elapsed()); state.send_layer_frames(output, state.start_time.elapsed()); diff --git a/src/backend/winit.rs b/src/backend/winit.rs index 998421c..3ae7fa0 100644 --- a/src/backend/winit.rs +++ b/src/backend/winit.rs @@ -523,6 +523,7 @@ pub fn init_winit( let output = &entry.output; if locked { state.send_lock_frames(output, state.start_time.elapsed()); + state.send_dnd_icon_frames(output, state.start_time.elapsed()); } else { state.send_window_frames(output, state.start_time.elapsed()); state.send_layer_frames(output, state.start_time.elapsed()); diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 5aedf54..ab8b98e 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -18,12 +18,16 @@ use crate::Smallvil; use smithay::desktop::utils::surface_primary_scanout_output; use smithay::desktop::{PopupKind, PopupManager}; -use smithay::input::{pointer::PointerHandle, Seat, SeatHandler, SeatState}; +use smithay::input::dnd::{DnDGrab, DndGrabHandler, DndTarget, GrabType, Source}; +use smithay::input::{ + pointer::{Focus, PointerHandle}, + Seat, SeatHandler, SeatState, +}; use smithay::output::Output; use smithay::reexports::wayland_server::protocol::wl_output::WlOutput; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::reexports::wayland_server::Resource; -use smithay::utils::{Logical, Rectangle}; +use smithay::utils::{Logical, Point, Rectangle, Serial}; use smithay::wayland::compositor::{get_parent, with_states}; use smithay::wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier}; use smithay::wayland::foreign_toplevel_list::{ @@ -141,7 +145,64 @@ impl DataDeviceHandler for Smallvil { } } -impl WaylandDndGrabHandler for Smallvil {} +// Client-initiated drag'n'drop (files out of Dolphin, Firefox tabs, text). +// Smithay's default `dnd_requested` cancels the source immediately, which is +// why every drag used to die on the spot and Firefox detached dragged tabs. +impl WaylandDndGrabHandler for Smallvil { + fn dnd_requested( + &mut self, + source: S, + icon: Option, + seat: Seat, + serial: Serial, + type_: GrabType, + ) { + match type_ { + GrabType::Pointer => { + let Some(pointer) = seat.get_pointer() else { + source.cancel(); + return; + }; + let Some(start_data) = pointer.grab_start_data() else { + source.cancel(); + return; + }; + self.dnd_icon = icon; + let grab = DnDGrab::new_pointer(&self.display_handle, start_data, source, seat); + pointer.set_grab(self, grab, serial, Focus::Keep); + } + GrabType::Touch => { + let Some(touch) = seat.get_touch() else { + source.cancel(); + return; + }; + let Some(start_data) = touch.grab_start_data() else { + source.cancel(); + return; + }; + self.dnd_icon = icon; + let grab = DnDGrab::new_touch(&self.display_handle, start_data, source, seat); + touch.set_grab(self, grab, serial); + } + } + } +} + +impl DndGrabHandler for Smallvil { + fn dropped( + &mut self, + _target: Option>, + _validated: bool, + _seat: Seat, + _location: Point, + ) { + self.dnd_icon = None; + } + + fn cancelled(&mut self, _seat: Seat, _location: Point) { + self.dnd_icon = None; + } +} delegate_data_device!(Smallvil); diff --git a/src/tide_core/state.rs b/src/tide_core/state.rs index 9791c4a..031a5da 100644 --- a/src/tide_core/state.rs +++ b/src/tide_core/state.rs @@ -872,6 +872,8 @@ pub struct Smallvil { /// Under winit the host compositor draws the real cursor, so this is /// tracked but never rendered from. pub cursor_status: CursorImageStatus, + /// Icon surface of the active client drag'n'drop, drawn at the pointer. + pub dnd_icon: Option, /// Loaded xcursor theme for `CursorImageStatus::Named`, only populated /// by the udev backend (`backend/udev.rs`, same pattern as `session`/ @@ -3897,6 +3899,7 @@ impl Smallvil { udev_gpu: None, session: None, cursor_status: CursorImageStatus::default_named(), + dnd_icon: None, cursor_theme: None, fullscreen: HashMap::new(), maximized: HashMap::new(), @@ -5438,6 +5441,17 @@ impl Smallvil { } } + /// Frame callbacks for the active drag'n'drop icon, a bare `wl_surface` + /// like the lock surface above. Without them a client that animates its + /// drag image (Firefox tab previews) would freeze after the first commit. + pub fn send_dnd_icon_frames(&self, output: &Output, time: Duration) { + if let Some(icon) = self.dnd_icon.as_ref() { + send_frames_surface_tree(icon, output, time, Some(Duration::ZERO), |_, _| { + Some(output.clone()) + }); + } + } + /// Entry point for `SessionLockHandler::lock` (`handlers/mod.rs`): /// cancels whatever interactive pointer/popup grab was live (grabs /// bypass `surface_under`-based routing entirely, so locking alone From 3286393985f7cf99a24e236f790b06b428556f7e Mon Sep 17 00:00:00 2001 From: Fi3w0 Date: Fri, 25 Sep 2026 02:49:59 +0200 Subject: [PATCH 03/13] Size the wallpaper in physical pixels inside captures Offscreen captures render at scale 1.0 in output-local physical pixels but used the on-screen wallpaper element, which is sized in logical pixels and relies on the frame's scale. On a 1.25x output the wallpaper inside glass, layer glass, shader and workspace-transition captures was drawn at 80% size from the top-left, so frost showed a shrunken wallpaper shifted away from the real one. Captures now build the wallpaper at the output's physical size, rounded like the visible frame. --- CHANGELOG.md | 1 + src/tide_core/state.rs | 55 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 219b394..8c6e681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ All notable changes to TideWM are documented here. Format loosely follows [Keep - High-priority source documentation now describes current ownership, coordinate, threading, merge, shader, and input contracts directly. Historical rollout stories and obsolete roadmap framing were removed from the minimap, overview, IPC, accessibility, portal, output-management, tearing-control, tiled-drag, floating-physics, output-selection, input-method, ripple-shader, and Wave-merge paths; verification history remains in this changelog and `report.md`. ### Fixed +- **Glass and captures show the wallpaper at the right size on fractional scales.** Offscreen captures (frost/water glass and layer glass backdrops, custom shader sources, workspace-transition snapshots) render at scale 1.0 in physical pixels, but reused the on-screen wallpaper element, which is sized in logical pixels. At 1.25x the wallpaper inside every capture was drawn at 80% size anchored top-left, so glass showed a shrunken, shifted wallpaper that drifted further from the real one across the output. Captures now use `wallpaper_capture_element`, sized to the output's physical size with the same rounding as the visible frame. - **Client drag'n'drop works.** `WaylandDndGrabHandler` was an empty impl, so Smithay's default `dnd_requested` cancelled every drag source the moment it started: nothing could be dragged between apps (files from Dolphin into Firefox, text, links) and Firefox treated every tab drag as a drop outside the window, detaching the tab into a new one. Drags now start a Smithay `DnDGrab` for pointer or touch; the client's drag icon is drawn at the pointer with the cursor elements on the udev backend (never over the lock screen) and receives frame callbacks; the icon is cleared on drop or cancel. Nested/winit runs get working drags but no compositor-drawn icon, since the host draws the cursor there, and screenshots do not include the icon yet. - Screenshots and screencasts of a whole output now include layer frost (`layer_rule { blur = true }`) and layer shaders. Both visible backends added the layer glass layer to the frame, but the output capture path only built window glass, so a blurred bar showed up unblurred in every capture. - A refused `spring(mass, stiffness, damping)` curve now says why in its journal warning: a coefficient outside its range, a spring that never settles within ten seconds, or not three numbers. Both refusals used to log the same generic easing warning. diff --git a/src/tide_core/state.rs b/src/tide_core/state.rs index 031a5da..9a25f44 100644 --- a/src/tide_core/state.rs +++ b/src/tide_core/state.rs @@ -6224,6 +6224,26 @@ impl Smallvil { .render_element(renderer, logical_size) } + /// The wallpaper for an offscreen capture (glass/layer backdrops, custom + /// shader sources, workspace-transition snapshots). Captures render at + /// scale 1.0 in output-local physical pixels, but `wallpaper_element` is + /// sized in logical pixels for the scaled output frame, so under a + /// fractional scale it came out `1/scale` too small inside every capture + /// (glass showed a shrunken, top-left-anchored wallpaper at 1.25x). + pub(crate) fn wallpaper_capture_element( + &mut self, + output: &Output, + renderer: &mut GlesRenderer, + ) -> Option> + { + if !self.config.builtin_wallpaper { + return None; + } + let logical_size = self.space.output_geometry(output)?.size; + let size = capture_wallpaper_size(logical_size, output.current_scale().fractional_scale()); + self.builtin_wallpaper.render_element(renderer, size) + } + /// The output-local physical rectangle produced by the shared placement /// and visual-animation contract. Backdrop capture and glass rendering /// use this same helper so they cannot drift from the window itself when @@ -6931,7 +6951,7 @@ impl Smallvil { let behind: Vec = space_elements .into_iter() .chain( - self.wallpaper_element(output, renderer) + self.wallpaper_capture_element(output, renderer) .map(crate::backend::udev::OutputRenderElements::Wallpaper), ) .collect(); @@ -7190,7 +7210,7 @@ impl Smallvil { ) else { return; }; - let wallpaper = self.wallpaper_element(output, renderer); + let wallpaper = self.wallpaper_capture_element(output, renderer); let behind: Vec = space_elements .into_iter() .chain(wallpaper.map(crate::backend::udev::OutputRenderElements::Wallpaper)) @@ -7752,7 +7772,7 @@ impl Smallvil { .chain(depth_elements) .chain(space_elements) .chain( - self.wallpaper_element(output, renderer) + self.wallpaper_capture_element(output, renderer) .map(crate::backend::udev::OutputRenderElements::Wallpaper), ) .collect(); @@ -13588,11 +13608,40 @@ fn tide_workspace_value(engine: crate::config::SpatialEngine, first_workspace: O } } +/// Physical size of an output's wallpaper for scale-1.0 offscreen captures, +/// rounded the same way the scaled on-screen frame rounds it. Returned as a +/// `Logical` size because the wallpaper element takes one; at the capture's +/// 1.0 scale logical and physical coincide. +fn capture_wallpaper_size(logical: Size, scale: f64) -> Size { + let physical = logical + .to_f64() + .to_physical_precise_round::(scale); + Size::from((physical.w, physical.h)) +} + #[cfg(test)] mod tests { use super::*; use smithay::output::{PhysicalProperties, Subpixel}; + #[test] + fn capture_wallpaper_matches_the_scaled_output_in_physical_pixels() { + // 1440p at 1.25x: the frame draws 2560x1440, so must every capture. + assert_eq!( + capture_wallpaper_size(Size::from((2048, 1152)), 1.25), + Size::from((2560, 1440)) + ); + assert_eq!( + capture_wallpaper_size(Size::from((1920, 1080)), 1.0), + Size::from((1920, 1080)) + ); + // Rounds like to_physical_precise_round on the visible path. + assert_eq!( + capture_wallpaper_size(Size::from((1707, 960)), 1.5), + Size::from((2561, 1440)) + ); + } + fn named_output(name: &str) -> Output { Output::new( name.to_string(), From 182df1bce3327a3032c283d195bb9d9f47c4d0f7 Mon Sep 17 00:00:00 2001 From: Fi3w0 Date: Fri, 25 Sep 2026 02:50:26 +0200 Subject: [PATCH 04/13] Accept zero for cursor_hide_after DOCUMENTATION.md says 0 disables cursor hiding, but the key used the duration parser that rejects zero, so cursor_hide_after = 0 (or 0ms) was ignored with a warning. Use the zero-allowing parser that exists for exactly this case. --- src/tide_core/config.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/tide_core/config.rs b/src/tide_core/config.rs index 3779eb1..7318091 100644 --- a/src/tide_core/config.rs +++ b/src/tide_core/config.rs @@ -5339,7 +5339,8 @@ fn apply_top_level_assign(raw: &mut RawConfig, key: &str, value: &str) { "backdrop_capture_scale" => set_i32(&mut raw.backdrop_capture_scale, key, value), "cursor_always_visible" => set_bool(&mut raw.cursor_always_visible, key, value), "cursor_hide_after" => { - if let Some(ms) = parse_duration_ms(value) { + // `0` is documented as "never hide", so zero must parse. + if let Some(ms) = parse_duration_ms_including_zero(value) { raw.cursor_hide_after_ms = ms as i32; } else { tracing::warn!( @@ -13210,6 +13211,18 @@ shader tinted-blur { assert_eq!(depth.urgent_alpha, 0.84); } + #[test] + fn cursor_hide_after_accepts_zero_as_disabled() { + for (value, expected) in [("0", 0), ("0ms", 0), ("2s", 2000), ("1500ms", 1500)] { + let mut raw = RawConfig { + cursor_hide_after_ms: 777, + ..RawConfig::default() + }; + apply_top_level_assign(&mut raw, "cursor_hide_after", value); + assert_eq!(raw.cursor_hide_after_ms, expected, "{value}"); + } + } + #[test] fn depth_defaults_match_generated_config() { for config in [ From 65179645e30f0297edf75c2bd221a7ef8dea1652 Mon Sep 17 00:00:00 2001 From: Fi3w0 Date: Fri, 25 Sep 2026 02:52:03 +0200 Subject: [PATCH 05/13] Let spawn take quoted arguments; fix tidectl flag and help parsing spawn split commands on whitespace only, so no keybind, startup spawn or tidectl spawn could pass an argument containing a space, and the doc comment's advice to use sh -c "..." could never work. The splitter now understands single quotes, double quotes and backslash escapes, still without any shell expansion; an unterminated quote falls back to the old whitespace split so existing configs keep working. tidectl quotes spawn arguments that need it when joining them, parses --json/--socket/--help only before the command so a spawned program's own flags reach it, and treats `tidectl help` as help instead of sending it to the compositor as an unknown action. --- src/bin/tidectl.rs | 57 ++++++++++++++++++++- src/main.rs | 123 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 170 insertions(+), 10 deletions(-) diff --git a/src/bin/tidectl.rs b/src/bin/tidectl.rs index e5adca9..b121643 100644 --- a/src/bin/tidectl.rs +++ b/src/bin/tidectl.rs @@ -56,6 +56,12 @@ fn main() { let mut args: Vec = Vec::with_capacity(raw_args.len()); let mut iter = raw_args.into_iter(); while let Some(arg) = iter.next() { + // Global flags only before the command: everything after it belongs + // to the command (`tidectl spawn mpv --help` must not print ours). + if !args.is_empty() { + args.push(arg); + continue; + } match arg.as_str() { "--json" | "-j" => json_output = true, "--socket" => match iter.next() { @@ -74,6 +80,10 @@ fn main() { print_help(); std::process::exit(1); } + if args[0] == "help" { + print_help(); + std::process::exit(0); + } // Host-side commands: run before any socket work, so a compositor that // won't even start can still be diagnosed. @@ -640,12 +650,32 @@ fn build_request(args: &[String]) -> Result { "swap-workspaces:{}", rest.join(" ") ))), - "spawn" if !rest.is_empty() => Ok(action_request(&format!("spawn:{}", rest.join(" ")))), + "spawn" if !rest.is_empty() => Ok(action_request(&format!("spawn:{}", join_quoted(rest)))), "submap" if !rest.is_empty() => Ok(action_request(&format!("submap:{}", rest.join(" ")))), _ => Ok(action_request(&args.join(" "))), } } +/// Joins argv back into one spawn string the compositor's quote-aware +/// splitter reads back verbatim: arguments with whitespace, quotes or +/// backslashes are single-quoted (`'` itself as `'\''`). +fn join_quoted(args: &[String]) -> String { + args.iter() + .map(|arg| { + if !arg.is_empty() + && !arg + .chars() + .any(|c| c.is_whitespace() || matches!(c, '\'' | '"' | '\\')) + { + arg.clone() + } else { + format!("'{}'", arg.replace('\'', "'\\''")) + } + }) + .collect::>() + .join(" ") +} + fn action_request(action: &str) -> Value { json!({ "request": "action", "action": action }) } @@ -925,6 +955,31 @@ tidewm-*.sock under $XDG_RUNTIME_DIR."# mod tests { use super::*; + fn spawn_action(args: &[&str]) -> String { + let args: Vec = args.iter().map(|a| a.to_string()).collect(); + build_request(&args).unwrap()["action"] + .as_str() + .unwrap() + .to_string() + } + + #[test] + fn spawn_quotes_arguments_that_need_it() { + assert_eq!( + spawn_action(&["spawn", "kitty", "-e", "fish"]), + "spawn:kitty -e fish" + ); + assert_eq!( + spawn_action(&["spawn", "kitty", "sh", "-c", "fastfetch; sleep 5"]), + "spawn:kitty sh -c 'fastfetch; sleep 5'" + ); + assert_eq!( + spawn_action(&["spawn", "notify-send", "it's here"]), + r"spawn:notify-send 'it'\''s here'" + ); + assert_eq!(spawn_action(&["spawn", "foo", ""]), "spawn:foo ''"); + } + #[test] fn cpu_percent_is_delta_over_wall_as_one_core_fraction() { // 200_000 us of CPU over 1_000_000 us of wall == 20% of one core. diff --git a/src/main.rs b/src/main.rs index 68fe502..38333a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -127,23 +127,84 @@ fn reap_spawned_children(state: &mut Smallvil) { }); } -/// Spawns `cmd`, splitting on whitespace so a simple invocation with -/// arguments (`"kitty -e fish"`) works. Deliberately not shell-parsed -- -/// no quoting/globs/pipes, and no injection surprise from untrusted -/// config content -- spawn `sh -c "..."` directly if you need those. -/// Shared by every spawn call site in the compositor (`-s`/`--spawn` -/// below, `config.spawn_at_startup`, and `Action::Spawn` in `input.rs`) -/// so they all get the same argument support for free. +/// Spawns `cmd`. Arguments split on whitespace, with POSIX-style quoting so +/// an argument can contain spaces: `'...'` is literal, `"..."` honors `\"` +/// and `\\`, and a backslash outside quotes escapes the next character +/// (`kitty sh -c 'fastfetch; sleep 5'`). Deliberately not a shell -- no +/// `$` expansion, globs, pipes or redirection, and no injection surprise +/// from untrusted config content. An unterminated quote falls back to the +/// plain whitespace split, so a config that relied on a literal quote keeps +/// working. Shared by every spawn call site in the compositor +/// (`-s`/`--spawn` below, `config.spawn_at_startup`, and `Action::Spawn` +/// in `input.rs`) so they all get the same argument support for free. pub(crate) fn spawn(cmd: &str) -> std::io::Result<()> { - let mut parts = cmd.split_whitespace(); + let parts = + split_command(cmd).unwrap_or_else(|| cmd.split_whitespace().map(str::to_string).collect()); + let mut parts = parts.into_iter(); let program = parts .next() .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "empty command"))?; - let child = child_command(program).args(parts).spawn()?; + let child = child_command(&program).args(parts).spawn()?; track_child(child); Ok(()) } +/// Splits a spawn command into argv (see [`spawn`]). `None` for an +/// unterminated quote or a trailing lone backslash. +fn split_command(cmd: &str) -> Option> { + let mut args = Vec::new(); + let mut current = String::new(); + let mut in_arg = false; + let mut chars = cmd.chars(); + while let Some(c) = chars.next() { + match c { + '\'' => { + in_arg = true; + loop { + match chars.next()? { + '\'' => break, + c => current.push(c), + } + } + } + '"' => { + in_arg = true; + loop { + match chars.next()? { + '"' => break, + '\\' => match chars.next()? { + c @ ('"' | '\\') => current.push(c), + c => { + current.push('\\'); + current.push(c); + } + }, + c => current.push(c), + } + } + } + '\\' => { + in_arg = true; + current.push(chars.next()?); + } + c if c.is_whitespace() => { + if in_arg { + args.push(std::mem::take(&mut current)); + in_arg = false; + } + } + c => { + in_arg = true; + current.push(c); + } + } + } + if in_arg { + args.push(current); + } + Some(args) +} + /// Applies `[env]` (`config.env`) to this process via `set_var`, before the /// backend starts -- so e.g. an `XCURSOR_THEME` override actually reaches /// `cursor::Theme::load()` (called from `init_udev`, right after this), @@ -972,3 +1033,47 @@ mod session_environment_tests { ); } } + +#[cfg(test)] +mod spawn_split_tests { + use super::split_command as argv; + + #[test] + fn keeps_plain_whitespace_behavior() { + assert_eq!(argv("kitty -e fish").unwrap(), ["kitty", "-e", "fish"]); + assert_eq!(argv(" foot --server ").unwrap(), ["foot", "--server"]); + assert_eq!(argv("").unwrap(), Vec::::new()); + } + + #[test] + fn honors_quotes_and_escapes() { + assert_eq!( + argv("kitty sh -c 'fastfetch; sleep 5'").unwrap(), + ["kitty", "sh", "-c", "fastfetch; sleep 5"] + ); + assert_eq!( + argv(r#"notify-send "hi \"there\"" done"#).unwrap(), + ["notify-send", r#"hi "there""#, "done"] + ); + assert_eq!(argv(r"foo a\ b").unwrap(), ["foo", "a b"]); + assert_eq!(argv("foo ''").unwrap(), ["foo", ""]); + assert_eq!(argv("foo x'y z'w").unwrap(), ["foo", "xy zw"]); + // tidectl's quoting of an apostrophe round-trips. + assert_eq!( + argv(r"notify-send 'it'\''s here'").unwrap(), + ["notify-send", "it's here"] + ); + // Not a shell: variables and globs stay literal. + assert_eq!( + argv("echo $HOME *.png").unwrap(), + ["echo", "$HOME", "*.png"] + ); + } + + #[test] + fn unterminated_quotes_return_none_for_the_fallback() { + assert!(argv("sh -c 'oops").is_none()); + assert!(argv(r#"say "unfinished"#).is_none()); + assert!(argv(r"trailing \").is_none()); + } +} From 490df73642fb72e74a1ad59749c802ce16c3e0f2 Mon Sep 17 00:00:00 2001 From: Fi3w0 Date: Fri, 25 Sep 2026 02:52:33 +0200 Subject: [PATCH 06/13] Report render and card nodes separately in doctor The gpu check probed three hard-coded paths and listed /dev/dri/card0 under "render nodes". Enumerate /dev/dri instead, and report renderD* nodes and card* nodes separately, so a card1-only machine is seen and the labels are right. --- src/bin/tidectl_diagnostics/mod.rs | 36 +++++++++++++++++++----------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/bin/tidectl_diagnostics/mod.rs b/src/bin/tidectl_diagnostics/mod.rs index 4c0f664..271203c 100644 --- a/src/bin/tidectl_diagnostics/mod.rs +++ b/src/bin/tidectl_diagnostics/mod.rs @@ -358,19 +358,29 @@ pub fn run_checks(socket_override: Option<&Path>) -> (Vec, Option = Vec::new(); + let mut card_nodes: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir("/dev/dri") { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if name.starts_with("renderD") { + render_nodes.push(format!("/dev/dri/{name}")); + } else if name.starts_with("card") { + card_nodes.push(format!("/dev/dri/{name}")); + } + } + } + render_nodes.sort(); + card_nodes.sort(); + let dri_present = !render_nodes.is_empty(); let dri_detail = if dri_present { - dri_paths - .iter() - .filter(|p| p.exists()) - .map(|p| p.display().to_string()) - .collect::>() - .join(", ") + let mut detail = format!("render nodes: {}", render_nodes.join(", ")); + if !card_nodes.is_empty() { + detail.push_str(&format!("; cards: {}", card_nodes.join(", "))); + } + detail } else { "/dev/dri has no render nodes".to_string() }; @@ -413,7 +423,7 @@ pub fn run_checks(socket_override: Option<&Path>) -> (Vec, Option Date: Fri, 25 Sep 2026 02:53:27 +0200 Subject: [PATCH 07/13] Answer IPC requests that end at EOF without a newline Requests were only handled once a newline arrived, so a client that closed its write side after an unterminated line got no reply at all: both a truncated request and a complete one sent without a trailing newline (printf ... | socat) were silently dropped. Treat EOF as the end of the last line, so the request is answered or rejected with the usual invalid-request error. Only the read step changed; the request handling moved out of the read match unchanged. --- src/tide_core/ipc.rs | 111 +++++++++++++++++++++++-------------------- 1 file changed, 59 insertions(+), 52 deletions(-) diff --git a/src/tide_core/ipc.rs b/src/tide_core/ipc.rs index 0eb1166..7eb493a 100644 --- a/src/tide_core/ipc.rs +++ b/src/tide_core/ipc.rs @@ -471,64 +471,22 @@ fn register_connection( let mut chunk = [0u8; 4096]; loop { let mut reader: &UnixStream = stream; - match reader.read(&mut chunk) { - Ok(0) => finish!(), + let eof = match reader.read(&mut chunk) { + // EOF ends the last line: a client that closes its write + // side without a trailing newline still gets a reply (an + // `invalid request` error when the JSON is truncated). + Ok(0) if buf.is_empty() => finish!(), + Ok(0) => { + buf.push(b'\n'); + true + } Ok(n) => { buf.extend_from_slice(&chunk[..n]); if buf.len() > MAX_REQUEST_BYTES { tracing::warn!("IPC request exceeded size cap; dropping connection"); finish!(); } - if let Some(pos) = buf.iter().position(|&b| b == b'\n') { - let line = buf[..pos].to_vec(); - // Subscribe takes a different lifecycle than the - // one-line-in/one-line-out requests: the connection - // stays open and gets converted into a long-lived - // subscriber. Detected here, before - // `response_payload` runs, so a Subscribe doesn't - // get formatted into the standard ok/error - // envelope (its ack is written by - // `register_subscriber` directly through the new - // subscriber's own pending buffer). - match serde_json::from_slice::(&line) { - Ok(Request::Subscribe { events }) => { - let filter = events - .unwrap_or_default() - .into_iter() - .collect(); - // This callback is removed immediately - // afterwards, so transfer its existing - // connection slot to the long-lived - // subscriber instead of dropping it. - let subscriber_lease = lease - .take() - .expect("IPC request lease must exist until conversion"); - register_subscriber( - &connection_handle, - stream, - filter, - subscriber_lease, - state, - ); - finish!(); - } - _ => { - let payload = response_payload(&line, state); - match stream.try_clone() { - Ok(stream) => register_response( - &connection_handle, - stream, - payload, - response_connections.clone(), - ), - Err(err) => { - tracing::warn!(%err, "Failed to clone IPC response stream") - } - } - finish!(); - } - } - } + false } Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { return Ok(PostAction::Continue); @@ -537,6 +495,55 @@ fn register_connection( tracing::warn!(%err, "IPC connection read error"); finish!(); } + }; + if let Some(pos) = buf.iter().position(|&b| b == b'\n') { + let line = buf[..pos].to_vec(); + // Subscribe takes a different lifecycle than the + // one-line-in/one-line-out requests: the connection + // stays open and gets converted into a long-lived + // subscriber. Detected here, before + // `response_payload` runs, so a Subscribe doesn't + // get formatted into the standard ok/error + // envelope (its ack is written by + // `register_subscriber` directly through the new + // subscriber's own pending buffer). + match serde_json::from_slice::(&line) { + Ok(Request::Subscribe { events }) => { + let filter = events.unwrap_or_default().into_iter().collect(); + // This callback is removed immediately + // afterwards, so transfer its existing + // connection slot to the long-lived + // subscriber instead of dropping it. + let subscriber_lease = lease + .take() + .expect("IPC request lease must exist until conversion"); + register_subscriber( + &connection_handle, + stream, + filter, + subscriber_lease, + state, + ); + finish!(); + } + _ => { + let payload = response_payload(&line, state); + match stream.try_clone() { + Ok(stream) => register_response( + &connection_handle, + stream, + payload, + response_connections.clone(), + ), + Err(err) => { + tracing::warn!(%err, "Failed to clone IPC response stream") + } + } + finish!(); + } + } + } else if eof { + finish!(); } } }, From 2830ab7acfca6c298397d1f87b841de2791d733b Mon Sep 17 00:00:00 2001 From: Fi3w0 Date: Fri, 25 Sep 2026 02:53:49 +0200 Subject: [PATCH 08/13] Document pointer_modifier and quoted spawn arguments pointer_modifier had no row in the top-level settings table, so nothing said it is separate from @mod and defaults to SUPER (the cause of Alt+drag not moving windows with an older config). The spawn key, spawn: action and --spawn flag now describe the quoting rules. --- DOCUMENTATION.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 4531f9f..9fe50a6 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -15,7 +15,7 @@ Full reference for configuring and controlling TideWM: every config key, every a | Flag | Notes | | --- | --- | | `-c, --config ` | Load this file instead of `$XDG_CONFIG_HOME/tidewm/config.wave`. Applies to the hot-reload watcher too, not just the initial load. | -| `-s, --spawn ` | Spawn one specific command right after startup, instead of nothing. No shell parsing (same rule as the `spawn` config key). | +| `-s, --spawn ` | Spawn one specific command right after startup, instead of nothing. Quoted arguments work, but no shell parsing (same rule as the `spawn` config key). | | `-v, --version` | Print the version and exit. | | `-h, --help` | Print usage and exit. | @@ -85,6 +85,7 @@ TideWM always provides the bundled `assets/tide-aqua-4k.png` artwork, so a fresh | Key | Type | Default | Notes | | --- | --- | --- | --- | | `terminal` | string | `"kitty"` | Spawned by the shipped `$mod+Return` bind (`@mod = SUPER` in the generated file). The terminal fallback is `wave(kitty, alacritty, foot, xterm)` — see the Wave format section above. | +| `pointer_modifier` (aliases `drag_modifier`, `mouse_modifier`) | modifier | `SUPER` | Held with left-drag to move and right-drag to resize windows, and for pointer-anchored zoom/pan. Independent of `@mod`: the generated config sets `drag_modifier = mod` so it follows `@mod`, but a config without the key keeps `SUPER` even when `@mod = ALT`. | | `engine` (aliases `spatial_engine`, `wm_mode`) | `classic` \| `ocean` | `classic` | Selects one of TideWM's two WM ownership models. Classic keeps numbered workspaces. Ocean has no workspaces: outputs are cameras into one continuous 2D world. Hot-reloadable: a change migrates every live window in place. Classic→Ocean turns each output's populated workspace trees into reefs on the lateral line at `X = (N-1) * (output width + 128)` with the camera at the previously-active workspace; depth-deck windows are recalled to their tiles, floating windows translate to world coordinates around their workspace's reef, and pinned windows become Ocean screen pins. Ocean→Classic turns reefs sorted left-to-right into workspaces `1..N` on the output whose camera is nearest, selects the active workspace from each camera, clamps floating windows into the visible area, and restores pins. Tab groups and fullscreen/maximized entries carry across both directions; Ocean bookmarks and camera history are dropped (no Classic counterpart). | | `drag_modifier` | modifier or `+`-joined modifiers | `super` | Modifier physically held for compositor mouse actions: left-drag moves floating windows or drag-swaps tiles; right-drag resizes floating or tiled windows. Accepts `super`/`logo`/`mod4`, `alt`/`mod1`, `ctrl`/`control`, and `shift`. The shipped config sets it to `mod`. | | `welcome_hint` | bool | `true` | Shows a persistent empty-desktop card reminding you to use your configured terminal bind. Disappears when a real window maps; delete this key (or set it `false`) to stop it returning. | @@ -105,7 +106,7 @@ TideWM always provides the bundled `assets/tide-aqua-4k.png` artwork, so a fresh | `split_bias` | `auto` \| `horizontal` \| `vertical` | `auto` | Manual override for `layout = bsp`'s per-split axis choice. `auto` is the existing aspect-ratio-driven behavior, unchanged. `horizontal`/`vertical` force every split one way regardless of window/output shape (Hyprland dwindle's `force_split` idea). One global setting, not per-workspace. | | `pseudo_tile_scale` | float, `0.05`–`1.0` | `0.7` | Fraction of its tile a pseudo-tiled window keeps, centered within it. Out-of-range values are clamped, not rejected. | | `adaptive_sync` (aliases `vrr`, `variable_refresh_rate`) | `off` \| `on` \| `on-demand` | `off` | Global adaptive-sync (VRR) preference; a `[[output]]`'s own `adaptive_sync` beats it. The udev backend applies the resolved value through DRM; `on-demand` enables VRR only while an unlocked fullscreen window owns the output. The nested winit backend has no DRM surface, so this setting is inert there. | -| `spawn` | list | none | Commands launched once at startup, as a real list: `spawn = [waybar, "swaybg -i ~/wallpaper.png -m fill"]`. Args split on whitespace — no shell involved, so quoting/globs/pipes aren't supported; wrap in `sh -c "..."` yourself if you need those. | +| `spawn` | list | none | Commands launched once at startup, as a real list: `spawn = [waybar, "swaybg -i ~/wallpaper.png -m fill"]`. Args split on whitespace, with quoting for arguments that contain spaces: `'...'` is literal, `"..."` honors `\"` and `\\`, and `\` escapes the next character outside quotes. No shell is involved, so `$VARS`, globs and pipes stay literal; use `sh -c '...'` when you need those, e.g. `spawn = ["kitty sh -c 'fastfetch; exec fish'"]`. An unterminated quote falls back to a plain whitespace split. | ### Classic edge and corner snap @@ -1851,7 +1852,7 @@ rewriting a line removes or changes it completely. - `toggle-dpms` — toggle every output's power together (all on, or all off) **Process and session** -- `spawn:` — args split on whitespace, no shell +- `spawn:` — args split on whitespace with `'...'`/`"..."` quoting and `\` escapes (see the `spawn` key); no shell - `quit` ## IPC and `tidectl` From 8ebb25909e2012663aec86efca0f6049bcde99f0 Mon Sep 17 00:00:00 2001 From: Fi3w0 Date: Fri, 25 Sep 2026 02:58:45 +0200 Subject: [PATCH 09/13] Apply output blocks on config reload output { } blocks only took effect when an output was set up, so a reload that moved, scaled or rotated a monitor was accepted and then ignored. The wlr-output-management apply path (layout validation, transform/scale/position change, floating-window translation, layer re-arrange, retile) is now a shared apply_output_changes() that config reload uses too. Outputs without a block keep their state. A different mode or enabled = false still needs a modeset at output setup, so those now produce a notice on the warning panel instead of being ignored silently. The transform conversion moved to OutputTransformConfig::to_transform() for both callers. --- src/backend/udev.rs | 17 +- src/handlers/wlr_output_management.rs | 215 ++++++++++++++++---------- src/tide_core/config.rs | 16 ++ src/tide_core/state.rs | 66 ++++++++ 4 files changed, 220 insertions(+), 94 deletions(-) diff --git a/src/backend/udev.rs b/src/backend/udev.rs index dd7130c..30d4529 100644 --- a/src/backend/udev.rs +++ b/src/backend/udev.rs @@ -62,14 +62,14 @@ use smithay::{ wayland_protocols::wp::presentation_time::server::wp_presentation_feedback, wayland_server::{backend::GlobalId, protocol::wl_surface::WlSurface}, }, - utils::{DeviceFd, Transform}, + utils::DeviceFd, wayland::{compositor::with_states, dmabuf::DmabufFeedbackBuilder, presentation::Refresh}, }; use smithay_drm_extras::drm_scanner::{DrmScanEvent, DrmScanner}; use crate::{ backend::multigpu::{GbmGlesApi, ImportBridge}, - config::{GpuSelector, GpuVendor, OutputTransformConfig}, + config::{GpuSelector, GpuVendor}, cursor, output_layout::{logical_output_size, resolve_output_position}, state::{LockRenderElement, SessionLock, Smallvil}, @@ -1367,20 +1367,11 @@ fn create_surface( // and scan flags; DRM's integer vrefresh field loses fractional rates. let output_mode = Mode::from(mode); let scale = Scale::Fractional(output_config.as_ref().map(|c| c.scale).unwrap_or(1.0)); - let transform = match output_config + let transform = output_config .as_ref() .map(|c| c.transform) .unwrap_or_default() - { - OutputTransformConfig::Normal => Transform::Normal, - OutputTransformConfig::Rotate90 => Transform::_90, - OutputTransformConfig::Rotate180 => Transform::_180, - OutputTransformConfig::Rotate270 => Transform::_270, - OutputTransformConfig::Flipped => Transform::Flipped, - OutputTransformConfig::Flipped90 => Transform::Flipped90, - OutputTransformConfig::Flipped180 => Transform::Flipped180, - OutputTransformConfig::Flipped270 => Transform::Flipped270, - }; + .to_transform(); let Some(logical_size) = logical_output_size(output_mode.size, transform, scale.fractional_scale()) else { diff --git a/src/handlers/wlr_output_management.rs b/src/handlers/wlr_output_management.rs index e00e66a..981061d 100644 --- a/src/handlers/wlr_output_management.rs +++ b/src/handlers/wlr_output_management.rs @@ -16,7 +16,7 @@ use smithay::reexports::wayland_server::{ protocol::wl_output::Transform as WlTransform, Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, WEnum, }; -use smithay::utils::{Logical, Rectangle, Transform}; +use smithay::utils::{Logical, Point, Rectangle, Transform}; use wayland_protocols_wlr::output_management::v1::server::{ zwlr_output_configuration_head_v1::{self, ZwlrOutputConfigurationHeadV1}, zwlr_output_configuration_v1::{self, ZwlrOutputConfigurationV1}, @@ -535,6 +535,121 @@ impl Dispatch for Smallvil { /// replying. Never applies a partial batch: either everything requested is /// supported and all of it lands, or none of it does and the client gets /// `failed`. +/// A live change to one output: `None` keeps the current value. Shared by +/// the wlr-output-management protocol and config hot reload so both go +/// through the same validation and apply path. +#[derive(Debug, Clone, Default)] +pub(crate) struct OutputChange { + pub position: Option>, + pub transform: Option, + pub scale: Option, + /// Only the current mode can be kept; anything else is unsupported here. + pub custom_mode: Option<(i32, i32, i32)>, +} + +/// Proposed logical rectangle of every changed output, or `None` when a +/// change needs a modeset or leaves the logical desktop invalid. +fn proposed_output_layout( + state: &Smallvil, + changes: &[(Output, OutputChange)], +) -> Option>> { + changes + .iter() + .map(|(output, change)| { + let mode = output.current_mode()?; + if change.custom_mode.is_some_and(|(w, h, r)| { + mode.size.w != w || mode.size.h != h || (r != 0 && mode.refresh != r) + }) { + return None; + } + let transform = change + .transform + .unwrap_or_else(|| output.current_transform()); + let scale = change + .scale + .unwrap_or_else(|| output.current_scale().fractional_scale()); + let size = logical_output_size(mode.size, transform, scale)?; + let position = change + .position + .or_else(|| state.space.output_geometry(output).map(|geo| geo.loc))?; + let old_position = state.space.output_geometry(output)?.loc; + checked_output_delta(old_position, position)?; + Some(Rectangle::new(position, size)) + }) + .collect() +} + +/// Whether `changes` (one entry per live output) would be applied. +pub(crate) fn output_changes_supported( + state: &Smallvil, + changes: &[(Output, OutputChange)], +) -> bool { + proposed_output_layout(state, changes) + .is_some_and(|rects| validate_desktop_layout(rects.iter().copied()).is_ok()) +} + +/// Validates `changes` as one transaction and applies them: transform, +/// scale and position, floating windows follow their output, layer shells +/// re-arrange, and the desktop retiles. Returns `false` (and changes +/// nothing) when the result would need a modeset or an invalid layout. +pub(crate) fn apply_output_changes( + state: &mut Smallvil, + changes: &[(Output, OutputChange)], +) -> bool { + if !output_changes_supported(state, changes) { + return false; + } + for (output, change) in changes { + if change.transform.is_none() && change.scale.is_none() && change.position.is_none() { + continue; + } + let scale = change.scale.map(Scale::Fractional); + let old_position = state.space.output_geometry(output).map(|geo| geo.loc); + output.change_current_state(None, change.transform, scale, change.position); + if change.scale.is_some() { + state.refresh_layer_fractional_scales(output); + } + if change.scale.is_some() || change.transform.is_some() { + state.refresh_lock_surface_geometry(output); + } + // Transform/scale changes require fresh layer exclusive zones + // before the retile uses the new logical size. + smithay::desktop::layer_map_for_output(output).arrange(); + if let Some(pos) = change.position { + state.space.map_output(output, pos); + // Tiling recomputes from the output area; floating geometry + // must be translated by the same output movement explicitly. + if let Some(old_position) = old_position { + // The transaction validator proved this exact translation + // fits the coordinate domain before any output state was + // mutated. + let delta = + checked_output_delta(old_position, pos).expect("validated output translation"); + if delta != (0, 0).into() { + state.translate_floating_windows_on_output(&output.name(), delta); + } + } + } + } + // Applying a new live scale switches cursor asset/cache keys. Drop + // prepared buffers for scales no output advertises anymore so repeated + // output changes cannot accumulate CPU buffers or per-renderer textures. + // Connector removal performs the same reconciliation after unmapping + // its output in the udev backend. + let space = &state.space; + if let Some(theme) = state.cursor_theme.as_mut() { + theme.retain_scales(|scale| { + space + .outputs() + .any(|output| output.current_scale().integer_scale() == scale) + }); + } + state.retile(); + state.wlr_output_management_state.refresh(&state.space); + state.refresh_pointer_focus(); + true +} + fn finish_configuration( state: &mut Smallvil, resource: &ZwlrOutputConfigurationV1, @@ -566,38 +681,28 @@ fn finish_configuration( return; } - let proposed_layout: Option>> = inner + let changes: Option> = inner .ops .iter() - .map(|(output, op)| { - let HeadOp::Enabled(cfg) = op else { - return None; - }; - let mode = output.current_mode()?; - if cfg.custom_mode.is_some_and(|(w, h, r)| { - mode.size.w != w || mode.size.h != h || (r != 0 && mode.refresh != r) - }) { - return None; - } - - let transform = cfg.transform.unwrap_or_else(|| output.current_transform()); - let scale = cfg - .scale - .unwrap_or_else(|| output.current_scale().fractional_scale()); - let size = logical_output_size(mode.size, transform, scale)?; - let position = cfg - .position - .map(Into::into) - .or_else(|| state.space.output_geometry(output).map(|geo| geo.loc))?; - let old_position = state.space.output_geometry(output)?.loc; - checked_output_delta(old_position, position)?; - Some(Rectangle::new(position, size)) + .map(|(output, op)| match op { + HeadOp::Enabled(cfg) => Some(( + output.clone(), + OutputChange { + position: cfg.position.map(Into::into), + transform: cfg.transform, + scale: cfg.scale, + custom_mode: cfg.custom_mode, + }, + )), + // Disabling heads needs a DRM modeset this layer does not own. + HeadOp::Disabled => None, }) .collect(); - let supported = proposed_layout - .as_ref() - .is_some_and(|rects| validate_desktop_layout(rects.iter().copied()).is_ok()); - + let supported = match changes { + Some(changes) if is_apply => apply_output_changes(state, &changes), + Some(changes) => output_changes_supported(state, &changes), + None => false, + }; if !supported { tracing::warn!( "Rejected output configuration whose mode or complete logical desktop is unsupported" @@ -606,57 +711,5 @@ fn finish_configuration( return; } - if is_apply { - for (output, op) in inner.ops.iter() { - let HeadOp::Enabled(cfg) = op else { continue }; - if cfg.transform.is_none() && cfg.scale.is_none() && cfg.position.is_none() { - continue; - } - let scale = cfg.scale.map(Scale::Fractional); - let old_position = state.space.output_geometry(output).map(|geo| geo.loc); - output.change_current_state(None, cfg.transform, scale, cfg.position.map(Into::into)); - if cfg.scale.is_some() { - state.refresh_layer_fractional_scales(output); - } - if cfg.scale.is_some() || cfg.transform.is_some() { - state.refresh_lock_surface_geometry(output); - } - // Transform/scale changes require fresh layer exclusive zones - // before the retile uses the new logical size. - smithay::desktop::layer_map_for_output(output).arrange(); - if let Some(pos) = cfg.position { - state.space.map_output(output, pos); - // Tiling recomputes from the output area; floating geometry - // must be translated by the same output movement explicitly. - if let Some(old_position) = old_position { - // The transaction validator proved this exact - // translation fits the coordinate domain before any - // output state was mutated. - let delta = checked_output_delta(old_position, pos.into()) - .expect("validated output translation"); - if delta != (0, 0).into() { - state.translate_floating_windows_on_output(&output.name(), delta); - } - } - } - } - // Applying a new live scale switches cursor asset/cache keys. Drop - // prepared buffers for scales no output advertises anymore so - // repeated output-management changes cannot accumulate CPU buffers - // or per-renderer textures. Connector removal performs the same - // reconciliation after unmapping its output in the udev backend. - let space = &state.space; - if let Some(theme) = state.cursor_theme.as_mut() { - theme.retain_scales(|scale| { - space - .outputs() - .any(|output| output.current_scale().integer_scale() == scale) - }); - } - state.retile(); - state.wlr_output_management_state.refresh(&state.space); - state.refresh_pointer_focus(); - } - resource.succeeded(); } diff --git a/src/tide_core/config.rs b/src/tide_core/config.rs index 7318091..3dc5429 100644 --- a/src/tide_core/config.rs +++ b/src/tide_core/config.rs @@ -2562,6 +2562,22 @@ pub enum OutputTransformConfig { Flipped270, } +impl OutputTransformConfig { + pub fn to_transform(self) -> smithay::utils::Transform { + use smithay::utils::Transform; + match self { + Self::Normal => Transform::Normal, + Self::Rotate90 => Transform::_90, + Self::Rotate180 => Transform::_180, + Self::Rotate270 => Transform::_270, + Self::Flipped => Transform::Flipped, + Self::Flipped90 => Transform::Flipped90, + Self::Flipped180 => Transform::Flipped180, + Self::Flipped270 => Transform::Flipped270, + } + } +} + /// Which DRM device TideWM should use for its own GLES rendering and /// scanout. Paths may name either a primary (`cardN`) or render node; the /// backend resolves both to the same physical device. diff --git a/src/tide_core/state.rs b/src/tide_core/state.rs index 9a25f44..8d61fa4 100644 --- a/src/tide_core/state.rs +++ b/src/tide_core/state.rs @@ -12927,6 +12927,67 @@ impl Smallvil { /// Re-reads the config file and applies what can be applied live /// (keybinds, input repeat rate). Shows a toast either way so a reload /// is never silent, success or failure. + /// Re-applies `output { }` blocks to connected outputs after a + /// reload: transform, scale and position go through the same validated + /// transaction as wlr-output-management. Outputs without a block keep + /// their current state. A different `mode` or `enabled = false` needs a + /// DRM modeset, which only happens at output setup, so those produce a + /// notice instead of being ignored silently. + fn apply_output_blocks_live(&mut self) -> Vec { + use crate::handlers::wlr_output_management::{apply_output_changes, OutputChange}; + let mut notices = Vec::new(); + let outputs: Vec = self.space.outputs().cloned().collect(); + let changes: Vec<(Output, OutputChange)> = outputs + .into_iter() + .map(|output| { + let name = output.name(); + let change = match self.config.outputs.iter().find(|cfg| cfg.name == name) { + None => OutputChange::default(), + Some(cfg) => { + if !cfg.enabled { + notices.push(format!( + "output {name}: enabled = false applies after restarting TideWM" + )); + } + if let (Some(requested), Some(current)) = + (cfg.mode.as_deref(), output.current_mode()) + { + let differs = match crate::config::parse_mode_str(requested) { + Some((w, h, refresh)) => { + w != current.size.w + || h != current.size.h + || refresh.is_some_and(|r| { + (f64::from(current.refresh) / 1000.0 - r).abs() >= 0.5 + }) + } + None => false, + }; + if differs { + notices.push(format!( + "output {name}: mode {requested} applies after restarting TideWM" + )); + } + } + OutputChange { + position: cfg.position.map(Into::into), + transform: Some(cfg.transform.to_transform()), + scale: Some(cfg.scale), + custom_mode: None, + } + } + }; + (output, change) + }) + .collect(); + if !apply_output_changes(self, &changes) { + notices.push( + "output blocks: the new layout is invalid (overlap or out of range); kept the current one" + .to_string(), + ); + } + notices + } + pub fn reload_config(&mut self) { self.sync_tide(); match Config::reload_staged(&self.tide) { @@ -12997,8 +13058,13 @@ impl Smallvil { } else { self.welcome_hint = None; } + let outputs_changed = + format!("{:?}", self.config.outputs) != format!("{:?}", new_config.outputs); self.config = new_config; self.sync_config_watch_paths(); + if outputs_changed { + warnings.extend(self.apply_output_blocks_live()); + } warnings.extend( self.custom_shader_programs .retain_definitions(&self.config.shader_definitions), From 13b104ed8e71686d3b04f68f61e07ef45dceb200 Mon Sep 17 00:00:00 2001 From: Fi3w0 Date: Fri, 25 Sep 2026 02:59:06 +0200 Subject: [PATCH 10/13] Changelog entries for the reload, spawn, tidectl, IPC and doctor fixes --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c6e681..babaaa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,12 @@ All notable changes to TideWM are documented here. Format loosely follows [Keep - High-priority source documentation now describes current ownership, coordinate, threading, merge, shader, and input contracts directly. Historical rollout stories and obsolete roadmap framing were removed from the minimap, overview, IPC, accessibility, portal, output-management, tearing-control, tiled-drag, floating-physics, output-selection, input-method, ripple-shader, and Wave-merge paths; verification history remains in this changelog and `report.md`. ### Fixed +- **`output { }` blocks apply on config reload.** Moving, scaling or rotating a monitor in the config used to be accepted by the reload and then ignored until the next output setup. Reload now runs the same validated transaction as wlr-output-management (shared `apply_output_changes`): transform, scale and position change live, floating windows follow their output, layer shells re-arrange, and the desktop retiles. Outputs without a block keep their state. A different `mode` or `enabled = false` still needs a DRM modeset at output setup and now shows a notice instead of being ignored silently. +- **`spawn` takes quoted arguments.** Commands were split on whitespace only, so no keybind, startup `spawn` or `tidectl spawn` could pass an argument containing a space, and the documented `sh -c "..."` workaround could not work. `'...'`, `"..."` (with `\"`/`\\`) and backslash escapes are honored, still with no shell expansion; an unterminated quote falls back to the old whitespace split. +- **tidectl argument handling.** `--json`, `--socket` and `--help` are only read before the command, so `tidectl spawn mpv --help` passes `--help` to mpv; `spawn` re-quotes arguments that need it; and `tidectl help` prints help instead of being sent to the compositor as an unknown action. +- **`cursor_hide_after = 0`** (or `0ms`) is accepted as documented (never hide) instead of being ignored with a warning. +- **IPC requests ending at EOF** without a trailing newline are answered (or rejected with the usual `invalid request` error) instead of being dropped with no reply. +- **`tidectl doctor` gpu check** enumerates `/dev/dri` and reports render nodes and card nodes separately; it listed `card0` as a render node and missed card-only layouts it did not hard-code. - **Glass and captures show the wallpaper at the right size on fractional scales.** Offscreen captures (frost/water glass and layer glass backdrops, custom shader sources, workspace-transition snapshots) render at scale 1.0 in physical pixels, but reused the on-screen wallpaper element, which is sized in logical pixels. At 1.25x the wallpaper inside every capture was drawn at 80% size anchored top-left, so glass showed a shrunken, shifted wallpaper that drifted further from the real one across the output. Captures now use `wallpaper_capture_element`, sized to the output's physical size with the same rounding as the visible frame. - **Client drag'n'drop works.** `WaylandDndGrabHandler` was an empty impl, so Smithay's default `dnd_requested` cancelled every drag source the moment it started: nothing could be dragged between apps (files from Dolphin into Firefox, text, links) and Firefox treated every tab drag as a drop outside the window, detaching the tab into a new one. Drags now start a Smithay `DnDGrab` for pointer or touch; the client's drag icon is drawn at the pointer with the cursor elements on the udev backend (never over the lock screen) and receives frame callbacks; the icon is cleared on drop or cancel. Nested/winit runs get working drags but no compositor-drawn icon, since the host draws the cursor there, and screenshots do not include the icon yet. - Screenshots and screencasts of a whole output now include layer frost (`layer_rule { blur = true }`) and layer shaders. Both visible backends added the layer glass layer to the frame, but the output capture path only built window glass, so a blurred bar showed up unblurred in every capture. From c41a9a4cce338639c53cb31afead98cf28710716 Mon Sep 17 00:00:00 2001 From: Fi3w0 Date: Fri, 25 Sep 2026 03:10:14 +0200 Subject: [PATCH 11/13] Record the first standalone Nvidia pass TECHNICAL_REPORT.md: the standalone DRM backend is verified on a real RTX 3060 with two outputs at mixed scale; drop the open "Nvidia native run" item. report.md: the validation pass with IPC robustness, idle and nine-glass-window cost, the Nvidia PSS breakdown (driver mappings, no leak), the fixes found during the pass, and what is still open. --- TECHNICAL_REPORT.md | 6 +++--- report.md | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/TECHNICAL_REPORT.md b/TECHNICAL_REPORT.md index 90f05dd..a6fb02f 100644 --- a/TECHNICAL_REPORT.md +++ b/TECHNICAL_REPORT.md @@ -40,14 +40,15 @@ Current release: **0.90.104**, second major pre-release. 1.0 is intentionally re | Floating-window ocean physics (`float_physics { tier = light|full }`): disturbance-driven bob/drift, `full` adds mass/collisions/a traveling wave field | Done, nested-verified only | | Ocean currents: bounded render-only downstream drift for visible unfocused floating windows, with focus/drag pause | Done, release-GLES nested-verified on AMD | | Weighted buoyancy: per-app render-only mass/sink for Classic and Ocean floaters, with Ocean flow attenuation | Done, release-GLES nested-verified on AMD under Ocean; Classic shares the validated render path | -| Nvidia support | Nested (EGL/GLES) verified on a real RTX 3060; standalone DRM backend not yet run natively on Nvidia | +| Nvidia support | Standalone DRM backend verified on a real RTX 3060 (proprietary 595.99.02, two outputs at mixed scale, 2026-09-25); nested verified earlier | | AUR package | Not yet | ## Hardware verification - **AMD**: primary development and test hardware. The standalone `udev`/DRM backend, the full water/decoration render stack, swim's real-touchpad gesture path, and Ocean's core navigation (reefs, cameras, freeform drag) are all verified here. - **Latest standalone AMD health pass (2026-08-13)**: release 0.90.72 on Renoir completed full-output and cursor-overlay screencopy, reversible workspace transitions with ordered IPC events, invalid-action/batch/eval handling, and 100 one-shot plus 20 interrupted subscription connections without descriptor or PSS growth. Doctor remained all-pass with no TideWM panic/error or coredump. One-window PSS settled at 86.8 MiB after the reload/capture pass. The configured 24-fps caustics were the dominant continuous CPU/GPU cost; see `report.md` for the measured A/B and its whole-GPU caveat. -- **Nvidia**: nested backend verified on a real RTX 3060 (proprietary driver): clean EGL/GLES context, correct rendering, no crashes. The standalone DRM backend and its Nvidia overlay-plane workaround are unverified on real Nvidia hardware. +- **Latest standalone NVIDIA pass (2026-09-25)**: 0.90.106 (`13b104e`, `fiw/testing-fixes`) on the RTX 3060 above: doctor all-pass; full-output and cursor-overlay `grim`; unknown actions, an invalid mixed batch, eval syntax errors and a runaway eval loop (execution budget) all failed cleanly; oversized, garbage and truncated IPC requests left the compositor running; 100 one-shot queries plus 20 interrupted subscribers left PSS and descriptors unchanged. Idle ~1% of one core, 131 MiB TideWM VRAM; nine frost windows ~0-1% CPU and 139 MiB. PSS plateaus near 180 MiB after warm-up, of which ~60-95 MiB is `/dev/nvidia*` mappings; open/close cycles returned to the same value with a flat heap, so this is not a leak. +- **Nvidia**: standalone `udev`/DRM backend verified on a real RTX 3060 (proprietary 595.99.02 with the open kernel modules, Gentoo, kernel 7.2.7), logged in from the display manager as a real session. GLES renders on the dGPU with the Radeon iGPU excluded through `gpu { render = vendor:nvidia; exclude = [vendor:amd] }`; two outputs run together (2560×1440@144 at 1.25×, 1920×1080@180 at 1.0×). The overlay-plane workaround branch runs for every output on this driver and scanout is correct on both; no overlay-specific test was done. Frost glass, custom shaders, screencopy, xwayland-satellite, portal and drag and drop worked. Measured cost is in `report.md`. The nested backend was verified earlier on the same card. - **Hybrid GPU**: selection/exclusion, render-node feedback, cross-device import, hot add/remove, and VT lifecycle compile and unit-test clean. The required end-to-end AMD+Nvidia PRIME/offload run is pending on the handoff laptop; do not call this hardware-verified until a non-primary game buffer displays correctly there at sustained frame rate. - **Intel**: untested so far. - **Still nested-only**: Ocean compass/overview, floating-window ocean physics (both tiers), `canvas_pan_button`, `modifier_pan_fingers`. @@ -134,7 +135,6 @@ sudo cp share/xdg-desktop-portal/tidewm-portals.conf /usr/share/xdg-desktop-port - **Non-water motion presets**: smooth window move/resize and workspace motion that works with `water_effects = false`, exposed as Wave-selectable presets and tunable fields. Exact feel and defaults require maintainer approval before implementation. - **Feel-tuning** across viscosity, sway, depth timings, cascade's drag feel, floating-window ocean physics, and the transition/ripple presets. All ship with working defaults; the actual feel still gets refined against real use. - **Standalone hardware pass** for what's still nested-only: the Ocean compass/overview, and both floating-window ocean physics tiers. -- **Nvidia native run**: the standalone DRM backend and its overlay-plane workaround still need a real TTY session on Nvidia. - **AUR package**: build from source for now. - **Design-pending identity features** (parking lot, needs a design conversation before pickup): tide contexts and workspace depth-moods. Currents, Cascade's pour/drain visual, and non-sorting weighted buoyancy are implemented with their approved contracts. diff --git a/report.md b/report.md index c0a3822..c0f5370 100644 --- a/report.md +++ b/report.md @@ -129,6 +129,11 @@ The finding text below is the original audit evidence. It is intentionally retai ### Validation state +- Live standalone NVIDIA pass on installed release 0.90.106 (`13b104e`, `fiw/testing-fixes` on top of `5c63b90`; RTX 3060, proprietary 595.99.02 with the open kernel modules, Gentoo, kernel 7.2.7; udev, logged in from the display manager; HDMI-A-2 2560×1440@144 at 1.25× and DP-4 1920×1080@180 at 1.0× as reported by IPC; `gpu { render = vendor:nvidia; exclude = [vendor:amd] }` on a machine that also has a Radeon iGPU). This is the first standalone DRM run on Nvidia. `doctor` was all-pass. Full-output and cursor-overlay `grim` captures succeeded per output and for the combined 4960×1440 desktop. Unknown actions, an invalid mixed batch (nothing executed), an eval syntax error and a runaway `while true` eval (stopped by the execution budget) all failed cleanly; a 2 MB request was dropped at the size cap, random bytes got an `invalid request` error, and a truncated request got an error once EOF handling landed (`e4867b1`). 100 one-shot queries plus 20 interrupted subscribers left PSS unchanged (187,957→187,957 KiB) and descriptors flat (97→97). Measurements (20 untouched 1 Hz samples each; VRAM from `nvidia-smi -q -d PIDS`, GPU busy from `nvidia-smi`, which reads high at the 210 MHz idle clock and is not comparable to amdgpu `gpu_busy_percent`): idle with one window ~1% of one core, PSS 179.5 MiB, 131 MiB TideWM VRAM, 309 MiB card total; nine frost-glass Kitty windows plus one ~0-1% CPU, 139 MiB TideWM VRAM (card 653 MiB, the rest is client buffers as on AMD), 4.6 MiB of backdrop texture; back to idle 133 MiB. An earlier run on `5c63b90` with larger tiles measured 156→196 MiB TideWM VRAM for the same nine windows (15.3 MiB of backdrops). + Memory: PSS rises once to a plateau and stays there. `smaps` splits it into ~75-79 MiB heap and ~60-95 MiB of `/dev/nvidia*` mappings (driver-owned GPU buffers mapped into the process, which amdgpu does not show as PSS). Three cycles of opening and closing eight frost windows returned to exactly the same PSS with a flat heap, so this is a high-water mark, not a leak. The doctor's decorated-tier soft limit (280 MiB) was reached after about 4 hours of use on the older build; on Nvidia the tier limits should probably count the driver mappings separately. + Found and fixed on the same branch, each with regression tests: client drag and drop never worked (`WaylandDndGrabHandler` was an empty impl, so Smithay cancelled every source; `2727de9`); glass and other captures drew the wallpaper at `1/scale` size on fractional outputs (`3286393`); `output { }` blocks were ignored on reload (`8ebb259`); `spawn` could not pass arguments containing spaces and tidectl consumed flags after the command (`6517964`); `cursor_hide_after = 0` was rejected (`182df1b`); doctor mislabeled card nodes (`490df73`). Automatic attention depth became opt-in (`c593da7`). + Still open from this pass: user-reported tearing on the HDMI output only, unchanged at scale 1.0, not reproduced as a TideWM fault (cable or HDMI bandwidth at 1440p144 suspected); PDF text selection in Firefox reported as improved but still imprecise; layer glass, layer shaders and screencast were not exercised (no layer-shell client or screencast consumer running yet). + - Live standalone AMD pass on installed release 0.90.101 (`384fe98`, Renoir, udev; all geometry and cadence read from IPC): `doctor --json` was all-pass with the expected TideWM portal, PipeWire, xwayland-satellite, and AMD render node. Full-output `grim` captures verified the normal desktop, an in-flight water transition, five-window BSP/master/cascade layouts, pseudo-tiling, floating conversion, invalid-config diagnostics/recovery, and an XWayland GLX client. Twenty rapid workspace reversals settled on workspace 1 with no transition texture retained; the original window remained tiled, focused, and non-fullscreen. Four disposable translucent Kitty clients mapped/unmapped cleanly; five-window frost/water measured 64.6 MiB PSS with 0.9 MiB of active backdrop texture. A 15-second static A/B disabling only 24 fps caustics and focused-border animation changed `animation_active` from true to false and compositor CPU from 8.30% to 1.55%; the three touched Wave files were restored byte-for-byte. An invalid Wave edit kept the previous live config, showed the exact file/line in the first-party overlay, and cleared after restoration. Two identical rounds of 700 one-shot queries plus 100 interrupted subscribers converged to the same 91,006 KiB PSS high-water mark, zero swap, and exactly 93 descriptors, showing bounded first-use/cache growth rather than continued leakage. `glxgears` through xwayland-satellite mapped, rendered at 58.53/58.32 fps against the live 60 Hz mode, and unmapped cleanly. No new TideWM panic/error or coredump appeared. P-12's real-DRM wake path is reconfirmed, but this stripped release exposes neither a caustics-present counter nor a usable external perf/BPF/AMD busy counter, so exact presented-caustics cadence remains pending. - Live standalone AMD pass on installed release 0.90.72 (`dfc00b7`, Renoir, udev, 1920×1080@60 as reported by IPC; no geometry or mode was supplied by the test): full-output `grim` and `grim -c` captures succeeded; a reversible workspace 1→2→1 switch emitted the expected focus/workspace sequence and left the original window state intact; unknown actions, an invalid mixed batch, and invalid Wave eval all failed cleanly; 100 one-shot queries plus 20 interrupted subscribers left PSS flat at 88,840→88,838 KiB, swap at zero, and descriptors non-growing (91→90). Post-test doctor remained all-pass with no TideWM panic/error or coredump. The live log exposed two config-lowering warnings (`workspace_motion_delay = 0ms`, bracket-list ripple triggers) and `tidectl --socket … report` exposed a global-argument parsing bug; 0.90.101 fixes all three with exact regression tests and an end-to-end release-`tidectl` report run. The original live config values were restored after the performance A/B, and workspace 1 was restored after transition testing. From ad0ed6b621cb21ddae699cf30dc44427e1a97292 Mon Sep 17 00:00:00 2001 From: Fi3w0 Date: Fri, 25 Sep 2026 03:12:46 +0200 Subject: [PATCH 12/13] Record the screencast result on Nvidia Portal screen sharing from native Vesktop through the TideWM ScreenCast backend worked on the RTX 3060 setup. Note that src/screencast only logs failures, so a successful session leaves no trace in the log. --- report.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/report.md b/report.md index c0f5370..6cbb8e5 100644 --- a/report.md +++ b/report.md @@ -132,7 +132,8 @@ The finding text below is the original audit evidence. It is intentionally retai - Live standalone NVIDIA pass on installed release 0.90.106 (`13b104e`, `fiw/testing-fixes` on top of `5c63b90`; RTX 3060, proprietary 595.99.02 with the open kernel modules, Gentoo, kernel 7.2.7; udev, logged in from the display manager; HDMI-A-2 2560×1440@144 at 1.25× and DP-4 1920×1080@180 at 1.0× as reported by IPC; `gpu { render = vendor:nvidia; exclude = [vendor:amd] }` on a machine that also has a Radeon iGPU). This is the first standalone DRM run on Nvidia. `doctor` was all-pass. Full-output and cursor-overlay `grim` captures succeeded per output and for the combined 4960×1440 desktop. Unknown actions, an invalid mixed batch (nothing executed), an eval syntax error and a runaway `while true` eval (stopped by the execution budget) all failed cleanly; a 2 MB request was dropped at the size cap, random bytes got an `invalid request` error, and a truncated request got an error once EOF handling landed (`e4867b1`). 100 one-shot queries plus 20 interrupted subscribers left PSS unchanged (187,957→187,957 KiB) and descriptors flat (97→97). Measurements (20 untouched 1 Hz samples each; VRAM from `nvidia-smi -q -d PIDS`, GPU busy from `nvidia-smi`, which reads high at the 210 MHz idle clock and is not comparable to amdgpu `gpu_busy_percent`): idle with one window ~1% of one core, PSS 179.5 MiB, 131 MiB TideWM VRAM, 309 MiB card total; nine frost-glass Kitty windows plus one ~0-1% CPU, 139 MiB TideWM VRAM (card 653 MiB, the rest is client buffers as on AMD), 4.6 MiB of backdrop texture; back to idle 133 MiB. An earlier run on `5c63b90` with larger tiles measured 156→196 MiB TideWM VRAM for the same nine windows (15.3 MiB of backdrops). Memory: PSS rises once to a plateau and stays there. `smaps` splits it into ~75-79 MiB heap and ~60-95 MiB of `/dev/nvidia*` mappings (driver-owned GPU buffers mapped into the process, which amdgpu does not show as PSS). Three cycles of opening and closing eight frost windows returned to exactly the same PSS with a flat heap, so this is a high-water mark, not a leak. The doctor's decorated-tier soft limit (280 MiB) was reached after about 4 hours of use on the older build; on Nvidia the tier limits should probably count the driver mappings separately. Found and fixed on the same branch, each with regression tests: client drag and drop never worked (`WaylandDndGrabHandler` was an empty impl, so Smithay cancelled every source; `2727de9`); glass and other captures drew the wallpaper at `1/scale` size on fractional outputs (`3286393`); `output { }` blocks were ignored on reload (`8ebb259`); `spawn` could not pass arguments containing spaces and tidectl consumed flags after the command (`6517964`); `cursor_hide_after = 0` was rejected (`182df1b`); doctor mislabeled card nodes (`490df73`). Automatic attention depth became opt-in (`c593da7`). - Still open from this pass: user-reported tearing on the HDMI output only, unchanged at scale 1.0, not reproduced as a TideWM fault (cable or HDMI bandwidth at 1440p144 suspected); PDF text selection in Firefox reported as improved but still imprecise; layer glass, layer shaders and screencast were not exercised (no layer-shell client or screencast consumer running yet). + Still open from this pass: user-reported tearing on the HDMI output only, unchanged at scale 1.0, not reproduced as a TideWM fault (cable or HDMI bandwidth at 1440p144 suspected); PDF text selection in Firefox reported as improved but still imprecise; layer glass and layer shaders were not exercised (no layer-shell client running yet). + Screencast: portal screen sharing from native Vesktop (vesktop-bin 1.6.7, Electron on the Wayland ozone backend) through xdg-desktop-portal to the TideWM ScreenCast backend and PipeWire 1.6.8 worked on this Nvidia setup, confirmed visually by the maintainer; the portal reported `AvailableSourceTypes = 7`. A successful session leaves no log line: `src/screencast` only logs failures, so a share is invisible in the session log unless it goes wrong. - Live standalone AMD pass on installed release 0.90.101 (`384fe98`, Renoir, udev; all geometry and cadence read from IPC): `doctor --json` was all-pass with the expected TideWM portal, PipeWire, xwayland-satellite, and AMD render node. Full-output `grim` captures verified the normal desktop, an in-flight water transition, five-window BSP/master/cascade layouts, pseudo-tiling, floating conversion, invalid-config diagnostics/recovery, and an XWayland GLX client. Twenty rapid workspace reversals settled on workspace 1 with no transition texture retained; the original window remained tiled, focused, and non-fullscreen. Four disposable translucent Kitty clients mapped/unmapped cleanly; five-window frost/water measured 64.6 MiB PSS with 0.9 MiB of active backdrop texture. A 15-second static A/B disabling only 24 fps caustics and focused-border animation changed `animation_active` from true to false and compositor CPU from 8.30% to 1.55%; the three touched Wave files were restored byte-for-byte. An invalid Wave edit kept the previous live config, showed the exact file/line in the first-party overlay, and cleared after restoration. Two identical rounds of 700 one-shot queries plus 100 interrupted subscribers converged to the same 91,006 KiB PSS high-water mark, zero swap, and exactly 93 descriptors, showing bounded first-use/cache growth rather than continued leakage. `glxgears` through xwayland-satellite mapped, rendered at 58.53/58.32 fps against the live 60 Hz mode, and unmapped cleanly. No new TideWM panic/error or coredump appeared. P-12's real-DRM wake path is reconfirmed, but this stripped release exposes neither a caustics-present counter nor a usable external perf/BPF/AMD busy counter, so exact presented-caustics cadence remains pending. From e2385eb505cbb6d960068043c2d5ae99979dc373 Mon Sep 17 00:00:00 2001 From: Fi3w0 Date: Fri, 25 Sep 2026 03:42:30 +0200 Subject: [PATCH 13/13] Accept command-local JSON flags in tidectl --- src/bin/tidectl.rs | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/bin/tidectl.rs b/src/bin/tidectl.rs index b121643..2ed3095 100644 --- a/src/bin/tidectl.rs +++ b/src/bin/tidectl.rs @@ -88,7 +88,11 @@ fn main() { // Host-side commands: run before any socket work, so a compositor that // won't even start can still be diagnosed. match args[0].as_str() { - "doctor" => return cmd_doctor(json_output, socket_override.as_deref()), + "doctor" => { + let json_output = + parse_doctor_args(&args[1..], json_output).unwrap_or_else(|message| fail(&message)); + return cmd_doctor(json_output, socket_override.as_deref()); + } "report" => return cmd_report(&args[1..], socket_override.as_deref()), _ => {} } @@ -240,6 +244,20 @@ fn cmd_doctor(json_output: bool, socket_override: Option<&Path>) { }); } +/// Accepts the documented command-local spelling (`doctor --json`) while the +/// global parser deliberately leaves every argument after the command alone. +/// This keeps flags intended for actions such as `spawn` out of tidectl's +/// parser without breaking doctor scripts. +fn parse_doctor_args(args: &[String], mut json_output: bool) -> Result { + for arg in args { + match arg.as_str() { + "--json" | "-j" => json_output = true, + other => return Err(format!("unrecognized argument '{other}' for doctor")), + } + } + Ok(json_output) +} + /// `tidectl report [--output ]`: writes the full diagnostic report /// to a file (default `tidewm-report.txt` in the current directory) and /// prints where it went. The quick check runs first and is embedded; the @@ -301,7 +319,7 @@ fn parse_report_output(args: &[String]) -> Result { /// process and needs no CLK_TCK). PSS/RSS/threads and the render /// self-stats come from the second snapshot. No GPU-busy%: that needs a /// vendor-specific source the compositor can't read portably. -fn cmd_perf(socket: &Path, args: &[String], json_output: bool) -> ! { +fn cmd_perf(socket: &Path, args: &[String], mut json_output: bool) -> ! { let mut window_secs: f64 = 3.0; let mut iter = args.iter().map(String::as_str); while let Some(arg) = iter.next() { @@ -319,8 +337,7 @@ fn cmd_perf(socket: &Path, args: &[String], json_output: bool) -> ! { std::process::exit(0); } "--json" | "-j" => { - // Already consumed by the global flag parser; accept quietly - // in case it appears after the subcommand. + json_output = true; } other => fail(&format!("unrecognized argument '{other}' for perf")), } @@ -1019,6 +1036,14 @@ mod tests { assert!(parse_report_output(&["/run/user/1000/tidewm.sock".into()]).is_err()); } + #[test] + fn doctor_accepts_json_before_or_after_the_subcommand() { + assert_eq!(parse_doctor_args(&[], true), Ok(true)); + assert_eq!(parse_doctor_args(&["--json".into()], false), Ok(true)); + assert_eq!(parse_doctor_args(&["-j".into()], false), Ok(true)); + assert!(parse_doctor_args(&["--bogus".into()], false).is_err()); + } + #[test] fn bounded_line_stops_at_newline_and_preserves_the_next_record() { let mut reader = BufReader::new(std::io::Cursor::new(b"first\nsecond\n"));