diff --git a/CHANGELOG.md b/CHANGELOG.md index 946604e..babaaa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to TideWM are documented here. Format loosely follows [Keep ## [Unreleased] ### Added +- **Custom shaders** (shader engine Phases 7A and 7B from `SHADER_DESIGN.md`). A `shaders { enabled }` master, off by default and separate from `water_effects`, gates named `shader "name" { }` definitions that a window selects with `rule { shader = name }`, or a workspace with `workspace_rule { shader = name }` (a window rule, including `shader = none`, wins). Bars and other layer surfaces take an effect only from an explicit `layer_rule { shader = name }`, masked by that rule's `ignore_alpha`. A definition runs up to four ordered `stage { file = "..." }` passes over the window's captured backdrop: every stage but the last renders offscreen before the visible bind, and the last draws in the slot the window's glass would use, with its rounding and opacity. Stages chain positionally, can `save = "name"` their output, read it back with `source = "get:name"` or bind it next to `tex` through `textures { name = "get:name" }`, and take up to eight typed `params { }` uniforms. A user file defines `vec4 tide_effect(vec2 uv)` against a fixed uniform contract (`tex`, `u_size`, `u_texture_size`, `u_content_rect`, `u_texel`, `u_time`, `u_delta`, `u_corner_radii`, `u_rounding_power`, `u_antialias`); TideWM owns the GLSL version, every declaration, `main`, premultiplied clamping, rounding and opacity, and a token-aware check rejects a file that tries to take any of that over. Files load at config time relative to the main config, at most 64 KiB each and 1 MiB per config, and the watcher reloads on a shader edit like an include. At most one program compiles per frame, a compile failure shows the driver's first error line on the warning panel with the line mapped back to the user's file, and the last good program keeps drawing until the file is fixed. At most 128 windows and 64 MiB of effect captures and stage textures are allowed, checked before anything allocates, and `render_scale` (default 2) sets each effect's own capture divisor. `tidectl perf` gains a `custom_shaders` summary. Lock clears every effect's state. `share/shaders/` ships `tint`, a two-pass Gaussian `blur-h`/`blur-v`, and `edge-mix`, and DOCUMENTATION.md has the full reference. A definition can build on another with `source = "shader:"`: the source's stages are spliced in front at load time, `backdrop` in the definition then means the source's output, and unknown names, loops, save clashes and chains over four stages are rejected with a warning. Other scopes and `always`/`manual` invalidation are later slices and are rejected with a visible warning. The shader tests compile and draw through Mesa's software EGL device where one exists, which checks real GLSL ES compilation and pixels but is not a hardware pass; the native live pass is still owed. - Classic floating windows now support configurable edge/corner snapping (manual spatial roadmap Phase 5). A `snap { }` block selects halves or halves-plus-quarters, can replace that preset with an exact zone list, controls pointer distance/gap and the bounded solid preview, and defaults on without affecting Ocean. Modifier/client move grabs reuse their existing visual follower and transactional release path: the pointer's logical output selects the target, exclusive-layer usable area and workspace gaps shape it, cross-output drops transfer durable output/workspace ownership and fractional scale, and cancelled grabs never commit. `snap:` actions use the same geometry for keyboard parity; `rule { snap }` and `workspace_rule { snap }` provide window/workspace opt-outs beneath the global master. Pure tests cover corners, explicit zones, non-zero/odd global geometry, and scaled/rotated logical-output behavior. Static gates are green. **Verified live by the maintainer on the real udev/DRM backend**: pointer drag-to-snap to an edge or corner confirmed working as expected, using a hand-written `snap { }` block (not yet the shipped default). Keyboard `snap:` parity and the rest of the roadmap's real-hardware matrix (cancellation, config reload, rule policy, multi-output) were not part of that report and remain open. Same session: a `fullscreen` zone rounds out the drag-to-snap gesture. Dropping dead-center on the top edge, clear of the corner targets, resizes the floater to fill the whole usable area -- the same rect-resize mechanism every other zone already uses, gapped the same way, not real xdg-shell fullscreen protocol state -- previewed as a full-output highlight; the center band is the middle third of the output's width so it doesn't compete with the ordinary top-edge half-snap on either side. `snap:fullscreen` gives it the same keyboard/IPC parity every other zone has, and `snap.fullscreen` (default `true`) is its standalone opt-out, independent of `preset`. Pure tests cover the band boundary, the disabled-config fallback to an ordinary top-half snap, and the target-rect geometry. **First implementation attempt froze the machine on real hardware, hard reboot required (fixed same session, never shipped past this CHANGELOG entry).** The first version routed the fullscreen zone through real `xdg_toplevel` fullscreen protocol state (`Smallvil::enter_fullscreen` calling the same `fullscreen_request`/`do_fullscreen_request` path `toggle-fullscreen` uses) so it could be dropped from inside `MoveSurfaceGrab::unset()`. `do_fullscreen_request`'s output resolution calls `preferred_output_for_toplevel` -> `pointer_output_for_surface` -> `self.seat.get_pointer()` -> `.current_focus()`/`.current_location()` -- querying the pointer handle's own locked state from inside a callback Smithay is already calling *while holding that same lock*. This is the exact 0.15.1 `TileMoveGrab` deadlock shape (CHANGELOG 0.15.1/0.16.0): a hard freeze of the whole machine, not a panic, since the single event-loop thread that would handle VT-switch recovery is the one stuck spinning on the lock. The maintainer confirmed the freeze live and asked for a plain resize instead ("not a fullscreen mode just a resize to fullscreen"), which sidesteps the hazard entirely: the fix reuses the ordinary rect-snap path unchanged, with `Fullscreen` as just another `target_rect` zone sized to the whole tiling area. No real-fullscreen code remains in this feature. @@ -33,11 +34,25 @@ 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`. ### 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. +- 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. +- `layer_rule { ignore_alpha }` now actually masks layer frost. The alpha mask is only ever sampled through a second texture unit, and Smithay sets sampling state only on the texture it binds to unit 0, so the mask kept GL's mipmapped default filter, read as an incomplete texture with alpha 1 everywhere, and transparent layer regions frosted as if the key were unset. Binding the mask now sets its sampling state; a software-GLES render test covers both a transparent and an opaque mask. Not retested on hardware yet. +- A window captured on its own (OBS's window source, a per-window screenshot) now keeps its frost or water glass. The toplevel capture path rendered the bare surface tree with only its opacity, so glass only ever appeared in output-level captures. It now takes the window's glass layer from the same `glass_layer_elements` the live frame uses and moves it onto the capture canvas behind the window's surfaces. `block_capture` still leaves both the surface and its glass out, and the glass is also left out when a `block_capture` window or layer overlaps the captured window, since the shared backdrop could otherwise show it. Not retested live yet. - Phase 3 of the manual correctness audit closes M-48, M-50, M-61 through M-71, and M-73; the explicitly measurement-only M-51 through M-60 queue remains open. GPU fallback detection now selects the lowest numeric DRM `cardN` deterministically (including double-digit cards) and the udev backend records the GPU actually selected; diagnostics judge PSS against the active basic/decorated/Ocean tier while retaining the 2 GB ceiling; per-device absolute input mapping from `61398e0` is now reflected as closed; live output scale/transform changes reconcile hidden and visible floating scales, restore rectangles, Ocean pins, lock geometry, pointer focus, and Ocean-visible keyboard focus; workspace swaps refresh floating scale; Ocean effects and preferred scale use rendered placement rather than stale world-Space overlap; and unplug closes/prunes layer and output-control state before the output disappears. Real multi-output, hotplug, and VRR hardware passes remain recorded as validation debt rather than claimed here. - The generated default config's commented binding examples now use executable Wave block syntax, so uncommenting an example no longer produces the removed `bind X = action` compile error. - Wayland clients are now flushed after every calloop dispatch instead of only from backend-specific render/maintenance sources. On an otherwise idle all-tiled desktop the old udev timer could back off to one second, delaying key-release and frame messages long enough to look like input lag, a repeat storm, and near-frozen redraws; the compositor-wide post-dispatch flush removes that latency without backend-specific duplicate flushes. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 9af2ada..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,11 +85,12 @@ 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. | | `reload_toast` | bool | `true` | Shows the short compositor card after a successful hot reload. `false` hides that confirmation only; parse errors and configuration warnings remain visible so a bad config cannot silently lock itself in. | -| `water_effects` | bool | `true` | Master toggle for TideWM's water/aqua render identity. Disables water-glass, backdrop capture, impulse ripples, wave workspace transitions, Cascade pour/drain, automatic depth/buoyancy, interactive viscosity, connected-vessel resize, and floating sway when `false`. | +| `water_effects` | bool | `true` | Master toggle for TideWM's water/aqua render identity. Disables water-glass, frost, glass backdrop capture (custom shaders keep theirs under `shaders { enabled }`), impulse ripples, wave workspace transitions, Cascade pour/drain, automatic depth/buoyancy, interactive viscosity, connected-vessel resize, and floating sway when `false`. | | `builtin_wallpaper` | bool | `true` | Whether the embedded 4K aqua fallback wallpaper is decoded and drawn. A layer-shell wallpaper (swaybg/swww/hyprpaper) renders above it regardless, so set this `false` to skip the decode and its GPU texture entirely and reclaim the CPU and VRAM it would otherwise cost — useful on low-RAM machines or when an external wallpaper daemon is always present. Live-reloadable: a running session stops drawing it the moment this becomes `false`. | | `viscosity` | float, `0`–`4` | `1.0` | Interactive window move/resize damping. `0` follows the pointer immediately; higher values settle more slowly. Render-only: logical geometry and hit-testing stay at the pointer target. Disabled by `water_effects = false`. | | `backdrop_capture_scale` | int, `1`–`4` | `1` | Linear downscale for the per-window backdrop capture that feeds frost glass, water glass, and layer-shell blur. `1` captures at native resolution (unchanged look). `2`/`4` allocate a texture with 1/4 or 1/16 the area — real VRAM/GPU savings with several glass windows open at once — at the cost of a visibly softer captured image once magnified back up to the window's size. Changes how the water identity looks, so it defaults to the unchanged behavior rather than a pre-picked value. Captures are also released automatically after their last output stops presenting the surface, including hidden Classic workspaces and off-camera Ocean windows. See `report.md`'s P-13/P-14 entries for the measurements behind this knob. | @@ -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 @@ -833,7 +834,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. | @@ -1212,6 +1213,128 @@ caustics { } ``` +### Custom shaders + +`shaders { }` and `shader "" { }`: your own GLSL effects, drawn over a window's captured backdrop in the slot its frost or water glass would use. The feature is off by default and follows its own master switch, so it works with `water_effects = false` and leaves that toggle's meaning alone. + +Custom shaders are trusted native GPU programs. TideWM checks that a file keeps to the contract below and caps how much memory effects may use, but it cannot sandbox GPU execution: a slow or looping shader can stall or hang the GPU driver. Only load shaders you have read. + +```wave +shaders { + enabled = true +} + +shader soft-blur { + render_scale = 2 + stage { + file = "shaders/blur-h.frag" + params { + radius = 2 + } + } + stage { + file = "shaders/blur-v.frag" + params { + radius = 2 + } + } +} + +rule { + app_id = kitty + shader = soft-blur +} +``` + +`rule { shader = }` assigns a definition to matching windows, last matching rule wins. `workspace_rule { shader = }` sets a default for every window on that workspace, and a window rule's own `shader` (including `shader = none`) takes precedence over it. Bars, launchers and other layer surfaces only get an effect from an explicit `layer_rule { shader = }`; there `ignore_alpha` masks the effect out of the layer's transparent pixels, and the last stage draws without rounding. An unknown name, or a definition that failed to load and never compiled, leaves the window on its normal render path. The repository ships example files in `share/shaders/` (`tint.frag`, `blur-h.frag`, `blur-v.frag`, `edge-mix.frag`); copy the ones you want next to your `config.wave`. + +**Definitions.** A later `shader` block with the same name replaces the earlier one whole, so a definition's stages are always the ones written in one block. At most 32 definitions load. + +| Key | Type | Default | Notes | +| --- | --- | --- | --- | +| `scope` | `window` | `window` | The only scope so far. | +| `source` | `backdrop` or `"shader:"` | `backdrop` | `backdrop` is the scene behind the window, captured the same way frost glass captures it. `"shader:"` starts from another definition's output instead; see below. | +| `invalidate` | `damage-box` | `damage-box` | Redraws only when the captured backdrop, geometry, rounding, opacity, parameters or the program change. `always` and `manual` are not supported yet. | +| `render_scale` | int, `1`–`4` | `2` | Downscale for this effect's capture and stage textures. Each costs `ceil(w / scale) * ceil(h / scale) * 4` bytes. | +| `stage { }` | block, 1 to 4 | | Ordered; see below. | + +A definition that asks for anything unsupported, or breaks a rule below, is left out with a warning on the config panel instead of drawing something other than what it asked for. + +**Stages.** Every stage but the last renders into an offscreen texture sized to the capture, in written order, before the visible frame is drawn. The last stage draws on screen and applies the window's rounding and opacity once; earlier stages are never clipped or faded. + +| Key | Type | Notes | +| --- | --- | --- | +| `file` | string, required | A `.frag` file. Relative paths resolve from the directory of the main `config.wave` (not the including file); `~/` and absolute paths also work. Must be a regular UTF-8 file of at most 64 KiB; all shader files together are capped at 1 MiB. The file is watched like an include, so saving it reloads the effect. | +| `params { }` | block | Up to 8 `name = value` uniforms: a number (`float`), a list of 2 to 4 numbers (`vec2`–`vec4`), or a color (`vec4` RGBA). A color whose hex digits are all numbers must be quoted with its `#` (`"#123456"`), or it reads as a number. Names are GLSL identifiers of at most 64 bytes and may not start with `u_`, `gl_` or `tide_`. Changing a value does not recompile. | +| `source` | `backdrop` or `"get:"` | The stage's `tex`. Defaults to the previous stage's output (the backdrop for the first stage). | +| `save` | name | Lets later stages read this stage's output with `"get:"`. A name must be saved by an earlier stage before anything reads it, and the last stage cannot save. | +| `textures { }` | block | Up to 4 extra `sampler2D` uniforms, each `name = backdrop` or `name = "get:"`, bound to texture units 1 to 4. | + +```wave +shader edged { + stage { + file = "shaders/blur-h.frag" + params { + radius = 3 + } + } + stage { + file = "shaders/blur-v.frag" + params { + radius = 3 + } + save = "blurred" + } + stage { + file = "shaders/edge-mix.frag" + source = "get:blurred" + params { + margin = 0.08 + } + textures { + original = backdrop + } + } +} +``` + +**Building on another definition.** With `source = "shader:"`, the named definition's stages run first and this definition's stages run on their result, so `soft-blur` above can be tinted without copying its stages: + +```wave +shader tinted-blur { + source = "shader:soft-blur" + stage { + file = "shaders/tint.frag" + params { + strength = 0.3 + tint_color = 88CCFF + } + } +} +``` + +The first stage's `tex` is the source's output, and `backdrop` anywhere in this definition (a stage `source` or a `textures` binding) also means that output. The source's own stages still see the real backdrop. This definition's `render_scale` applies to the whole chain, the four-stage limit counts the source's stages too, and sources can themselves have sources. The source's parameters are its own, so a definition that needs different ones is written as its own block. A source that doesn't exist, a loop of sources, going over four stages, or saving a name the source already saves leaves the definition out with a warning. If one of the source's files fails to load, windows using this definition render without it, the same as windows using the source. + +**The fragment contract.** A file defines `vec4 tide_effect(vec2 uv)` and may add its own helper functions, structs and constants. It returns premultiplied RGBA; TideWM clamps alpha to `0`–`1` and RGB to alpha. `uv` addresses the input texture with `(0, 0)` at its top-left on both backends. TideWM writes everything else: the `#version 100` GLSL ES 1.00 header, precision, every uniform declaration, `main`, rounding and opacity. So a file may not contain `#version`, `#extension`, `#include`, `#line` or `#pragma` (`#define`, `#undef`, `#if`/`#ifdef`/`#ifndef`/`#elif`/`#else`/`#endif` and `#error` are fine), token pasting (`##`), `uniform`, `attribute`, `varying`, `precision` or `invariant`, and it may not use `main`, `alpha`, `tint`, `v_coords`, `gl_FragColor` or `gl_FragData`, or declare `tex`, `size` or anything starting with `u_`, `gl_` or `tide_`. The check reads GLSL tokens, so comments may mention any of these. + +| Uniform | Type | Meaning | +| --- | --- | --- | +| `tex` | `sampler2D` | The stage input. | +| `u_size` | `vec2` | Destination size in physical pixels: the window's rect for the last stage, the stage texture for earlier ones. | +| `u_texture_size` | `vec2` | Size of `tex` in texels. | +| `u_content_rect` | `vec4` | Content origin and extent inside `tex`, in texels. Currently always the whole texture. | +| `u_texel` | `vec2` | `1.0 / u_texture_size`. | +| `u_time` | `float` | Seconds since the effect started on this window, modulo 4096, sampled once per update. Under `damage-box` it does not by itself cause redraws. | +| `u_delta` | `float` | Seconds since the previous update, clamped to `0`–`0.1`; `0` on the first. | +| `u_corner_radii` | `vec4` | Destination corner radii in physical pixels, top-left, top-right, bottom-right, bottom-left. Zero in earlier stages and on layers. | +| `u_rounding_power`, `u_antialias` | `float` | The resolved rounding shape and edge softness. | + +Porting a Hyprland `screen_shader` usually means deleting its `precision` line and the `varying`/`uniform` declarations TideWM already provides, renaming `void main()` to `vec4 tide_effect(vec2 uv)`, using `uv` for `v_texcoord`, and turning `gl_FragColor = x;` into `return x;`. Shaders written for desktop GLSL (`#version 330`, `texture(...)`, `textureSize(...)`) need rewriting for GLSL ES 1.00 (`texture2D`, `u_texture_size`). + +**Errors and reloads.** A file that can't be read or breaks the contract shows on the config warning panel with its definition, stage and path. A compile failure shows the driver's first error line, with Mesa's line number rewritten to the line in your file. A failed stage never recompiles until its source changes, and until then the effect keeps drawing with the last program that compiled; if none has, the window renders without the effect. At most one new program compiles per frame. Removing a definition or turning `shaders.enabled` off always takes effect immediately. + +**Budgets and privacy.** At most 128 windows draw a custom effect at once, and all effect captures and stage textures together are capped at 64 MiB. A window over either cap renders normally with a warning until there is room. Locking the session clears every effect's state and stage textures. Screenshots and screencasts of a whole output include effects exactly as shown on screen. A window captured on its own includes its effect too, unless a `block_capture` window or layer overlaps it, in which case the capture leaves the effect (and any glass) out so blocked content can't show through it. `tidectl perf` reports definitions, compiled programs, compiles and failures, live windows, capture and stage bytes, windows over budget and update counts under `custom_shaders`. + ### `env { }` `KEY = VALUE` pairs, applied to TideWM's own process before the backend starts (so e.g. `XCURSOR_THEME` here actually changes the cursor theme TideWM itself loads, not just what child processes see) and exported on standalone sessions to the systemd/D-Bus activation environment alongside `WAYLAND_DISPLAY`. That external export is an ordered, best-effort background task: direct children receive TideWM's process environment immediately, while a missing or wedged session helper may delay session-activated services seeing the update without delaying the compositor, input, or startup commands. Invalid Unix environment names/values are ignored with a config warning; values are never repeated in that diagnostic because they may contain secrets. @@ -1424,6 +1547,7 @@ Per-app placement applied the moment a window first maps, before it's ever tiled | `inactive_opacity` | float | Extra multiplier while the window is unfocused. `unfocused_opacity` is an alias. | | `fullscreen_opacity` | float | Extra multiplier while fullscreen; takes priority over active/inactive state. | | `glass` | `water`, `frost`, or `none` | Captured-backdrop treatment for tiled and floating windows. Explicit `water`/`frost` works with client-provided alpha; when unset, a TideWM `opacity` below `1.0` implicitly selects `water`. `none` preserves plain transparency. Gaps stay unblurred. `glass_mode` is an alias. | +| `shader` | definition name, or `none` | Custom effect from a `shader "" { }` definition, drawn in place of the window's glass while `shaders { enabled = true }`. `none` also opts out of a `workspace_rule` default. See "Custom shaders". | | `viscosity` | float, `0`–`4` | Per-app interactive move/resize damping. Last matching rule wins; `0` disables it for the matched app. | | `sway` | bool | Per-app opt-in/out for floating sway. Last matching rule wins; unset falls back to `sway.enabled`. | | `weight` | float, `0`–`1` | Per-app apparent weight for `buoyancy { }`. Last matching rule wins; unset falls back to `buoyancy.default_weight`; `0` opts the matched floater out. | @@ -1548,7 +1672,8 @@ Matches a layer-shell surface (a bar, panel, or launcher, not an ordinary app wi | `dim_amount` | float 0.0-1.0, optional | The dim overlay's alpha. Default `0.35` when `dim_around` is on and this is unset. | | `above_lock_screen` | bool | Default `false`. Keeps the matched surface rendered on top of the session-lock surface instead of being blanked with everything else — Hyprland's `abovelock`. Render-only: input still never reaches anything but the lock surface itself while locked, so this cannot be used to bypass the lock. Screenshots/screencasts taken while locked still honor `block_capture` for an `above_lock_screen` surface. | | `blur` | bool | Default `false`. Frost-glasses the matched surface's own backdrop — Hyprland's `layerrule = blur`. Requires the global `frost { enabled = true }` (checked separately from this per-namespace opt-in); always frost, there's no water-refraction choice for a bar. | -| `ignore_alpha` | float 0.0-1.0, optional | Masks layer blur using the composed alpha of the complete layer surface tree. Pixels below the threshold do not blur, which prevents a transparent full-output click-catcher from frosting the entire monitor. Has no effect unless `blur = true`. | +| `ignore_alpha` | float 0.0-1.0, optional | Masks layer blur (or a layer's `shader`) using the composed alpha of the complete layer surface tree. Pixels below the threshold do not blur, which prevents a transparent full-output click-catcher from frosting the entire monitor. Has no effect unless `blur = true` or `shader` is set. | +| `shader` | definition name, or `none`, optional | Custom effect from a `shader "" { }` definition over the layer's backdrop while `shaders { enabled = true }`, in place of its frost if `blur = true` is also set (frost takes over again while the shader has no compiled program). Last matching rule with a `shader` wins; layers never inherit a window or workspace shader. See "Custom shaders". | ``` layer_rule { @@ -1591,6 +1716,7 @@ Per-workspace-number overrides — Hyprland's `workspace_rule` block. Matches by | `rounding` | bool, radii, or a `rounding { }` sub-block | Same shape as `rule { rounding }`, same base-layer role as `border` above. | | `shadow` | bool, or a `shadow { }` sub-block | Same shape as `rule { shadow }`, same base-layer role as `border` above. | | `snap` | bool, optional | Enables/disables Classic snap for floaters on this workspace. A matching window `rule { snap = ... }` wins; `snap.enabled = false` remains a global master off switch. Cross-output pointer targeting evaluates the destination output's active workspace. | +| `shader` | definition name, or `none`, optional | Default custom effect for windows on this workspace. A matching window `rule { shader = ... }` wins. See "Custom shaders". | | `on_created_empty` | string, optional | Command run the first time this workspace is switched into while it has zero windows. TideWM's numbered workspaces always exist as addressable slots — there's no real create/destroy lifecycle the way Hyprland has — so this fires once per (output, workspace) pair for the process lifetime, the closest honest analog available, rather than repeating on every later empty visit. | ``` @@ -1726,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` @@ -1771,7 +1897,7 @@ tidectl subscribe focus workspace window # long-lived event stream, one JSON l `tidectl subscribe [event...]` is the one long-lived CLI command: it opens the subscribe mode above and prints each `{"event": "", "data": ...}` line verbatim until TideWM exits (socket EOF ends the process cleanly, so a supervisor can restart it). With no event names every channel is subscribed. A bar or panel runs it as a persistent process and parses stdout — instant, event-driven updates with no polling; the QuickShell Tide rice uses exactly this instead of its old fixed-rate `tidectl` polls. -`tidectl perf [--window ] [--json]` takes two IPC `perf` snapshots spaced by the window (default 3s) and prints a compact PSS/RSS/thread-count/render-state summary. CPU% comes from the delta of the compositor's own `getrusage` microsecond counters between the two snapshots, so it's always about the right process and needs no `CLK_TCK` constant. It also reports the ARGB pixel payload of TideWM-owned backdrop, built-in-wallpaper, caustics, and active workspace-transition textures, derived from their live allocations. That estimate excludes client buffers and driver metadata, while GPU-busy% still requires a vendor-specific tool. +`tidectl perf [--window ] [--json]` takes two IPC `perf` snapshots spaced by the window (default 3s) and prints a compact PSS/RSS/thread-count/render-state summary. CPU% comes from the delta of the compositor's own `getrusage` microsecond counters between the two snapshots, so it's always about the right process and needs no `CLK_TCK` constant. It also reports the ARGB pixel payload of TideWM-owned backdrop, custom shader stage, built-in-wallpaper, caustics, and active workspace-transition textures, derived from their live allocations, plus a `custom_shaders` summary while that feature is on. That estimate excludes client buffers and driver metadata, while GPU-busy% still requires a vendor-specific tool. **Diagnostics.** Two host-side commands run entirely outside the socket, so they work even when TideWM won't start: diff --git a/README.md b/README.md index 75ed9e9..e62db9c 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Underneath the water is a real tiling window manager: fast keyboard-driven layou - **It actually looks like something.** Ripples on open, wave transitions between workspaces, glass and shadow on your windows, floating windows that drift like they're sitting on water. Every bit of it is a config toggle, and none of it costs you performance you'll notice. - **Two ways to work.** Stick with classic numbered workspaces, or switch to Ocean: one endless zoomable canvas where every window lives in real 2D space instead of a workspace number. Switch between them live, no restart. - **Runs on what you already own.** Measured at ~60-70MB PSS at idle with the full water stack on, ~63MB with nine frosted-glass windows -- about half of Hyprland on the same hardware -- and it only climbs past that if you turn on more than the defaults. -- **Genuinely yours.** Every ripple, wave, border, and shadow is a value in a config file, hot-reloaded on save. Nothing about how it looks is hardcoded. +- **Genuinely yours.** Every ripple, wave, border, and shadow is a value in a config file, hot-reloaded on save. Nothing about how it looks is hardcoded. If that's not enough, write your own GLSL effect for a window and chain up to four passes of it (opt-in, see [Custom shaders](DOCUMENTATION.md#custom-shaders)). ## Try it diff --git a/TECHNICAL_REPORT.md b/TECHNICAL_REPORT.md index 039b69e..a6fb02f 100644 --- a/TECHNICAL_REPORT.md +++ b/TECHNICAL_REPORT.md @@ -32,6 +32,7 @@ Current release: **0.90.104**, second major pre-release. 1.0 is intentionally re | IPC: request/response plus an event-stream subscribe mode | Done | | `tidectl` CLI, including `doctor`/`report` for bug triage | Done | | Water/decoration render stack: impulse ripples, wave workspace transitions, tiled/floating water-glass and per-app liquid frost glass, analytical shadows, animated gradient borders, rounded clipping, configurable window animations, move/resize viscosity, connected-vessel BSP resizing, opt-in floating sway, automatic depth/buoyancy, ambient caustics | Done; tiled/liquid frost is release-GLES nested-verified in Classic/Ocean, standalone AMD remains, and the earlier stack is standalone-AMD-verified | +| Custom shaders (`shaders { }`, `shader "" { }`): opt-in user `.frag` effects over a window's backdrop, up to four chained stages with `save`/`get` and extra texture bindings, per-window and per-workspace assignment, bounded budgets | Done through Phase 7B; compile/draw covered by Mesa software-GLES tests, not yet run in a nested or native session | | Classic Depth Deck (tiled-window park/swap recall) | Done, standalone-AMD-verified | | Ocean spatial engine: reefs, per-output cameras, sink/dredge/surface depth, bookmarks, freeform window detach, smart tiling, live Classic↔Ocean migration | Done, standalone-AMD-verified | | Ocean compass (off-screen urgent/deep glow cues) and whole-world overview minimap | Done, nested-verified only | @@ -39,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`. @@ -133,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..6cbb8e5 100644 --- a/report.md +++ b/report.md @@ -129,6 +129,12 @@ 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 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. - 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. diff --git a/share/shaders/blur-h.frag b/share/shaders/blur-h.frag new file mode 100644 index 0000000..a1f6437 --- /dev/null +++ b/share/shaders/blur-h.frag @@ -0,0 +1,11 @@ +// Horizontal half of a separable Gaussian blur. Pair it with blur-v.frag +// as the next stage. Params: radius (float, texels between taps). +vec4 tide_effect(vec2 uv) { + vec2 offset = vec2(u_texel.x * radius, 0.0); + vec4 sum = texture2D(tex, uv) * 0.2270270270; + sum += texture2D(tex, uv + offset * 1.3846153846) * 0.3162162162; + sum += texture2D(tex, uv - offset * 1.3846153846) * 0.3162162162; + sum += texture2D(tex, uv + offset * 3.2307692308) * 0.0702702703; + sum += texture2D(tex, uv - offset * 3.2307692308) * 0.0702702703; + return sum; +} diff --git a/share/shaders/blur-v.frag b/share/shaders/blur-v.frag new file mode 100644 index 0000000..2d7083e --- /dev/null +++ b/share/shaders/blur-v.frag @@ -0,0 +1,11 @@ +// Vertical half of a separable Gaussian blur, run after blur-h.frag. +// Params: radius (float, texels between taps). +vec4 tide_effect(vec2 uv) { + vec2 offset = vec2(0.0, u_texel.y * radius); + vec4 sum = texture2D(tex, uv) * 0.2270270270; + sum += texture2D(tex, uv + offset * 1.3846153846) * 0.3162162162; + sum += texture2D(tex, uv - offset * 1.3846153846) * 0.3162162162; + sum += texture2D(tex, uv + offset * 3.2307692308) * 0.0702702703; + sum += texture2D(tex, uv - offset * 3.2307692308) * 0.0702702703; + return sum; +} diff --git a/share/shaders/edge-mix.frag b/share/shaders/edge-mix.frag new file mode 100644 index 0000000..1aca0e0 --- /dev/null +++ b/share/shaders/edge-mix.frag @@ -0,0 +1,7 @@ +// Shows `original` (a textures binding) near the window's edges and the +// stage input in the middle. Params: margin (float, fraction of the size). +vec4 tide_effect(vec2 uv) { + vec2 edge = min(uv, 1.0 - uv); + float inner = smoothstep(0.0, margin, min(edge.x, edge.y)); + return mix(texture2D(original, uv), texture2D(tex, uv), inner); +} diff --git a/share/shaders/tint.frag b/share/shaders/tint.frag new file mode 100644 index 0000000..5006182 --- /dev/null +++ b/share/shaders/tint.frag @@ -0,0 +1,7 @@ +// Pulls the backdrop toward `tint_color` by `strength`. +// Params: strength (float), tint_color (color). +vec4 tide_effect(vec2 uv) { + vec4 color = texture2D(tex, uv); + vec3 tinted = mix(color.rgb, tint_color.rgb * color.a, strength); + return vec4(tinted, color.a); +} diff --git a/src/backend/udev.rs b/src/backend/udev.rs index 07a1540..30d4529 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::{ @@ -61,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}, @@ -119,6 +120,8 @@ smithay::backend::renderer::element::render_elements! { WaterGlass = crate::water_glass::WaterGlassElement, /// Frosted-glass mode over the same captured backdrop as water glass. FrostGlass = crate::frost_glass::FrostGlassElement, + /// A window's custom `.frag` effect over the same captured backdrop. + CustomShader = crate::shader_effect::CustomShaderElement, /// Fixed-cost analytical shadow inserted directly behind each window. Shadow = crate::shadow::ShadowElement, /// Client surface tree clipped to compositor-owned rounded geometry. @@ -1364,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 { @@ -1899,6 +1893,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 @@ -2154,6 +2170,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/bin/tidectl.rs b/src/bin/tidectl.rs index a2953c8..2ed3095 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,11 +80,19 @@ 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. 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()), _ => {} } @@ -230,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 @@ -291,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() { @@ -309,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")), } @@ -431,6 +458,20 @@ fn print_perf_summary(snap: &Value, cpu_pct: Option, wall: Duration) { texture_mib("workspace_transition_bytes"), ); } + if let Some(shaders) = snap + .get("custom_shaders") + .filter(|shaders| shaders.get("enabled").and_then(Value::as_bool) == Some(true)) + { + let count = |key: &str| shaders.get(key).and_then(Value::as_u64).unwrap_or(0); + println!( + " custom shaders: {} windows, {} programs, {:.1} MiB captures, {} bypassed, {} compile failures", + count("instances"), + count("programs"), + (count("capture_bytes") + count("stage_bytes")) as f64 / (1024.0 * 1024.0), + count("bypassed"), + count("compile_failures"), + ); + } if let Some(outputs) = outputs { for o in outputs { let hz = o.get("refresh_hz").and_then(Value::as_f64).unwrap_or(0.0); @@ -626,12 +667,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 }) } @@ -911,6 +972,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. @@ -950,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")); 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; + Surface = WaylandSurfaceRenderElement, + /// A glass layer from `glass_layer_elements`, moved from its output-local + /// rect onto the toplevel capture canvas. + Glass = RelocateRenderElement, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CaptureQueueLimit { Client, @@ -493,27 +505,65 @@ impl Smallvil { // A toplevel source is rendered on its own transparent/black canvas // at the scale of its owning output. It includes subsurfaces and // popups belonging to that toplevel, but no neighboring windows, - // compositor chrome, wallpaper, or pointer. + // compositor chrome, wallpaper, or pointer. The one exception is the + // window's own glass layer, which shows its captured backdrop just + // as the live frame does. if let Some(window_target) = window_target { - let blocked = window_target.toplevel().is_some_and(|toplevel| { - self.resolve_window_rules_for(toplevel.wl_surface()) - .block_capture - }); - let opacity = window_target + let surface = window_target .toplevel() - .map(|toplevel| self.window_render_alpha(toplevel.wl_surface())) + .map(|toplevel| toplevel.wl_surface().clone()); + let blocked = surface + .as_ref() + .is_some_and(|surface| self.resolve_window_rules_for(surface).block_capture); + let opacity = surface + .as_ref() + .map(|surface| self.window_render_alpha(surface)) .unwrap_or(1.0); - let window_elements: Vec> = if blocked { - Vec::new() - } else { - AsRenderElements::render_elements( - &window_target, - renderer, - window_origin, - Scale::from(scale), - opacity, - ) - }; + let mut window_elements: Vec = Vec::new(); + if !blocked { + window_elements.extend( + AsRenderElements::::render_elements::< + WaylandSurfaceRenderElement, + >( + &window_target, + renderer, + window_origin, + Scale::from(scale), + opacity, + ) + .into_iter() + .map(WindowCaptureElements::Surface), + ); + // The glass layer is built by the live desktop path at the + // window's output-local rect, then moved onto this canvas at + // the window geometry's own origin, behind its surfaces. + let placements = self.render_placements(&output).unwrap_or_default(); + if let Some(surface) = surface.as_ref().filter(|surface| { + !self.backdrop_may_hold_blocked_content(&output, &placements, surface) + }) { + let glass_surfaces: Vec = self + .glass_eligible_surfaces(&placements) + .into_iter() + .filter(|eligible| eligible == surface) + .collect(); + let glass_origin: Point = (window_target.geometry().loc + - window_target.bbox_with_popups().loc) + .to_physical_precise_round(scale); + window_elements.extend( + self.glass_layer_elements(renderer, &output, &placements, &glass_surfaces) + .remove(surface) + .unwrap_or_default() + .into_iter() + .map(|layer| { + WindowCaptureElements::Glass(RelocateRenderElement::from_element( + layer, + glass_origin, + Relocate::Absolute, + )) + }), + ); + } + } let mut damage_tracker = OutputDamageTracker::new((size.w, size.h), 1.0, Transform::Normal); if let Err(err) = damage_tracker.render_output( @@ -684,6 +734,9 @@ impl Smallvil { #[allow(clippy::mutable_key_type)] let mut glass_layers = self.glass_layer_elements(renderer, &output, &placements, &glass_surfaces); + // Layer frost and layer shaders, as both visible backends add them. + glass_layers.extend(self.layer_glass_elements(renderer, &output)); + self.drop_glass_over_blocked_layers(&output, &mut glass_layers); let (depth_elements, depth_surfaces) = self.depth_frame_elements(renderer, &output, &placements); // Glass windows render in their normal z-slot; only @@ -847,6 +900,84 @@ impl Smallvil { self.finish_capture_readback(renderer, target, size, rect, excluded_rects, completion); } + /// Output captures black out a `block_capture` layer's rect after + /// rendering, but frost or a displacing shader over it would have spread + /// its pixels beyond that rect. Glass overlapping one is left out. + #[allow(clippy::mutable_key_type)] + fn drop_glass_over_blocked_layers( + &self, + output: &Output, + glass_layers: &mut std::collections::HashMap>, + ) { + if !self.config.has_layer_capture_exclusions() { + return; + } + let scale = output.current_scale().fractional_scale(); + let blocked: Vec> = { + let layer_map = layer_map_for_output(output); + let rects = layer_map + .layers() + .filter(|layer| self.config.layer_blocks_capture(layer.namespace())) + .filter_map(|layer| layer_map.layer_geometry(layer)) + .map(|geometry| geometry.to_physical_precise_round(scale)) + .collect(); + rects + }; + if blocked.is_empty() { + return; + } + glass_layers.retain(|_, elements| { + !elements.iter().any(|element| { + let geometry = element.geometry(Scale::from(scale)); + blocked.iter().any(|rect| rect.overlaps(geometry)) + }) + }); + } + + /// Whether `surface`'s captured backdrop could contain `block_capture` + /// content: a blocked window or layer overlapping its placement. That + /// backdrop is shared with the live frame, so a per-window capture leaves + /// the glass out rather than letting blocked pixels through it. + fn backdrop_may_hold_blocked_content( + &self, + output: &Output, + placements: &[crate::placement::PlacedWindow], + surface: &WlSurface, + ) -> bool { + let Some(target) = placements + .iter() + .find(|placement| placement.surface() == Some(surface)) + .map(|placement| placement.rect) + else { + return false; + }; + let blocked_window = placements.iter().any(|placement| { + placement.surface().is_some_and(|other| { + other != surface + && placement.rect.overlaps(target) + && self.resolve_window_rules_for(other).block_capture + }) + }); + if blocked_window { + return true; + } + if !self.config.has_layer_capture_exclusions() { + return false; + } + let output_loc = self + .space + .output_geometry(output) + .map(|geo| geo.loc) + .unwrap_or_default(); + let layer_map = layer_map_for_output(output); + let blocked_layer = layer_map + .layers() + .filter(|layer| self.config.layer_blocks_capture(layer.namespace())) + .filter_map(|layer| layer_map.layer_geometry(layer)) + .any(|geo| Rectangle::new(geo.loc + output_loc, geo.size).overlaps(target)); + blocked_layer + } + fn finish_capture_readback( &mut self, renderer: &mut GlesRenderer, diff --git a/src/handlers/layer_shell.rs b/src/handlers/layer_shell.rs index 6ad7e01..fa94070 100644 --- a/src/handlers/layer_shell.rs +++ b/src/handlers/layer_shell.rs @@ -184,6 +184,9 @@ impl WlrLayerShellHandler for Smallvil { // nothing else ever removes this map's entries for a layer surface. self.backdrop_textures.remove(surface.wl_surface()); self.layer_alpha_masks.remove(surface.wl_surface()); + self.layer_shader_assignments.remove(surface.wl_surface()); + self.custom_shader_instances.remove(surface.wl_surface()); + self.custom_shader_bypassed.remove(surface.wl_surface()); // Unmap from whichever output actually has it, then let retile() // (which re-tiles every output, re-reading each one's fresh 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/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/handlers/xdg_shell.rs b/src/handlers/xdg_shell.rs index 3ca6357..042b3c2 100644 --- a/src/handlers/xdg_shell.rs +++ b/src/handlers/xdg_shell.rs @@ -1070,6 +1070,12 @@ impl Smallvil { } else { self.window_glass_modes.remove(surface); } + if let Some(assignment) = &rule.shader { + self.window_shader_assignments + .insert(surface.clone(), assignment.clone()); + } else { + self.window_shader_assignments.remove(surface); + } let output = self .fullscreen @@ -1504,6 +1510,9 @@ impl Smallvil { self.output_pins.remove(surface); self.window_opacity.remove(surface); self.window_glass_modes.remove(surface); + self.window_shader_assignments.remove(surface); + self.custom_shader_instances.remove(surface); + self.custom_shader_bypassed.remove(surface); self.window_open_animations.remove(surface); self.cascade_window_animations.remove(surface); self.window_move_animations.remove(surface); @@ -1587,6 +1596,10 @@ impl Smallvil { if let Some(mode) = rule.glass { self.window_glass_modes.insert(entry.surface.clone(), mode); } + if let Some(assignment) = &rule.shader { + self.window_shader_assignments + .insert(entry.surface.clone(), assignment.clone()); + } self.window_depths .insert(entry.surface.clone(), crate::depth::WindowDepth::new()); self.announce_foreign_toplevel(&entry.surface); diff --git a/src/main.rs b/src/main.rs index 4a373b1..38333a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,8 +22,8 @@ pub(crate) use visual::source_picker; pub(crate) use visual::{ animation, backdrop, buoyancy, cascade_transition, caustics, compass, currents, decoration, depth, depth_deck, depth_transition, error_overlay, float_physics, frost_glass, minimap, - ocean_canvas, overview, ripple, shadow, sway, swim, tab_strip, text, toast, ui_theme, - viscosity, wallpaper, water_glass, welcome, window_animation, workspace_transition, + ocean_canvas, overview, ripple, shader_effect, shadow, sway, swim, tab_strip, text, toast, + ui_theme, viscosity, wallpaper, water_glass, welcome, window_animation, workspace_transition, }; use std::{ @@ -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()); + } +} diff --git a/src/tide_core/config.rs b/src/tide_core/config.rs index 12ce6a8..3dc5429 100644 --- a/src/tide_core/config.rs +++ b/src/tide_core/config.rs @@ -426,6 +426,75 @@ impl Default for WaterGlassConfig { } } +/// A window or workspace rule's `shader` value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShaderAssignment { + None, + Named(String), +} + +impl ShaderAssignment { + fn parse(value: &str) -> Option { + match value.trim().to_lowercase() { + name if name == "none" => Some(Self::None), + name if valid_ripple_preset_name(&name) => Some(Self::Named(name)), + _ => None, + } + } + + /// The effect name a window uses: its rule's assignment, else its + /// workspace's default. `none` at the window level also opts out of the + /// workspace default. + pub fn resolve<'a>(rule: Option<&'a Self>, workspace: Option<&'a Self>) -> Option<&'a str> { + match rule.or(workspace)? { + Self::Named(name) => Some(name), + Self::None => None, + } + } +} + +/// One ordered stage of a custom effect (`shader "name" { stage { } }`). +#[derive(Debug, Clone, PartialEq)] +pub struct ShaderStage { + /// The `file` value exactly as written. + pub file: String, + /// `file` resolved against the main config directory at load time. + pub path: PathBuf, + pub params: Arc<[(String, crate::shader_effect::ShaderParam)]>, + /// Positional input (`tex`). `None` reads the previous stage's output, + /// or the backdrop for the first stage. + pub input: Option, + /// Name this stage's output can be read back by with `"get:name"`. + pub save: Option, + /// Extra `sampler2D` bindings, declared by the host. + pub textures: Arc<[(String, crate::shader_effect::StageTexture)]>, + /// Host-wrapped GLSL once `path` passed the fragment contract. `None` + /// before loading and after a read or contract failure. + pub source: Option>, +} + +/// A named custom effect (`shader "name" { }`), selected per window with +/// `rule { shader = name }` or a workspace default: up to four ordered +/// stages over a window's captured backdrop, damage-driven. Every stage but +/// the last renders offscreen; the last draws in the window's glass slot. +#[derive(Debug, Clone, PartialEq)] +pub struct ShaderDefinition { + /// Processing divisor for the effect's own capture, `1..=4`. + pub render_scale: i32, + /// `source = "shader:"`: another definition whose output stands in + /// for the backdrop. Its stages are spliced in front of this one's at + /// load time, so `stages` is then the whole chain. + pub source: Option, + pub stages: Vec, +} + +impl ShaderDefinition { + /// Whether every stage's file loaded and passed the contract. + pub fn fully_loaded(&self) -> bool { + self.stages.iter().all(|stage| stage.source.is_some()) + } +} + /// Continuous lateral "swim" between workspaces (spatial roadmap S0). /// Instead of the discrete one-shot switch (and its wave transition), a /// horizontal trackpad swipe pans the viewport continuously: neighboring @@ -1085,6 +1154,11 @@ pub struct Config { /// Water-glass refraction motion: static, disturbance-reactive /// (default), or constant ambient drift. pub water_glass: WaterGlassConfig, + /// Master switch for custom `.frag` effects (`shaders { enabled }`), + /// off by default and independent of `water_effects`. + pub shaders_enabled: bool, + /// Named custom effects that parsed and passed the definition caps. + pub shader_definitions: HashMap, /// Ambient caustic light over the wallpaper, below windows. pub caustics: CausticsConfig, /// Analytical window shadows. Independent of `water_effects`: shadows @@ -1331,6 +1405,7 @@ impl Config { warnings.extend(include_warnings); config.loaded_entries = entries; config.source_paths = source_paths; + warnings.extend(config.load_shader_sources(&path)); (config, error, warnings) } @@ -1350,12 +1425,13 @@ impl Config { lua: &mlua::Lua, tide: &wave::TideInfo, ) -> Result<(Self, Vec), String> { - let (raw, include_warnings, entries, source_paths) = - load_raw_config_in(lua, tide, &config_path())?; + let path = config_path(); + let (raw, include_warnings, entries, source_paths) = load_raw_config_in(lua, tide, &path)?; let (mut config, mut warnings) = Self::from_raw(raw); warnings.extend(include_warnings); config.loaded_entries = entries; config.source_paths = source_paths; + warnings.extend(config.load_shader_sources(&path)); Ok((config, warnings)) } @@ -1379,9 +1455,67 @@ impl Config { warnings.extend(include_warnings); config.loaded_entries = entries; config.source_paths = source_paths; + warnings.extend(config.load_shader_sources(path)); Ok((lua, config, warnings)) } + /// Reads, checks and wraps every definition's `.frag` files. Relative + /// paths resolve from the main config's directory (lowered entries carry + /// no per-include provenance). Each path joins `source_paths`, so the + /// watcher reloads on a shader edit, deletion or late creation exactly + /// like an include. A failed stage keeps its definition with no source, + /// which leaves any last-good program to the renderer's cache. + fn load_shader_sources(&mut self, config_path: &Path) -> Vec { + let base_dir = config_path.parent().unwrap_or_else(|| Path::new(".")); + let mut warnings = Vec::new(); + let mut loaded_bytes = 0usize; + let mut names: Vec<&String> = self.shader_definitions.keys().collect(); + names.sort_unstable(); + let names: Vec = names.into_iter().cloned().collect(); + for name in names { + let Some(definition) = self.shader_definitions.get_mut(&name) else { + continue; + }; + for (index, stage) in definition.stages.iter_mut().enumerate() { + stage.path = waves::resolve_include_path(base_dir, stage.file.trim()); + if !self.source_paths.contains(&stage.path) { + self.source_paths.push(stage.path.clone()); + } + let loaded = + crate::shader_effect::read_fragment_file(&stage.path).and_then(|source| { + crate::shader_effect::validate_fragment_contract(&source)?; + let wrapped = crate::shader_effect::wrap_texture_fragment( + &source, + &stage.params, + &stage.textures, + ); + if loaded_bytes + wrapped.len() > crate::shader_effect::MAX_CANDIDATE_BYTES + { + return Err(format!( + "all shader sources together exceed {} bytes", + crate::shader_effect::MAX_CANDIDATE_BYTES + )); + } + loaded_bytes += wrapped.len(); + Ok(wrapped) + }); + match loaded { + Ok(wrapped) => stage.source = Some(Arc::from(wrapped)), + Err(reason) => { + stage.source = None; + warnings.push(format!( + "Shader {name:?} stage {} ({}): {reason}", + index + 1, + stage.path.display() + )); + } + } + } + } + warnings.extend(expand_shader_sources(&mut self.shader_definitions)); + warnings + } + /// The path being watched/loaded, so callers can set up a file watcher on it. pub fn path() -> PathBuf { config_path() @@ -1390,7 +1524,7 @@ impl Config { /// Returns the parsed config plus any diagnostics worth showing on the /// compositor-owned panel (dropped keybind entries, footgun lints) -- /// see `parse_keybind`. Empty when nothing needs a second look. - fn from_raw(raw: RawConfig) -> (Self, Vec) { + fn from_raw(mut raw: RawConfig) -> (Self, Vec) { let mut warnings = Vec::new(); let ocean_selected = raw.spatial_engine.trim().eq_ignore_ascii_case("ocean"); let classic_depth_enabled = raw.classic_depth.enabled; @@ -1480,6 +1614,7 @@ impl Config { .filter_map(|(combo, action)| parse_keybind(combo, action, false, &mut Vec::new())) .filter(|bind| !matches!(bind.action, Action::EnterSubmap(_))) .collect(); + warnings.append(&mut raw.shader_warnings); let env = raw .env .into_iter() @@ -1521,6 +1656,8 @@ impl Config { classic_depth: raw.classic_depth, frost: raw.frost, water_glass: raw.water_glass, + shaders_enabled: raw.shaders_enabled, + shader_definitions: raw.shader_definitions, caustics: raw.caustics, shadow: raw.shadow, rounding: raw.rounding, @@ -1704,6 +1841,9 @@ impl Config { if rule.glass.is_some() { effective.glass = rule.glass; } + if rule.shader.is_some() { + effective.shader = rule.shader.clone(); + } if rule.viscosity.is_some() { effective.viscosity = rule.viscosity; } @@ -1840,6 +1980,18 @@ impl Config { .any(|rule| rule.blur && rule.matches(namespace)) } + /// The custom effect a layer's last matching `layer_rule { shader }` + /// names, `None` for `shader = none` or no matching assignment. + pub(crate) fn layer_shader(&self, namespace: &str) -> Option<&str> { + let assignment = self + .layer_rules + .iter() + .rev() + .filter(|rule| rule.matches(namespace)) + .find_map(|rule| rule.shader.as_ref()); + ShaderAssignment::resolve(assignment, None) + } + /// Last matching alpha threshold for a blurred layer. An unset value /// leaves the full negotiated layer geometry blurred. pub(crate) fn layer_ignore_alpha(&self, namespace: &str) -> Option { @@ -1864,6 +2016,37 @@ impl Config { map } + /// `resolve_workspace_rule(workspace).shader` without the fold's clones, + /// for the per-frame shader resolution. + pub(crate) fn workspace_shader(&self, workspace: u32) -> Option<&ShaderAssignment> { + self.workspace_rules + .iter() + .rev() + .filter(|rule| rule.workspace == Some(workspace)) + .find_map(|rule| rule.shader.as_ref()) + } + + /// The definition a window draws with: its rule's assignment, else its + /// workspace's default, live against the master switch. Takes the + /// window's own assignment rather than the window so render code can + /// call it while holding other state mutably. + pub(crate) fn shader_for<'a>( + &'a self, + assignment: Option<&'a ShaderAssignment>, + workspace: Option, + ) -> Option<(&'a str, &'a ShaderDefinition)> { + if !self.shaders_enabled { + return None; + } + let name = ShaderAssignment::resolve( + assignment, + workspace.and_then(|workspace| self.workspace_shader(workspace)), + )?; + self.shader_definitions + .get_key_value(name) + .map(|(name, definition)| (name.as_str(), definition)) + } + /// Folds every `[[workspace_rule]]` naming `workspace` into one /// effective rule, last-match-wins per field -- same fold shape as /// `resolve_window_rules`, just against a single numeric match instead @@ -1889,6 +2072,9 @@ impl Config { if rule.snap.is_some() { effective.snap = rule.snap; } + if rule.shader.is_some() { + effective.shader = rule.shader.clone(); + } if rule.on_created_empty.is_some() { effective.on_created_empty = rule.on_created_empty.clone(); } @@ -1934,6 +2120,11 @@ struct RawConfig { classic_depth: ClassicDepthConfig, frost: FrostConfig, water_glass: WaterGlassConfig, + shaders_enabled: bool, + shader_definitions: HashMap, + /// Definition problems for the error panel. A rejected definition is + /// left out entirely instead of rendering something other than asked. + shader_warnings: Vec, caustics: CausticsConfig, shadow: ShadowConfig, rounding: RoundingConfig, @@ -2103,6 +2294,9 @@ impl Default for RawConfig { classic_depth: ClassicDepthConfig::default(), frost: FrostConfig::default(), water_glass: WaterGlassConfig::default(), + shaders_enabled: false, + shader_definitions: HashMap::new(), + shader_warnings: Vec::new(), caustics: CausticsConfig::default(), shadow: ShadowConfig::default(), rounding: RoundingConfig::default(), @@ -2368,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. @@ -2657,6 +2867,11 @@ pub struct WindowRule { /// behavior where compositor `opacity` below one implies water; /// `Plain` disables backdrop substitution while preserving `opacity`. pub glass: Option, + /// Custom effect by definition name, or `shader = none` to clear an + /// earlier match. Last matching rule wins. The name resolves live + /// against `shaders { enabled }` and `shader_definitions`, so a typo + /// or a removed definition leaves the window on its normal path. + pub shader: Option, /// Per-window interactive move/resize damping. Last matching rule wins; /// `0.0` disables damping for the matched app. pub viscosity: Option, @@ -4051,7 +4266,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, @@ -4533,6 +4749,9 @@ pub struct LayerRule { pub above_lock_screen: bool, pub blur: bool, pub ignore_alpha: Option, + /// Custom effect for the matched layer, or `none` to clear an earlier + /// match. Layers never inherit a window or workspace shader. + pub shader: Option, } impl LayerRule { @@ -4577,6 +4796,9 @@ pub struct WorkspaceRule { pub shadow: Option, /// Per-workspace snap opt-in or opt-out. Window rules take precedence. pub snap: Option, + /// Default custom effect for windows on this workspace. A window rule's + /// own `shader` (including `none`) takes precedence. + pub shader: Option, /// Command run the first time this workspace becomes active while /// having zero windows, once per (output, workspace) pair for the /// process lifetime (`Smallvil::workspace_created_empty_fired`) -- @@ -5133,7 +5355,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!( @@ -5177,6 +5400,8 @@ fn apply_top_level_block(raw: &mut RawConfig, keyword: &str, header: &str, body: "xwayland" => apply_xwayland_block(&mut raw.xwayland, body), "transition" => apply_workspace_transition_block(&mut raw.workspace_transition, body), "snap" => apply_snap_block(&mut raw.snap, body), + "shaders" => apply_shaders_block(raw, body), + "shader" => lower_shader_block(raw, header, body), "vessels" => apply_connected_vessels_block(&mut raw.connected_vessels, body), "sway" => apply_sway_block(&mut raw.sway, body), "physics" => apply_float_physics_block(&mut raw.float_physics, body), @@ -5322,6 +5547,358 @@ fn apply_snap_block(cfg: &mut SnapConfig, body: &[waves::Entry]) { } } +fn apply_shaders_block(raw: &mut RawConfig, body: &[waves::Entry]) { + for entry in body { + match entry { + waves::Entry::Assign(key, value) if key == "enabled" => { + set_bool(&mut raw.shaders_enabled, key, value) + } + _ => raw.shader_warnings.push( + "Only `enabled` is valid in a `shaders` block; ignoring an entry".to_string(), + ), + } + } +} + +/// Lowers one named custom effect. A later definition with the same name +/// replaces the earlier one whole, so `stage` order is always the written +/// order of a single block. Anything this slice can't render as written +/// rejects the definition with a panel warning. +fn lower_shader_block(raw: &mut RawConfig, header: &str, body: &[waves::Entry]) { + let name = header.trim().to_lowercase(); + match parse_shader_definition(&name, body) { + Ok(definition) => { + if !raw.shader_definitions.contains_key(&name) + && raw.shader_definitions.len() >= crate::shader_effect::MAX_DEFINITIONS + { + raw.shader_warnings.push(format!( + "Shader {name:?} skipped: at most {} definitions are allowed", + crate::shader_effect::MAX_DEFINITIONS + )); + return; + } + raw.shader_definitions.insert(name, definition); + } + Err(reason) => { + raw.shader_definitions.remove(&name); + raw.shader_warnings + .push(format!("Shader {name:?} skipped: {reason}")); + } + } +} + +fn parse_shader_definition(name: &str, body: &[waves::Entry]) -> Result { + if !valid_ripple_preset_name(name) || name == "none" { + return Err( + "needs an alphanumeric, dash, or underscore name other than `none`".to_string(), + ); + } + let mut definition = ShaderDefinition { + render_scale: 2, + source: None, + stages: Vec::new(), + }; + for entry in body { + match entry { + waves::Entry::Assign(key, value) => match (key.as_str(), value.trim()) { + ("scope", "window") | ("source", "backdrop") | ("invalidate", "damage-box") => {} + ("source", value) if value.starts_with("shader:") => { + let source = value["shader:".len()..].trim().to_lowercase(); + if !valid_ripple_preset_name(&source) { + return Err(format!("source = {value} does not name a definition")); + } + definition.source = Some(source); + } + ("scope" | "source" | "invalidate", other) => { + return Err(format!("{key} = {other} is not supported yet")); + } + ("render_scale", value) => match value.parse::() { + Ok(scale @ 1..=4) => definition.render_scale = scale, + _ => { + return Err(format!( + "render_scale must be an integer from 1 to 4, got {value}" + )) + } + }, + (other, _) => return Err(format!("unknown key `{other}`")), + }, + waves::Entry::Block(keyword, _, stage_body) if keyword == "stage" => { + if definition.stages.len() == crate::shader_effect::MAX_STAGES { + return Err(format!( + "at most {} stages are allowed", + crate::shader_effect::MAX_STAGES + )); + } + definition.stages.push(parse_shader_stage(stage_body)?); + } + _ => return Err("only settings and `stage` blocks are allowed".to_string()), + } + } + let Some(last) = definition.stages.len().checked_sub(1) else { + return Err("needs a `stage { file = \"...\" }` block".to_string()); + }; + // The written order is the dependency order: a stage may only read + // names saved by stages above it, so there is never a cycle to detect. + let mut saved: Vec<&str> = Vec::new(); + for (index, stage) in definition.stages.iter().enumerate() { + let reads = stage + .input + .iter() + .chain(stage.textures.iter().map(|(_, texture)| texture)); + for texture in reads { + if let crate::shader_effect::StageTexture::Saved(name) = texture { + if !saved.contains(&name.as_str()) { + return Err(format!( + "stage {} reads \"get:{name}\", which no earlier stage saves", + index + 1 + )); + } + } + } + if let Some(name) = &stage.save { + if index == last { + return Err("the last stage draws on screen and cannot `save`".to_string()); + } + if saved.contains(&name.as_str()) { + return Err(format!("\"{name}\" is saved by more than one stage")); + } + saved.push(name); + } + } + Ok(definition) +} + +/// Splices every `source = "shader:"` definition's source chain in +/// front of its own stages, after files have loaded so each file is read +/// once. The source's last output is saved under a host-reserved name (save +/// names starting with `tide_` can't be written by hand), the definition's +/// first stage reads it positionally, and its explicit `backdrop` reads +/// become reads of that output. Unknown names, cycles, clashing save names +/// and chains over the stage cap reject the definition, and everything that +/// sources it, with a warning. +pub(crate) fn expand_shader_sources( + definitions: &mut HashMap, +) -> Vec { + use crate::shader_effect::StageTexture; + type Expanded = Result, String>; + fn expand( + name: &str, + definitions: &HashMap, + visiting: &mut Vec, + done: &mut HashMap, + ) -> Expanded { + if let Some(result) = done.get(name) { + return result.clone(); + } + if visiting.iter().any(|visited| visited == name) { + return Err(format!("its source chain loops back to {name:?}")); + } + let definition = definitions + .get(name) + .ok_or_else(|| format!("its source {name:?} is not a loaded definition"))?; + let Some(source) = &definition.source else { + return Ok(definition.stages.clone()); + }; + visiting.push(name.to_string()); + let base = expand(source, definitions, visiting, done); + visiting.pop(); + let mut stages = base?; + let saved = format!("tide_source_{}", stages.len()); + if let Some(last) = stages.last_mut() { + last.save = Some(saved.clone()); + } + let redirect = |texture: &StageTexture| match texture { + StageTexture::Backdrop => StageTexture::Saved(saved.clone()), + other => other.clone(), + }; + for stage in &definition.stages { + if let Some(name) = stage.save.as_ref().filter(|name| { + stages + .iter() + .any(|earlier| earlier.save.as_ref() == Some(*name)) + }) { + return Err(format!("it and its source both save \"{name}\"")); + } + let mut stage = stage.clone(); + stage.input = stage.input.as_ref().map(redirect); + stage.textures = stage + .textures + .iter() + .map(|(binding, texture)| (binding.clone(), redirect(texture))) + .collect::>() + .into(); + stages.push(stage); + } + if stages.len() > crate::shader_effect::MAX_STAGES { + return Err(format!( + "with its source it has {} stages, more than {}", + stages.len(), + crate::shader_effect::MAX_STAGES + )); + } + Ok(stages) + } + + let mut names: Vec = definitions + .iter() + .filter(|(_, definition)| definition.source.is_some()) + .map(|(name, _)| name.clone()) + .collect(); + names.sort_unstable(); + let mut done = HashMap::new(); + let mut results = Vec::new(); + for name in names { + let result = expand(&name, definitions, &mut Vec::new(), &mut done); + done.insert(name.clone(), result.clone()); + results.push((name, result)); + } + let mut warnings = Vec::new(); + for (name, result) in results { + match result { + Ok(stages) => { + if let Some(definition) = definitions.get_mut(&name) { + definition.stages = stages; + } + } + Err(reason) => { + definitions.remove(&name); + warnings.push(format!("Shader {name:?} skipped: {reason}")); + } + } + } + warnings +} + +fn parse_shader_stage(body: &[waves::Entry]) -> Result { + use crate::shader_effect::StageTexture; + let mut file = None; + let mut params = Vec::new(); + let mut input = None; + let mut save = None; + let mut textures: Vec<(String, StageTexture)> = Vec::new(); + for entry in body { + match entry { + waves::Entry::Assign(key, value) if key == "file" => file = Some(value.clone()), + waves::Entry::Assign(key, value) if key == "source" => { + input = Some(StageTexture::parse(value).ok_or_else(|| { + format!("stage source must be backdrop or \"get:name\", got {value}") + })?); + } + waves::Entry::Assign(key, value) if key == "save" => { + let name = value.trim(); + if !crate::shader_effect::valid_param_name(name) { + return Err(format!("`{name}` cannot be a saved texture name")); + } + save = Some(name.to_string()); + } + waves::Entry::Block(keyword, _, texture_body) if keyword == "textures" => { + for binding in texture_body { + let waves::Entry::Assign(name, value) = binding else { + return Err( + "`textures` may only contain `name = source` entries".to_string() + ); + }; + if !crate::shader_effect::valid_param_name(name) { + return Err(format!("`{name}` cannot be a texture binding name")); + } + let texture = StageTexture::parse(value).ok_or_else(|| { + format!("texture `{name}` must be backdrop or \"get:name\", got {value}") + })?; + if let Some(existing) = + textures.iter_mut().find(|(existing, _)| existing == name) + { + existing.1 = texture; + } else { + textures.push((name.clone(), texture)); + } + } + if textures.len() > crate::shader_effect::MAX_TEXTURES_PER_STAGE { + return Err(format!( + "at most {} extra textures per stage are allowed", + crate::shader_effect::MAX_TEXTURES_PER_STAGE + )); + } + } + waves::Entry::Block(keyword, _, param_body) if keyword == "params" => { + for param in param_body { + let waves::Entry::Assign(name, value) = param else { + return Err("`params` may only contain `name = value` entries".to_string()); + }; + if !crate::shader_effect::valid_param_name(name) { + return Err(format!("`{name}` cannot be a parameter name")); + } + let value = parse_shader_param(value).ok_or_else(|| { + format!("parameter `{name}` needs a finite number, a list of 2 to 4 numbers, or a color") + })?; + if let Some(existing) = params.iter_mut().find(|(existing, _)| existing == name) + { + existing.1 = value; + } else { + params.push((name.clone(), value)); + } + } + if params.len() > crate::shader_effect::MAX_PARAMS_PER_STAGE { + return Err(format!( + "at most {} parameters per stage are allowed", + crate::shader_effect::MAX_PARAMS_PER_STAGE + )); + } + } + waves::Entry::Assign(key, _) | waves::Entry::Block(key, _, _) => { + return Err(format!("`{key}` is not supported in a stage yet")); + } + _ => return Err("unexpected entry in a stage".to_string()), + } + } + let file = file + .filter(|file| !file.trim().is_empty()) + .ok_or_else(|| "a stage needs `file = \"path.frag\"`".to_string())?; + if let Some((name, _)) = textures + .iter() + .find(|(name, _)| params.iter().any(|(param, _)| param == name)) + { + return Err(format!("`{name}` is both a parameter and a texture")); + } + Ok(ShaderStage { + file, + path: PathBuf::new(), + params: params.into(), + input, + save, + textures: textures.into(), + source: None, + }) +} + +/// A color, a number, or a list of 2 to 4 numbers. Wave serializes colors +/// without the `#`, so six or eight hex digits with a letter among them read +/// as a color (even `1E0000`, which would also parse as a float), and a +/// color whose digits are all decimal has to be quoted with its `#`. +fn parse_shader_param(value: &str) -> Option { + use crate::shader_effect::ShaderParam; + let bare = value.trim(); + if matches!(bare.len(), 6 | 8) + && bare.chars().all(|c| c.is_ascii_hexdigit()) + && bare.chars().any(|c| c.is_ascii_alphabetic()) + { + return parse_rgba_color(bare).map(ShaderParam::Vec4); + } + let finite = |item: &str| item.trim().parse::().ok().filter(|v| v.is_finite()); + if let Some(number) = finite(value) { + return Some(ShaderParam::Float(number)); + } + if let Some(items) = parse_list_value(value) { + let items: Option> = items.iter().map(|item| finite(item)).collect(); + return match items?.as_slice() { + [x, y] => Some(ShaderParam::Vec2([*x, *y])), + [x, y, z] => Some(ShaderParam::Vec3([*x, *y, *z])), + [x, y, z, w] => Some(ShaderParam::Vec4([*x, *y, *z, *w])), + _ => None, + }; + } + parse_rgba_color(value).map(ShaderParam::Vec4) +} + fn apply_gpu_block(gpu: &mut GpuConfigRaw, body: &[waves::Entry]) { for entry in body { let waves::Entry::Assign(key, value) = entry else { @@ -5660,10 +6237,13 @@ fn apply_workspace_animation_block(cfg: &mut WorkspaceAnimationConfig, body: &[w }, "curve" | "ease" => match parse_window_animation_curve(value) { Some(value) => cfg.curve = value, - None => tracing::warn!( - value, - "Expected workspace easing, cubic-bezier(x1,y1,x2,y2), or spring(mass,stiffness,damping), ignoring" - ), + None => match spring_curve_rejection(value) { + Some(reason) => tracing::warn!(value, reason, "Invalid workspace spring, ignoring"), + None => tracing::warn!( + value, + "Expected workspace easing, cubic-bezier(x1,y1,x2,y2), or spring(mass,stiffness,damping), ignoring" + ), + }, }, "travel" | "distance" => match value.parse::() { Ok(value) if value.is_finite() && (0.0..=1.0).contains(&value) => { @@ -5731,10 +6311,13 @@ fn apply_window_animation_block(cfg: &mut WindowAnimationConfig, body: &[waves:: }, "curve" | "ease" => match parse_window_animation_curve(value) { Some(value) => cfg.curve = value, - None => tracing::warn!( - value, - "Expected built-in easing, cubic-bezier(x1,y1,x2,y2), or spring(mass,stiffness,damping), ignoring" - ), + None => match spring_curve_rejection(value) { + Some(reason) => tracing::warn!(value, reason, "Invalid animation spring, ignoring"), + None => tracing::warn!( + value, + "Expected built-in easing, cubic-bezier(x1,y1,x2,y2), or spring(mass,stiffness,damping), ignoring" + ), + }, }, "opacity_duration" => match parse_duration_ms(value) { Some(value) if (1..=10_000).contains(&value) => { @@ -5748,10 +6331,15 @@ fn apply_window_animation_block(cfg: &mut WindowAnimationConfig, body: &[waves:: "opacity_curve" | "fade_curve" | "opacity_ease" => { match parse_window_animation_curve(value) { Some(value) => cfg.opacity_curve = Some(value), - None => tracing::warn!( - value, - "Expected opacity easing, cubic-bezier(x1,y1,x2,y2), or spring(mass,stiffness,damping), ignoring" - ), + None => match spring_curve_rejection(value) { + Some(reason) => { + tracing::warn!(value, reason, "Invalid opacity spring, ignoring") + } + None => tracing::warn!( + value, + "Expected opacity easing, cubic-bezier(x1,y1,x2,y2), or spring(mass,stiffness,damping), ignoring" + ), + }, } } "offset" | "travel" => match parse_position(value) { @@ -5816,6 +6404,26 @@ fn apply_window_animation_block(cfg: &mut WindowAnimationConfig, body: &[waves:: } } +/// Why a `spring(...)` curve was refused, for its warning. `None` for a +/// value that isn't a spring or is a valid one. +fn spring_curve_rejection(value: &str) -> Option<&'static str> { + let normalized = value.trim().to_ascii_lowercase(); + let inner = normalized + .strip_prefix("spring(")? + .strip_suffix(')') + .unwrap_or_default(); + let values: Option> = inner + .split(',') + .map(|value| value.trim().parse::().ok()) + .collect(); + match values.as_deref() { + Some([mass, stiffness, damping]) => { + crate::visual::spring::SpringCurve::try_new(*mass, *stiffness, *damping).err() + } + _ => Some("spring needs three numbers: spring(mass, stiffness, damping)"), + } +} + fn parse_window_animation_curve(value: &str) -> Option { let normalized = value.trim().to_ascii_lowercase().replace('_', "-"); if let Some(inner) = normalized @@ -8006,6 +8614,12 @@ fn lower_window_rule_block(body: &[waves::Entry]) -> WindowRule { "Expected a rule glass mode: water frost none, ignoring" ), }, + "shader" => match ShaderAssignment::parse(value) { + Some(assignment) => rule.shader = Some(assignment), + None => { + tracing::warn!(value, "Expected a shader definition name or none, ignoring") + } + }, "viscosity" => match parse_viscosity(value) { Some(value) => rule.viscosity = Some(value), None => { @@ -8205,6 +8819,12 @@ fn lower_workspace_rule_block(body: &[waves::Entry]) -> WorkspaceRule { }, "on_created_empty" => rule.on_created_empty = Some(value.clone()), "snap" => set_opt_bool(&mut rule.snap, key, value), + "shader" => match ShaderAssignment::parse(value) { + Some(assignment) => rule.shader = Some(assignment), + None => { + tracing::warn!(value, "Expected a shader definition name or none, ignoring") + } + }, "shadow" => match value.as_str() { "true" | "on" => { rule.shadow @@ -8308,6 +8928,12 @@ fn lower_layer_rule_block(body: &[waves::Entry]) -> LayerRule { }, "above_lock_screen" => set_bool(&mut rule.above_lock_screen, key, value), "blur" => set_bool(&mut rule.blur, key, value), + "shader" => match ShaderAssignment::parse(value) { + Some(assignment) => rule.shader = Some(assignment), + None => { + tracing::warn!(value, "Expected a shader definition name or none, ignoring") + } + }, "ignore_alpha" => match value.parse::() { Ok(value) if value.is_finite() => { rule.ignore_alpha = Some(value.clamp(0.0, 1.0)); @@ -8931,6 +9557,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 @@ -10059,6 +10692,8 @@ mod tests { classic_depth: ClassicDepthConfig::default(), frost: FrostConfig::default(), water_glass: WaterGlassConfig::default(), + shaders_enabled: false, + shader_definitions: HashMap::new(), caustics: CausticsConfig::default(), shadow: ShadowConfig::default(), rounding: RoundingConfig::default(), @@ -11652,6 +12287,16 @@ animations { ] { assert!(parse_window_animation_curve(invalid).is_none()); } + // The two refusal reasons from the 2026-09-12 live pass are told apart. + let range = spring_curve_rejection("spring(200, 400, 30)").unwrap(); + let settle = spring_curve_rejection("spring(1, 0.01, 0.01)").unwrap(); + assert!(range.contains("mass 0.01 to 100"), "{range}"); + assert!(settle.contains("does not settle"), "{settle}"); + assert!(spring_curve_rejection("spring(1,2)") + .unwrap() + .contains("three numbers")); + assert_eq!(spring_curve_rejection("spring(1, 400, 30)"), None); + assert_eq!(spring_curve_rejection("cubic-out"), None); } #[test] @@ -11674,6 +12319,557 @@ animations { assert!(!WindowAnimationsConfig::hypr_smooth().interactive.enabled); } + #[test] + fn shader_definitions_parse_and_default_off() { + let entries = wave_entries( + "shaders {\n\ + enabled = true\n\ + }\n\ + shader \"Soft-Glass\" {\n\ + scope = window\n\ + source = backdrop\n\ + invalidate = damage-box\n\ + render_scale = 3\n\ + stage {\n\ + file = \"shaders/soft.frag\"\n\ + params {\n\ + strength = 0.25\n\ + offset = [1, 2]\n\ + tint_color = #8EDDFF\n\ + exponent_like = #1E0000\n\ + quoted = \"#123456\"\n\ + }\n\ + }\n\ + }\n", + ); + let (config, warnings) = Config::from_raw(lower_entries(&entries)); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(config.shaders_enabled); + let definition = &config.shader_definitions["soft-glass"]; + assert_eq!(definition.render_scale, 3); + let [stage] = definition.stages.as_slice() else { + panic!("one stage expected"); + }; + assert_eq!(stage.file, "shaders/soft.frag"); + assert_eq!(stage.source, None); + use crate::shader_effect::ShaderParam; + assert_eq!( + stage.params.to_vec(), + vec![ + ("strength".to_string(), ShaderParam::Float(0.25)), + ("offset".to_string(), ShaderParam::Vec2([1.0, 2.0])), + ( + "tint_color".to_string(), + ShaderParam::Vec4([142.0 / 255.0, 221.0 / 255.0, 1.0, 1.0]) + ), + ( + "exponent_like".to_string(), + ShaderParam::Vec4([30.0 / 255.0, 0.0, 0.0, 1.0]) + ), + ( + "quoted".to_string(), + ShaderParam::Vec4([18.0 / 255.0, 52.0 / 255.0, 86.0 / 255.0, 1.0]) + ), + ] + ); + + let defaults = parse_default_config(); + assert!(!defaults.shaders_enabled); + assert!(defaults.shader_definitions.is_empty()); + } + + #[test] + fn unsupported_or_malformed_shader_definitions_are_rejected_visibly() { + let stage = "stage {\n file = \"a.frag\"\n }\n"; + for (body, reason) in [ + (format!("scope = desktop\n{stage}"), "scope = desktop"), + (format!("source = surface\n{stage}"), "source = surface"), + (format!("source = \"shader:\"\n{stage}"), "does not name a definition"), + ( + format!("invalidate = always\n{stage}"), + "invalidate = always", + ), + (format!("render_scale = 5\n{stage}"), "render_scale"), + (format!("blend = add\n{stage}"), "unknown key"), + (String::new(), "needs a `stage"), + ( + format!("{stage}{stage}{stage}{stage}{stage}"), + "at most 4 stages", + ), + ( + "stage {\n save = x\n file = \"a.frag\"\n }\n".to_string(), + "cannot `save`", + ), + ( + "stage {\n file = \"a.frag\"\n source = \"get:x\"\n }\n".to_string(), + "no earlier stage saves", + ), + ( + format!( + "stage {{\n file = \"a.frag\"\n save = x\n }}\n\ + stage {{\n file = \"a.frag\"\n save = x\n }}\n{stage}" + ), + "more than one stage", + ), + ( + format!("{stage}stage {{\n file = \"a.frag\"\n save = later\n }}\n"), + "cannot `save`", + ), + ( + format!( + "stage {{\n file = \"a.frag\"\n textures {{\n m = \"get:later\"\n }}\n save = later\n }}\n{stage}" + ), + "no earlier stage saves", + ), + ( + "stage {\n file = \"a.frag\"\n source = elsewhere\n }\n".to_string(), + "stage source must be", + ), + ( + "stage {\n file = \"a.frag\"\n textures {\n u_m = backdrop\n }\n }\n" + .to_string(), + "cannot be a texture binding", + ), + ( + "stage {\n file = \"a.frag\"\n textures {\n a = backdrop\n b = backdrop\n c = backdrop\n d = backdrop\n e = backdrop\n }\n }\n" + .to_string(), + "at most 4 extra textures", + ), + ( + "stage {\n file = \"a.frag\"\n params {\n m = 1\n }\n textures {\n m = backdrop\n }\n }\n" + .to_string(), + "both a parameter and a texture", + ), + ( + "stage {\n params {\n x = 1\n }\n }\n".to_string(), + "needs `file", + ), + ( + "stage {\n file = \"a.frag\"\n params {\n u_time = 1\n }\n }\n".to_string(), + "cannot be a parameter", + ), + ( + "stage {\n file = \"a.frag\"\n params {\n x = [1, 2, 3, 4, 5]\n }\n }\n" + .to_string(), + "finite number", + ), + ( + "stage {\n file = \"a.frag\"\n params {\n x = wide\n }\n }\n".to_string(), + "finite number", + ), + ] { + let entries = wave_entries(&format!("shader broken {{\n{body}}}\n")); + let (config, warnings) = Config::from_raw(lower_entries(&entries)); + assert!(config.shader_definitions.is_empty(), "{body}"); + assert!( + warnings.iter().any(|warning| warning.contains(reason)), + "{body}: {warnings:?}" + ); + } + + let params: String = (0..=crate::shader_effect::MAX_PARAMS_PER_STAGE) + .map(|index| format!(" p{index} = 1\n")) + .collect(); + let entries = wave_entries(&format!( + "shader many {{\n stage {{\n file = \"a.frag\"\n params {{\n{params} }}\n }}\n}}\n" + )); + let (config, warnings) = Config::from_raw(lower_entries(&entries)); + assert!(config.shader_definitions.is_empty()); + assert!(warnings + .iter() + .any(|warning| warning.contains("at most 8 parameters"))); + + let entries = wave_entries(&format!("shader none {{\n{stage}}}\n")); + assert!(Config::from_raw(lower_entries(&entries)) + .0 + .shader_definitions + .is_empty()); + } + + #[test] + fn shader_chains_parse_saves_sources_and_texture_bindings() { + use crate::shader_effect::StageTexture; + let entries = wave_entries( + "shader wet {\n\ + stage {\n\ + file = \"blur.frag\"\n\ + save = \"blurred\"\n\ + }\n\ + stage {\n\ + file = \"mask.frag\"\n\ + source = backdrop\n\ + save = mask\n\ + }\n\ + stage {\n\ + file = \"composite.frag\"\n\ + source = \"get:blurred\"\n\ + textures {\n\ + mask = \"get:mask\"\n\ + original = backdrop\n\ + }\n\ + }\n\ + }\n", + ); + let (config, warnings) = Config::from_raw(lower_entries(&entries)); + assert!(warnings.is_empty(), "{warnings:?}"); + let stages = &config.shader_definitions["wet"].stages; + assert_eq!(stages.len(), 3); + assert_eq!(stages[0].save.as_deref(), Some("blurred")); + assert_eq!(stages[0].input, None); + assert_eq!(stages[1].input, Some(StageTexture::Backdrop)); + assert_eq!( + stages[2].input, + Some(StageTexture::Saved("blurred".to_string())) + ); + assert_eq!( + stages[2].textures.to_vec(), + vec![ + ("mask".to_string(), StageTexture::Saved("mask".to_string())), + ("original".to_string(), StageTexture::Backdrop), + ] + ); + } + + /// The two examples in DOCUMENTATION.md's "Custom shaders" section, + /// verbatim, so the documented syntax can't drift from what parses. + #[test] + fn documented_shader_examples_parse_without_warnings() { + let entries = wave_entries( + r#"shaders { + enabled = true +} + +shader soft-blur { + render_scale = 2 + stage { + file = "shaders/blur-h.frag" + params { + radius = 2 + } + } + stage { + file = "shaders/blur-v.frag" + params { + radius = 2 + } + } +} + +rule { + app_id = kitty + shader = soft-blur +} + +shader edged { + stage { + file = "shaders/blur-h.frag" + params { + radius = 3 + } + } + stage { + file = "shaders/blur-v.frag" + params { + radius = 3 + } + save = "blurred" + } + stage { + file = "shaders/edge-mix.frag" + source = "get:blurred" + params { + margin = 0.08 + } + textures { + original = backdrop + } + } +} + +shader tinted-blur { + source = "shader:soft-blur" + stage { + file = "shaders/tint.frag" + params { + strength = 0.3 + tint_color = 88CCFF + } + } +} +"#, + ); + let (mut config, mut warnings) = Config::from_raw(lower_entries(&entries)); + warnings.extend(expand_shader_sources(&mut config.shader_definitions)); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(config.shaders_enabled); + assert_eq!(config.shader_definitions["soft-blur"].stages.len(), 2); + assert_eq!(config.shader_definitions["edged"].stages.len(), 3); + assert_eq!(config.shader_definitions["tinted-blur"].stages.len(), 3); + assert_eq!( + config.resolve_window_rules(facts_for("kitty")).shader, + Some(ShaderAssignment::Named("soft-blur".to_string())) + ); + } + + #[test] + fn shader_sources_splice_the_source_chain_in_front() { + use crate::shader_effect::StageTexture; + let entries = wave_entries( + "shader blur {\n\ + stage {\n file = \"h.frag\"\n }\n\ + stage {\n file = \"v.frag\"\n }\n\ + }\n\ + shader wet {\n\ + source = \"shader:blur\"\n\ + stage {\n file = \"mix.frag\"\n textures {\n original = backdrop\n }\n }\n\ + }\n\ + shader looped-a {\n source = \"shader:looped-b\"\n stage {\n file = \"a.frag\"\n }\n }\n\ + shader looped-b {\n source = \"shader:looped-a\"\n stage {\n file = \"b.frag\"\n }\n }\n\ + shader orphan {\n source = \"shader:missing\"\n stage {\n file = \"a.frag\"\n }\n }\n\ + shader long {\n source = \"shader:wet\"\n stage {\n file = \"a.frag\"\n }\n stage {\n file = \"b.frag\"\n }\n }\n\ + shader clash-base {\n stage {\n file = \"a.frag\"\n save = x\n }\n stage {\n file = \"b.frag\"\n }\n }\n\ + shader clash {\n source = \"shader:clash-base\"\n stage {\n file = \"a.frag\"\n save = x\n }\n stage {\n file = \"b.frag\"\n }\n }\n", + ); + let (mut config, warnings) = Config::from_raw(lower_entries(&entries)); + assert!(warnings.is_empty(), "{warnings:?}"); + let warnings = expand_shader_sources(&mut config.shader_definitions); + let wet = &config.shader_definitions["wet"].stages; + let files: Vec<&str> = wet.iter().map(|stage| stage.file.as_str()).collect(); + assert_eq!(files, ["h.frag", "v.frag", "mix.frag"]); + assert_eq!(wet[1].save.as_deref(), Some("tide_source_2")); + // `backdrop` in the definition means its source's output. + assert_eq!( + wet[2].textures[0].1, + StageTexture::Saved("tide_source_2".to_string()) + ); + assert_eq!(wet[2].input, None); + assert_eq!(config.shader_definitions["blur"].stages.len(), 2); + for (name, reason) in [ + ("looped-a", "loops back"), + ("looped-b", "loops back"), + ("orphan", "not a loaded definition"), + ("long", "more than 4"), + ("clash", "both save"), + ] { + assert!(!config.shader_definitions.contains_key(name), "{name}"); + assert!( + warnings + .iter() + .any(|warning| warning.contains(name) && warning.contains(reason)), + "{name}: {warnings:?}" + ); + } + } + + #[test] + fn a_later_shader_definition_replaces_the_earlier_one_whole() { + let entries = wave_entries( + "shader glass {\n\ + render_scale = 1\n\ + stage {\n\ + file = \"first.frag\"\n\ + }\n\ + }\n\ + shader glass {\n\ + stage {\n\ + file = \"second.frag\"\n\ + }\n\ + }\n", + ); + let config = Config::from_raw(lower_entries(&entries)).0; + let definition = &config.shader_definitions["glass"]; + assert_eq!(definition.render_scale, 2); + assert_eq!(definition.stages.len(), 1); + assert_eq!(definition.stages[0].file, "second.frag"); + + // A broken redefinition removes the name instead of keeping the + // earlier, different effect around under it. + let entries = wave_entries( + "shader glass {\n stage {\n file = \"first.frag\"\n }\n }\n\ + shader glass {\n scope = output\n stage {\n file = \"first.frag\"\n }\n }\n", + ); + assert!(Config::from_raw(lower_entries(&entries)) + .0 + .shader_definitions + .is_empty()); + } + + #[test] + fn shader_definition_count_is_capped() { + let source: String = (0..=crate::shader_effect::MAX_DEFINITIONS) + .map(|index| format!("shader s{index} {{\n stage {{\n file = \"a.frag\"\n }}\n}}\n")) + .collect(); + let (config, warnings) = Config::from_raw(lower_entries(&wave_entries(&source))); + assert_eq!( + config.shader_definitions.len(), + crate::shader_effect::MAX_DEFINITIONS + ); + assert!(warnings + .iter() + .any(|warning| warning.contains("at most 32 definitions"))); + } + + #[test] + fn rule_shader_is_last_match_wins_and_none_clears() { + let entries = wave_entries( + "rule {\n app_id = kitty\n shader = Soft-Glass\n }\n\ + rule {\n app_id = foot\n shader = crt\n }\n\ + rule {\n app_id = foot\n shader = none\n }\n\ + rule {\n app_id = mpv\n shader = crt\n }\n\ + rule {\n app_id = mpv\n shader = \"bad name!\"\n }\n", + ); + let config = Config::from_raw(lower_entries(&entries)).0; + assert_eq!( + config.resolve_window_rules(facts_for("kitty")).shader, + Some(ShaderAssignment::Named("soft-glass".to_string())) + ); + assert_eq!( + config.resolve_window_rules(facts_for("foot")).shader, + Some(ShaderAssignment::None) + ); + // An invalid later value is ignored, not treated as `none`. + assert_eq!( + config.resolve_window_rules(facts_for("mpv")).shader, + Some(ShaderAssignment::Named("crt".to_string())) + ); + assert_eq!( + config.resolve_window_rules(facts_for("alacritty")).shader, + None + ); + } + + #[test] + fn workspace_shader_is_a_default_the_window_rule_overrides() { + let entries = wave_entries( + "workspace_rule {\n workspace = 2\n shader = crt\n }\n\ + workspace_rule {\n workspace = 2\n shader = glow\n }\n\ + workspace_rule {\n workspace = 3\n shader = none\n }\n\ + workspace_rule {\n workspace = 3\n snap = false\n }\n", + ); + let config = Config::from_raw(lower_entries(&entries)).0; + let glow = ShaderAssignment::Named("glow".to_string()); + assert_eq!(config.workspace_shader(2), Some(&glow)); + assert_eq!(config.resolve_workspace_rule(2).shader, Some(glow.clone())); + assert_eq!(config.workspace_shader(3), Some(&ShaderAssignment::None)); + assert_eq!(config.workspace_shader(4), None); + + let crt = ShaderAssignment::Named("crt".to_string()); + assert_eq!(ShaderAssignment::resolve(None, Some(&glow)), Some("glow")); + assert_eq!( + ShaderAssignment::resolve(Some(&crt), Some(&glow)), + Some("crt") + ); + assert_eq!( + ShaderAssignment::resolve(Some(&ShaderAssignment::None), Some(&glow)), + None + ); + assert_eq!( + ShaderAssignment::resolve(None, Some(&ShaderAssignment::None)), + None + ); + assert_eq!(ShaderAssignment::resolve(None, None), None); + } + + #[test] + fn layer_shader_is_explicit_and_last_match_wins() { + let entries = wave_entries( + "layer_rule {\n namespace = waybar\n shader = glow\n }\n\ + layer_rule {\n namespace = waybar\n ignore_alpha = 0.1\n }\n\ + layer_rule {\n namespace = rofi\n shader = glow\n }\n\ + layer_rule {\n namespace = rofi\n shader = none\n }\n", + ); + let config = Config::from_raw(lower_entries(&entries)).0; + // A later rule without `shader` leaves the earlier assignment alone. + assert_eq!(config.layer_shader("waybar"), Some("glow")); + assert_eq!(config.layer_ignore_alpha("waybar"), Some(0.1)); + assert_eq!(config.layer_shader("rofi"), None); + assert_eq!(config.layer_shader("mako"), None); + } + + #[test] + fn shader_files_load_relative_to_the_config_and_join_the_watch_set() { + let dir = TestDir::new("shader-load"); + fs::create_dir_all(dir.0.join("shaders")).unwrap(); + dir.write( + "shaders/pass.frag", + "vec4 tide_effect(vec2 uv) {\n return texture2D(tex, uv) * strength;\n}\n", + ); + dir.write("shaders/bad.frag", "void main() {}\n"); + let main = dir.write( + "config.wave", + "shader pass {\n\ + stage {\n\ + file = \"shaders/pass.frag\"\n\ + params {\n\ + strength = 0.5\n\ + }\n\ + }\n\ + }\n\ + shader bad {\n\ + stage {\n\ + file = \"shaders/bad.frag\"\n\ + }\n\ + }\n\ + shader missing {\n\ + stage {\n\ + file = \"shaders/later.frag\"\n\ + }\n\ + }\n\ + shader dimmer {\n\ + source = \"shader:pass\"\n\ + stage {\n\ + file = \"shaders/pass.frag\"\n\ + params {\n\ + strength = 0.25\n\ + }\n\ + }\n\ + }\n", + ); + let (_, config, warnings) = + Config::reload_staged_from(&main, &wave::TideInfo::default()).expect("config loads"); + + let pass = &config.shader_definitions["pass"]; + assert_eq!(pass.stages[0].path, dir.0.join("shaders/pass.frag")); + let source = pass.stages[0].source.as_ref().expect("pass.frag loads"); + assert!(source.contains("uniform float strength;")); + assert!(source.contains("return texture2D(tex, uv) * strength;")); + + assert!(config.shader_definitions["bad"].stages[0] + .source + .as_ref() + .is_none()); + assert!(config.shader_definitions["missing"].stages[0] + .source + .as_ref() + .is_none()); + assert!(warnings + .iter() + .any(|w| w.contains("\"bad\" stage 1") && w.contains("`main` is reserved"))); + assert!(warnings.iter().any(|w| w.contains("\"missing\" stage 1"))); + // A sourced definition carries its source's loaded stages in front. + let dimmer = &config.shader_definitions["dimmer"].stages; + assert_eq!(dimmer.len(), 2); + assert!(dimmer.iter().all(|stage| stage.source.is_some())); + assert_eq!( + dimmer[0].params[0].1, + crate::shader_effect::ShaderParam::Float(0.5) + ); + assert_eq!( + dimmer[1].params[0].1, + crate::shader_effect::ShaderParam::Float(0.25) + ); + assert_eq!(dimmer[0].save.as_deref(), Some("tide_source_1")); + + for name in ["pass.frag", "bad.frag", "later.frag"] { + assert!( + config + .source_paths + .contains(&dir.0.join("shaders").join(name)), + "{name} must be watched" + ); + } + } + #[test] fn frost_block_and_per_window_glass_mode_parse() { let entries = wave_entries( @@ -12031,6 +13227,18 @@ animations { 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 [ @@ -12038,7 +13246,7 @@ animations { 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/tide_core/ipc.rs b/src/tide_core/ipc.rs index 0c917e0..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!(); } } }, @@ -1098,7 +1105,29 @@ fn perf_snapshot_json(state: &mut Smallvil) -> serde_json::Value { total.saturating_add(transition.estimated_texture_bytes()) }), ); + let shader_capture_bytes = state + .backdrop_textures + .iter() + .filter(|(surface, _)| state.custom_shader_instances.contains_key(*surface)) + .fold(0_u64, |total, (_, capture)| { + total.saturating_add(capture.estimated_texture_bytes()) + }); + let shader_stage_bytes = state + .custom_shader_instances + .values() + .fold(0_u64, |total, instance| { + total.saturating_add(instance.target_bytes()) + }); + let (shader_programs, shader_compiles, shader_compile_failures) = + state.custom_shader_programs.stats(); + let shader_updates = state + .custom_shader_instances + .values() + .fold(0_u64, |total, instance| { + total.saturating_add(instance.updates()) + }); let tide_texture_estimate_bytes = backdrop_texture_bytes + .saturating_add(shader_stage_bytes) .saturating_add(layer_alpha_mask_bytes) .saturating_add(wallpaper_texture_bytes) .saturating_add(caustics_texture_bytes) @@ -1142,6 +1171,20 @@ fn perf_snapshot_json(state: &mut Smallvil) -> serde_json::Value { "workspace_transition_bytes": transition_texture_bytes, "scope": "ARGB payload for TideWM-owned backdrop, wallpaper, caustics, and active water/non-water workspace-transition textures; excludes client buffers and driver overhead", }, + "custom_shaders": { + "enabled": state.config.shaders_enabled, + "definitions": state.config.shader_definitions.len(), + "programs": shader_programs, + "compiles": shader_compiles, + "compile_failures": shader_compile_failures, + "instances": state.custom_shader_instances.len(), + "capture_bytes": shader_capture_bytes, + "stage_bytes": shader_stage_bytes, + "bypassed": state.custom_shader_bypassed.len(), + "bypasses": state.custom_shader_bypasses, + "updates": shader_updates, + "scope": "capture_bytes is the share of backdrop_bytes feeding custom effects; stage_bytes are chained effects' offscreen stage outputs, included in the total; updates count redraws with new content by live instances", + }, "outputs": outputs, }) } diff --git a/src/tide_core/state.rs b/src/tide_core/state.rs index 7cb26ba..8d61fa4 100644 --- a/src/tide_core/state.rs +++ b/src/tide_core/state.rs @@ -319,6 +319,27 @@ pub struct Smallvil { /// entries preserve the original behavior: translucent floating windows /// use water refraction. pub(crate) window_glass_modes: HashMap, + /// The last matching `rule { shader = ... }`, including an explicit + /// `none` that opts out of the workspace default. Resolved per frame by + /// `resolved_shader_assignment`, so toggling `shaders { enabled }`, a + /// definition or a workspace default needs no per-window re-resolve. + pub(crate) window_shader_assignments: HashMap, + /// Assignments from `layer_rule { shader }`, refreshed for each output's + /// mapped layers during its capture pass and cleared on reload. + pub(crate) layer_shader_assignments: HashMap, + /// New custom programs still allowed to compile in this output's frame, + /// shared by the chain passes, the glass slots and any capture served + /// before the next frame. + pub(crate) custom_shader_compile_budget: usize, + /// Last-good compiled program per custom effect definition. + pub(crate) custom_shader_programs: crate::shader_effect::CustomShaderPrograms, + /// `u_time`/`u_delta` clocks for windows currently drawing an effect. + /// Evicted in `detach_mapped_toplevel`, pruned on reload, cleared on lock. + pub(crate) custom_shader_instances: HashMap, + /// Windows refused a custom effect by the instance or payload cap. They + /// take their normal path until a later capture pass admits them. + pub(crate) custom_shader_bypassed: HashSet, + pub(crate) custom_shader_bypasses: u64, /// Captured immediately before a visible frame and sampled by /// water/frost glass while building that same frame's elements. The /// window-sized texture is reused until its dimensions change. Evicted in @@ -851,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`/ @@ -1546,7 +1569,8 @@ impl Smallvil { }) } - /// Re-derives `window_opacity`/`window_glass_modes` for one window from + /// Re-derives `window_opacity`/`window_glass_modes`/ + /// `window_shader_assignments` for one window from /// its currently-resolved rule. Shared by the full post-reload battery /// (`reload_config`, one call per mapped window) and by anything that /// needs a single window's opacity/glass to react to a live @@ -1574,10 +1598,16 @@ impl Smallvil { self.window_glass_modes.remove(surface); } } - if self - .glass_mode_for_surface(surface, self.fullscreen.contains_key(surface)) - .is_none() - { + match rule.shader { + Some(assignment) => { + self.window_shader_assignments + .insert(surface.clone(), assignment); + } + None => { + self.window_shader_assignments.remove(surface); + } + } + if !self.backdrop_effect_for_surface(surface, self.fullscreen.contains_key(surface)) { self.backdrop_textures.remove(surface); self.glass_anim.remove(surface); } @@ -3736,6 +3766,13 @@ impl Smallvil { space, window_opacity: HashMap::new(), window_glass_modes: HashMap::new(), + window_shader_assignments: HashMap::new(), + layer_shader_assignments: HashMap::new(), + custom_shader_compile_budget: 0, + custom_shader_programs: Default::default(), + custom_shader_instances: HashMap::new(), + custom_shader_bypassed: HashSet::new(), + custom_shader_bypasses: 0, backdrop_textures: HashMap::new(), layer_alpha_masks: HashMap::new(), layer_dim_buffers: HashMap::new(), @@ -3862,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(), @@ -5403,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 @@ -5439,6 +5488,8 @@ impl Smallvil { // recaptures visible glass before composing its first desktop frame. self.backdrop_textures.clear(); self.layer_alpha_masks.clear(); + self.custom_shader_instances.clear(); + self.custom_shader_bypassed.clear(); // Closing snapshots contain client pixels and normally render above // the desktop. They are irrelevant once the security boundary is // active and must never survive into a locked composition. @@ -6173,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 @@ -6195,6 +6266,163 @@ impl Smallvil { Some(logical_rect.to_physical_precise_round(output_scale)) } + /// The custom effect a window's rule assigns, live against the + /// `shaders { enabled }` master and the current definitions. + pub(crate) fn resolved_shader_assignment( + &self, + surface: &WlSurface, + ) -> Option<(&str, &crate::config::ShaderDefinition)> { + surface_shader( + &self.config, + &self.layer_shader_assignments, + &self.window_shader_assignments, + surface, + self.workspace_of_surface(surface), + ) + } + + /// Puts a render-time compile failure on the persistent warning panel. + /// Deferred to idle: the panel reserves tiling space, so it must not + /// appear while a frame is being assembled. A hard config error on the + /// panel stays in front. + fn queue_shader_failure(&mut self, message: String) { + tracing::warn!(%message, "Custom shader unavailable"); + self.loop_handle.insert_idle(move |state| { + if state.config_warnings.contains(&message) { + return; + } + state.config_warnings.push(message); + if state.config_error_overlay.as_ref().is_some_and(|overlay| { + overlay.severity() == crate::error_overlay::OverlaySeverity::Error + }) { + return; + } + let ui_theme = crate::ui_theme::UiTheme::from_config(&state.config); + state.config_error_overlay = Some(crate::error_overlay::ConfigErrorOverlay::new( + state.config_warnings.join("; "), + crate::error_overlay::OverlaySeverity::Warning, + ui_theme, + )); + state.toast = None; + state.retile(); + state.request_redraw(); + }); + } + + /// Whether a custom effect can draw for this window this frame: assigned + /// with the master on, and a last-good program or an untried source. + fn custom_shader_drawable(&self, surface: &WlSurface) -> bool { + !self.custom_shader_bypassed.contains(surface) && self.custom_shader_wanted(surface) + } + + /// `custom_shader_drawable` before the budget: what admission judges. + fn custom_shader_wanted(&self, surface: &WlSurface) -> bool { + self.resolved_shader_assignment(surface) + .is_some_and(|(name, definition)| { + self.custom_shader_programs + .can_draw(name, &definition.stages) + }) + } + + /// Admits or bypasses each shaded window on this output against the + /// instance and payload caps, before any capture allocates. Payload is + /// what the window's capture will cost at its definition's + /// `render_scale`, plus every other admitted window's current capture. + fn admit_custom_shader_captures( + &mut self, + candidates: &[(WlSurface, Rectangle)], + ) { + // Windows admitted earlier in this pass count at their wanted size + // even though nothing is allocated for them yet. + let mut admitted_now: Vec<(WlSurface, u64)> = Vec::new(); + if !self.custom_shader_bypassed.is_empty() { + let unwanted: Vec = self + .custom_shader_bypassed + .iter() + .filter(|surface| !self.custom_shader_wanted(surface)) + .cloned() + .collect(); + for surface in unwanted { + self.custom_shader_bypassed.remove(&surface); + } + } + for (surface, physical_rect) in candidates { + if !self.custom_shader_wanted(surface) { + continue; + } + let Some((name, render_scale, stage_count)) = self + .resolved_shader_assignment(surface) + .map(|(name, definition)| { + ( + name.to_string(), + definition.render_scale, + definition.stages.len(), + ) + }) + else { + continue; + }; + // The capture plus one same-sized target per offscreen stage. + let wanted = crate::shader_effect::capture_payload_bytes( + physical_rect.size.w, + physical_rect.size.h, + render_scale, + ) + .saturating_mul(stage_count as u64); + let counted = |other: &WlSurface| { + other != surface + && !self.custom_shader_bypassed.contains(other) + && !admitted_now.iter().any(|(admitted, _)| admitted == other) + }; + let existing = self + .custom_shader_instances + .iter() + .filter(|(other, _)| counted(other)); + let (existing_count, existing_bytes) = + existing.fold((0usize, 0_u64), |(count, bytes), (other, instance)| { + let capture = self + .backdrop_textures + .get(other) + .map_or(0, |capture| capture.estimated_texture_bytes()); + ( + count + 1, + bytes + .saturating_add(capture) + .saturating_add(instance.target_bytes()), + ) + }); + let pass_bytes = admitted_now + .iter() + .fold(0_u64, |total, (_, bytes)| total.saturating_add(*bytes)); + let instances = existing_count + admitted_now.len() + 1; + let bytes = existing_bytes + .saturating_add(pass_bytes) + .saturating_add(wanted); + if instances <= crate::shader_effect::MAX_ACTIVE_INSTANCES + && bytes <= crate::shader_effect::MAX_PAYLOAD_BYTES + { + self.custom_shader_bypassed.remove(surface); + admitted_now.push((surface.clone(), wanted)); + } else if self.custom_shader_bypassed.insert(surface.clone()) { + self.custom_shader_instances.remove(surface); + self.custom_shader_bypasses += 1; + self.queue_shader_failure(format!( + "Shader {name:?} skipped on a window: custom effects are capped at {} windows and {} MiB of captures", + crate::shader_effect::MAX_ACTIVE_INSTANCES, + crate::shader_effect::MAX_PAYLOAD_BYTES / (1024 * 1024) + )); + } + } + } + + /// Whether a window's captured backdrop feeds anything: a custom effect + /// under its own master, or water/frost glass under `water_effects`. + fn backdrop_effect_for_surface(&self, surface: &WlSurface, fullscreen: bool) -> bool { + self.custom_shader_drawable(surface) + || (self.config.water_effects + && self.glass_mode_for_surface(surface, fullscreen).is_some()) + } + fn glass_mode_for_surface( &self, surface: &WlSurface, @@ -6649,8 +6877,21 @@ impl Smallvil { output: &Output, placements: &[crate::placement::PlacedWindow], ) { - if !self.config.water_effects { - return; + self.custom_shader_compile_budget = 1; + if self.config.shaders_enabled { + let candidates: Vec<(WlSurface, Rectangle)> = placements + .iter() + .filter(|placement| placement.replacement_eligible()) + .filter_map(|placement| { + let surface = placement.surface()?; + self.custom_shader_wanted(surface).then_some(())?; + Some(( + surface.clone(), + self.placement_physical_rect(placement, output)?, + )) + }) + .collect(); + self.admit_custom_shader_captures(&candidates); } let surfaces: Vec = placements .iter() @@ -6661,14 +6902,26 @@ impl Smallvil { .window_depths .get(surface) .is_none_or(|depth| depth.tier() < 2) - && self - .glass_mode_for_surface(surface, placement.is_fullscreen()) - .is_some()) + && self.backdrop_effect_for_surface(surface, placement.is_fullscreen())) .then(|| surface.clone()) }) .collect(); let output_name = output.name(); self.reconcile_backdrop_visibility(&output_name, &surfaces, true); + // A window whose capture was released (hidden, off every output) or + // that no longer has an effect drops its stage textures. + let unshaded: Vec = self + .custom_shader_instances + .keys() + .filter(|surface| { + !self.backdrop_textures.contains_key(*surface) + || self.resolved_shader_assignment(surface).is_none() + }) + .cloned() + .collect(); + for surface in unshaded { + self.custom_shader_instances.remove(&surface); + } if surfaces.is_empty() { return; } @@ -6698,11 +6951,21 @@ 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(); + let chained: Vec = surfaces + .iter() + .filter(|surface| { + self.custom_shader_drawable(surface) + && self + .resolved_shader_assignment(surface) + .is_some_and(|(_, definition)| definition.stages.len() > 1) + }) + .cloned() + .collect(); let mut rendered = 0usize; let mut skipped = 0usize; let mut captured_first_backdrop = false; @@ -6717,7 +6980,13 @@ impl Smallvil { continue; }; - let capture_scale = self.config.backdrop_capture_scale; + // A custom effect processes its capture at its own divisor. + let capture_scale = self + .custom_shader_drawable(&surface) + .then(|| self.resolved_shader_assignment(&surface)) + .flatten() + .map(|(_, definition)| definition.render_scale) + .unwrap_or(self.config.backdrop_capture_scale); let first_capture = !self.backdrop_textures.contains_key(&surface); if first_capture { let Some(mut capture) = crate::backdrop::BackdropCapture::new( @@ -6748,6 +7017,7 @@ impl Smallvil { skipped, "Window backdrop captures" ); + self.run_custom_shader_chains(renderer, &chained); // The frame that triggered the first capture could otherwise be the // last dirty frame on a static desktop. Schedule exactly one more so // the newly available texture is actually consumed; later capture @@ -6757,27 +7027,108 @@ impl Smallvil { } } - /// Captures backdrops for mapped layer surfaces whose rule enables blur. - /// Window and layer surfaces safely share the `WlSurface`-keyed cache. - /// Layer frost is independent of the water-effects master toggle. + /// Renders the offscreen stages of every chained effect on this output + /// from its fresh capture, still before the visible bind. + fn run_custom_shader_chains(&mut self, renderer: &mut GlesRenderer, surfaces: &[WlSurface]) { + let mut failures = Vec::new(); + let mut deferred = false; + let work: Vec<(WlSurface, String)> = surfaces + .iter() + .filter_map(|surface| { + self.resolved_shader_assignment(surface) + .map(|(name, _)| (surface.clone(), name.to_string())) + }) + .collect(); + for (surface, name) in &work { + let (Some(definition), Some(capture)) = ( + self.config.shader_definitions.get(name), + self.backdrop_textures.get(surface), + ) else { + continue; + }; + let instance = self + .custom_shader_instances + .entry(surface.clone()) + .or_insert_with(crate::shader_effect::ShaderInstance::new); + let outcome = crate::shader_effect::run_chain( + renderer, + &mut self.custom_shader_programs, + instance, + name, + definition, + &capture.texture, + (&capture.id, capture.version), + &mut self.custom_shader_compile_budget, + ); + failures.extend(outcome.failures); + deferred |= outcome.deferred; + } + if deferred { + self.request_redraw(); + } + for message in failures { + self.queue_shader_failure(message); + } + } + + /// Captures backdrops for mapped layer surfaces whose rule enables blur + /// or names a custom effect. Window and layer surfaces safely share the + /// `WlSurface`-keyed cache. Layer frost is independent of the + /// water-effects master toggle; layer shaders follow `shaders.enabled`. pub(crate) fn capture_layer_backdrops( &mut self, renderer: &mut GlesRenderer, output: &Output, placements: &[crate::placement::PlacedWindow], ) { - if !self.config.frost.enabled { + let frost_enabled = self.config.frost.enabled; + if !frost_enabled && !self.config.shaders_enabled { self.layer_alpha_masks.clear(); return; } let output_scale = output.current_scale().fractional_scale(); + let mut candidates = Vec::new(); + if self.config.shaders_enabled { + let layer_map = layer_map_for_output(output); + for layer in layer_map + .layers() + .filter(|layer| !self.unmapped_layer_surfaces.contains(layer.wl_surface())) + { + let surface = layer.wl_surface(); + match self.config.layer_shader(layer.namespace()) { + Some(name) => { + let current = self.layer_shader_assignments.get(surface); + if !matches!(current, Some(crate::config::ShaderAssignment::Named(existing)) if existing == name) + { + self.layer_shader_assignments.insert( + surface.clone(), + crate::config::ShaderAssignment::Named(name.to_string()), + ); + } + if let Some(geometry) = layer_map.layer_geometry(layer) { + candidates.push(( + surface.clone(), + geometry.to_physical_precise_round(output_scale), + )); + } + } + None => { + self.layer_shader_assignments.remove(surface); + } + } + } + } + self.admit_custom_shader_captures(&candidates); #[allow(clippy::mutable_key_type)] let (surfaces, alpha_surfaces) = { let layer_map = layer_map_for_output(output); let layers: Vec<_> = layer_map .layers() .filter(|layer| !self.unmapped_layer_surfaces.contains(layer.wl_surface())) - .filter(|layer| self.config.layer_blur(layer.namespace())) + .filter(|layer| { + self.custom_shader_drawable(layer.wl_surface()) + || (frost_enabled && self.config.layer_blur(layer.namespace())) + }) .filter_map(|layer| { let geometry = layer_map.layer_geometry(layer)?; Some(( @@ -6859,17 +7210,33 @@ 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)) .collect(); + let chained: Vec = surfaces + .iter() + .map(|(surface, _)| surface) + .filter(|surface| { + self.custom_shader_drawable(surface) + && self + .resolved_shader_assignment(surface) + .is_some_and(|(_, definition)| definition.stages.len() > 1) + }) + .cloned() + .collect(); let mut rendered = 0usize; let mut skipped = 0usize; let mut captured_first_backdrop = false; for (surface, physical_rect) in surfaces { - let capture_scale = self.config.backdrop_capture_scale; + let capture_scale = self + .custom_shader_drawable(&surface) + .then(|| self.resolved_shader_assignment(&surface)) + .flatten() + .map(|(_, definition)| definition.render_scale) + .unwrap_or(self.config.backdrop_capture_scale); let first_capture = !self.backdrop_textures.contains_key(&surface); if first_capture { let Some(mut capture) = crate::backdrop::BackdropCapture::new( @@ -6900,25 +7267,24 @@ impl Smallvil { skipped, "Layer backdrop captures" ); + self.run_custom_shader_chains(renderer, &chained); if captured_first_backdrop { self.request_redraw(); } } /// Windows on `output` eligible for a captured glass layer this frame: - /// `water_effects` on, either an explicit `glass` mode or the - /// backward-compatible implicit trigger (`opacity` below 1.0 means - /// water), and a backdrop already captured for them. Tiled and floating - /// placements share this path; ordinary opaque tiles never enter it. + /// a drawable custom effect, or `water_effects` on with either an + /// explicit `glass` mode or the backward-compatible implicit trigger + /// (`opacity` below 1.0 means water), and a backdrop already captured. + /// Tiled and floating placements share this path; ordinary opaque tiles + /// never enter it. /// Callers use this list to build glass layers that are inserted directly /// behind each surface in its normal z-slot. pub(crate) fn glass_eligible_surfaces( &self, placements: &[crate::placement::PlacedWindow], ) -> Vec { - if !self.config.water_effects { - return Vec::new(); - } placements .iter() .filter(|placement| placement.replacement_eligible()) @@ -6928,9 +7294,7 @@ impl Smallvil { .window_depths .get(surface) .is_none_or(|depth| depth.tier() < 2) - && self - .glass_mode_for_surface(surface, placement.is_fullscreen()) - .is_some() + && self.backdrop_effect_for_surface(surface, placement.is_fullscreen()) && self.backdrop_textures.contains_key(surface)) .then(|| surface.clone()) }) @@ -6952,14 +7316,16 @@ impl Smallvil { if surfaces.is_empty() { return layers; } - let needs_water = surfaces.iter().any(|surface| { - self.window_glass_modes - .get(surface) - .is_none_or(|mode| *mode == crate::config::GlassMode::Water) - }); - let needs_frost = surfaces.iter().any(|surface| { - self.window_glass_modes.get(surface) == Some(&crate::config::GlassMode::Frost) - }); + let needs_water = self.config.water_effects + && surfaces.iter().any(|surface| { + self.window_glass_modes + .get(surface) + .is_none_or(|mode| *mode == crate::config::GlassMode::Water) + }); + let needs_frost = self.config.water_effects + && surfaces.iter().any(|surface| { + self.window_glass_modes.get(surface) == Some(&crate::config::GlassMode::Frost) + }); let water_program = needs_water .then(|| { crate::water_glass::water_glass_program(&mut self.water_glass_program, renderer) @@ -6992,6 +7358,32 @@ impl Smallvil { }; let location = placement.rect.loc; let visual = self.placement_visual_sample(placement); + // A custom effect replaces the glass pass. Without a program yet + // (or ever) the window takes its normal glass path instead. + if let Some(element) = self.custom_shader_last_stage( + renderer, + surface, + (capture_id.clone(), capture_version, &capture_texture), + physical_rect, + Some(( + visual.opacity, + output.current_scale().fractional_scale() as f32, + )), + None, + ) { + self.glass_anim.remove(surface); + layers.entry(surface.clone()).or_default().push( + crate::backend::udev::OutputRenderElements::CustomShader(element), + ); + continue; + } + if !self.config.water_effects + || self + .glass_mode_for_surface(surface, placement.is_fullscreen()) + .is_none() + { + continue; + } match self.window_glass_modes.get(surface).copied() { Some(crate::config::GlassMode::Frost) => { if let Some(program) = &frost_program { @@ -7123,22 +7515,26 @@ impl Smallvil { output: &Output, ) -> HashMap> { let mut result = HashMap::new(); - if !self.config.frost.enabled { - return result; - } let output_scale = output.current_scale().fractional_scale(); - let eligible: Vec<(WlSurface, Rectangle, Option)> = { + let frost_enabled = self.config.frost.enabled; + // Surface, physical rect, `ignore_alpha` threshold, frost fallback. + type LayerEffect = (WlSurface, Rectangle, Option, bool); + let eligible: Vec = { let layer_map = layer_map_for_output(output); layer_map .layers() .filter(|layer| !self.unmapped_layer_surfaces.contains(layer.wl_surface())) - .filter(|layer| self.config.layer_blur(layer.namespace())) .filter_map(|layer| { + let blur = frost_enabled && self.config.layer_blur(layer.namespace()); + if !blur && !self.custom_shader_drawable(layer.wl_surface()) { + return None; + } let geometry = layer_map.layer_geometry(layer)?; Some(( layer.wl_surface().clone(), geometry.to_physical_precise_round(output_scale), self.config.layer_ignore_alpha(layer.namespace()), + blur, )) }) .collect() @@ -7146,17 +7542,19 @@ impl Smallvil { if eligible.is_empty() { return result; } - let Some(program) = - crate::frost_glass::frost_glass_program(&mut self.frost_glass_program, renderer) - else { - return result; - }; + let program = eligible + .iter() + .any(|(_, _, _, blur)| *blur) + .then(|| { + crate::frost_glass::frost_glass_program(&mut self.frost_glass_program, renderer) + }) + .flatten(); let frost = self.config.frost.clone(); let scale = output_scale as f32; let corner_radii = [frost.corner_radius * scale; 4]; let rounding_power = 2.0; let corner_softness = frost.corner_softness * scale; - for (surface, physical_rect, ignore_alpha) in eligible { + for (surface, physical_rect, ignore_alpha, blur) in eligible { let Some(capture) = self.backdrop_textures.get(&surface) else { continue; }; @@ -7167,6 +7565,24 @@ impl Smallvil { .get(&surface) .map(|mask| (mask.texture.clone(), mask.version, threshold)) }); + // A layer's own effect replaces its frost; without a program yet + // it falls back to frost if its rule also blurs. + if let Some(element) = self.custom_shader_last_stage( + renderer, + &surface, + (id.clone(), version, &texture), + physical_rect, + None, + alpha_mask.as_ref(), + ) { + result.entry(surface).or_insert_with(Vec::new).push( + crate::backend::udev::OutputRenderElements::CustomShader(element), + ); + continue; + } + let Some(program) = program.as_ref().filter(|_| blur) else { + continue; + }; let commit_mask = alpha_mask .as_ref() .map(|(_, version, threshold)| (*version, *threshold)); @@ -7198,6 +7614,128 @@ impl Smallvil { result } + /// A shaded window's or layer's last stage, or `None` when it has no + /// drawable effect this frame (unassigned, over budget, no program yet, + /// or a chain that hasn't rendered). `window` carries a window's visual + /// opacity and output scale for its rounding; layers draw unrounded at + /// full opacity, masked by their `ignore_alpha` capture when set. + fn custom_shader_last_stage( + &mut self, + renderer: &mut GlesRenderer, + surface: &WlSurface, + (capture_id, capture_version, capture_texture): ( + smithay::backend::renderer::element::Id, + usize, + &GlesTexture, + ), + physical_rect: Rectangle, + window: Option<(f32, f32)>, + alpha_mask: Option<&(GlesTexture, usize, f32)>, + ) -> Option { + if self.custom_shader_bypassed.contains(surface) { + return None; + } + let workspace = self.workspace_of_surface(surface); + let (name, definition) = surface_shader( + &self.config, + &self.layer_shader_assignments, + &self.window_shader_assignments, + surface, + workspace, + )?; + let stages = &definition.stages; + let index = stages.len().checked_sub(1)?; + let lookup = self.custom_shader_programs.lookup( + renderer, + name, + index, + stages.len(), + &stages[index], + &mut self.custom_shader_compile_budget, + ); + // The last stage reads the backdrop or the chain's outputs; a chain + // that hasn't rendered can't draw yet. + let instance = self.custom_shader_instances.get(surface); + let targets = instance.map(|instance| instance.targets()).unwrap_or(&[]); + let chain_version = instance.and_then(|instance| instance.chain_version()); + let resolved = lookup.program.as_ref().and_then(|program| { + if index > 0 && chain_version.is_none() { + return None; + } + let input = crate::shader_effect::stage_input(index, stages, targets, capture_texture)?; + let textures = + crate::shader_effect::stage_textures(program, stages, targets, capture_texture)?; + let base = crate::shader_effect::with_mask_version( + chain_version.unwrap_or(capture_version as u64), + alpha_mask.map(|(_, version, threshold)| (*version, *threshold)), + ); + let content_version = + crate::shader_effect::stage_content_version(base, &stages[index], program); + Some((input, textures, content_version)) + }); + if lookup.deferred { + self.request_redraw(); + } + if let Some(message) = lookup.failure { + self.queue_shader_failure(message); + } + let (program, (input, textures, content_version)) = lookup.program.zip(resolved)?; + let (corner_radii, rounding_power, antialias, opacity) = match window { + Some((opacity, output_scale)) => { + let rounding = self.rounding_config_for_surface(surface); + let corner_radii = if rounding.enabled { + rounding.radii.map(|radius| radius * output_scale) + } else { + [0.0; 4] + }; + ( + corner_radii, + rounding.power, + rounding.antialias * output_scale, + opacity, + ) + } + None => ([0.0; 4], 2.0, 0.0, 1.0), + }; + let commit = crate::shader_effect::custom_shader_commit( + content_version, + program.generation, + (physical_rect.size.w, physical_rect.size.h), + &program.params, + corner_radii, + rounding_power, + antialias, + opacity, + ); + let instance = self + .custom_shader_instances + .entry(surface.clone()) + .or_insert_with(crate::shader_effect::ShaderInstance::new); + // A chain sampled its clock when its stages rendered. + let timing = if chain_version.is_some() { + instance.timing() + } else { + instance.sample(commit) + }; + let mut element = crate::shader_effect::CustomShaderElement::new( + capture_id, + commit, + input, + physical_rect, + program, + corner_radii, + rounding_power, + antialias, + opacity, + timing, + ) + .with_textures(textures); + if let Some((mask, _, threshold)) = alpha_mask { + element = element.with_alpha_mask(mask.clone(), *threshold); + } + Some(element) + } + fn capture_workspace_desktop( &mut self, renderer: &mut GlesRenderer, @@ -7234,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(); @@ -11428,9 +11966,11 @@ impl Smallvil { } /// Cycles keyboard focus to the next mapped window (tiled or floating) - /// and raises it to the top of the stack. This is the only way to reach - /// a window that's fully covered by another one, since you can't click - /// something you can't see. + /// and raises it to the top of the stack if it floats. This is the only + /// way to reach a window that's fully covered by another one, since you + /// can't click something you can't see. A tiled window is focused where + /// it is: raising it would lift it above every floating window and break + /// the floating-above-tiled z-order until the next retile. pub fn cycle_focus(&mut self) { // Don't let a keybind tab focus away from an exclusive-interactivity // layer (e.g. a lock screen) while it's still mapped. @@ -11477,7 +12017,9 @@ impl Smallvil { return; }; - self.space.raise_element(next, false); + if !self.spatial_is_tiled(&next_surface) { + self.space.raise_element(next, false); + } self.cycling_focus = true; self.focus_window(Some(next_surface), SERIAL_COUNTER.next_serial()); self.cycling_focus = false; @@ -12385,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) { @@ -12455,8 +13058,17 @@ 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), + ); self.rescue_keybinds_active = false; self.helper_keys_down.clear(); // A live `workspace_count` change should show up in the @@ -12589,10 +13201,22 @@ impl Smallvil { } self.window_opacity.clear(); self.window_glass_modes.clear(); + self.window_shader_assignments.clear(); + self.layer_shader_assignments.clear(); let surfaces: Vec = self.foreign_toplevels.keys().cloned().collect(); for surface in surfaces { self.refresh_window_opacity_and_glass_for(&surface); } + let unshaded: Vec = self + .custom_shader_instances + .keys() + .filter(|surface| self.resolved_shader_assignment(surface).is_none()) + .cloned() + .collect(); + for surface in unshaded { + self.custom_shader_instances.remove(&surface); + } + self.custom_shader_bypassed.clear(); // The mode or frost tuning may have changed. Force the // shared pre-frame pipeline to rebuild against the current // window geometry instead of briefly showing stale content. @@ -13050,11 +13674,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(), @@ -13350,3 +14003,20 @@ pub(crate) fn trusted_client(client: &Client) -> bool { .get_data::() .is_none_or(|state| state.security_context.is_none()) } + +/// A surface's effect: a layer's `layer_rule { shader }` assignment, or a +/// window's rule assignment and workspace default. Takes the fields it reads +/// so render code can resolve while holding other `Smallvil` state mutably. +#[allow(clippy::mutable_key_type)] // WlSurface keys, same as every other map here +fn surface_shader<'a>( + config: &'a crate::config::Config, + layers: &'a HashMap, + windows: &'a HashMap, + surface: &WlSurface, + workspace: Option, +) -> Option<(&'a str, &'a crate::config::ShaderDefinition)> { + match layers.get(surface) { + Some(assignment) => config.shader_for(Some(assignment), None), + None => config.shader_for(windows.get(surface), workspace), + } +} 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, diff --git a/src/visual/error_overlay.rs b/src/visual/error_overlay.rs index 56910e1..3d49863 100644 --- a/src/visual/error_overlay.rs +++ b/src/visual/error_overlay.rs @@ -65,6 +65,10 @@ impl ConfigErrorOverlay { &self.message } + pub fn severity(&self) -> OverlaySeverity { + self.severity + } + pub fn render_element( &mut self, renderer: &mut GlesRenderer, diff --git a/src/visual/frost_glass.rs b/src/visual/frost_glass.rs index b0fd7e2..4f3c475 100644 --- a/src/visual/frost_glass.rs +++ b/src/visual/frost_glass.rs @@ -393,6 +393,30 @@ impl RenderElement for FrostGlassElement { frame.with_context(|gl| unsafe { gl.ActiveTexture(ffi::TEXTURE1); gl.BindTexture(ffi::TEXTURE_2D, mask.tex_id()); + // Smithay sets sampling state only on the texture it binds + // to unit 0. The mask is never drawn there, so without this + // it keeps GL's mipmapped default, samples as an incomplete + // texture reading alpha 1, and masks nothing. + gl.TexParameteri( + ffi::TEXTURE_2D, + ffi::TEXTURE_MIN_FILTER, + ffi::NEAREST as i32, + ); + gl.TexParameteri( + ffi::TEXTURE_2D, + ffi::TEXTURE_MAG_FILTER, + ffi::NEAREST as i32, + ); + gl.TexParameteri( + ffi::TEXTURE_2D, + ffi::TEXTURE_WRAP_S, + ffi::CLAMP_TO_EDGE as i32, + ); + gl.TexParameteri( + ffi::TEXTURE_2D, + ffi::TEXTURE_WRAP_T, + ffi::CLAMP_TO_EDGE as i32, + ); gl.ActiveTexture(ffi::TEXTURE0); })?; } @@ -522,6 +546,86 @@ mod tests { assert_ne!(before, after); } + #[test] + fn ignore_alpha_masks_the_frost_where_the_layer_is_transparent() { + use smithay::backend::{ + allocator::Fourcc, + renderer::{damage::OutputDamageTracker, Bind, ExportMem, ImportMem, Offscreen}, + }; + let Some(mut renderer) = crate::shader_effect::software_renderer() else { + eprintln!("no software EGL device; skipping the real compile and draw"); + return; + }; + const SIZE: i32 = 8; + let size = smithay::utils::Size::::from((SIZE, SIZE)); + let program = frost_glass_program(&mut None, &mut renderer).expect("frost compiles"); + let backdrop = renderer + .import_memory( + &[96; (SIZE * SIZE * 4) as usize], + Fourcc::Abgr8888, + size, + false, + ) + .expect("import backdrop"); + // Built the way `capture_layer_backdrops` builds a mask: an offscreen + // target that is only ever sampled through texture unit 1. + let mut mask = |alpha: f32| { + let mut mask: GlesTexture = renderer + .create_buffer(Fourcc::Argb8888, size) + .expect("allocate mask"); + let mut target = renderer.bind(&mut mask).expect("bind mask"); + OutputDamageTracker::new((SIZE, SIZE), 1.0, Transform::Normal) + .render_output( + &mut renderer, + &mut target, + 0, + &Vec::::new(), + [0.0, 0.0, 0.0, alpha], + ) + .expect("clear mask"); + drop(target); + mask + }; + let transparent = mask(0.0); + let opaque = mask(1.0); + let mut alpha_through = |mask: GlesTexture| { + let element = FrostGlassElement::new( + Id::new(), + CommitCounter::default(), + backdrop.clone(), + Rectangle::from_size((SIZE, SIZE).into()), + program.clone(), + FrostConfig::default(), + [0.0; 4], + 2.0, + 0.0, + ) + .with_alpha_mask(mask, 0.5); + let mut output: GlesTexture = renderer + .create_buffer(Fourcc::Abgr8888, size) + .expect("allocate target"); + let mut target = renderer.bind(&mut output).expect("bind target"); + OutputDamageTracker::new((SIZE, SIZE), 1.0, Transform::Normal) + .render_output(&mut renderer, &mut target, 0, &[element], [0.0; 4]) + .expect("draw frost"); + let mapping = renderer + .copy_framebuffer(&target, Rectangle::from_size(size), Fourcc::Abgr8888) + .expect("read back"); + drop(target); + let pixels = renderer.map_texture(&mapping).expect("map readback"); + pixels.chunks(4).map(|pixel| pixel[3]).max().unwrap_or(0) + }; + assert_eq!( + alpha_through(transparent), + 0, + "a transparent layer must not frost" + ); + assert!( + alpha_through(opaque) > 0, + "an opaque layer frosts as before" + ); + } + #[test] fn frost_glass_commit_advances_on_liquid_change() { let config = FrostConfig::default(); diff --git a/src/visual/mod.rs b/src/visual/mod.rs index 9cb116e..b137f2a 100644 --- a/src/visual/mod.rs +++ b/src/visual/mod.rs @@ -22,6 +22,7 @@ pub(crate) mod minimap; pub(crate) mod ocean_canvas; pub(crate) mod overview; pub(crate) mod ripple; +pub(crate) mod shader_effect; pub(crate) mod shadow; #[cfg(feature = "screencast")] pub(crate) mod source_picker; diff --git a/src/visual/shader_effect.rs b/src/visual/shader_effect.rs new file mode 100644 index 0000000..471452d --- /dev/null +++ b/src/visual/shader_effect.rs @@ -0,0 +1,2349 @@ +//! Custom `.frag` effects: the fixed fragment contract, the host wrapper and +//! bounded file loading (Phase 7, see `SHADER_DESIGN.md`). +//! +//! A user file defines `vec4 tide_effect(vec2 uv)` plus optional helpers and +//! returns premultiplied RGBA. The host owns everything else: `#version`, +//! precision, Smithay's `//_DEFINES_` variants, every uniform declaration, +//! `main`, opacity and rounded clipping. The contract check below is API +//! hygiene over a small GLSL token stream, not an execution sandbox; custom +//! shaders are trusted native GPU programs. + +use std::{ + collections::{HashMap, HashSet}, + fs, + hash::{Hash, Hasher}, + io::Read, + os::unix::fs::OpenOptionsExt, + path::Path, + sync::Arc, + time::Instant, +}; + +use smithay::{ + backend::renderer::{ + element::{Element, Id, Kind, RenderElement}, + gles::{ + ffi, GlesError, GlesFrame, GlesRenderer, GlesTexProgram, GlesTexture, Uniform, + UniformName, UniformType, UniformValue, + }, + utils::CommitCounter, + ContextId, Renderer, Texture, + }, + utils::{user_data::UserDataMap, Buffer, Physical, Rectangle, Scale, Transform}, +}; + +/// Largest accepted `.frag` source. +pub const MAX_SOURCE_BYTES: usize = 64 * 1024; +/// Largest total of wrapped sources one configuration may load. +pub const MAX_CANDIDATE_BYTES: usize = 1024 * 1024; +pub const MAX_DEFINITIONS: usize = 32; +pub const MAX_STAGES: usize = 4; +pub const MAX_PARAMS_PER_STAGE: usize = 8; +pub const MAX_NAME_BYTES: usize = 64; +/// Extra `textures { }` sampler bindings one stage may declare. With the +/// implicit `tex` that is five texture units, inside GLES2's guaranteed +/// minimum of eight, so no driver query is needed yet. +pub const MAX_TEXTURES_PER_STAGE: usize = 4; +/// Texture unit of a layer's `ignore_alpha` mask, after the extra textures. +const ALPHA_MASK_UNIT: usize = 1 + MAX_TEXTURES_PER_STAGE; +/// Windows that may draw a custom effect at once. +pub const MAX_ACTIVE_INSTANCES: usize = 128; +/// Aggregate RGBA8-equivalent payload of every custom effect capture. +pub const MAX_PAYLOAD_BYTES: u64 = 64 * 1024 * 1024; + +/// `ceil(w / divisor) * ceil(h / divisor) * 4`, saturating. +pub fn capture_payload_bytes(width: i32, height: i32, divisor: i32) -> u64 { + let divisor = i64::from(divisor.max(1)); + let side = |length: i32| { + u64::try_from((i64::from(length.max(0)) + divisor - 1) / divisor).unwrap_or(0) + }; + side(width).saturating_mul(side(height)).saturating_mul(4) +} + +/// The only function a user file must define. +pub const ENTRY_POINT: &str = "tide_effect"; + +/// Host-declared uniforms every effect can read, in draw order. +pub const CONTRACT_UNIFORMS: &[(&str, &str)] = &[ + ("u_size", "vec2"), + ("u_texture_size", "vec2"), + ("u_content_rect", "vec4"), + ("u_texel", "vec2"), + ("u_time", "float"), + ("u_delta", "float"), + ("u_corner_radii", "vec4"), + ("u_rounding_power", "float"), + ("u_antialias", "float"), +]; + +/// Names that belong to Smithay's texture program or the host output path. +/// They may not appear in user source at all. +const FORBIDDEN_IDENTIFIERS: &[&str] = &[ + "main", + "alpha", + "tint", + "v_coords", + "matrix", + "tex_matrix", + "vert", + "vert_position", + "gl_FragColor", + "gl_FragData", +]; + +/// The host emits every declaration and the precision statement. +const FORBIDDEN_KEYWORDS: &[&str] = &["uniform", "attribute", "varying", "precision", "invariant"]; + +const ALLOWED_DIRECTIVES: &[&str] = &[ + "define", "undef", "if", "ifdef", "ifndef", "else", "elif", "endif", "error", +]; + +const BUILTIN_TYPES: &[&str] = &[ + "void", + "bool", + "int", + "float", + "vec2", + "vec3", + "vec4", + "bvec2", + "bvec3", + "bvec4", + "ivec2", + "ivec3", + "ivec4", + "mat2", + "mat3", + "mat4", + "sampler2D", + "samplerCube", +]; + +/// GLSL ES 1.00 keywords and reserved words a parameter name may not use. +const GLSL_KEYWORDS: &[&str] = &[ + "attribute", + "const", + "uniform", + "varying", + "break", + "continue", + "do", + "for", + "while", + "if", + "else", + "in", + "out", + "inout", + "true", + "false", + "lowp", + "mediump", + "highp", + "precision", + "invariant", + "discard", + "return", + "struct", + "asm", + "class", + "union", + "enum", + "typedef", + "template", + "this", + "packed", + "goto", + "switch", + "default", + "inline", + "noinline", + "volatile", + "public", + "static", + "extern", + "external", + "interface", + "flat", + "long", + "short", + "double", + "half", + "fixed", + "unsigned", + "superp", + "input", + "output", + "hvec2", + "hvec3", + "hvec4", + "dvec2", + "dvec3", + "dvec4", + "fvec2", + "fvec3", + "fvec4", + "sampler1D", + "sampler3D", + "sampler1DShadow", + "sampler2DShadow", + "sampler2DRect", + "sampler3DRect", + "sampler2DRectShadow", + "sizeof", + "cast", + "namespace", + "using", +]; + +/// Where a stage reads a texture from: the window's captured backdrop, or +/// the output of an earlier stage that `save`d it (`"get:name"`). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum StageTexture { + Backdrop, + Saved(String), +} + +impl StageTexture { + pub fn parse(value: &str) -> Option { + let value = value.trim(); + if value == "backdrop" { + return Some(Self::Backdrop); + } + value + .strip_prefix("get:") + .filter(|name| valid_param_name(name)) + .map(|name| Self::Saved(name.to_string())) + } +} + +/// One user parameter, lowered to a GLSL uniform type. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ShaderParam { + Float(f32), + Vec2([f32; 2]), + Vec3([f32; 3]), + Vec4([f32; 4]), +} + +impl ShaderParam { + pub fn glsl_type(&self) -> &'static str { + match self { + Self::Float(_) => "float", + Self::Vec2(_) => "vec2", + Self::Vec3(_) => "vec3", + Self::Vec4(_) => "vec4", + } + } +} + +impl ShaderParam { + fn uniform_type(&self) -> UniformType { + match self { + Self::Float(_) => UniformType::_1f, + Self::Vec2(_) => UniformType::_2f, + Self::Vec3(_) => UniformType::_3f, + Self::Vec4(_) => UniformType::_4f, + } + } + + fn uniform_value(&self) -> UniformValue { + match *self { + Self::Float(value) => value.into(), + Self::Vec2(value) => value.into(), + Self::Vec3(value) => value.into(), + Self::Vec4(value) => value.into(), + } + } + + fn hash_bits(&self, hash: &mut impl Hasher) { + let values: &[f32] = match self { + Self::Float(value) => std::slice::from_ref(value), + Self::Vec2(value) => value, + Self::Vec3(value) => value, + Self::Vec4(value) => value, + }; + for value in values { + value.to_bits().hash(hash); + } + } +} + +/// Whether `name` can become a user parameter uniform: an ASCII GLSL +/// identifier within the length cap that cannot shadow host names. +pub fn valid_param_name(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + name.len() <= MAX_NAME_BYTES + && (first.is_ascii_alphabetic() || first == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') + && !name.contains("__") + && !reserved_prefix(name) + && !FORBIDDEN_IDENTIFIERS.contains(&name) + && !BUILTIN_TYPES.contains(&name) + && !GLSL_KEYWORDS.contains(&name) + && name != "tex" + && name != "size" +} + +fn reserved_prefix(name: &str) -> bool { + name.starts_with("u_") || name.starts_with("gl_") || name.starts_with("tide_") +} + +#[derive(Debug, Clone, PartialEq)] +enum Token<'a> { + Ident(&'a str), + Punct(char), + Other, +} + +/// Replaces comments with spaces, keeping every newline so driver line +/// numbers still match the user's file. Comments are dropped rather than +/// passed through because Smithay substitutes every `//_DEFINES_` marker in +/// the whole source, including one inside a user comment. +fn strip_comments(source: &str) -> Result { + let bytes = source.as_bytes(); + let mut out = String::with_capacity(source.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'/' && bytes.get(index + 1) == Some(&b'/') { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } else if bytes[index] == b'/' && bytes.get(index + 1) == Some(&b'*') { + index += 2; + loop { + if index >= bytes.len() { + return Err("unterminated /* comment".to_string()); + } + if bytes[index] == b'*' && bytes.get(index + 1) == Some(&b'/') { + index += 2; + out.push(' '); + break; + } + if bytes[index] == b'\n' { + out.push('\n'); + } + index += 1; + } + } else { + let ch = source[index..] + .chars() + .next() + .expect("index is a char boundary"); + out.push(ch); + index += ch.len_utf8(); + } + } + Ok(out) +} + +fn tokenize(line: &str) -> Vec> { + let mut tokens = Vec::new(); + let bytes = line.as_bytes(); + let mut index = 0; + while index < bytes.len() { + let byte = bytes[index]; + if byte.is_ascii_alphabetic() || byte == b'_' { + let start = index; + while index < bytes.len() + && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') + { + index += 1; + } + tokens.push(Token::Ident(&line[start..index])); + } else if byte.is_ascii_digit() + || (byte == b'.' && bytes.get(index + 1).is_some_and(u8::is_ascii_digit)) + { + while index < bytes.len() + && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'.') + { + index += 1; + } + tokens.push(Token::Other); + } else if byte.is_ascii_whitespace() { + index += 1; + } else { + tokens.push(Token::Punct(byte as char)); + index += 1; + } + } + tokens +} + +/// Checks a user `.frag` against the fixed contract: no NUL, ASCII outside +/// comments, only conditional/define directives, no host-owned declarations +/// or identifiers, and exactly one `vec4 tide_effect(vec2 uv)` definition. +pub fn validate_fragment_contract(source: &str) -> Result<(), String> { + if source.len() > MAX_SOURCE_BYTES { + return Err(format!( + "source is {} bytes, the limit is {MAX_SOURCE_BYTES}", + source.len() + )); + } + if source.contains('\0') { + return Err("source contains a NUL byte".to_string()); + } + let code = strip_comments(source)?; + let mut tokens = Vec::new(); + for (line_no, line) in code.lines().enumerate() { + let line_no = line_no + 1; + if let Some(bad) = line.chars().find(|c| !c.is_ascii()) { + return Err(format!( + "line {line_no}: non-ASCII character {bad:?} outside a comment" + )); + } + let trimmed = line.trim_start(); + if let Some(directive) = trimmed.strip_prefix('#') { + let name = directive + .trim_start() + .split(|c: char| !c.is_ascii_alphanumeric() && c != '_') + .next() + .unwrap_or(""); + if !ALLOWED_DIRECTIVES.contains(&name) { + return Err(format!( + "line {line_no}: #{name} is not allowed, the host owns the GLSL version, extensions and line mapping" + )); + } + if directive.contains("##") { + return Err(format!("line {line_no}: token pasting (##) is not allowed")); + } + } + for token in tokenize(line) { + if let Token::Ident(ident) = token { + if FORBIDDEN_IDENTIFIERS.contains(&ident) { + return Err(format!( + "line {line_no}: `{ident}` is reserved, the host writes the output and applies opacity" + )); + } + if FORBIDDEN_KEYWORDS.contains(&ident) { + return Err(format!( + "line {line_no}: `{ident}` is not allowed, the host emits every declaration and the precision" + )); + } + } + tokens.push((line_no, token)); + } + } + + let mut types: HashSet<&str> = BUILTIN_TYPES.iter().copied().collect(); + let mut entry_definitions = 0usize; + let mut depth = 0i32; + let mut index = 0; + while index < tokens.len() { + let (line_no, token) = &tokens[index]; + match token { + Token::Punct('{') => depth += 1, + Token::Punct('}') => depth -= 1, + Token::Ident("struct") => { + if let Some((_, Token::Ident(name))) = tokens.get(index + 1) { + check_declared_name(name, *line_no)?; + types.insert(name); + } + } + Token::Ident(ty) if types.contains(ty) => { + if let Some((decl_line, Token::Ident(name))) = tokens.get(index + 1) { + if *name == ENTRY_POINT { + let signature = tokens.get(index + 2..index + 6).map(|window| { + window + .iter() + .map(|(_, token)| token.clone()) + .collect::>() + }); + let is_definition = *ty == "vec4" + && depth == 0 + && matches!( + signature.as_deref(), + Some([ + Token::Punct('('), + Token::Ident("vec2"), + Token::Ident(_), + Token::Punct(')') + ]) + ); + if !is_definition { + return Err(format!( + "line {decl_line}: `{ENTRY_POINT}` must be declared as `vec4 {ENTRY_POINT}(vec2 uv)`" + )); + } + if matches!(tokens.get(index + 6), Some((_, Token::Punct('{')))) { + entry_definitions += 1; + } + } else { + check_declared_name(name, *decl_line)?; + check_declarator_list(&tokens, index + 2)?; + } + } + } + _ => {} + } + index += 1; + } + match entry_definitions { + 0 => Err(format!("no `vec4 {ENTRY_POINT}(vec2 uv)` definition")), + 1 => Ok(()), + _ => Err(format!("`{ENTRY_POINT}` is defined more than once")), + } +} + +/// Checks the trailing declarators of `float a = f(x, y), b;`. Function +/// parameters need no special case: each has its own type token. +fn check_declarator_list(tokens: &[(usize, Token<'_>)], start: usize) -> Result<(), String> { + let mut nesting = 0i32; + let mut index = start; + while let Some((line_no, token)) = tokens.get(index) { + match token { + Token::Punct('(') | Token::Punct('[') => nesting += 1, + Token::Punct(')') | Token::Punct(']') if nesting > 0 => nesting -= 1, + Token::Punct(')') | Token::Punct(';') | Token::Punct('{') if nesting == 0 => { + return Ok(()); + } + Token::Punct(',') if nesting == 0 => { + if let Some((_, Token::Ident(name))) = tokens.get(index + 1) { + check_declared_name(name, *line_no)?; + } + } + _ => {} + } + index += 1; + } + Ok(()) +} + +fn check_declared_name(name: &str, line_no: usize) -> Result<(), String> { + if name == "tex" || name == "size" || reserved_prefix(name) { + return Err(format!( + "line {line_no}: `{name}` is reserved for host uniforms and may not be declared" + )); + } + Ok(()) +} + +/// Wraps a validated user source into a Smithay texture-shader program: +/// host prelude (source string 0), the user file from line 1 (string 1), +/// then the host epilogue (string 2) so driver logs name the right file. +pub fn wrap_texture_fragment( + user_source: &str, + params: &[(String, ShaderParam)], + textures: &[(String, StageTexture)], +) -> String { + let code = strip_comments(user_source).unwrap_or_else(|_| user_source.to_string()); + let mut out = String::with_capacity(code.len() + 2048); + out.push_str( + "#version 100\n\ + //_DEFINES_\n\ + #if defined(EXTERNAL)\n\ + #extension GL_OES_EGL_image_external : require\n\ + #endif\n\ + precision highp float;\n\ + #if defined(EXTERNAL)\n\ + uniform samplerExternalOES tex;\n\ + #else\n\ + uniform sampler2D tex;\n\ + #endif\n\ + uniform float alpha;\n\ + #if defined(DEBUG_FLAGS)\n\ + uniform float tint;\n\ + #endif\n\ + varying vec2 v_coords;\n\ + uniform sampler2D u_alpha_mask;\n\ + uniform float u_ignore_alpha;\n", + ); + for (name, ty) in CONTRACT_UNIFORMS { + out.push_str(&format!("uniform {ty} {name};\n")); + } + for (name, value) in params { + out.push_str(&format!("uniform {} {name};\n", value.glsl_type())); + } + for (name, _) in textures { + out.push_str(&format!("uniform sampler2D {name};\n")); + } + out.push_str("#line 1 1\n"); + out.push_str(&code); + if !code.ends_with('\n') { + out.push('\n'); + } + out.push_str( + "#line 1 2\n\ + float tide_rounded_mask() {\n\ + vec2 point = v_coords * u_size;\n\ + vec2 center;\n\ + float radius;\n\ + if (point.x < u_corner_radii.x && point.y < u_corner_radii.x) {\n\ + radius = u_corner_radii.x;\n\ + center = vec2(radius);\n\ + } else if (point.x > u_size.x - u_corner_radii.y && point.y < u_corner_radii.y) {\n\ + radius = u_corner_radii.y;\n\ + center = vec2(u_size.x - radius, radius);\n\ + } else if (point.x > u_size.x - u_corner_radii.z && point.y > u_size.y - u_corner_radii.z) {\n\ + radius = u_corner_radii.z;\n\ + center = vec2(u_size.x - radius, u_size.y - radius);\n\ + } else if (point.x < u_corner_radii.w && point.y > u_size.y - u_corner_radii.w) {\n\ + radius = u_corner_radii.w;\n\ + center = vec2(radius, u_size.y - radius);\n\ + } else {\n\ + return 1.0;\n\ + }\n\ + radius = min(radius, min(u_size.x, u_size.y) * 0.5);\n\ + if (radius < 0.01)\n\ + return 1.0;\n\ + vec2 q = abs(point - center) / radius;\n\ + float distance = pow(pow(q.x, u_rounding_power) + pow(q.y, u_rounding_power), 1.0 / u_rounding_power);\n\ + float antialias = u_antialias / radius;\n\ + return 1.0 - smoothstep(1.0 - antialias, 1.0 + antialias, distance);\n\ + }\n\ + void main() {\n\ + vec4 color = tide_effect(v_coords);\n\ + color.a = clamp(color.a, 0.0, 1.0);\n\ + color.rgb = clamp(color.rgb, vec3(0.0), vec3(color.a));\n\ + color *= tide_rounded_mask() * alpha;\n\ + if (u_ignore_alpha >= 0.0)\n\ + color *= step(u_ignore_alpha, texture2D(u_alpha_mask, v_coords).a);\n\ + #if defined(DEBUG_FLAGS)\n\ + if (tint == 1.0)\n\ + color = vec4(0.0, 0.2, 0.0, 0.2) + color * 0.8;\n\ + #endif\n\ + gl_FragColor = color;\n\ + }\n", + ); + out +} + +/// Reads one `.frag` file without following it into a FIFO or device and +/// without trusting metadata length: at most `MAX_SOURCE_BYTES + 1` bytes are +/// read, then the result must be UTF-8. +pub fn read_fragment_file(path: &Path) -> Result { + if path.extension().and_then(|ext| ext.to_str()) != Some("frag") { + return Err("shader files must use the .frag extension".to_string()); + } + let file = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(path) + .map_err(|err| err.to_string())?; + let metadata = file.metadata().map_err(|err| err.to_string())?; + if !metadata.is_file() { + return Err("not a regular file".to_string()); + } + let mut bytes = Vec::new(); + file.take(MAX_SOURCE_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|err| err.to_string())?; + if bytes.len() > MAX_SOURCE_BYTES { + return Err(format!("larger than the {MAX_SOURCE_BYTES}-byte limit")); + } + String::from_utf8(bytes).map_err(|_| "not valid UTF-8".to_string()) +} + +fn contract_uniform_type(glsl_type: &str) -> UniformType { + match glsl_type { + "vec2" => UniformType::_2f, + "vec4" => UniformType::_4f, + _ => UniformType::_1f, + } +} + +fn source_hash(source: &str) -> u64 { + let mut hash = std::collections::hash_map::DefaultHasher::new(); + source.hash(&mut hash); + hash.finish() +} + +/// A compiled program together with the schema it was built for: its +/// parameters and texture bindings. Drawing with any other schema would bind +/// uniforms the program lacks. +#[derive(Clone)] +pub struct ShaderProgram { + /// Hash of the wrapped source, for damage identity. + pub generation: u64, + pub program: GlesTexProgram, + pub params: Arc<[(String, ShaderParam)]>, + pub textures: Arc<[(String, StageTexture)]>, +} + +#[derive(Default)] +struct CachedProgram { + good: Option, + good_source: Option>, + /// Source that failed to compile, never retried until it changes. + failed: Option>, + /// The driver's reason for `failed`, re-reported after a reload. + failure_reason: Option, +} + +impl CachedProgram { + /// A last-good program, or a source that hasn't failed yet. + fn can_draw(&self, source: Option<&Arc>) -> bool { + self.good.is_some() + || source.is_some_and(|source| { + self.failed + .as_ref() + .is_none_or(|failed| !Arc::ptr_eq(failed, source) && **failed != **source) + }) + } +} + +/// Pointer equality first so a steady frame never rereads the source; a +/// reload hands in a fresh `Arc`, which is adopted once it compares equal. +fn same_source(cached: &mut Option>, source: &Arc) -> bool { + match cached { + Some(cached) if Arc::ptr_eq(cached, source) => true, + Some(cached) if **cached == **source => { + *cached = source.clone(); + true + } + _ => false, + } +} + +pub struct ProgramLookup { + pub program: Option, + /// Set once, the first time a new source fails to compile. + pub failure: Option, + /// A compile was needed but this frame's budget was spent. + pub deferred: bool, +} + +fn stage_label(name: &str, index: usize, stage_count: usize) -> String { + if stage_count > 1 { + format!("Shader {name:?} stage {}", index + 1) + } else { + format!("Shader {name:?}") + } +} + +/// Last-good compiled program per definition stage. A changed source +/// compiles once; a failure is remembered so a broken file never recompiles +/// every frame, and the previous program stays in use until the file is +/// fixed. Removing a definition drops its entries. +#[derive(Default)] +pub struct CustomShaderPrograms { + entries: HashMap>, + /// Programs belong to one GL context; a replacement renderer drops them. + context: Option>, + compiles: u64, + compile_failures: u64, +} + +impl CustomShaderPrograms { + /// `(programs, compiles, compile_failures)` for `tidectl perf`. + pub fn stats(&self) -> (usize, u64, u64) { + let programs = self + .entries + .values() + .flatten() + .filter(|entry| entry.good.is_some()) + .count(); + (programs, self.compiles, self.compile_failures) + } + + /// Whether every stage could draw without compiling anything known to + /// fail: each has a last-good program or a source not yet tried. + pub fn can_draw(&self, name: &str, stages: &[crate::config::ShaderStage]) -> bool { + let cached = self.entries.get(name); + stages.iter().enumerate().all(|(index, stage)| { + match cached.and_then(|entries| entries.get(index)) { + Some(entry) => entry.can_draw(stage.source.as_ref()), + None => stage.source.is_some(), + } + }) + } + + /// The program to draw stage `index` of `name` with this frame. A stage + /// whose source failed to load or compile keeps drawing its last-good + /// program. At most `compile_budget` new programs compile per call site + /// and frame. + pub fn lookup( + &mut self, + renderer: &mut GlesRenderer, + name: &str, + index: usize, + stage_count: usize, + stage: &crate::config::ShaderStage, + compile_budget: &mut usize, + ) -> ProgramLookup { + let context = renderer.context_id(); + if self.context.as_ref() != Some(&context) { + self.entries.clear(); + self.context = Some(context); + } + if !self.entries.contains_key(name) { + self.entries.insert(name.to_string(), Vec::new()); + } + let Self { + entries, + compiles, + compile_failures, + .. + } = self; + let stages = entries.get_mut(name).expect("entry inserted above"); + stages.resize_with(stage_count, CachedProgram::default); + let entry = &mut stages[index]; + let mut lookup = ProgramLookup { + program: None, + failure: None, + deferred: false, + }; + let Some(source) = stage.source.as_ref() else { + lookup.program = entry.good.clone(); + return lookup; + }; + if same_source(&mut entry.good_source, source) { + if let Some(good) = &mut entry.good { + // Same source means the same declarations, so only parameter + // values and texture binding sources can have moved. + good.params = stage.params.clone(); + good.textures = stage.textures.clone(); + } + lookup.program = entry.good.clone(); + return lookup; + } + if same_source(&mut entry.failed, source) { + lookup.program = entry.good.clone(); + return lookup; + } + if *compile_budget == 0 { + lookup.deferred = true; + lookup.program = entry.good.clone(); + return lookup; + } + *compile_budget -= 1; + *compiles += 1; + let uniforms: Vec> = CONTRACT_UNIFORMS + .iter() + .map(|(name, ty)| UniformName::new(*name, contract_uniform_type(ty))) + .chain( + stage + .params + .iter() + .map(|(name, value)| UniformName::new(name.as_str(), value.uniform_type())), + ) + .chain( + stage + .textures + .iter() + .map(|(name, _)| UniformName::new(name.as_str(), UniformType::_1i)), + ) + .chain([ + UniformName::new("u_alpha_mask", UniformType::_1i), + UniformName::new("u_ignore_alpha", UniformType::_1f), + ]) + .collect(); + match renderer.compile_custom_texture_shader(&**source, &uniforms) { + Ok(program) => { + entry.failed = None; + entry.good_source = Some(source.clone()); + entry.good = Some(ShaderProgram { + generation: source_hash(source), + program, + params: stage.params.clone(), + textures: stage.textures.clone(), + }); + lookup.program = entry.good.clone(); + } + Err(err) => { + *compile_failures += 1; + let reason = driver_compile_log(renderer, source) + .unwrap_or_else(|| format!("{err}; the driver log is in the journal")); + lookup.failure = Some(compile_failure_message( + &stage_label(name, index, stage_count), + &reason, + entry.good.is_some(), + )); + entry.failed = Some(source.clone()); + entry.failure_reason = Some(reason); + lookup.program = entry.good.clone(); + } + } + lookup + } + + /// Drops entries for removed definitions and stages and reports every + /// stage whose current source is still the one that failed, so a reload + /// doesn't clear a live compile diagnostic from the panel. + pub fn retain_definitions( + &mut self, + definitions: &HashMap, + ) -> Vec { + self.entries + .retain(|name, _| definitions.contains_key(name)); + let mut failures = Vec::new(); + for (name, entries) in &mut self.entries { + let stages = &definitions[name].stages; + entries.truncate(stages.len()); + for (index, (entry, stage)) in entries.iter_mut().zip(stages.iter()).enumerate() { + let Some(source) = stage.source.as_ref() else { + continue; + }; + if same_source(&mut entry.failed, source) { + failures.push(compile_failure_message( + &stage_label(name, index, stages.len()), + entry.failure_reason.as_deref().unwrap_or("compile error"), + entry.good.is_some(), + )); + } + } + } + failures.sort_unstable(); + failures + } +} + +fn compile_failure_message(label: &str, reason: &str, has_previous: bool) -> String { + format!( + "{label} did not compile: {reason}; {}", + if has_previous { + "keeping the previous program" + } else { + "rendering the window without it" + } + ) +} + +/// Longest driver diagnostic carried onto the panel. +const MAX_DRIVER_LOG_BYTES: usize = 240; + +/// Smithay reports a failed compile only as `ShaderCompileError` and sends +/// the driver's text to the journal, so a failure is compiled once more +/// here, alone, to read that text for the panel. Runs only after a failure, +/// never per frame. +fn driver_compile_log(renderer: &mut GlesRenderer, source: &str) -> Option { + let source = source.replace("//_DEFINES_", ""); + let log = renderer + .with_context(|gl| { + // SAFETY: `with_context` made this renderer's context current; + // the pointers stay valid for these calls and the shader object + // is deleted before returning. + unsafe { + let shader = gl.CreateShader(ffi::FRAGMENT_SHADER); + if shader == 0 { + return None; + } + let pointer = source.as_ptr() as *const ffi::types::GLchar; + let length = source.len() as ffi::types::GLint; + gl.ShaderSource(shader, 1, &pointer, &length); + gl.CompileShader(shader); + let mut log_length = 0; + gl.GetShaderiv(shader, ffi::INFO_LOG_LENGTH, &mut log_length); + let mut log = vec![0u8; log_length.max(0) as usize]; + let mut written = 0; + gl.GetShaderInfoLog( + shader, + log_length, + &mut written, + log.as_mut_ptr() as *mut ffi::types::GLchar, + ); + gl.DeleteShader(shader); + log.truncate(written.max(0) as usize); + Some(String::from_utf8_lossy(&log).into_owned()) + } + }) + .ok()??; + summarize_driver_log(&log) +} + +/// First error line of a driver log, with Mesa's `1:LINE(COL)` source +/// prefix rewritten to the user's file (source string 1 in the wrapper). +fn summarize_driver_log(log: &str) -> Option { + let line = log + .lines() + .map(str::trim) + .find(|line| line.to_ascii_lowercase().contains("error")) + .or_else(|| log.lines().map(str::trim).find(|line| !line.is_empty()))?; + let line = match line.split_once(':') { + Some(("1", rest)) if rest.starts_with(|c: char| c.is_ascii_digit()) => { + let digits = rest + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(rest.len()); + let (number, rest) = rest.split_at(digits); + format!("line {number}{rest}") + } + Some(("0" | "2", rest)) => match rest.split_once(": ") { + Some((_, message)) => format!("host wrapper: {message}"), + None => line.to_string(), + }, + _ => line.to_string(), + }; + let mut line: String = line.split_whitespace().collect::>().join(" "); + if line.len() > MAX_DRIVER_LOG_BYTES { + let mut end = MAX_DRIVER_LOG_BYTES; + while !line.is_char_boundary(end) { + end -= 1; + } + line.truncate(end); + line.push_str("..."); + } + Some(line) +} + +/// Per-window effect state: the clock behind `u_time` and `u_delta`, and a +/// chained definition's offscreen stage outputs. Time is sampled only when +/// the content changes, so time alone never schedules a redraw under +/// damage-driven invalidation. +pub struct ShaderInstance { + epoch: Instant, + last_update: Option, + commit: Option, + time: f32, + delta: f32, + updates: u64, + /// Outputs of every stage but the last, each sized to the capture. + targets: Vec, + /// Content identity the targets were last rendered for; `None` until a + /// chain has rendered completely. + chain_version: Option, +} + +impl ShaderInstance { + pub fn new() -> Self { + Self { + epoch: Instant::now(), + last_update: None, + commit: None, + time: 0.0, + delta: 0.0, + updates: 0, + targets: Vec::new(), + chain_version: None, + } + } + + /// `(u_time, u_delta)` for this update: seconds since the instance + /// started, modulo 4096, and seconds since the previous update clamped + /// to `0..=0.1`, zero on the first. + pub fn sample(&mut self, commit: CommitCounter) -> (f32, f32) { + if self.commit != Some(commit) { + let now = Instant::now(); + self.time = now.duration_since(self.epoch).as_secs_f32() % 4096.0; + self.delta = self + .last_update + .map(|last| now.duration_since(last).as_secs_f32().clamp(0.0, 0.1)) + .unwrap_or(0.0); + self.last_update = Some(now); + self.commit = Some(commit); + self.updates += 1; + } + (self.time, self.delta) + } + + /// The last sampled `(u_time, u_delta)`, shared by a chain's final stage. + pub fn timing(&self) -> (f32, f32) { + (self.time, self.delta) + } + + /// Content updates this instance has drawn. + pub fn updates(&self) -> u64 { + self.updates + } + + pub fn chain_version(&self) -> Option { + self.chain_version + } + + pub fn targets(&self) -> &[GlesTexture] { + &self.targets + } + + /// RGBA payload of the stage targets. + pub fn target_bytes(&self) -> u64 { + self.targets.iter().fold(0, |total, target| { + let size = target.size(); + total.saturating_add(capture_payload_bytes(size.w, size.h, 1)) + }) + } +} + +/// A stage's texture source, resolved against the backdrop and the chain's +/// stage outputs. `"get:name"` reads the output of the stage that saved it. +fn stage_texture( + which: &StageTexture, + stages: &[crate::config::ShaderStage], + targets: &[GlesTexture], + backdrop: &GlesTexture, +) -> Option { + match which { + StageTexture::Backdrop => Some(backdrop.clone()), + StageTexture::Saved(name) => stages + .iter() + .position(|stage| stage.save.as_deref() == Some(name.as_str())) + .and_then(|index| targets.get(index)) + .cloned(), + } +} + +/// Stage `index`'s positional input: its `source`, else the previous stage's +/// output, else (for the first stage) the backdrop. +pub fn stage_input( + index: usize, + stages: &[crate::config::ShaderStage], + targets: &[GlesTexture], + backdrop: &GlesTexture, +) -> Option { + match stages.get(index)?.input.as_ref() { + Some(which) => stage_texture(which, stages, targets, backdrop), + None if index == 0 => Some(backdrop.clone()), + None => targets.get(index - 1).cloned(), + } +} + +/// The extra textures a compiled program binds, in its declared order. +pub fn stage_textures( + program: &ShaderProgram, + stages: &[crate::config::ShaderStage], + targets: &[GlesTexture], + backdrop: &GlesTexture, +) -> Option> { + program + .textures + .iter() + .map(|(_, which)| stage_texture(which, stages, targets, backdrop)) + .collect() +} + +/// Hashes which textures a stage reads, so rewiring `source` or +/// `textures { }` without touching the file still counts as a change. +pub fn hash_stage_bindings( + hash: &mut impl Hasher, + stage: &crate::config::ShaderStage, + program: &ShaderProgram, +) { + stage.input.hash(hash); + program.textures.hash(hash); +} + +/// The last stage's content identity: what feeds it (the capture or the +/// chain's version) plus which textures it reads. +pub fn stage_content_version( + base: u64, + stage: &crate::config::ShaderStage, + program: &ShaderProgram, +) -> u64 { + let mut hash = std::collections::hash_map::DefaultHasher::new(); + base.hash(&mut hash); + hash_stage_bindings(&mut hash, stage, program); + hash.finish() +} + +/// `base` extended with a layer mask's content version and threshold, so a +/// changed mask redraws the effect. +pub fn with_mask_version(base: u64, mask: Option<(usize, f32)>) -> u64 { + let Some((version, threshold)) = mask else { + return base; + }; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + base.hash(&mut hash); + version.hash(&mut hash); + threshold.to_bits().hash(&mut hash); + hash.finish() +} + +/// What a chain pass produced for the panel and the frame pump. +#[derive(Default)] +pub struct ChainOutcome { + pub failures: Vec, + pub deferred: bool, +} + +/// Renders every stage of a chained definition but the last into the +/// instance's offscreen targets, in written order, when anything feeding +/// them changed. Offscreen work must happen before the visible bind, so the +/// callers run this with the backdrop captures. +#[allow(clippy::too_many_arguments)] +pub fn run_chain( + renderer: &mut GlesRenderer, + programs: &mut CustomShaderPrograms, + instance: &mut ShaderInstance, + name: &str, + definition: &crate::config::ShaderDefinition, + backdrop: &GlesTexture, + (capture_id, capture_version): (&Id, usize), + compile_budget: &mut usize, +) -> ChainOutcome { + use smithay::backend::{ + allocator::Fourcc, + renderer::{damage::OutputDamageTracker, Bind, Offscreen}, + }; + let mut outcome = ChainOutcome::default(); + let stages = &definition.stages; + let count = stages.len(); + if count < 2 { + return outcome; + } + let mut chain = Vec::with_capacity(count - 1); + for (index, stage) in stages[..count - 1].iter().enumerate() { + let lookup = programs.lookup(renderer, name, index, count, stage, compile_budget); + outcome.deferred |= lookup.deferred; + outcome.failures.extend(lookup.failure); + match lookup.program { + Some(program) => chain.push(program), + None => { + instance.chain_version = None; + return outcome; + } + } + } + let size = backdrop.size(); + // A capture's version restarts in every new capture, so its identity + // is part of the key too. + let mut hash = std::collections::hash_map::DefaultHasher::new(); + capture_id.hash(&mut hash); + capture_version.hash(&mut hash); + (size.w, size.h).hash(&mut hash); + for (stage, program) in stages.iter().zip(&chain) { + program.generation.hash(&mut hash); + hash_stage_bindings(&mut hash, stage, program); + for (param, value) in program.params.iter() { + param.hash(&mut hash); + value.hash_bits(&mut hash); + } + } + let version = hash.finish(); + if instance.chain_version == Some(version) { + return outcome; + } + instance.chain_version = None; + if instance.targets.len() != count - 1 + || instance.targets.iter().any(|target| target.size() != size) + { + instance.targets.clear(); + for _ in 1..count { + match renderer.create_buffer(Fourcc::Argb8888, size) { + Ok(target) => instance.targets.push(target), + Err(err) => { + tracing::warn!(%err, "Failed to allocate a custom shader stage texture"); + instance.targets.clear(); + return outcome; + } + } + } + } + let timing = instance.sample(CommitCounter::from(version as usize)); + let geometry = Rectangle::from_size((size.w, size.h).into()); + for (index, program) in chain.into_iter().enumerate() { + let inputs = stage_input(index, stages, &instance.targets, backdrop).zip(stage_textures( + &program, + stages, + &instance.targets, + backdrop, + )); + let Some((input, textures)) = inputs else { + return outcome; + }; + // Intermediate stages are never clipped or faded; the last stage + // applies the window's rounding and opacity once. + let element = CustomShaderElement::new( + Id::new(), + CommitCounter::default(), + input, + geometry, + program, + [0.0; 4], + 2.0, + 0.0, + 1.0, + timing, + ) + .with_textures(textures); + let rendered = renderer + .bind(&mut instance.targets[index]) + .map_err(|err| err.to_string()) + .and_then(|mut target| { + OutputDamageTracker::new((size.w, size.h), 1.0, Transform::Normal) + .render_output(renderer, &mut target, 0, &[element], [0.0; 4]) + .map(|_| ()) + .map_err(|err| format!("{err:?}")) + }); + if let Err(err) = rendered { + outcome.failures.push(format!( + "{} could not render ({err}); rendering the window without it", + stage_label(name, index, count) + )); + return outcome; + } + } + instance.chain_version = Some(version); + outcome +} + +/// Everything that changes a custom effect's pixels, except time. +/// `content_version` is the capture version, or a chain's version. +#[allow(clippy::too_many_arguments)] +pub fn custom_shader_commit( + content_version: u64, + generation: u64, + size: (i32, i32), + params: &[(String, ShaderParam)], + corner_radii: [f32; 4], + rounding_power: f32, + antialias: f32, + opacity: f32, +) -> CommitCounter { + let mut hash = std::collections::hash_map::DefaultHasher::new(); + content_version.hash(&mut hash); + generation.hash(&mut hash); + size.hash(&mut hash); + for (name, value) in params { + name.hash(&mut hash); + value.hash_bits(&mut hash); + } + for radius in corner_radii { + radius.to_bits().hash(&mut hash); + } + rounding_power.to_bits().hash(&mut hash); + antialias.to_bits().hash(&mut hash); + opacity.to_bits().hash(&mut hash); + CommitCounter::from(hash.finish() as usize) +} + +/// One stage of a window's custom effect. The last stage draws in the glass +/// layer's z-slot directly behind the window's own surfaces; earlier stages +/// draw the same element into offscreen targets. +pub struct CustomShaderElement { + id: Id, + commit: CommitCounter, + texture: GlesTexture, + geometry: Rectangle, + program: ShaderProgram, + corner_radii: [f32; 4], + rounding_power: f32, + antialias: f32, + opacity: f32, + time: f32, + delta: f32, + /// Bound to texture units 1.. in the program's `textures` order. + textures: Vec, + /// A layer's composed alpha and `ignore_alpha` threshold. Pixels whose + /// layer alpha is below the threshold draw nothing. + alpha_mask: Option<(GlesTexture, f32)>, +} + +impl CustomShaderElement { + #[allow(clippy::too_many_arguments)] + pub fn new( + id: Id, + commit: CommitCounter, + texture: GlesTexture, + geometry: Rectangle, + program: ShaderProgram, + corner_radii: [f32; 4], + rounding_power: f32, + antialias: f32, + opacity: f32, + (time, delta): (f32, f32), + ) -> Self { + Self { + id, + commit, + texture, + geometry, + program, + corner_radii, + rounding_power, + antialias, + opacity, + time, + delta, + textures: Vec::new(), + alpha_mask: None, + } + } + + pub fn with_textures(mut self, textures: Vec) -> Self { + self.textures = textures; + self + } + + pub fn with_alpha_mask(mut self, mask: GlesTexture, threshold: f32) -> Self { + self.alpha_mask = Some((mask, threshold.clamp(0.0, 1.0))); + self + } + + fn uniforms(&self) -> Vec> { + let size = self.texture.size(); + let (texture_w, texture_h) = (size.w.max(1) as f32, size.h.max(1) as f32); + let mut uniforms = Vec::with_capacity( + CONTRACT_UNIFORMS.len() + self.program.params.len() + self.program.textures.len(), + ); + uniforms.extend([ + Uniform::new( + "u_size", + [ + self.geometry.size.w.max(1) as f32, + self.geometry.size.h.max(1) as f32, + ], + ), + Uniform::new("u_texture_size", [texture_w, texture_h]), + Uniform::new("u_content_rect", [0.0, 0.0, texture_w, texture_h]), + Uniform::new("u_texel", [1.0 / texture_w, 1.0 / texture_h]), + Uniform::new("u_time", self.time), + Uniform::new("u_delta", self.delta), + Uniform::new("u_corner_radii", self.corner_radii), + Uniform::new("u_rounding_power", self.rounding_power), + Uniform::new("u_antialias", self.antialias), + ]); + uniforms.extend( + self.program + .params + .iter() + .map(|(name, value)| Uniform::new(name.as_str(), value.uniform_value())), + ); + uniforms.extend( + self.program + .textures + .iter() + .enumerate() + .map(|(unit, (name, _))| Uniform::new(name.as_str(), unit as i32 + 1)), + ); + uniforms.push(Uniform::new("u_alpha_mask", ALPHA_MASK_UNIT as i32)); + uniforms.push(Uniform::new( + "u_ignore_alpha", + self.alpha_mask + .as_ref() + .map_or(-1.0, |(_, threshold)| *threshold), + )); + uniforms + } + + fn bind_textures(&self, frame: &mut GlesFrame<'_, '_>, bind: bool) -> Result<(), GlesError> { + if self.textures.is_empty() && self.alpha_mask.is_none() { + return Ok(()); + } + let units = self + .textures + .iter() + .enumerate() + .map(|(index, texture)| (index + 1, texture)) + .chain( + self.alpha_mask + .as_ref() + .map(|(mask, _)| (ALPHA_MASK_UNIT, mask)), + ); + frame.with_context(|gl| { + for (unit, texture) in units { + // SAFETY: plain texture-unit state on the frame's current + // context, restored to unit 0 for Smithay's own draws. + unsafe { + gl.ActiveTexture(ffi::TEXTURE0 + unit as u32); + gl.BindTexture(ffi::TEXTURE_2D, if bind { texture.tex_id() } else { 0 }); + if bind { + // Smithay only sets sampling state on the unit-0 + // texture it draws; a target read solely through an + // extra unit would keep GL's mipmapped default and + // sample as an incomplete (black) texture. + let linear = ffi::LINEAR as i32; + let clamp = ffi::CLAMP_TO_EDGE as i32; + gl.TexParameteri(ffi::TEXTURE_2D, ffi::TEXTURE_MIN_FILTER, linear); + gl.TexParameteri(ffi::TEXTURE_2D, ffi::TEXTURE_MAG_FILTER, linear); + gl.TexParameteri(ffi::TEXTURE_2D, ffi::TEXTURE_WRAP_S, clamp); + gl.TexParameteri(ffi::TEXTURE_2D, ffi::TEXTURE_WRAP_T, clamp); + } + } + } + // SAFETY: as above. + unsafe { gl.ActiveTexture(ffi::TEXTURE0) }; + }) + } +} + +impl Element for CustomShaderElement { + fn id(&self) -> &Id { + &self.id + } + + fn current_commit(&self) -> CommitCounter { + self.commit + } + + fn src(&self) -> Rectangle { + Rectangle::from_size(self.texture.size().to_f64()) + } + + fn geometry(&self, _scale: Scale) -> Rectangle { + self.geometry + } + + fn alpha(&self) -> f32 { + self.opacity + } + + fn kind(&self) -> Kind { + Kind::Unspecified + } +} + +impl RenderElement for CustomShaderElement { + fn draw( + &self, + frame: &mut GlesFrame<'_, '_>, + src: Rectangle, + dst: Rectangle, + damage: &[Rectangle], + opaque_regions: &[Rectangle], + _cache: Option<&UserDataMap>, + ) -> Result<(), GlesError> { + self.bind_textures(frame, true)?; + let result = frame.render_texture_from_to( + &self.texture, + src, + dst, + damage, + opaque_regions, + Transform::Normal, + self.alpha(), + Some(&self.program.program), + &self.uniforms(), + ); + self.bind_textures(frame, false)?; + result + } +} + +#[cfg(test)] +/// A headless renderer on Mesa's software EGL device. Tests that need +/// one skip where it's missing, so they prove a real GLSL ES compile and +/// draw where they run, without making a bare CI image fail. +pub(crate) fn software_renderer() -> Option { + use smithay::backend::egl::{EGLContext, EGLDevice, EGLDisplay}; + let device = EGLDevice::enumerate() + .ok()? + .find(|device| device.is_software())?; + // SAFETY: the display owns the device handle for the renderer's life. + let display = unsafe { EGLDisplay::new(device) }.ok()?; + let context = EGLContext::new(&display).ok()?; + // SAFETY: the context is fresh and used only by this renderer. + unsafe { GlesRenderer::new(context) }.ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + const PASSTHROUGH: &str = "vec4 tide_effect(vec2 uv) {\n return texture2D(tex, uv);\n}\n"; + + #[test] + fn passthrough_satisfies_the_contract() { + assert_eq!(validate_fragment_contract(PASSTHROUGH), Ok(())); + } + + #[test] + fn helpers_structs_and_contract_uniforms_are_allowed() { + let source = "\ +struct Wave { float height; }; +const float PI = 3.14159; +float ripple(vec2 p, float t) { return sin(length(p) * 10.0 - t) * 0.01; } +// a comment mentioning main and uniform is fine +/* so is gl_FragColor in a block comment */ +vec4 tide_effect(vec2 uv) { + Wave wave; + float shift = ripple(uv * u_size, u_time), other = 1.0; + return texture2D(tex, uv + vec2(shift)) * other; +} +"; + assert_eq!(validate_fragment_contract(source), Ok(())); + } + + #[test] + fn host_owned_directives_are_rejected() { + for directive in [ + "#version 100", + "#extension GL_OES_standard_derivatives : enable", + "#include \"x\"", + "#line 4", + "#pragma optimize(off)", + ] { + let source = format!("{directive}\n{PASSTHROUGH}"); + assert!(validate_fragment_contract(&source).is_err(), "{directive}"); + } + let source = format!("#define X 1\n#ifdef X\n#endif\n{PASSTHROUGH}"); + assert_eq!(validate_fragment_contract(&source), Ok(())); + let source = format!("#define CAT(a, b) a ## b\n{PASSTHROUGH}"); + assert!(validate_fragment_contract(&source).is_err()); + } + + #[test] + fn host_owned_identifiers_and_declarations_are_rejected() { + for bad in [ + "void main() {}\n", + "uniform float strength;\n", + "precision mediump float;\n", + "varying vec2 v_other;\n", + "float alpha = 1.0;\n", + "float u_time = 1.0;\n", + "float x, u_extra;\n", + "float f(vec2 p, float u_param) { return 1.0; }\n", + "vec4 tint_it() { return vec4(tint); }\n", + "void f() { gl_FragColor = vec4(1.0); }\n", + "float tide_helper() { return 1.0; }\n", + "struct u_bad { float a; };\n", + "sampler2D tex;\n", + ] { + let source = format!("{bad}{PASSTHROUGH}"); + assert!(validate_fragment_contract(&source).is_err(), "{bad}"); + } + } + + #[test] + fn entry_point_must_be_defined_exactly_once_with_the_fixed_signature() { + assert!(validate_fragment_contract("float f() { return 1.0; }\n").is_err()); + assert!( + validate_fragment_contract("vec3 tide_effect(vec2 uv) { return vec3(0.0); }\n") + .is_err() + ); + assert!( + validate_fragment_contract("vec4 tide_effect(vec3 uv) { return vec4(0.0); }\n") + .is_err() + ); + assert!(validate_fragment_contract(&format!("{PASSTHROUGH}{PASSTHROUGH}")).is_err()); + let prototype = format!("vec4 tide_effect(vec2 uv);\n{PASSTHROUGH}"); + assert_eq!(validate_fragment_contract(&prototype), Ok(())); + } + + #[test] + fn nul_non_ascii_oversize_and_open_comments_are_rejected() { + assert!(validate_fragment_contract(&format!("{PASSTHROUGH}\0")).is_err()); + assert!(validate_fragment_contract(&format!("float café = 1.0;\n{PASSTHROUGH}")).is_err()); + assert_eq!( + validate_fragment_contract(&format!("// café\n{PASSTHROUGH}")), + Ok(()) + ); + assert!(validate_fragment_contract(&format!("{PASSTHROUGH}/* open")).is_err()); + let huge = format!("{PASSTHROUGH}{}", " ".repeat(MAX_SOURCE_BYTES)); + assert!(validate_fragment_contract(&huge).is_err()); + } + + #[test] + fn wrapper_keeps_smithay_markers_line_mapping_and_declares_params() { + let params = vec![ + ("strength".to_string(), ShaderParam::Float(0.25)), + ("tint_color".to_string(), ShaderParam::Vec4([1.0; 4])), + ]; + let source = format!("// //_DEFINES_ in a user comment\n{PASSTHROUGH}"); + let textures = vec![("mask".to_string(), StageTexture::Backdrop)]; + let wrapped = wrap_texture_fragment(&source, ¶ms, &textures); + assert!(wrapped.contains("uniform sampler2D mask;")); + assert!(wrapped.starts_with("#version 100\n")); + assert_eq!(wrapped.matches("//_DEFINES_").count(), 1); + assert!(wrapped.contains("uniform sampler2D tex;")); + assert!(wrapped.contains("uniform float alpha;")); + for (name, ty) in CONTRACT_UNIFORMS { + assert!(wrapped.contains(&format!("uniform {ty} {name};"))); + } + assert!(wrapped.contains("uniform float strength;")); + assert!(wrapped.contains("uniform vec4 tint_color;")); + let user_start = wrapped.find("#line 1 1\n").expect("user line mapping"); + let epilogue = wrapped.find("#line 1 2\n").expect("epilogue line mapping"); + assert!(user_start < epilogue); + assert!(wrapped[user_start..epilogue].contains("return texture2D(tex, uv);")); + assert!(wrapped[epilogue..].contains("tide_effect(v_coords)")); + assert!(wrapped[epilogue..].contains("gl_FragColor = color;")); + // Comment stripping keeps user line numbers aligned. + assert_eq!( + wrapped[user_start..epilogue].lines().count(), + source.lines().count() + 1 + ); + } + + #[test] + fn param_names_cannot_shadow_host_or_glsl_names() { + assert!(valid_param_name("strength")); + assert!(valid_param_name("_scale2")); + for bad in [ + "", "2x", "u_time", "gl_x", "tide_x", "tex", "alpha", "size", "main", "float", "vec4", + "for", "a__b", "has-dash", + ] { + assert!(!valid_param_name(bad), "{bad}"); + } + assert!(!valid_param_name(&"x".repeat(MAX_NAME_BYTES + 1))); + } + + #[test] + fn file_reads_require_a_bounded_regular_frag_file() { + let dir = std::env::temp_dir().join(format!("tidewm-shader-test-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + let good = dir.join("pass.frag"); + fs::write(&good, PASSTHROUGH).unwrap(); + assert_eq!(read_fragment_file(&good).as_deref(), Ok(PASSTHROUGH)); + + let wrong_extension = dir.join("pass.glsl"); + fs::write(&wrong_extension, PASSTHROUGH).unwrap(); + assert!(read_fragment_file(&wrong_extension).is_err()); + + let directory = dir.join("dir.frag"); + fs::create_dir_all(&directory).unwrap(); + assert!(read_fragment_file(&directory).is_err()); + + let fifo = dir.join("pipe.frag"); + let fifo_path = std::ffi::CString::new(fifo.as_os_str().as_encoded_bytes()).unwrap(); + // SAFETY: a valid NUL-terminated path and plain permission bits. + assert_eq!(unsafe { libc::mkfifo(fifo_path.as_ptr(), 0o600) }, 0); + assert!(read_fragment_file(&fifo).is_err()); + + let oversized = dir.join("big.frag"); + fs::write(&oversized, vec![b' '; MAX_SOURCE_BYTES + 1]).unwrap(); + assert!(read_fragment_file(&oversized).is_err()); + + let not_utf8 = dir.join("bytes.frag"); + fs::write(¬_utf8, [0xff, 0xfe]).unwrap(); + assert!(read_fragment_file(¬_utf8).is_err()); + + assert!(read_fragment_file(&dir.join("missing.frag")).is_err()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn capture_payload_rounds_each_axis_up_and_saturates() { + assert_eq!(capture_payload_bytes(1920, 1080, 1), 1920 * 1080 * 4); + assert_eq!(capture_payload_bytes(1921, 1081, 2), 961 * 541 * 4); + assert_eq!(capture_payload_bytes(3, 3, 4), 4); + assert_eq!(capture_payload_bytes(-5, 10, 1), 0); + assert_eq!( + capture_payload_bytes(i32::MAX, i32::MAX, 1), + (i32::MAX as u64).pow(2) * 4 + ); + // Two native 4K captures already use most of the aggregate budget. + assert!(2 * capture_payload_bytes(3840, 2160, 1) < MAX_PAYLOAD_BYTES); + assert!(3 * capture_payload_bytes(3840, 2160, 1) > MAX_PAYLOAD_BYTES); + } + + #[test] + fn driver_logs_are_summarized_onto_the_users_file() { + assert_eq!( + summarize_driver_log("1:2(12): error: `return' with wrong type float\n1:3: warning: x") + .as_deref(), + Some("line 2(12): error: `return' with wrong type float") + ); + assert_eq!( + summarize_driver_log("0:40(1): error: something in the prelude").as_deref(), + Some("host wrapper: error: something in the prelude") + ); + assert_eq!( + summarize_driver_log("some vendor text\nERROR: 1(2) : bad").as_deref(), + Some("ERROR: 1(2) : bad") + ); + assert_eq!(summarize_driver_log(" \n"), None); + let long = format!("1:1(1): error: {}", "é".repeat(400)); + let summary = summarize_driver_log(&long).unwrap(); + assert!(summary.len() <= MAX_DRIVER_LOG_BYTES + 3 && summary.ends_with("...")); + } + + #[test] + fn commit_tracks_content_but_not_time() { + let params = [("strength".to_string(), ShaderParam::Float(0.5))]; + let commit = |version, generation, params: &[(String, ShaderParam)], opacity| { + custom_shader_commit( + version, + generation, + (100, 80), + params, + [4.0; 4], + 2.0, + 1.0, + opacity, + ) + }; + let baseline = commit(3, 7, ¶ms, 1.0); + assert_eq!(baseline, commit(3, 7, ¶ms, 1.0)); + assert_ne!(baseline, commit(4, 7, ¶ms, 1.0)); + assert_ne!(baseline, commit(3, 8, ¶ms, 1.0)); + assert_ne!(baseline, commit(3, 7, ¶ms, 0.5)); + let changed = [("strength".to_string(), ShaderParam::Float(0.75))]; + assert_ne!(baseline, commit(3, 7, &changed, 1.0)); + } + + #[test] + fn instance_clock_advances_only_on_a_new_commit() { + let mut instance = ShaderInstance::new(); + let first = CommitCounter::from(1usize); + let (time, delta) = instance.sample(first); + assert_eq!(delta, 0.0); + std::thread::sleep(std::time::Duration::from_millis(5)); + assert_eq!(instance.sample(first), (time, delta)); + let (later, delta) = instance.sample(CommitCounter::from(2usize)); + assert!(later > time); + assert!(delta > 0.0 && delta <= 0.1); + } + + #[test] + fn source_identity_adopts_an_equal_reloaded_source() { + let original: Arc = Arc::from("vec4 a;"); + let reloaded: Arc = Arc::from("vec4 a;"); + let mut cached = Some(original.clone()); + assert!(same_source(&mut cached, &original)); + assert!(same_source(&mut cached, &reloaded)); + assert!(Arc::ptr_eq(cached.as_ref().unwrap(), &reloaded)); + assert!(!same_source(&mut cached, &Arc::from("vec4 b;"))); + assert!(!same_source(&mut None, &original)); + } + + fn stage( + user_source: &str, + params: &[(String, ShaderParam)], + textures: &[(String, StageTexture)], + input: Option, + save: Option<&str>, + ) -> crate::config::ShaderStage { + validate_fragment_contract(user_source).expect("fixture passes the contract"); + crate::config::ShaderStage { + file: String::new(), + path: Default::default(), + params: params.to_vec().into(), + input, + save: save.map(str::to_string), + textures: textures.to_vec().into(), + source: Some(Arc::from(wrap_texture_fragment( + user_source, + params, + textures, + ))), + } + } + + fn compile( + renderer: &mut GlesRenderer, + programs: &mut CustomShaderPrograms, + name: &str, + user_source: &str, + params: &[(String, ShaderParam)], + ) -> ProgramLookup { + let stage = stage(user_source, params, &[], None, None); + programs.lookup(renderer, name, 0, 1, &stage, &mut 1) + } + + const SIZE: i32 = 8; + + /// RGBA with red rising left to right and green top to bottom, so any + /// flip or transpose shows up in the readback. + fn gradient() -> Vec { + (0..SIZE) + .flat_map(|y| (0..SIZE).flat_map(move |x| [x as u8 * 32, y as u8 * 32, 128, 255])) + .collect() + } + + fn render( + renderer: &mut GlesRenderer, + program: ShaderProgram, + opacity: f32, + corner_radii: [f32; 4], + ) -> Vec { + let input = import_gradient(renderer); + render_texture(renderer, input, Vec::new(), program, opacity, corner_radii) + } + + fn render_texture( + renderer: &mut GlesRenderer, + input: GlesTexture, + textures: Vec, + program: ShaderProgram, + opacity: f32, + corner_radii: [f32; 4], + ) -> Vec { + let element = CustomShaderElement::new( + Id::new(), + CommitCounter::default(), + input, + Rectangle::from_size((SIZE, SIZE).into()), + program, + corner_radii, + 2.0, + 0.5, + opacity, + (0.0, 0.0), + ) + .with_textures(textures); + draw_element(renderer, element) + } + + /// Draws one element into a fresh `SIZE`-square target and reads it back. + fn draw_element(renderer: &mut GlesRenderer, element: CustomShaderElement) -> Vec { + use smithay::backend::{ + allocator::Fourcc, + renderer::{damage::OutputDamageTracker, Bind, ExportMem, Offscreen}, + }; + let size = smithay::utils::Size::::from((SIZE, SIZE)); + let mut output: GlesTexture = renderer + .create_buffer(Fourcc::Abgr8888, size) + .expect("allocate target"); + let mut target = renderer.bind(&mut output).expect("bind target"); + OutputDamageTracker::new((SIZE, SIZE), 1.0, Transform::Normal) + .render_output(renderer, &mut target, 0, &[element], [0.0, 0.0, 0.0, 0.0]) + .expect("draw"); + let mapping = renderer + .copy_framebuffer(&target, Rectangle::from_size(size), Fourcc::Abgr8888) + .expect("read back"); + drop(target); + renderer + .map_texture(&mapping) + .expect("map readback") + .to_vec() + } + + fn pixel(image: &[u8], x: i32, y: i32) -> [u8; 4] { + let index = ((y * SIZE + x) * 4) as usize; + image[index..index + 4].try_into().unwrap() + } + + #[test] + fn passthrough_draws_the_input_unchanged_and_upright() { + let Some(mut renderer) = software_renderer() else { + eprintln!("no software EGL device; skipping the real compile and draw"); + return; + }; + let mut programs = CustomShaderPrograms::default(); + let lookup = compile(&mut renderer, &mut programs, "pass", PASSTHROUGH, &[]); + assert!(lookup.failure.is_none()); + let image = render( + &mut renderer, + lookup.program.expect("compiled"), + 1.0, + [0.0; 4], + ); + assert_eq!(image, gradient()); + + let uv = "vec4 tide_effect(vec2 uv) {\n return vec4(uv, 0.0, 1.0);\n}\n"; + let lookup = compile(&mut renderer, &mut programs, "uv", uv, &[]); + let image = render( + &mut renderer, + lookup.program.expect("compiled"), + 1.0, + [0.0; 4], + ); + // uv (0, 0) is the top-left of the captured input. + assert!(pixel(&image, 0, 0)[0] < 32 && pixel(&image, 0, 0)[1] < 32); + assert!(pixel(&image, SIZE - 1, 0)[0] > 220 && pixel(&image, SIZE - 1, 0)[1] < 32); + assert!(pixel(&image, 0, SIZE - 1)[1] > 220); + } + + #[test] + fn host_applies_params_opacity_premultiplication_and_rounding() { + let Some(mut renderer) = software_renderer() else { + eprintln!("no software EGL device; skipping the real compile and draw"); + return; + }; + let mut programs = CustomShaderPrograms::default(); + let source = "vec4 tide_effect(vec2 uv) {\n return vec4(tint_color.rgb * 4.0, tint_color.a);\n}\n"; + let params = [( + "tint_color".to_string(), + ShaderParam::Vec4([0.5, 0.25, 0.0, 0.5]), + )]; + let program = compile(&mut renderer, &mut programs, "tint", source, ¶ms) + .program + .expect("compiled"); + + // RGB is bounded by alpha, then opacity scales the whole premultiplied color. + let image = render(&mut renderer, program.clone(), 1.0, [0.0; 4]); + assert_eq!(pixel(&image, 4, 4), [128, 128, 0, 128]); + let image = render(&mut renderer, program.clone(), 0.5, [0.0; 4]); + let faded = pixel(&image, 4, 4); + assert!(faded + .iter() + .zip([64, 64, 0, 64]) + .all(|(got, want)| got.abs_diff(want) <= 1)); + + // Rounding clips even though the shader never reads the radii. + let image = render(&mut renderer, program, 1.0, [4.0; 4]); + assert_eq!(pixel(&image, 0, 0)[3], 0); + assert_eq!(pixel(&image, 4, 4)[3], 128); + } + + #[test] + fn compile_failures_are_reported_once_and_keep_the_last_good_program() { + let Some(mut renderer) = software_renderer() else { + eprintln!("no software EGL device; skipping the real compile and draw"); + return; + }; + let mut programs = CustomShaderPrograms::default(); + // Passes the token contract, fails GLSL type checking. + let broken = "vec4 tide_effect(vec2 uv) {\n return 1.0;\n}\n"; + let first = compile(&mut renderer, &mut programs, "fx", broken, &[]); + assert!(first.program.is_none()); + let message = first.failure.expect("first failure is reported"); + assert!(message.contains("without it"), "{message}"); + // Mesa names the user's own line and column, not wrapper-relative ones. + assert!(message.contains(": line 2("), "{message}"); + assert!(message.contains("error"), "{message}"); + let again = compile(&mut renderer, &mut programs, "fx", broken, &[]); + assert!(again.program.is_none() && again.failure.is_none()); + + let good = compile(&mut renderer, &mut programs, "fx", PASSTHROUGH, &[]); + let good_generation = good.program.expect("compiled").generation; + let regressed = compile(&mut renderer, &mut programs, "fx", broken, &[]); + assert_eq!( + regressed.program.map(|program| program.generation), + Some(good_generation) + ); + assert!(regressed + .failure + .as_deref() + .is_some_and(|message| message.contains("keeping the previous program"))); + + // The budget defers a compile instead of stalling one frame on several. + let pass = stage(PASSTHROUGH, &[], &[], None, None); + let deferred = programs.lookup(&mut renderer, "other", 0, 1, &pass, &mut 0); + assert!(deferred.deferred && deferred.program.is_none()); + } + + fn import_gradient(renderer: &mut GlesRenderer) -> GlesTexture { + use smithay::backend::{allocator::Fourcc, renderer::ImportMem}; + renderer + .import_memory(&gradient(), Fourcc::Abgr8888, (SIZE, SIZE).into(), false) + .expect("import fixture") + } + + const INVERT: &str = + "vec4 tide_effect(vec2 uv) {\n vec4 c = texture2D(tex, uv);\n return vec4(c.a - c.rgb, c.a);\n}\n"; + + /// Runs a chained definition's offscreen stages, then draws its last + /// stage the way `glass_layer_elements` does. + fn render_chain( + renderer: &mut GlesRenderer, + stages: Vec, + ) -> Vec { + let definition = crate::config::ShaderDefinition { + render_scale: 1, + source: None, + stages, + }; + let backdrop = import_gradient(renderer); + let mut programs = CustomShaderPrograms::default(); + let mut instance = ShaderInstance::new(); + let outcome = run_chain( + renderer, + &mut programs, + &mut instance, + "chain", + &definition, + &backdrop, + (&Id::new(), 1), + &mut 8, + ); + assert!(outcome.failures.is_empty(), "{:?}", outcome.failures); + assert!(instance.chain_version().is_some()); + let last = definition.stages.len() - 1; + let program = programs + .lookup( + renderer, + "chain", + last, + definition.stages.len(), + &definition.stages[last], + &mut 1, + ) + .program + .expect("last stage compiles"); + let input = stage_input(last, &definition.stages, instance.targets(), &backdrop) + .expect("last stage input resolves"); + let textures = stage_textures(&program, &definition.stages, instance.targets(), &backdrop) + .expect("last stage textures resolve"); + render_texture(renderer, input, textures, program, 1.0, [0.0; 4]) + } + + fn inverted(image: &[u8]) -> Vec { + image + .chunks(4) + .flat_map(|px| [255 - px[0], 255 - px[1], 255 - px[2], px[3]]) + .collect() + } + + #[test] + fn chains_pass_stage_outputs_forward_in_written_order_and_upright() { + let Some(mut renderer) = software_renderer() else { + eprintln!("no software EGL device; skipping the real compile and draw"); + return; + }; + // Two inversions through an offscreen target give the input back, + // pixel for pixel, so the intermediate kept its orientation. + let image = render_chain( + &mut renderer, + vec![ + stage(INVERT, &[], &[], None, None), + stage(INVERT, &[], &[], None, None), + ], + ); + assert_eq!(image, gradient()); + let image = render_chain( + &mut renderer, + vec![ + stage(INVERT, &[], &[], None, None), + stage(PASSTHROUGH, &[], &[], None, None), + ], + ); + assert_eq!(image, inverted(&gradient())); + } + + #[test] + fn a_sourced_definition_reads_its_sources_output_as_the_backdrop() { + let Some(mut renderer) = software_renderer() else { + eprintln!("no software EGL device; skipping the real compile and draw"); + return; + }; + let pick = "vec4 tide_effect(vec2 uv) {\n return texture2D(other, uv);\n}\n"; + let mut definitions = std::collections::HashMap::from([ + ( + "inverse".to_string(), + crate::config::ShaderDefinition { + render_scale: 1, + source: None, + stages: vec![stage(INVERT, &[], &[], None, None)], + }, + ), + ( + "picked".to_string(), + crate::config::ShaderDefinition { + render_scale: 1, + source: Some("inverse".to_string()), + stages: vec![stage( + pick, + &[], + &[("other".to_string(), StageTexture::Backdrop)], + None, + None, + )], + }, + ), + ]); + let warnings = crate::config::expand_shader_sources(&mut definitions); + assert!(warnings.is_empty(), "{warnings:?}"); + // Without the source, `backdrop` would be the gradient itself. + let stages = definitions.remove("picked").expect("expanded").stages; + assert_eq!(render_chain(&mut renderer, stages), inverted(&gradient())); + } + + #[test] + fn shipped_example_shaders_pass_the_contract_and_compile() { + let radius = [("radius".to_string(), ShaderParam::Float(2.0))]; + type Example = ( + &'static str, + &'static str, + Vec<(String, ShaderParam)>, + Vec<(String, StageTexture)>, + ); + let examples: [Example; 4] = [ + ( + "tint", + include_str!("../../share/shaders/tint.frag"), + vec![ + ("strength".to_string(), ShaderParam::Float(0.3)), + ( + "tint_color".to_string(), + ShaderParam::Vec4([0.5, 0.8, 1.0, 1.0]), + ), + ], + Vec::new(), + ), + ( + "blur-h", + include_str!("../../share/shaders/blur-h.frag"), + radius.to_vec(), + Vec::new(), + ), + ( + "blur-v", + include_str!("../../share/shaders/blur-v.frag"), + radius.to_vec(), + Vec::new(), + ), + ( + "edge-mix", + include_str!("../../share/shaders/edge-mix.frag"), + vec![("margin".to_string(), ShaderParam::Float(0.1))], + vec![("original".to_string(), StageTexture::Backdrop)], + ), + ]; + for (name, source, _, _) in &examples { + assert_eq!(validate_fragment_contract(source), Ok(()), "{name}"); + } + let Some(mut renderer) = software_renderer() else { + eprintln!("no software EGL device; skipping the real compile and draw"); + return; + }; + let mut programs = CustomShaderPrograms::default(); + for (name, source, params, textures) in &examples { + let stage = stage(source, params, textures, None, None); + let lookup = programs.lookup(&mut renderer, name, 0, 1, &stage, &mut 1); + assert!(lookup.program.is_some(), "{name}: {:?}", lookup.failure); + } + // The documented two-pass blur keeps a flat input flat. + let flat = "vec4 tide_effect(vec2 uv) {\n return vec4(0.25, 0.5, 0.75, 1.0);\n}\n"; + let image = render_chain( + &mut renderer, + vec![ + stage(flat, &[], &[], None, None), + stage(examples[1].1, &radius, &[], None, None), + stage(examples[2].1, &radius, &[], None, None), + ], + ); + assert!(image.chunks(4).all(|px| px[0].abs_diff(64) <= 1 + && px[1].abs_diff(128) <= 1 + && px[2].abs_diff(191) <= 1)); + } + + #[test] + fn rewiring_bindings_or_a_new_capture_counts_as_a_change() { + let Some(mut renderer) = software_renderer() else { + eprintln!("no software EGL device; skipping the real compile and draw"); + return; + }; + let saved = |name: &str| StageTexture::Saved(name.to_string()); + let pick = "vec4 tide_effect(vec2 uv) {\n return texture2D(other, uv);\n}\n"; + let definition = + |binding: StageTexture, input: Option| crate::config::ShaderDefinition { + render_scale: 1, + source: None, + stages: vec![ + stage(INVERT, &[], &[], None, Some("inverse")), + stage(pick, &[], &[("other".to_string(), binding)], input, None), + ], + }; + let backdrop = import_gradient(&mut renderer); + let mut programs = CustomShaderPrograms::default(); + let mut instance = ShaderInstance::new(); + let first_capture = Id::new(); + let mut version = |definition: &crate::config::ShaderDefinition, capture: &Id| { + run_chain( + &mut renderer, + &mut programs, + &mut instance, + "rewire", + definition, + &backdrop, + (capture, 1), + &mut 8, + ); + instance.chain_version().expect("chain rendered") + }; + let original = definition(StageTexture::Backdrop, None); + let baseline = version(&original, &first_capture); + assert_eq!(baseline, version(&original, &first_capture)); + // A new capture restarts its version at the same number. + assert_ne!(baseline, version(&original, &Id::new())); + + let chain_base = 7; + let last = &original.stages[1]; + let lookup = programs.lookup(&mut renderer, "rewire", 1, 2, last, &mut 1); + let program = lookup.program.expect("last stage compiles"); + let before = stage_content_version(chain_base, last, &program); + // Same file and params, different wiring: the cached program adopts + // the new binding sources and the content identity moves. + let rewired = definition(saved("inverse"), Some(StageTexture::Backdrop)); + let last = &rewired.stages[1]; + let program = programs + .lookup(&mut renderer, "rewire", 1, 2, last, &mut 0) + .program + .expect("no recompile needed"); + assert_eq!(program.textures.to_vec(), last.textures.to_vec()); + assert_ne!(before, stage_content_version(chain_base, last, &program)); + } + + #[test] + fn a_layer_alpha_mask_hides_the_effect_where_the_layer_is_transparent() { + use smithay::backend::{ + allocator::Fourcc, + renderer::{damage::OutputDamageTracker, Bind, Offscreen}, + }; + let Some(mut renderer) = software_renderer() else { + eprintln!("no software EGL device; skipping the real compile and draw"); + return; + }; + let mut programs = CustomShaderPrograms::default(); + let program = compile(&mut renderer, &mut programs, "mask", PASSTHROUGH, &[]) + .program + .expect("compiled"); + let size = smithay::utils::Size::::from((SIZE, SIZE)); + let mut mask = |alpha: f32| { + let mut mask: GlesTexture = renderer + .create_buffer(Fourcc::Argb8888, size) + .expect("allocate mask"); + let mut target = renderer.bind(&mut mask).expect("bind mask"); + OutputDamageTracker::new((SIZE, SIZE), 1.0, Transform::Normal) + .render_output( + &mut renderer, + &mut target, + 0, + &Vec::::new(), + [0.0, 0.0, 0.0, alpha], + ) + .expect("clear mask"); + drop(target); + mask + }; + let (transparent, opaque) = (mask(0.0), mask(1.0)); + let masked = |renderer: &mut GlesRenderer, mask: GlesTexture| { + let input = import_gradient(renderer); + let element = CustomShaderElement::new( + Id::new(), + CommitCounter::default(), + input, + Rectangle::from_size((SIZE, SIZE).into()), + program.clone(), + [0.0; 4], + 2.0, + 0.5, + 1.0, + (0.0, 0.0), + ) + .with_alpha_mask(mask, 0.5); + draw_element(renderer, element) + }; + assert!(masked(&mut renderer, transparent) + .chunks(4) + .all(|px| px[3] == 0)); + assert_eq!(masked(&mut renderer, opaque), gradient()); + } + + #[test] + fn saved_outputs_and_extra_textures_bind_where_the_stage_asks() { + let Some(mut renderer) = software_renderer() else { + eprintln!("no software EGL device; skipping the real compile and draw"); + return; + }; + let pick = |name: &str| { + format!("vec4 tide_effect(vec2 uv) {{\n return texture2D({name}, uv);\n}}\n") + }; + let saved = |name: &str| StageTexture::Saved(name.to_string()); + let chain = |last: crate::config::ShaderStage| { + vec![ + stage(INVERT, &[], &[], None, Some("inverse")), + stage( + PASSTHROUGH, + &[], + &[], + Some(StageTexture::Backdrop), + Some("copy"), + ), + last, + ] + }; + // An extra binding reads a saved output other than the previous one. + let textures = [("inverse".to_string(), saved("inverse"))]; + let image = render_chain( + &mut renderer, + chain(stage(&pick("inverse"), &[], &textures, None, None)), + ); + assert_eq!(image, inverted(&gradient())); + // `tex` is the previous stage's output unless `source` says otherwise. + let image = render_chain( + &mut renderer, + chain(stage(PASSTHROUGH, &[], &[], None, None)), + ); + assert_eq!(image, gradient()); + let image = render_chain( + &mut renderer, + chain(stage(PASSTHROUGH, &[], &[], Some(saved("inverse")), None)), + ); + assert_eq!(image, inverted(&gradient())); + // Two extra units at once, mixed half and half. + let textures = [ + ("inverse".to_string(), saved("inverse")), + ("original".to_string(), StageTexture::Backdrop), + ]; + let mix = "vec4 tide_effect(vec2 uv) {\n return mix(texture2D(inverse, uv), texture2D(original, uv), 0.5);\n}\n"; + let image = render_chain(&mut renderer, chain(stage(mix, &[], &textures, None, None))); + assert!(image + .chunks(4) + .all(|px| px[..3].iter().all(|channel| channel.abs_diff(128) <= 1) && px[3] == 255)); + } +} diff --git a/src/visual/spring.rs b/src/visual/spring.rs index 29ba9d1..b9fc9b5 100644 --- a/src/visual/spring.rs +++ b/src/visual/spring.rs @@ -14,6 +14,11 @@ impl SpringCurve { /// in mass/s. Require positive finite coefficients and a conservative /// position/velocity settling bound within ten seconds before slowdown. pub fn new(mass: f64, stiffness: f64, damping: f64) -> Option { + Self::try_new(mass, stiffness, damping).ok() + } + + /// `new`, saying which check a rejected spring failed. + pub fn try_new(mass: f64, stiffness: f64, damping: f64) -> Result { if !mass.is_finite() || !stiffness.is_finite() || !damping.is_finite() @@ -21,7 +26,9 @@ impl SpringCurve { || !(0.01..=100_000.0).contains(&stiffness) || !(0.01..=10_000.0).contains(&damping) { - return None; + return Err( + "spring needs mass 0.01 to 100, stiffness 0.01 to 100000 and damping 0.01 to 10000", + ); } let mut spring = Self { beta: damping / (2.0 * mass), @@ -29,7 +36,9 @@ impl SpringCurve { settle: 0.0, }; if !spring.settled_bound(10.0) { - return None; + return Err( + "spring does not settle within ten seconds; raise its stiffness or damping", + ); } let mut low = 0.0; let mut high = 10.0; @@ -42,7 +51,7 @@ impl SpringCurve { } } spring.settle = high; - Some(spring) + Ok(spring) } fn discriminant(self) -> f64 {