detached c2e9def

#9 README refocus, user/dev docs split and containerised demo recording

state
merged · c2e9def · fast-forward
branch
feat/presentation → main
opened
bnema ·
updated

Makes the project presentable.

  • Headless backend writes frames.ffconcat with real frame timing so ffmpeg -f concat rebuilds a video; the last frame keeps its idle duration.
  • README leads with performance, games, overview, stash, hint mode and animations, then install and a compact configuration showcase. The full defaults and binds live in docs/config.md and examples/config.
  • docs/ holds user pages only (config, desktop with session locking merged in, capture, performance); developer pages move to docs/dev/ (architecture, headless, profiling).
  • tools/demo/record.sh records the README demo: NeferWL headless on the host, apps (Firefox, Mousepad, foot, btop) in a disposable Arch container, Vesper theme, no gaps. Output: docs/media/demo.mp4 and the demo.png poster.

Checks: make check, make race, make mocks-check, staticcheck ./..., shellcheck. Reviews resolved for the Go recording path and the demo script.

CommitSubjectAuthorDateSignatureStatus
c2e9deffix(demo): kill launcher process groups and trap before creating the work dirbriceGPG Verified
87924a1fix(demo): robust startup, timeline sync and cleanup in record.shbriceGPG Verified
9d71793feat(demo): record the README demo with containerised appsbriceGPG Verified
d34c114fix(demo): hide file ownership in listings and drop the GIFbriceGPG Verified
02223f0fix(demo): readable font and filled terminalsbriceGPG Verified
2470c88docs(readme): add demo video and posterbriceGPG Verified
f7cd1e3feat(demo): headless recording script for the README demobriceGPG Verified
447c74cMerge branch 'docs/readme-refocus' into feat/presentationbriceGPG Verified
a29ced4fix(headless): finalize the last frame duration in frames.ffconcatbriceGPG Verified
0ab75e3docs(readme): plain paragraph for the Go summarybriceGPG Verified
c5cb9bcdocs(readme): shorten the documentation listbriceGPG Verified
8406894docs: split user and developer documentationbriceGPG Verified
b2b939adocs(readme): lead with performance and configurationbriceGPG Verified
241d9befeat(headless): write frames.ffconcat index for video recordingbriceGPG Verified

Checks 2

26 files · +742 -340
2/2
M AGENTS.md +2 -2
@@ -26,10 +26,10 @@ Test doubles are always generated by Mockery v3 (`testify` template). Never hand
2626
2727 ## End-to-end tests (headless backend)
2828
29-Unit tests are not enough for user-facing behavior (binds, gestures, window placement, config keys). Test it end to end on the headless backend before declaring it done; see [docs/headless.md](docs/headless.md).
29+Unit tests are not enough for user-facing behavior (binds, gestures, window placement, config keys). Test it end to end on the headless backend before declaring it done; see [docs/dev/headless.md](docs/dev/headless.md).
3030
3131 - Build the branch binary (`CGO_ENABLED=0 go build -o <tmp>/neferwl ./cmd/neferwl`) and run it with a throwaway config: `<tmp>/neferwl --backend=headless --config <tmp>/config --no-xwayland --screenshot <tmp>/shots --input <script> --timeout 10s`.
32-- Input scripts drive keys and gestures (`key Super+s`, `swipe4 down`, `sleep 1s`); see the table in docs/headless.md.
32+- Input scripts drive keys and gestures (`key Super+s`, `swipe4 down`, `sleep 1s`); see the table in docs/dev/headless.md.
3333 - Logs go to `$XDG_STATE_HOME/neferwl/runs/headless/latest.log`, not stderr. Read the headless `WAYLAND_DISPLAY` from its `listening` entry.
3434 - Isolate from the user's live session: run the headless compositor, its clients and `neferwl state` with `env -u NEFERWL_STATE -u NEFERWL_SLOT WAYLAND_DISPLAY=<headless>`. `NEFERWL_STATE` wins over `WAYLAND_DISPLAY` and would read the live session's state.
3535 - Check results with `WAYLAND_DISPLAY=<headless> neferwl state` (windows, stash index/count, visibility) and the `latest.png` screenshot.
@@ -26,10 +26,10 @@ Test doubles are always generated by Mockery v3 (`testify` template). Never hand
26 26
27 ## End-to-end tests (headless backend)27 ## End-to-end tests (headless backend)
28 28
29-Unit tests are not enough for user-facing behavior (binds, gestures, window placement, config keys). Test it end to end on the headless backend before declaring it done; see [docs/headless.md](docs/headless.md).29+Unit tests are not enough for user-facing behavior (binds, gestures, window placement, config keys). Test it end to end on the headless backend before declaring it done; see [docs/dev/headless.md](docs/dev/headless.md).
30 30
31 - Build the branch binary (`CGO_ENABLED=0 go build -o <tmp>/neferwl ./cmd/neferwl`) and run it with a throwaway config: `<tmp>/neferwl --backend=headless --config <tmp>/config --no-xwayland --screenshot <tmp>/shots --input <script> --timeout 10s`.31 - Build the branch binary (`CGO_ENABLED=0 go build -o <tmp>/neferwl ./cmd/neferwl`) and run it with a throwaway config: `<tmp>/neferwl --backend=headless --config <tmp>/config --no-xwayland --screenshot <tmp>/shots --input <script> --timeout 10s`.
32-- Input scripts drive keys and gestures (`key Super+s`, `swipe4 down`, `sleep 1s`); see the table in docs/headless.md.32+- Input scripts drive keys and gestures (`key Super+s`, `swipe4 down`, `sleep 1s`); see the table in docs/dev/headless.md.
33 - Logs go to `$XDG_STATE_HOME/neferwl/runs/headless/latest.log`, not stderr. Read the headless `WAYLAND_DISPLAY` from its `listening` entry.33 - Logs go to `$XDG_STATE_HOME/neferwl/runs/headless/latest.log`, not stderr. Read the headless `WAYLAND_DISPLAY` from its `listening` entry.
34 - Isolate from the user's live session: run the headless compositor, its clients and `neferwl state` with `env -u NEFERWL_STATE -u NEFERWL_SLOT WAYLAND_DISPLAY=<headless>`. `NEFERWL_STATE` wins over `WAYLAND_DISPLAY` and would read the live session's state.34 - Isolate from the user's live session: run the headless compositor, its clients and `neferwl state` with `env -u NEFERWL_STATE -u NEFERWL_SLOT WAYLAND_DISPLAY=<headless>`. `NEFERWL_STATE` wins over `WAYLAND_DISPLAY` and would read the live session's state.
35 - Check results with `WAYLAND_DISPLAY=<headless> neferwl state` (windows, stash index/count, visibility) and the `latest.png` screenshot.35 - Check results with `WAYLAND_DISPLAY=<headless> neferwl state` (windows, stash index/count, visibility) and the `latest.png` screenshot.
M README.md +78 -265
@@ -4,90 +4,33 @@
44 <a href="LICENSE"><img src="https://img.shields.io/badge/license-GPLv3-blue?style=flat-square" alt="License: GPLv3"></a>
55 <a href="https://github.com/bnema/neferwl"><img src="https://img.shields.io/badge/platform-Linux-blue?style=flat-square" alt="Platform: Linux"></a>
66 <a href="https://github.com/bnema/neferwl/commits/main"><img src="https://img.shields.io/github/last-commit/bnema/neferwl/main?style=flat-square&logo=github" alt="Last commit"></a>
7- <a href="https://github.com/bnema/neferwl/stargazers"><img src="https://img.shields.io/github/stars/bnema/neferwl?style=flat-square&logo=github" alt="GitHub stars"></a>
87 </p>
98
109 <p align="center">A Wayland compositor that spends its frames on your apps, not on itself.</p>
1110
11+<p align="center"><a href="docs/media/demo.mp4"><img src="docs/media/demo.png" alt="NeferWL demo: tiling, stash and overview" width="800"></a></p>
12+<p align="center"><sub>Stash, overview and animations, recorded on the headless backend at 1080p.</sub></p>
13+
1214 > [!WARNING]
1315 > **Early alpha, but stable enough for daily use.** Developed and tested mostly on AMD CPUs and GPUs. NVIDIA support is incomplete and untested (contribs welcome). Expect bugs and breaking config changes.
1416
1517 ## Why NeferWL
1618
17-I wanted a compositor I would never have to think about. It takes the newest Wayland protocols and kernel features as they land, gives games the whole GPU, and otherwise stays out of sight.
18-
19-It is small on purpose, and it will stay small. No blur, no shadows, no rounded corners, no themes. No built-in bar or wallpaper either: bring your own, any layer-shell client works.
20-
21-Animations are short, cheap and off by default (experimental, `animations = on`).
22-
23-About 40–80 MB of RAM with two 4K monitors, and almost no CPU while the screen does not change. See [Performance](docs/performance.md).
24-
25-## OK, but why Go?
26-
27-Most Wayland compositors are written in C (wlroots, Sway, Mutter), C++ (KWin, Hyprland, gamescope) or Rust (niri, COSMIC). The usual objection to Go is the garbage collector. It is a fair one, so here is how NeferWL deals with it, and why Go turns out to fit a compositor well.
28-
29-### A compositor is a concurrency problem
30-
31-A compositor sits in the middle of many independent streams that never stop: keyboard and pointer events, client requests and buffer commits, one vblank and page-flip cycle per monitor, frame fences from the GPU, presentation feedback, screen captures, config reloads, hotplug, idle and lock state. None of them should wait on another. A slow client must not delay a flip, and rendering must not delay input.
32-
33-Go has this built in. Goroutines are cheap, channels carry messages between them, and the runtime spreads them over every CPU core. NeferWL follows one idiom everywhere: **each piece of state has one owner goroutine, and others talk to it over channels.**
34-
35-- **Input** runs on its own thread, reads libinput and sends events to core.
36-- **Core** owns windows, workspaces and focus. It turns input and client events into layouts and scenes.
37-- **The Wayland server** owns client connections. It sends client events and surface content, and receives commands back.
38-- **Each output** owns its DRM state, renders its scenes with Vulkan, commits frames and reports flips and presentation times.
39-- **Capture, config, idle, sessions and leases** each run in their own goroutine.
40-
41-About 35 channels connect them, all created in one place, so the full data flow is easy to read. There is no mutex on window state, no lock ordering to get wrong, and the race detector checks every test. When CAP_SYS_NICE is granted, the input and output goroutines lock their OS thread and request real-time scheduling, so a busy CPU cannot delay a cursor move or a flip.
42-
43-### Keeping the garbage collector out of the way
44-
45-- **The hot path does not allocate.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse memory owned by the goroutine that handles them. `make check` runs allocation guards that fail the build when one of these paths starts allocating.
46-- **The GC has almost nothing to collect.** Client buffers live in shared and GPU memory, not on the Go heap, so the live heap is a few megabytes. Playing a 4K HDR video with 65 tiled subsurfaces committing every frame, GC takes under 9 % of a process that itself takes 13 % of a core. See [Performance](docs/performance.md).
47-- **No cgo.** NeferWL builds with `CGO_ENABLED=0`. libwayland, Vulkan and libinput are loaded at runtime through [purego](https://github.com/ebitengine/purego) with fixed-arity calls: no reflection, no allocation at the boundary.
48-- **Work only when something changes.** Damage tracking redraws only what changed, surfaces hidden behind opaque ones are skipped, and an idle output does nothing.
49-- **Tuned on real hardware.** Under VRR, cursor moves ride on the game's next frame instead of forcing their own refresh, and game frames keep a short gap after each flip so the panel does not fall back to its slowest rate.
50-
51-I also like bringing more tools to the Go ecosystem. NeferWL grew its own libraries along the way, and other Go projects can use them:
52-
53-- [purego-libwayland](https://github.com/bnema/purego-libwayland): libwayland-server without cgo.
54-- [purego-vulkan](https://github.com/bnema/purego-vulkan): Vulkan without cgo, generated from `vk.xml`.
55-- [go-wayland-bindings](https://github.com/bnema/go-wayland-bindings): Wayland protocol bindings, generated from the upstream XML.
56-- [wlturbo](https://github.com/bnema/wlturbo): a fast Wayland client.
57-- [neferclient](https://github.com/bnema/neferclient): a client toolkit on top of them: connection, outputs, surface roles, seat and dmabuf presentation.
19+- **Performance:** allocation-free hot paths, no wasted CPU passes and no cgo; about 40–80 MB of RAM with two 4K monitors. On 4K HDR video, GC takes under 9% of a process using 13% of a core; see [Architecture](docs/dev/architecture.md#keeping-the-garbage-collector-out-of-the-way) and [Performance](docs/performance.md).
20+- **Games:** direct scanout, VRR, tearing, explicit sync and [HDR](docs/config.md#hdr) give games the whole GPU instead of spending it on composition; see [Performance](docs/performance.md).
21+- **Overview:** `cmd+o` or a four-finger swipe up shows live workspace previews. Select a window with the keyboard, touchpad or mouse; see [Overview](docs/config.md#overview).
22+- **Stash:** `cmd+s` toggles a floating strip of windows set aside for the current workspace; `cmd+shift+s` moves a window into or out of it. See [Stash](docs/config.md#stash).
23+- **Hint mode:** click links and buttons by typing their labels, without reaching for the mouse. Enable experimental `hint.enable` and add a bind; see [Hint mode](docs/config.md#hint-mode).
24+- **Animations:** short, cheap and off by default. Opt in with experimental `animations = on`; see [Configuration](docs/config.md#keys).
5825
59-## Features
60-
61-### Display and games
62-
63-- **Direct scanout:** eligible fullscreen buffers go straight to the display, without composition.
64-- **VRR and tearing:** enabled by default for supported fullscreen use (`render.vrr`, `render.tearing`). Tearing requires direct scanout and a client request.
65-- **Explicit sync:** client fences go to the GPU and KMS. Enabled by default, with no flag to set.
66-- **HDR:** HDR10 on capable displays, with configurable brightness for SDR content.
67-- **Multi-monitor:** separate workspaces and fractional scale per output. Workspaces move when a monitor is unplugged and return when it reconnects.
68-- **Hidden apps rest:** a window you cannot see (another workspace, a hidden stash, scrolled off screen, under a fullscreen window) gets frame callbacks once per second and the suspended state. Browsers and games stop drawing frames nobody sees. A window being captured keeps its output's refresh rate.
69-- **X11 apps:** Steam and other X11 clients run through xwayland-satellite. Wine can run natively on Wayland.
26+Written in Go, without cgo. Each piece of state has one owner goroutine, connected to the others by channels; hot paths reuse memory and client buffers stay off the Go heap. See [Architecture](docs/dev/architecture.md) for the concurrency model, the GC strategy and the reusable Go libraries.
7027
71-### Windows and workspaces
72-
73-- **Cascade tiling (default):** an original layout. Columns fill the screen side by side, then continue in a new band below; scroll bands vertically and switch workspaces sideways. See [Cascade](docs/config.md#cascade).
74-- **Other layouts:** scroll horizontally, as in PaperWM and niri, or keep columns on screen in a fixed spiral. Choose per output or workspace.
75-- **Overview:** `cmd+o` or a four-finger swipe up shows live workspace previews. Select a window with the keyboard, touchpad or mouse.
76-- **Stash:** `cmd+s` toggles a floating strip of windows set aside for the current workspace. `cmd+shift+s` moves a window into or out of it.
77-- **Workspaces:** numbered workspaces appear as needed. Named workspaces can start commands and place their windows in predefined columns.
78-
79-### Desktop integration
80-
81-Bring your own desktop UI: any layer-shell client, such as Noctalia or Waybar, works. For capture and idle management, see [nefercap](https://github.com/bnema/nefercap) and [neferafk](https://github.com/bnema/neferafk).
82-
83-Configuration is a flat `key = value` file that reloads on save. A JSON state file exposes outputs, workspaces and windows to scripts. See [Desktop integration](docs/desktop.md).
28+Small on purpose, and staying small: no blur, shadows, rounded corners or themes. No built-in bar or wallpaper either: bring your own, any layer-shell client works.
8429
8530 ## Install
8631
8732 ### Arch Linux
8833
89-Choose an AUR package:
90-
9134 ```sh
9235 paru -S neferwl-bin # latest release, pre-built for x86_64 and aarch64
9336 paru -S neferwl-git # latest main, built from source
@@ -110,17 +53,13 @@ sha256sum -c --ignore-missing checksums.txt
11053 sudo apt install ./neferwl_<version>_amd64.deb
11154 ```
11255
113-Optional tools:
114-
11556 ```sh
11657 sudo apt install foot fuzzel xdg-desktop-portal-gtk xdg-desktop-portal-gnome
11758 ```
11859
11960 X11 apps need [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7 or later, which you may have to build. The package is tested on Ubuntu 24.04 and 26.04; Debian with the same library packages should work but is untested.
12061
121-### What the packages provide
122-
123-The Arch and Debian packages provide:
62+The packages provide:
12463
12564 - `neferwl` with CAP_SYS_NICE for real-time scheduling.
12665 - A **NeferWL** session for display managers such as Ly, GDM and SDDM.
@@ -134,18 +73,6 @@ Copy `/usr/share/doc/neferwl/config.example` to `~/.config/neferwl/config` to st
13473
13574 Download a `linux_amd64` or `linux_arm64` archive from [Releases](https://github.com/bnema/neferwl/releases) and verify it against `checksums.txt`. To build locally, run `make bin`; the binary is `bin/neferwl`.
13675
137-## Hardware support
138-
139-NeferWL requires Linux, atomic KMS and a Vulkan 1.3 device that can export images as dmabufs. It reports an error at startup if these requirements are not met.
140-
141-| GPU | Status |
142-| --- | --- |
143-| AMD | Developed and tested on radv |
144-| NVIDIA | Untested; explicit sync is implemented, but support is incomplete |
145-| Intel | Untested |
146-
147-Reports from NVIDIA and Intel users are welcome.
148-
14976 ## Configuration
15077
15178 The default file is `$XDG_CONFIG_HOME/neferwl/config`, or `~/.config/neferwl/config`.
@@ -167,211 +94,97 @@ workspace.dev.column.2 = 33%, foot
16794 bind.cmd+d = workspace dev
16895 ```
16996
170-See [Configuration](docs/config.md) for all keys, actions and default bindings, or [examples/config](examples/config) for commented defaults.
171-
17297 - Use one `key = value` per line; `#` starts a comment.
17398 - Missing keys use defaults. Invalid lines warn and keep that key's default; other lines still apply.
17499 - Save to reload. The log lists changed keys.
175100 - Use `neferwl --config <path>` to select a file, or `neferwl validate-config [path]` to check it without starting the compositor.
176101
177-### Default configuration
178-
179-<details>
180-<summary>All default settings and key bindings</summary>
102+### Common settings
103+
104+| Key | Default | Meaning |
105+| --- | --- | --- |
106+| `keyboard.layout` | system | xkb layout, e.g. `fr` |
107+| `keyboard.cmd` | `super` | Modifier used as `cmd` |
108+| `terminal` | `$TERMINAL`, then `foot` | Terminal command |
109+| `terminal.auto-open` | `first` | Open on initial numbered workspace; also `all` or `off` |
110+| `startup` | none | Session-start commands, separated by `;` |
111+| `layout.max-columns` | `2` | Columns sharing the screen |
112+| `layout.overflow` | `cascade` | Overflow layout: `cascade`, `scroll`, `fixed` |
113+| `layout.gaps` | `0` | Space between windows |
114+| `output.<name>` | `preferred` | Mode: `WxH@Hz`, `preferred` or `off` |
115+| `output.<name>.scale` | `1` | Fractional scale, 1 to 4 |
116+| `output.<name>.hdr` | `off` | HDR on capable displays |
117+| `touchpad.natural-scroll` | `off` | Content follows fingers |
118+| `stash.width` | `80` | Selected stash window width, percent |
119+| `hint.enable` | `off` | Experimental keyboard clicking; restart required |
120+| `animations` | `off` | Experimental layout and swipe animations |
121+
122+### Default key bindings
123+
124+`cmd` means Super by default. Hint mode has no default binding.
125+
126+| Keys | Action |
127+| --- | --- |
128+| `cmd+return` | Open terminal |
129+| `ctrl+cmd+space` | Launch fuzzel |
130+| `cmd+q` | Close window |
131+| `cmd+arrows` / `cmd+h/j/k/l` | Focus columns and windows |
132+| `cmd+shift+arrows` | Move columns and windows |
133+| `cmd+o` | Toggle overview |
134+| `cmd+s` | Show/hide stash |
135+| `cmd+shift+s` | Move window into/out of stash |
136+| `cmd+shift+f` | Toggle fullscreen |
137+| `cmd+shift+space` | Toggle floating |
138+| `cmd+1`…`9` (physical digit keys) | Focus numbered workspace |
139+| `cmd+pageup/pagedown` | Previous/next workspace |
140+
141+See [Configuration](docs/config.md) for all keys, actions, bindings and automatic-terminal behavior, or [examples/config](examples/config) for commented defaults.
181142
182-These values apply without a config file. Empty values use the system setting; `terminal` uses `$TERMINAL`, then `foot`. No startup commands or named workspaces are configured by default. `cmd` means Super.
143+## Features
183144
184-```text
185-# Keyboard and commands
186-keyboard.layout =
187-keyboard.variant =
188-keyboard.options =
189-keyboard.repeat-rate = 25
190-keyboard.repeat-delay = 600
191-keyboard.cmd = super
192-terminal =
193-terminal.auto-open = first
194-xwayland = xwayland-satellite
195-
196-# Appearance and layout
197-background = #111111
198-border.width = 2
199-border.active = #808080
200-border.inactive = #111111
201-floating.dim = 0.3
202-layout.gaps = 0
203-layout.max-columns = 2
204-layout.overflow = cascade
205-stash.width = 80
206-stash.gap = 2
207-stash.dim = 0.5
208-stash.capture = on
209-stash.spawn-terminal = on
210-
211-# Input
212-touchpad.natural-scroll = off
213-touchpad.tap = on
214-touchpad.accel-speed = 0
215-touchpad.accel-profile = adaptive
216-touchpad.left-handed = off
217-touchpad.scroll-factor = 1
218-mouse.natural-scroll = off
219-mouse.accel-speed = 0
220-mouse.accel-profile = adaptive
221-mouse.left-handed = off
222-cursor.hide-after = 5s
223-
224-# Focus and animations
225-focus.follow-move = off
226-animations = off
227-animations.speed = normal
228-focus.flash = on
229-focus.flash-width = 2
230-focus.flash-opacity = 0.6
231-focus.flash-duration = 200ms
232-
233-# Rendering and logging
234-render.direct-scanout = on
235-render.tearing = on
236-render.vrr = on
237-render.vrr-flip-gap = 1ms
238-performance.realtime = on
239-log.level = info
240-log.debug =
241-
242-# Launch, close and session
243-bind.cmd+return = spawn-terminal
244-bind.ctrl+cmd+space = spawn fuzzel
245-bind.cmd+q = close-window
246-bind.ctrl+alt+backspace = quit
247-
248-# Focus and move
249-bind.cmd+left = focus-column-left
250-bind.cmd+right = focus-column-right
251-bind.cmd+up = focus-window-up
252-bind.cmd+down = focus-window-down
253-bind.cmd+h = focus-column-left
254-bind.cmd+l = focus-column-right
255-bind.cmd+k = focus-window-up
256-bind.cmd+j = focus-window-down
257-bind.cmd+shift+left = move-column-left
258-bind.cmd+shift+right = move-column-right
259-bind.cmd+shift+h = move-column-left
260-bind.cmd+shift+l = move-column-right
261-bind.cmd+shift+up = move-window-up
262-bind.cmd+shift+down = move-window-down
263-bind.cmd+shift+k = move-window-up
264-bind.cmd+shift+j = move-window-down
265-bind.cmd+bracketleft = consume-or-expel-window-left
266-bind.cmd+bracketright = consume-or-expel-window-right
267-
268-# Resize and window modes
269-bind.cmd+alt+left = set-column-width -1
270-bind.cmd+alt+right = set-column-width +1
271-bind.cmd+alt+up = set-window-height -10%
272-bind.cmd+alt+down = set-window-height +10%
273-bind.cmd+alt+h = set-column-width -1
274-bind.cmd+alt+l = set-column-width +1
275-bind.cmd+alt+k = set-window-height -10%
276-bind.cmd+alt+j = set-window-height +10%
277-bind.cmd+shift+space = toggle-floating
278-bind.cmd+r = cycle-column-width
279-bind.cmd+f = maximize-column
280-bind.cmd+shift+f = toggle-fullscreen
281-bind.cmd+s = toggle-stash-visible
282-bind.cmd+shift+s = toggle-window-stash
283-bind.cmd+o = toggle-overview
284-# bind.cmd+g = hint
285-bind.cmd+tab = switch-column-next
286-bind.cmd+shift+tab = switch-column-prev
287-
288-# Workspaces
289-bind.cmd+pageup = focus-workspace-prev
290-bind.cmd+pagedown = focus-workspace-next
291-bind.cmd+shift+pageup = move-column-to-workspace-prev
292-bind.cmd+shift+pagedown = move-column-to-workspace-next
293-bind.cmd+ctrl+shift+up = move-workspace-prev
294-bind.cmd+ctrl+shift+down = move-workspace-next
295-bind.cmd+ctrl+shift+k = move-workspace-prev
296-bind.cmd+ctrl+shift+j = move-workspace-next
297-bind.cmd+code:2 = focus-workspace 1
298-bind.cmd+code:3 = focus-workspace 2
299-bind.cmd+code:4 = focus-workspace 3
300-bind.cmd+code:5 = focus-workspace 4
301-bind.cmd+code:6 = focus-workspace 5
302-bind.cmd+code:7 = focus-workspace 6
303-bind.cmd+code:8 = focus-workspace 7
304-bind.cmd+code:9 = focus-workspace 8
305-bind.cmd+code:10 = focus-workspace 9
306-bind.cmd+shift+code:2 = move-column-to-workspace 1
307-bind.cmd+shift+code:3 = move-column-to-workspace 2
308-bind.cmd+shift+code:4 = move-column-to-workspace 3
309-bind.cmd+shift+code:5 = move-column-to-workspace 4
310-bind.cmd+shift+code:6 = move-column-to-workspace 5
311-bind.cmd+shift+code:7 = move-column-to-workspace 6
312-bind.cmd+shift+code:8 = move-column-to-workspace 7
313-bind.cmd+shift+code:9 = move-column-to-workspace 8
314-bind.cmd+shift+code:10 = move-column-to-workspace 9
315-
316-# Monitors and scale
317-bind.cmd+ctrl+left = focus-monitor-left
318-bind.cmd+ctrl+right = focus-monitor-right
319-bind.cmd+ctrl+h = focus-monitor-left
320-bind.cmd+ctrl+l = focus-monitor-right
321-bind.cmd+ctrl+shift+left = move-workspace-to-monitor-left
322-bind.cmd+ctrl+shift+right = move-workspace-to-monitor-right
323-bind.cmd+ctrl+shift+h = move-workspace-to-monitor-left
324-bind.cmd+ctrl+shift+l = move-workspace-to-monitor-right
325-bind.cmd+code:13 = scale-up
326-bind.cmd+code:12 = scale-down
327-```
145+### Display and games
328146
329-The focus flash marks the window, column or output an action changes with a brief
330-inner border. Workspace switches and mouse focus do not trigger a flash.
331-It runs when `animations = on`.
147+- Eligible fullscreen buffers use direct scanout; VRR and tearing are on by default for supported fullscreen use (`render.vrr`, `render.tearing`). Tearing requires direct scanout and a client request.
148+- Explicit sync carries client fences to the GPU and KMS, enabled by default with no flag. HDR10 on capable displays includes configurable SDR brightness.
149+- Separate workspaces and fractional scale per output; workspaces move when a monitor is unplugged and return when it reconnects.
150+- Hidden apps get frame callbacks once per second and the suspended state, whether on another workspace, in a hidden stash, off screen or under fullscreen. Captured windows keep their output's refresh rate.
151+- Steam and other X11 clients run through xwayland-satellite; Wine can run natively on Wayland.
332152
333-Per-output defaults (replace `<name>` with a connector such as `DP-1`):
153+### Windows and workspaces
334154
335-```text
336-output.<name> = preferred
337-output.<name>.scale = 1
338-output.<name>.transform = normal
339-output.<name>.primary = off
340-output.<name>.offset = 0
341-output.<name>.hdr = off
342-output.<name>.sdr-brightness = 203
343-```
155+- [Cascade tiling](docs/config.md#cascade) is the default; horizontal scrolling (as in PaperWM and niri) and fixed spiral layouts are available per output or workspace.
156+- Numbered workspaces appear as needed; named workspaces can start commands and place windows in predefined columns.
157+- Overview and stash keep workspaces and set-aside windows close at hand, with keyboard, pointer and touchpad navigation.
344158
345-Outputs are arranged automatically unless `.right-of`, `.left-of`, `.above` or `.below` is set. Per-output layouts inherit `layout.*`; named workspaces inherit their output's layout and size, use the focused output unless `.monitor` is set, and have no predefined columns. Media keys have no default bindings.
159+### Desktop integration
346160
347-See [Configuration](docs/config.md) for accepted values and workspace options.
161+Bring your own layer-shell UI, such as Noctalia or Waybar. For capture and idle management, see [nefercap](https://github.com/bnema/nefercap) and [neferafk](https://github.com/bnema/neferafk).
348162
349-</details>
163+Configuration is a flat `key = value` file that reloads on save. A JSON state file exposes outputs, workspaces and windows to scripts; see [Desktop integration](docs/desktop.md).
350164
351-### Automatic terminal
165+## Hardware support
352166
353-The command comes from `terminal`, then `$TERMINAL`, then `foot`.
167+NeferWL requires Linux, atomic KMS and a Vulkan 1.3 device that can export images as dmabufs. It reports an error at startup if these requirements are not met.
354168
355-| `terminal.auto-open` | Behaviour |
169+| GPU | Status |
356170 | --- | --- |
357-| `first` | Open once on the initial numbered workspace |
358-| `all` | Open on each visible empty workspace without predefined slots |
359-| `off` | Do not open automatically |
171+| AMD | Developed and tested on radv |
172+| NVIDIA | Untested; explicit sync is implemented, but support is incomplete |
173+| Intel | Untested |
360174
361-`--no-terminal` disables automatic opening for one run; `spawn-terminal` still works. Launched programs inherit `$SHELL`, `$EDITOR` and `$VISUAL`.
175+Reports from NVIDIA and Intel users are welcome.
362176
363177 ## Documentation
364178
365-- [Configuration](docs/config.md): keys, actions, bindings and HDR.
366-- [Desktop integration](docs/desktop.md): sessions, clipboard, input methods, X11 apps and scripting.
367-- [Session locking](docs/session-lock.md): external lockers, recovery and security limits.
179+- [Configuration](docs/config.md): keys, actions, bindings, layouts, overview, stash, hint mode and HDR.
180+- [Desktop integration](docs/desktop.md): sessions, clipboard, input methods, X11 apps, session locking and scripting.
368181 - [Screen capture](docs/capture.md): protocols, indicator, executable allowlist and portals.
369-- [Headless mode](docs/headless.md): screenshots, input scripts and input-method testing without a screen.
370-- [Performance](docs/performance.md): VRR, real-time priority, profiling and frame stats.
182+- [Performance](docs/performance.md): VRR and real-time priority.
183+- [Developer documentation](docs/dev/index.md): architecture, headless testing, profiling and frame stats.
371184
372185 ## Contributing
373186
374-Open an issue and wait for acceptance before sending a pull request, including for bug fixes. Pull requests without an accepted issue are closed.
187+Issues are welcome, including NVIDIA and Intel reports. Please open an issue before a large pull request; small fixes are fine directly.
375188
376189 Before submitting, run `make check`, `make race`, `make mocks-check` and `staticcheck ./...`. See [AGENTS.md](AGENTS.md) for repository rules.
377190
@@ -4,90 +4,33 @@
4 <a href="LICENSE"><img src="https://img.shields.io/badge/license-GPLv3-blue?style=flat-square" alt="License: GPLv3"></a>4 <a href="LICENSE"><img src="https://img.shields.io/badge/license-GPLv3-blue?style=flat-square" alt="License: GPLv3"></a>
5 <a href="https://github.com/bnema/neferwl"><img src="https://img.shields.io/badge/platform-Linux-blue?style=flat-square" alt="Platform: Linux"></a>5 <a href="https://github.com/bnema/neferwl"><img src="https://img.shields.io/badge/platform-Linux-blue?style=flat-square" alt="Platform: Linux"></a>
6 <a href="https://github.com/bnema/neferwl/commits/main"><img src="https://img.shields.io/github/last-commit/bnema/neferwl/main?style=flat-square&logo=github" alt="Last commit"></a>6 <a href="https://github.com/bnema/neferwl/commits/main"><img src="https://img.shields.io/github/last-commit/bnema/neferwl/main?style=flat-square&logo=github" alt="Last commit"></a>
7- <a href="https://github.com/bnema/neferwl/stargazers"><img src="https://img.shields.io/github/stars/bnema/neferwl?style=flat-square&logo=github" alt="GitHub stars"></a>
8 </p>7 </p>
9 8
10 <p align="center">A Wayland compositor that spends its frames on your apps, not on itself.</p>9 <p align="center">A Wayland compositor that spends its frames on your apps, not on itself.</p>
11 10
11+<p align="center"><a href="docs/media/demo.mp4"><img src="docs/media/demo.png" alt="NeferWL demo: tiling, stash and overview" width="800"></a></p>
12+<p align="center"><sub>Stash, overview and animations, recorded on the headless backend at 1080p.</sub></p>
13+
12 > [!WARNING]14 > [!WARNING]
13 > **Early alpha, but stable enough for daily use.** Developed and tested mostly on AMD CPUs and GPUs. NVIDIA support is incomplete and untested (contribs welcome). Expect bugs and breaking config changes.15 > **Early alpha, but stable enough for daily use.** Developed and tested mostly on AMD CPUs and GPUs. NVIDIA support is incomplete and untested (contribs welcome). Expect bugs and breaking config changes.
14 16
15 ## Why NeferWL17 ## Why NeferWL
16 18
17-I wanted a compositor I would never have to think about. It takes the newest Wayland protocols and kernel features as they land, gives games the whole GPU, and otherwise stays out of sight.19+- **Performance:** allocation-free hot paths, no wasted CPU passes and no cgo; about 40–80 MB of RAM with two 4K monitors. On 4K HDR video, GC takes under 9% of a process using 13% of a core; see [Architecture](docs/dev/architecture.md#keeping-the-garbage-collector-out-of-the-way) and [Performance](docs/performance.md).
18-20+- **Games:** direct scanout, VRR, tearing, explicit sync and [HDR](docs/config.md#hdr) give games the whole GPU instead of spending it on composition; see [Performance](docs/performance.md).
19-It is small on purpose, and it will stay small. No blur, no shadows, no rounded corners, no themes. No built-in bar or wallpaper either: bring your own, any layer-shell client works.21+- **Overview:** `cmd+o` or a four-finger swipe up shows live workspace previews. Select a window with the keyboard, touchpad or mouse; see [Overview](docs/config.md#overview).
20-22+- **Stash:** `cmd+s` toggles a floating strip of windows set aside for the current workspace; `cmd+shift+s` moves a window into or out of it. See [Stash](docs/config.md#stash).
21-Animations are short, cheap and off by default (experimental, `animations = on`).23+- **Hint mode:** click links and buttons by typing their labels, without reaching for the mouse. Enable experimental `hint.enable` and add a bind; see [Hint mode](docs/config.md#hint-mode).
22-24+- **Animations:** short, cheap and off by default. Opt in with experimental `animations = on`; see [Configuration](docs/config.md#keys).
23-About 40–80 MB of RAM with two 4K monitors, and almost no CPU while the screen does not change. See [Performance](docs/performance.md).
24-
25-## OK, but why Go?
26-
27-Most Wayland compositors are written in C (wlroots, Sway, Mutter), C++ (KWin, Hyprland, gamescope) or Rust (niri, COSMIC). The usual objection to Go is the garbage collector. It is a fair one, so here is how NeferWL deals with it, and why Go turns out to fit a compositor well.
28-
29-### A compositor is a concurrency problem
30-
31-A compositor sits in the middle of many independent streams that never stop: keyboard and pointer events, client requests and buffer commits, one vblank and page-flip cycle per monitor, frame fences from the GPU, presentation feedback, screen captures, config reloads, hotplug, idle and lock state. None of them should wait on another. A slow client must not delay a flip, and rendering must not delay input.
32-
33-Go has this built in. Goroutines are cheap, channels carry messages between them, and the runtime spreads them over every CPU core. NeferWL follows one idiom everywhere: **each piece of state has one owner goroutine, and others talk to it over channels.**
34-
35-- **Input** runs on its own thread, reads libinput and sends events to core.
36-- **Core** owns windows, workspaces and focus. It turns input and client events into layouts and scenes.
37-- **The Wayland server** owns client connections. It sends client events and surface content, and receives commands back.
38-- **Each output** owns its DRM state, renders its scenes with Vulkan, commits frames and reports flips and presentation times.
39-- **Capture, config, idle, sessions and leases** each run in their own goroutine.
40-
41-About 35 channels connect them, all created in one place, so the full data flow is easy to read. There is no mutex on window state, no lock ordering to get wrong, and the race detector checks every test. When CAP_SYS_NICE is granted, the input and output goroutines lock their OS thread and request real-time scheduling, so a busy CPU cannot delay a cursor move or a flip.
42-
43-### Keeping the garbage collector out of the way
44-
45-- **The hot path does not allocate.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse memory owned by the goroutine that handles them. `make check` runs allocation guards that fail the build when one of these paths starts allocating.
46-- **The GC has almost nothing to collect.** Client buffers live in shared and GPU memory, not on the Go heap, so the live heap is a few megabytes. Playing a 4K HDR video with 65 tiled subsurfaces committing every frame, GC takes under 9 % of a process that itself takes 13 % of a core. See [Performance](docs/performance.md).
47-- **No cgo.** NeferWL builds with `CGO_ENABLED=0`. libwayland, Vulkan and libinput are loaded at runtime through [purego](https://github.com/ebitengine/purego) with fixed-arity calls: no reflection, no allocation at the boundary.
48-- **Work only when something changes.** Damage tracking redraws only what changed, surfaces hidden behind opaque ones are skipped, and an idle output does nothing.
49-- **Tuned on real hardware.** Under VRR, cursor moves ride on the game's next frame instead of forcing their own refresh, and game frames keep a short gap after each flip so the panel does not fall back to its slowest rate.
50-
51-I also like bringing more tools to the Go ecosystem. NeferWL grew its own libraries along the way, and other Go projects can use them:
52-
53-- [purego-libwayland](https://github.com/bnema/purego-libwayland): libwayland-server without cgo.
54-- [purego-vulkan](https://github.com/bnema/purego-vulkan): Vulkan without cgo, generated from `vk.xml`.
55-- [go-wayland-bindings](https://github.com/bnema/go-wayland-bindings): Wayland protocol bindings, generated from the upstream XML.
56-- [wlturbo](https://github.com/bnema/wlturbo): a fast Wayland client.
57-- [neferclient](https://github.com/bnema/neferclient): a client toolkit on top of them: connection, outputs, surface roles, seat and dmabuf presentation.
58 25
59-## Features26+Written in Go, without cgo. Each piece of state has one owner goroutine, connected to the others by channels; hot paths reuse memory and client buffers stay off the Go heap. See [Architecture](docs/dev/architecture.md) for the concurrency model, the GC strategy and the reusable Go libraries.
60-
61-### Display and games
62-
63-- **Direct scanout:** eligible fullscreen buffers go straight to the display, without composition.
64-- **VRR and tearing:** enabled by default for supported fullscreen use (`render.vrr`, `render.tearing`). Tearing requires direct scanout and a client request.
65-- **Explicit sync:** client fences go to the GPU and KMS. Enabled by default, with no flag to set.
66-- **HDR:** HDR10 on capable displays, with configurable brightness for SDR content.
67-- **Multi-monitor:** separate workspaces and fractional scale per output. Workspaces move when a monitor is unplugged and return when it reconnects.
68-- **Hidden apps rest:** a window you cannot see (another workspace, a hidden stash, scrolled off screen, under a fullscreen window) gets frame callbacks once per second and the suspended state. Browsers and games stop drawing frames nobody sees. A window being captured keeps its output's refresh rate.
69-- **X11 apps:** Steam and other X11 clients run through xwayland-satellite. Wine can run natively on Wayland.
70 27
71-### Windows and workspaces28+Small on purpose, and staying small: no blur, shadows, rounded corners or themes. No built-in bar or wallpaper either: bring your own, any layer-shell client works.
72-
73-- **Cascade tiling (default):** an original layout. Columns fill the screen side by side, then continue in a new band below; scroll bands vertically and switch workspaces sideways. See [Cascade](docs/config.md#cascade).
74-- **Other layouts:** scroll horizontally, as in PaperWM and niri, or keep columns on screen in a fixed spiral. Choose per output or workspace.
75-- **Overview:** `cmd+o` or a four-finger swipe up shows live workspace previews. Select a window with the keyboard, touchpad or mouse.
76-- **Stash:** `cmd+s` toggles a floating strip of windows set aside for the current workspace. `cmd+shift+s` moves a window into or out of it.
77-- **Workspaces:** numbered workspaces appear as needed. Named workspaces can start commands and place their windows in predefined columns.
78-
79-### Desktop integration
80-
81-Bring your own desktop UI: any layer-shell client, such as Noctalia or Waybar, works. For capture and idle management, see [nefercap](https://github.com/bnema/nefercap) and [neferafk](https://github.com/bnema/neferafk).
82-
83-Configuration is a flat `key = value` file that reloads on save. A JSON state file exposes outputs, workspaces and windows to scripts. See [Desktop integration](docs/desktop.md).
84 29
85 ## Install30 ## Install
86 31
87 ### Arch Linux32 ### Arch Linux
88 33
89-Choose an AUR package:
90-
91 ```sh34 ```sh
92 paru -S neferwl-bin # latest release, pre-built for x86_64 and aarch6435 paru -S neferwl-bin # latest release, pre-built for x86_64 and aarch64
93 paru -S neferwl-git # latest main, built from source36 paru -S neferwl-git # latest main, built from source
@@ -110,17 +53,13 @@ sha256sum -c --ignore-missing checksums.txt
110 sudo apt install ./neferwl_<version>_amd64.deb53 sudo apt install ./neferwl_<version>_amd64.deb
111 ```54 ```
112 55
113-Optional tools:
114-
115 ```sh56 ```sh
116 sudo apt install foot fuzzel xdg-desktop-portal-gtk xdg-desktop-portal-gnome57 sudo apt install foot fuzzel xdg-desktop-portal-gtk xdg-desktop-portal-gnome
117 ```58 ```
118 59
119 X11 apps need [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7 or later, which you may have to build. The package is tested on Ubuntu 24.04 and 26.04; Debian with the same library packages should work but is untested.60 X11 apps need [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7 or later, which you may have to build. The package is tested on Ubuntu 24.04 and 26.04; Debian with the same library packages should work but is untested.
120 61
121-### What the packages provide62+The packages provide:
122-
123-The Arch and Debian packages provide:
124 63
125 - `neferwl` with CAP_SYS_NICE for real-time scheduling.64 - `neferwl` with CAP_SYS_NICE for real-time scheduling.
126 - A **NeferWL** session for display managers such as Ly, GDM and SDDM.65 - A **NeferWL** session for display managers such as Ly, GDM and SDDM.
@@ -134,18 +73,6 @@ Copy `/usr/share/doc/neferwl/config.example` to `~/.config/neferwl/config` to st
134 73
135 Download a `linux_amd64` or `linux_arm64` archive from [Releases](https://github.com/bnema/neferwl/releases) and verify it against `checksums.txt`. To build locally, run `make bin`; the binary is `bin/neferwl`.74 Download a `linux_amd64` or `linux_arm64` archive from [Releases](https://github.com/bnema/neferwl/releases) and verify it against `checksums.txt`. To build locally, run `make bin`; the binary is `bin/neferwl`.
136 75
137-## Hardware support
138-
139-NeferWL requires Linux, atomic KMS and a Vulkan 1.3 device that can export images as dmabufs. It reports an error at startup if these requirements are not met.
140-
141-| GPU | Status |
142-| --- | --- |
143-| AMD | Developed and tested on radv |
144-| NVIDIA | Untested; explicit sync is implemented, but support is incomplete |
145-| Intel | Untested |
146-
147-Reports from NVIDIA and Intel users are welcome.
148-
149 ## Configuration76 ## Configuration
150 77
151 The default file is `$XDG_CONFIG_HOME/neferwl/config`, or `~/.config/neferwl/config`.78 The default file is `$XDG_CONFIG_HOME/neferwl/config`, or `~/.config/neferwl/config`.
@@ -167,211 +94,97 @@ workspace.dev.column.2 = 33%, foot
167 bind.cmd+d = workspace dev94 bind.cmd+d = workspace dev
168 ```95 ```
169 96
170-See [Configuration](docs/config.md) for all keys, actions and default bindings, or [examples/config](examples/config) for commented defaults.
171-
172 - Use one `key = value` per line; `#` starts a comment.97 - Use one `key = value` per line; `#` starts a comment.
173 - Missing keys use defaults. Invalid lines warn and keep that key's default; other lines still apply.98 - Missing keys use defaults. Invalid lines warn and keep that key's default; other lines still apply.
174 - Save to reload. The log lists changed keys.99 - Save to reload. The log lists changed keys.
175 - Use `neferwl --config <path>` to select a file, or `neferwl validate-config [path]` to check it without starting the compositor.100 - Use `neferwl --config <path>` to select a file, or `neferwl validate-config [path]` to check it without starting the compositor.
176 101
177-### Default configuration102+### Common settings
178-103+
179-<details>104+| Key | Default | Meaning |
180-<summary>All default settings and key bindings</summary>105+| --- | --- | --- |
106+| `keyboard.layout` | system | xkb layout, e.g. `fr` |
107+| `keyboard.cmd` | `super` | Modifier used as `cmd` |
108+| `terminal` | `$TERMINAL`, then `foot` | Terminal command |
109+| `terminal.auto-open` | `first` | Open on initial numbered workspace; also `all` or `off` |
110+| `startup` | none | Session-start commands, separated by `;` |
111+| `layout.max-columns` | `2` | Columns sharing the screen |
112+| `layout.overflow` | `cascade` | Overflow layout: `cascade`, `scroll`, `fixed` |
113+| `layout.gaps` | `0` | Space between windows |
114+| `output.<name>` | `preferred` | Mode: `WxH@Hz`, `preferred` or `off` |
115+| `output.<name>.scale` | `1` | Fractional scale, 1 to 4 |
116+| `output.<name>.hdr` | `off` | HDR on capable displays |
117+| `touchpad.natural-scroll` | `off` | Content follows fingers |
118+| `stash.width` | `80` | Selected stash window width, percent |
119+| `hint.enable` | `off` | Experimental keyboard clicking; restart required |
120+| `animations` | `off` | Experimental layout and swipe animations |
121+
122+### Default key bindings
123+
124+`cmd` means Super by default. Hint mode has no default binding.
125+
126+| Keys | Action |
127+| --- | --- |
128+| `cmd+return` | Open terminal |
129+| `ctrl+cmd+space` | Launch fuzzel |
130+| `cmd+q` | Close window |
131+| `cmd+arrows` / `cmd+h/j/k/l` | Focus columns and windows |
132+| `cmd+shift+arrows` | Move columns and windows |
133+| `cmd+o` | Toggle overview |
134+| `cmd+s` | Show/hide stash |
135+| `cmd+shift+s` | Move window into/out of stash |
136+| `cmd+shift+f` | Toggle fullscreen |
137+| `cmd+shift+space` | Toggle floating |
138+| `cmd+1`…`9` (physical digit keys) | Focus numbered workspace |
139+| `cmd+pageup/pagedown` | Previous/next workspace |
140+
141+See [Configuration](docs/config.md) for all keys, actions, bindings and automatic-terminal behavior, or [examples/config](examples/config) for commented defaults.
181 142
182-These values apply without a config file. Empty values use the system setting; `terminal` uses `$TERMINAL`, then `foot`. No startup commands or named workspaces are configured by default. `cmd` means Super.143+## Features
183 144
184-```text145+### Display and games
185-# Keyboard and commands
186-keyboard.layout =
187-keyboard.variant =
188-keyboard.options =
189-keyboard.repeat-rate = 25
190-keyboard.repeat-delay = 600
191-keyboard.cmd = super
192-terminal =
193-terminal.auto-open = first
194-xwayland = xwayland-satellite
195-
196-# Appearance and layout
197-background = #111111
198-border.width = 2
199-border.active = #808080
200-border.inactive = #111111
201-floating.dim = 0.3
202-layout.gaps = 0
203-layout.max-columns = 2
204-layout.overflow = cascade
205-stash.width = 80
206-stash.gap = 2
207-stash.dim = 0.5
208-stash.capture = on
209-stash.spawn-terminal = on
210-
211-# Input
212-touchpad.natural-scroll = off
213-touchpad.tap = on
214-touchpad.accel-speed = 0
215-touchpad.accel-profile = adaptive
216-touchpad.left-handed = off
217-touchpad.scroll-factor = 1
218-mouse.natural-scroll = off
219-mouse.accel-speed = 0
220-mouse.accel-profile = adaptive
221-mouse.left-handed = off
222-cursor.hide-after = 5s
223-
224-# Focus and animations
225-focus.follow-move = off
226-animations = off
227-animations.speed = normal
228-focus.flash = on
229-focus.flash-width = 2
230-focus.flash-opacity = 0.6
231-focus.flash-duration = 200ms
232-
233-# Rendering and logging
234-render.direct-scanout = on
235-render.tearing = on
236-render.vrr = on
237-render.vrr-flip-gap = 1ms
238-performance.realtime = on
239-log.level = info
240-log.debug =
241-
242-# Launch, close and session
243-bind.cmd+return = spawn-terminal
244-bind.ctrl+cmd+space = spawn fuzzel
245-bind.cmd+q = close-window
246-bind.ctrl+alt+backspace = quit
247-
248-# Focus and move
249-bind.cmd+left = focus-column-left
250-bind.cmd+right = focus-column-right
251-bind.cmd+up = focus-window-up
252-bind.cmd+down = focus-window-down
253-bind.cmd+h = focus-column-left
254-bind.cmd+l = focus-column-right
255-bind.cmd+k = focus-window-up
256-bind.cmd+j = focus-window-down
257-bind.cmd+shift+left = move-column-left
258-bind.cmd+shift+right = move-column-right
259-bind.cmd+shift+h = move-column-left
260-bind.cmd+shift+l = move-column-right
261-bind.cmd+shift+up = move-window-up
262-bind.cmd+shift+down = move-window-down
263-bind.cmd+shift+k = move-window-up
264-bind.cmd+shift+j = move-window-down
265-bind.cmd+bracketleft = consume-or-expel-window-left
266-bind.cmd+bracketright = consume-or-expel-window-right
267-
268-# Resize and window modes
269-bind.cmd+alt+left = set-column-width -1
270-bind.cmd+alt+right = set-column-width +1
271-bind.cmd+alt+up = set-window-height -10%
272-bind.cmd+alt+down = set-window-height +10%
273-bind.cmd+alt+h = set-column-width -1
274-bind.cmd+alt+l = set-column-width +1
275-bind.cmd+alt+k = set-window-height -10%
276-bind.cmd+alt+j = set-window-height +10%
277-bind.cmd+shift+space = toggle-floating
278-bind.cmd+r = cycle-column-width
279-bind.cmd+f = maximize-column
280-bind.cmd+shift+f = toggle-fullscreen
281-bind.cmd+s = toggle-stash-visible
282-bind.cmd+shift+s = toggle-window-stash
283-bind.cmd+o = toggle-overview
284-# bind.cmd+g = hint
285-bind.cmd+tab = switch-column-next
286-bind.cmd+shift+tab = switch-column-prev
287-
288-# Workspaces
289-bind.cmd+pageup = focus-workspace-prev
290-bind.cmd+pagedown = focus-workspace-next
291-bind.cmd+shift+pageup = move-column-to-workspace-prev
292-bind.cmd+shift+pagedown = move-column-to-workspace-next
293-bind.cmd+ctrl+shift+up = move-workspace-prev
294-bind.cmd+ctrl+shift+down = move-workspace-next
295-bind.cmd+ctrl+shift+k = move-workspace-prev
296-bind.cmd+ctrl+shift+j = move-workspace-next
297-bind.cmd+code:2 = focus-workspace 1
298-bind.cmd+code:3 = focus-workspace 2
299-bind.cmd+code:4 = focus-workspace 3
300-bind.cmd+code:5 = focus-workspace 4
301-bind.cmd+code:6 = focus-workspace 5
302-bind.cmd+code:7 = focus-workspace 6
303-bind.cmd+code:8 = focus-workspace 7
304-bind.cmd+code:9 = focus-workspace 8
305-bind.cmd+code:10 = focus-workspace 9
306-bind.cmd+shift+code:2 = move-column-to-workspace 1
307-bind.cmd+shift+code:3 = move-column-to-workspace 2
308-bind.cmd+shift+code:4 = move-column-to-workspace 3
309-bind.cmd+shift+code:5 = move-column-to-workspace 4
310-bind.cmd+shift+code:6 = move-column-to-workspace 5
311-bind.cmd+shift+code:7 = move-column-to-workspace 6
312-bind.cmd+shift+code:8 = move-column-to-workspace 7
313-bind.cmd+shift+code:9 = move-column-to-workspace 8
314-bind.cmd+shift+code:10 = move-column-to-workspace 9
315-
316-# Monitors and scale
317-bind.cmd+ctrl+left = focus-monitor-left
318-bind.cmd+ctrl+right = focus-monitor-right
319-bind.cmd+ctrl+h = focus-monitor-left
320-bind.cmd+ctrl+l = focus-monitor-right
321-bind.cmd+ctrl+shift+left = move-workspace-to-monitor-left
322-bind.cmd+ctrl+shift+right = move-workspace-to-monitor-right
323-bind.cmd+ctrl+shift+h = move-workspace-to-monitor-left
324-bind.cmd+ctrl+shift+l = move-workspace-to-monitor-right
325-bind.cmd+code:13 = scale-up
326-bind.cmd+code:12 = scale-down
327-```
328 146
329-The focus flash marks the window, column or output an action changes with a brief147+- Eligible fullscreen buffers use direct scanout; VRR and tearing are on by default for supported fullscreen use (`render.vrr`, `render.tearing`). Tearing requires direct scanout and a client request.
330-inner border. Workspace switches and mouse focus do not trigger a flash.148+- Explicit sync carries client fences to the GPU and KMS, enabled by default with no flag. HDR10 on capable displays includes configurable SDR brightness.
331-It runs when `animations = on`.149+- Separate workspaces and fractional scale per output; workspaces move when a monitor is unplugged and return when it reconnects.
150+- Hidden apps get frame callbacks once per second and the suspended state, whether on another workspace, in a hidden stash, off screen or under fullscreen. Captured windows keep their output's refresh rate.
151+- Steam and other X11 clients run through xwayland-satellite; Wine can run natively on Wayland.
332 152
333-Per-output defaults (replace `<name>` with a connector such as `DP-1`):153+### Windows and workspaces
334 154
335-```text155+- [Cascade tiling](docs/config.md#cascade) is the default; horizontal scrolling (as in PaperWM and niri) and fixed spiral layouts are available per output or workspace.
336-output.<name> = preferred156+- Numbered workspaces appear as needed; named workspaces can start commands and place windows in predefined columns.
337-output.<name>.scale = 1157+- Overview and stash keep workspaces and set-aside windows close at hand, with keyboard, pointer and touchpad navigation.
338-output.<name>.transform = normal
339-output.<name>.primary = off
340-output.<name>.offset = 0
341-output.<name>.hdr = off
342-output.<name>.sdr-brightness = 203
343-```
344 158
345-Outputs are arranged automatically unless `.right-of`, `.left-of`, `.above` or `.below` is set. Per-output layouts inherit `layout.*`; named workspaces inherit their output's layout and size, use the focused output unless `.monitor` is set, and have no predefined columns. Media keys have no default bindings.159+### Desktop integration
346 160
347-See [Configuration](docs/config.md) for accepted values and workspace options.161+Bring your own layer-shell UI, such as Noctalia or Waybar. For capture and idle management, see [nefercap](https://github.com/bnema/nefercap) and [neferafk](https://github.com/bnema/neferafk).
348 162
349-</details>163+Configuration is a flat `key = value` file that reloads on save. A JSON state file exposes outputs, workspaces and windows to scripts; see [Desktop integration](docs/desktop.md).
350 164
351-### Automatic terminal165+## Hardware support
352 166
353-The command comes from `terminal`, then `$TERMINAL`, then `foot`.167+NeferWL requires Linux, atomic KMS and a Vulkan 1.3 device that can export images as dmabufs. It reports an error at startup if these requirements are not met.
354 168
355-| `terminal.auto-open` | Behaviour |169+| GPU | Status |
356 | --- | --- |170 | --- | --- |
357-| `first` | Open once on the initial numbered workspace |171+| AMD | Developed and tested on radv |
358-| `all` | Open on each visible empty workspace without predefined slots |172+| NVIDIA | Untested; explicit sync is implemented, but support is incomplete |
359-| `off` | Do not open automatically |173+| Intel | Untested |
360 174
361-`--no-terminal` disables automatic opening for one run; `spawn-terminal` still works. Launched programs inherit `$SHELL`, `$EDITOR` and `$VISUAL`.175+Reports from NVIDIA and Intel users are welcome.
362 176
363 ## Documentation177 ## Documentation
364 178
365-- [Configuration](docs/config.md): keys, actions, bindings and HDR.179+- [Configuration](docs/config.md): keys, actions, bindings, layouts, overview, stash, hint mode and HDR.
366-- [Desktop integration](docs/desktop.md): sessions, clipboard, input methods, X11 apps and scripting.180+- [Desktop integration](docs/desktop.md): sessions, clipboard, input methods, X11 apps, session locking and scripting.
367-- [Session locking](docs/session-lock.md): external lockers, recovery and security limits.
368 - [Screen capture](docs/capture.md): protocols, indicator, executable allowlist and portals.181 - [Screen capture](docs/capture.md): protocols, indicator, executable allowlist and portals.
369-- [Headless mode](docs/headless.md): screenshots, input scripts and input-method testing without a screen.182+- [Performance](docs/performance.md): VRR and real-time priority.
370-- [Performance](docs/performance.md): VRR, real-time priority, profiling and frame stats.183+- [Developer documentation](docs/dev/index.md): architecture, headless testing, profiling and frame stats.
371 184
372 ## Contributing185 ## Contributing
373 186
374-Open an issue and wait for acceptance before sending a pull request, including for bug fixes. Pull requests without an accepted issue are closed.187+Issues are welcome, including NVIDIA and Intel reports. Please open an issue before a large pull request; small fixes are fine directly.
375 188
376 Before submitting, run `make check`, `make race`, `make mocks-check` and `staticcheck ./...`. See [AGENTS.md](AGENTS.md) for repository rules.189 Before submitting, run `make check`, `make race`, `make mocks-check` and `staticcheck ./...`. See [AGENTS.md](AGENTS.md) for repository rules.
377 190
M docs/config.md +30 -0
@@ -7,6 +7,8 @@ neferwl --config <path> # use another file
77 neferwl validate-config [path] # check a file
88 ```
99
10+Missing keys use defaults. Empty keyboard values use the system setting. No startup commands or named workspaces are configured by default. The log lists changed keys after a reload.
11+
1012 ## Format
1113
1214 - One `key = value` per line. `#` starts a comment.
@@ -97,6 +99,34 @@ destination output, even if that output has no windows. Same-screen workspace
9799 switches and mouse focus do not trigger a flash.
98100 It runs when `animations = on`.
99101
102+## Automatic terminal
103+
104+The command comes from `terminal`, then `$TERMINAL`, then `foot`.
105+
106+| `terminal.auto-open` | Behaviour |
107+| --- | --- |
108+| `first` | Open once on the initial numbered workspace |
109+| `all` | Open on each visible empty workspace without predefined slots |
110+| `off` | Do not open automatically |
111+
112+`--no-terminal` disables automatic opening for one run; `spawn-terminal` still works. Launched programs inherit `$SHELL`, `$EDITOR` and `$VISUAL`.
113+
114+## Per-output defaults
115+
116+Per-output defaults (replace `<name>` with a connector such as `DP-1`):
117+
118+```text
119+output.<name> = preferred
120+output.<name>.scale = 1
121+output.<name>.transform = normal
122+output.<name>.primary = off
123+output.<name>.offset = 0
124+output.<name>.hdr = off
125+output.<name>.sdr-brightness = 203
126+```
127+
128+Outputs are arranged automatically unless `.right-of`, `.left-of`, `.above` or `.below` is set. Per-output layouts inherit `layout.*`; named workspaces inherit their output's layout and size, use the focused output unless `.monitor` is set, and have no predefined columns. Media keys have no default bindings.
129+
100130 ## Window rules
101131
102132 Rules place an app's windows when they first open.
@@ -7,6 +7,8 @@ neferwl --config <path> # use another file
7 neferwl validate-config [path] # check a file7 neferwl validate-config [path] # check a file
8 ```8 ```
9 9
10+Missing keys use defaults. Empty keyboard values use the system setting. No startup commands or named workspaces are configured by default. The log lists changed keys after a reload.
11+
10 ## Format12 ## Format
11 13
12 - One `key = value` per line. `#` starts a comment.14 - One `key = value` per line. `#` starts a comment.
@@ -97,6 +99,34 @@ destination output, even if that output has no windows. Same-screen workspace
97 switches and mouse focus do not trigger a flash.99 switches and mouse focus do not trigger a flash.
98 It runs when `animations = on`.100 It runs when `animations = on`.
99 101
102+## Automatic terminal
103+
104+The command comes from `terminal`, then `$TERMINAL`, then `foot`.
105+
106+| `terminal.auto-open` | Behaviour |
107+| --- | --- |
108+| `first` | Open once on the initial numbered workspace |
109+| `all` | Open on each visible empty workspace without predefined slots |
110+| `off` | Do not open automatically |
111+
112+`--no-terminal` disables automatic opening for one run; `spawn-terminal` still works. Launched programs inherit `$SHELL`, `$EDITOR` and `$VISUAL`.
113+
114+## Per-output defaults
115+
116+Per-output defaults (replace `<name>` with a connector such as `DP-1`):
117+
118+```text
119+output.<name> = preferred
120+output.<name>.scale = 1
121+output.<name>.transform = normal
122+output.<name>.primary = off
123+output.<name>.offset = 0
124+output.<name>.hdr = off
125+output.<name>.sdr-brightness = 203
126+```
127+
128+Outputs are arranged automatically unless `.right-of`, `.left-of`, `.above` or `.below` is set. Per-output layouts inherit `layout.*`; named workspaces inherit their output's layout and size, use the focused output unless `.monitor` is set, and have no predefined columns. Media keys have no default bindings.
129+
100 ## Window rules130 ## Window rules
101 131
102 Rules place an app's windows when they first open.132 Rules place an app's windows when they first open.
M docs/desktop.md +24 -1
@@ -60,7 +60,30 @@ xwayland = off # no X11 support
6060
6161 ## Session locking
6262
63-Use an external locker such as swaylock or hyprlock. See [Session locking](session-lock.md).
63+NeferWL supports `ext-session-lock-v1` with an external locker such as swaylock or hyprlock. The locker checks the password; NeferWL hides the desktop and blocks input.
64+
65+### While locked
66+
67+- Only the locker gets input. Binds, clipboard requests and new captures are refused.
68+- Outputs show black and the locker's surfaces only.
69+- Running captures stop.
70+- `Ctrl+Alt+Backspace` is disabled. VT switching still works.
71+- A locker that uses a screenshot as background (hyprlock `screenshot`, swaylock-effects `--screenshots`) must be in the [capture allowlist](capture.md#the-allowlist).
72+
73+The locker is told `locked` only once every output shows a protected frame or is off.
74+
75+### If the locker dies
76+
77+The session stays locked and black. Start a new locker to take over. There is no emergency unlock key and no timeout.
78+
79+A locker that exits right after unlocking must do a Wayland roundtrip first, or its exit counts as a crash and the session stays locked.
80+
81+### Limits
82+
83+- NeferWL does not authenticate: no PAM, no stored credentials.
84+- Any process running as your user can start its own locker and unlock.
85+- Clipboard data and window contents a client received before the lock stay with that client.
86+- The [state file](desktop.md#state-for-scripts) is not cleared while locked.
6487
6588 ## Screen capture
6689
@@ -60,7 +60,30 @@ xwayland = off # no X11 support
60 60
61 ## Session locking61 ## Session locking
62 62
63-Use an external locker such as swaylock or hyprlock. See [Session locking](session-lock.md).63+NeferWL supports `ext-session-lock-v1` with an external locker such as swaylock or hyprlock. The locker checks the password; NeferWL hides the desktop and blocks input.
64+
65+### While locked
66+
67+- Only the locker gets input. Binds, clipboard requests and new captures are refused.
68+- Outputs show black and the locker's surfaces only.
69+- Running captures stop.
70+- `Ctrl+Alt+Backspace` is disabled. VT switching still works.
71+- A locker that uses a screenshot as background (hyprlock `screenshot`, swaylock-effects `--screenshots`) must be in the [capture allowlist](capture.md#the-allowlist).
72+
73+The locker is told `locked` only once every output shows a protected frame or is off.
74+
75+### If the locker dies
76+
77+The session stays locked and black. Start a new locker to take over. There is no emergency unlock key and no timeout.
78+
79+A locker that exits right after unlocking must do a Wayland roundtrip first, or its exit counts as a crash and the session stays locked.
80+
81+### Limits
82+
83+- NeferWL does not authenticate: no PAM, no stored credentials.
84+- Any process running as your user can start its own locker and unlock.
85+- Clipboard data and window contents a client received before the lock stay with that client.
86+- The [state file](desktop.md#state-for-scripts) is not cleared while locked.
64 87
65 ## Screen capture88 ## Screen capture
66 89
A docs/dev/architecture.md +34 -0
@@ -0,0 +1,34 @@
1+# Architecture
2+
3+Most Wayland compositors are written in C (wlroots, Sway, Mutter), C++ (KWin, Hyprland, gamescope) or Rust (niri, COSMIC). The usual objection to Go is the garbage collector. It is a fair one, so here is how NeferWL deals with it, and why Go turns out to fit a compositor well.
4+
5+### A compositor is a concurrency problem
6+
7+A compositor sits in the middle of many independent streams that never stop: keyboard and pointer events, client requests and buffer commits, one vblank and page-flip cycle per monitor, frame fences from the GPU, presentation feedback, screen captures, config reloads, hotplug, idle and lock state. None of them should wait on another. A slow client must not delay a flip, and rendering must not delay input.
8+
9+Go has this built in. Goroutines are cheap, channels carry messages between them, and the runtime spreads them over every CPU core. NeferWL follows one idiom everywhere: **each piece of state has one owner goroutine, and others talk to it over channels.**
10+
11+- **Input** runs on its own thread, reads libinput and sends events to core.
12+- **Core** owns windows, workspaces and focus. It turns input and client events into layouts and scenes.
13+- **The Wayland server** owns client connections. It sends client events and surface content, and receives commands back.
14+- **Each output** owns its DRM state, renders its scenes with Vulkan, commits frames and reports flips and presentation times.
15+- **Capture, config, idle, sessions and leases** each run in their own goroutine.
16+
17+About 35 channels connect them, all created in one place, so the full data flow is easy to read. There is no mutex on window state, no lock ordering to get wrong, and the race detector checks every test. When CAP_SYS_NICE is granted, the input and output goroutines lock their OS thread and request real-time scheduling, so a busy CPU cannot delay a cursor move or a flip.
18+
19+### Keeping the garbage collector out of the way
20+
21+- **The hot path does not allocate.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse memory owned by the goroutine that handles them. `make check` runs allocation guards that fail the build when one of these paths starts allocating.
22+- **The GC has almost nothing to collect.** Client buffers live in shared and GPU memory, not on the Go heap, so the live heap is a few megabytes. Playing a 4K HDR video with 65 tiled subsurfaces committing every frame, GC takes under 9 % of a process that itself takes 13 % of a core. See [Performance](../performance.md).
23+- **No cgo.** NeferWL builds with `CGO_ENABLED=0`. libwayland, Vulkan and libinput are loaded at runtime through [purego](https://github.com/ebitengine/purego) with fixed-arity calls: no reflection, no allocation at the boundary.
24+- **Work only when something changes.** Damage tracking redraws only what changed, surfaces hidden behind opaque ones are skipped, and an idle output does nothing.
25+- **Tuned on real hardware.** Under VRR, cursor moves ride on the game's next frame instead of forcing their own refresh, and game frames keep a short gap after each flip so the panel does not fall back to its slowest rate.
26+
27+I also like bringing more tools to the Go ecosystem. NeferWL grew its own libraries along the way, and other Go projects can use them:
28+
29+- [purego-libwayland](https://github.com/bnema/purego-libwayland): libwayland-server without cgo.
30+- [purego-vulkan](https://github.com/bnema/purego-vulkan): Vulkan without cgo, generated from `vk.xml`.
31+- [go-wayland-bindings](https://github.com/bnema/go-wayland-bindings): Wayland protocol bindings, generated from the upstream XML.
32+- [wlturbo](https://github.com/bnema/wlturbo): a fast Wayland client.
33+- [neferclient](https://github.com/bnema/neferclient): a client toolkit on top of them: connection, outputs, surface roles, seat and dmabuf presentation.
34+
@@ -0,0 +1,34 @@
1+# Architecture
2+
3+Most Wayland compositors are written in C (wlroots, Sway, Mutter), C++ (KWin, Hyprland, gamescope) or Rust (niri, COSMIC). The usual objection to Go is the garbage collector. It is a fair one, so here is how NeferWL deals with it, and why Go turns out to fit a compositor well.
4+
5+### A compositor is a concurrency problem
6+
7+A compositor sits in the middle of many independent streams that never stop: keyboard and pointer events, client requests and buffer commits, one vblank and page-flip cycle per monitor, frame fences from the GPU, presentation feedback, screen captures, config reloads, hotplug, idle and lock state. None of them should wait on another. A slow client must not delay a flip, and rendering must not delay input.
8+
9+Go has this built in. Goroutines are cheap, channels carry messages between them, and the runtime spreads them over every CPU core. NeferWL follows one idiom everywhere: **each piece of state has one owner goroutine, and others talk to it over channels.**
10+
11+- **Input** runs on its own thread, reads libinput and sends events to core.
12+- **Core** owns windows, workspaces and focus. It turns input and client events into layouts and scenes.
13+- **The Wayland server** owns client connections. It sends client events and surface content, and receives commands back.
14+- **Each output** owns its DRM state, renders its scenes with Vulkan, commits frames and reports flips and presentation times.
15+- **Capture, config, idle, sessions and leases** each run in their own goroutine.
16+
17+About 35 channels connect them, all created in one place, so the full data flow is easy to read. There is no mutex on window state, no lock ordering to get wrong, and the race detector checks every test. When CAP_SYS_NICE is granted, the input and output goroutines lock their OS thread and request real-time scheduling, so a busy CPU cannot delay a cursor move or a flip.
18+
19+### Keeping the garbage collector out of the way
20+
21+- **The hot path does not allocate.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse memory owned by the goroutine that handles them. `make check` runs allocation guards that fail the build when one of these paths starts allocating.
22+- **The GC has almost nothing to collect.** Client buffers live in shared and GPU memory, not on the Go heap, so the live heap is a few megabytes. Playing a 4K HDR video with 65 tiled subsurfaces committing every frame, GC takes under 9 % of a process that itself takes 13 % of a core. See [Performance](../performance.md).
23+- **No cgo.** NeferWL builds with `CGO_ENABLED=0`. libwayland, Vulkan and libinput are loaded at runtime through [purego](https://github.com/ebitengine/purego) with fixed-arity calls: no reflection, no allocation at the boundary.
24+- **Work only when something changes.** Damage tracking redraws only what changed, surfaces hidden behind opaque ones are skipped, and an idle output does nothing.
25+- **Tuned on real hardware.** Under VRR, cursor moves ride on the game's next frame instead of forcing their own refresh, and game frames keep a short gap after each flip so the panel does not fall back to its slowest rate.
26+
27+I also like bringing more tools to the Go ecosystem. NeferWL grew its own libraries along the way, and other Go projects can use them:
28+
29+- [purego-libwayland](https://github.com/bnema/purego-libwayland): libwayland-server without cgo.
30+- [purego-vulkan](https://github.com/bnema/purego-vulkan): Vulkan without cgo, generated from `vk.xml`.
31+- [go-wayland-bindings](https://github.com/bnema/go-wayland-bindings): Wayland protocol bindings, generated from the upstream XML.
32+- [wlturbo](https://github.com/bnema/wlturbo): a fast Wayland client.
33+- [neferclient](https://github.com/bnema/neferclient): a client toolkit on top of them: connection, outputs, surface roles, seat and dmabuf presentation.
34+
R docs/headless.md → docs/dev/headless.md +20 -2 renamed, 61% similar
@@ -7,12 +7,30 @@ neferwl --backend=headless --screenshot /tmp/neferwl-shots
77 WAYLAND_DISPLAY=<name from the log> foot
88 ```
99
10-- `latest.png` shows the current frame. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows.
10+- `latest.png` shows the current frame; `frames.ffconcat` records frame timing. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows.
1111 - `--size 640x360,640x360` creates two outputs, arranged left to right,
1212 for testing `focus-monitor-left/right` and moving columns between outputs.
1313 - With several outputs, each writes to `<dir>/HEADLESS-<n>/`.
1414 - `--timeout 5s` stops NeferWL after 5 seconds.
15-- `grim` and recorders work too; see [Screen capture](capture.md).
15+- `grim` and recorders work too; see [Screen capture](../capture.md).
16+
17+## Recording a video
18+
19+Convert the screenshots and their real-time timing index into a video:
20+
21+```sh
22+ffmpeg -f concat -safe 0 -i /tmp/shots/frames.ffconcat -vf "fps=60,format=yuv420p" -c:v libx264 -crf 18 demo.mp4
23+```
24+
25+For a GIF, convert `demo.mp4` with ffmpeg's `palettegen` and `paletteuse` filters.
26+
27+### Demo recording
28+Run `tools/demo/record.sh` with Go 1.27, rootless Docker and ffmpeg installed.
29+It builds an Arch app image and the host compositor, then records the 1080p README demo.
30+Apps run in a disposable, offline container; only the headless Wayland socket is mounted.
31+A FIFO synchronizes `input.txt` with app launches after container startup.
32+Temporary captures are removed by default; set `KEEP_CAPTURE=1` to keep successful runs.
33+The recording and poster replace `docs/media/demo.mp4` and `docs/media/demo.png`.
1634
1735 ## Input scripts
1836
@@ -7,12 +7,30 @@ neferwl --backend=headless --screenshot /tmp/neferwl-shots
7 WAYLAND_DISPLAY=<name from the log> foot7 WAYLAND_DISPLAY=<name from the log> foot
8 ```8 ```
9 9
10-- `latest.png` shows the current frame. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows.10+- `latest.png` shows the current frame; `frames.ffconcat` records frame timing. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows.
11 - `--size 640x360,640x360` creates two outputs, arranged left to right,11 - `--size 640x360,640x360` creates two outputs, arranged left to right,
12 for testing `focus-monitor-left/right` and moving columns between outputs.12 for testing `focus-monitor-left/right` and moving columns between outputs.
13 - With several outputs, each writes to `<dir>/HEADLESS-<n>/`.13 - With several outputs, each writes to `<dir>/HEADLESS-<n>/`.
14 - `--timeout 5s` stops NeferWL after 5 seconds.14 - `--timeout 5s` stops NeferWL after 5 seconds.
15-- `grim` and recorders work too; see [Screen capture](capture.md).15+- `grim` and recorders work too; see [Screen capture](../capture.md).
16+
17+## Recording a video
18+
19+Convert the screenshots and their real-time timing index into a video:
20+
21+```sh
22+ffmpeg -f concat -safe 0 -i /tmp/shots/frames.ffconcat -vf "fps=60,format=yuv420p" -c:v libx264 -crf 18 demo.mp4
23+```
24+
25+For a GIF, convert `demo.mp4` with ffmpeg's `palettegen` and `paletteuse` filters.
26+
27+### Demo recording
28+Run `tools/demo/record.sh` with Go 1.27, rootless Docker and ffmpeg installed.
29+It builds an Arch app image and the host compositor, then records the 1080p README demo.
30+Apps run in a disposable, offline container; only the headless Wayland socket is mounted.
31+A FIFO synchronizes `input.txt` with app launches after container startup.
32+Temporary captures are removed by default; set `KEEP_CAPTURE=1` to keep successful runs.
33+The recording and poster replace `docs/media/demo.mp4` and `docs/media/demo.png`.
16 34
17 ## Input scripts35 ## Input scripts
18 36
A docs/dev/index.md +8 -0
@@ -0,0 +1,8 @@
1+# Developer documentation
2+
3+These guides cover NeferWL's internals and tools for development, testing and performance investigation. For configuration and desktop setup, see the [user documentation](../index.md).
4+
5+- [Architecture](architecture.md): owner goroutines, GC strategy and reusable purego libraries.
6+- [Headless mode](headless.md): screenshots, scripted input and testing without a screen.
7+- [Profiling](profiling.md): CPU and allocation profiling, logging and frame statistics.
8+- [Repository rules](../../AGENTS.md): architecture constraints, test requirements and checks before merge.
@@ -0,0 +1,8 @@
1+# Developer documentation
2+
3+These guides cover NeferWL's internals and tools for development, testing and performance investigation. For configuration and desktop setup, see the [user documentation](../index.md).
4+
5+- [Architecture](architecture.md): owner goroutines, GC strategy and reusable purego libraries.
6+- [Headless mode](headless.md): screenshots, scripted input and testing without a screen.
7+- [Profiling](profiling.md): CPU and allocation profiling, logging and frame statistics.
8+- [Repository rules](../../AGENTS.md): architecture constraints, test requirements and checks before merge.
A docs/dev/profiling.md +36 -0
@@ -0,0 +1,36 @@
1+# Profiling
2+
3+Run these from another TTY or over SSH:
4+
5+```sh
6+# CPU, Go and C frames
7+perf record -g -p (pgrep -f bin/neferwl) -- sleep 8
8+perf report --children --percent-limit 1
9+
10+# Allocations
11+neferwl --backend=drm --pprof=localhost:6060
12+go tool pprof -sample_index=alloc_objects -top http://localhost:6060/debug/pprof/heap
13+
14+# One line per GC cycle
15+GODEBUG=gctrace=1 neferwl --backend=drm
16+```
17+
18+`make tty` serves pprof on `localhost:6060` by default (`make tty PPROF=` turns it off).
19+
20+Logs are in `~/.local/state/neferwl/runs/<backend>/`, with `latest.log` pointing at the newest run. `--debug=all` enables every category except the noisy `drm-flip`, `input-motion`, `input-keys`, `wayland-damage` (one `client damage` entry per client commit) and `render-damage` (one `frame damage` entry per rendered frame); name them to turn them on.
21+
22+## Frame stats
23+
24+Every 10 s, each output logs a `stats` entry:
25+
26+| Field | Meaning |
27+|---|---|
28+| `missed_vblanks` | Frames that flipped more than 1.5 refresh periods late (not counted under VRR) |
29+| `max_flip_interval_ms` | Longest gap between two flips |
30+| `max_commit_delay_ms` | Longest wait from "frame due" to commit |
31+| `late_fences` | Missed vblanks where the GPU finished too late (needs `--debug=drm-flip`) |
32+| `redrawn_pixels` | Pixels drawn |
33+
34+If outputs report flips faster than the display loop reads them, wayland merges them and logs a warning with `merged` at most every 10 s.
35+
36+Missed vblanks with `late_fences` mean the GPU was late. Without `late_fences` but with a large `max_commit_delay_ms`, the CPU was late.
@@ -0,0 +1,36 @@
1+# Profiling
2+
3+Run these from another TTY or over SSH:
4+
5+```sh
6+# CPU, Go and C frames
7+perf record -g -p (pgrep -f bin/neferwl) -- sleep 8
8+perf report --children --percent-limit 1
9+
10+# Allocations
11+neferwl --backend=drm --pprof=localhost:6060
12+go tool pprof -sample_index=alloc_objects -top http://localhost:6060/debug/pprof/heap
13+
14+# One line per GC cycle
15+GODEBUG=gctrace=1 neferwl --backend=drm
16+```
17+
18+`make tty` serves pprof on `localhost:6060` by default (`make tty PPROF=` turns it off).
19+
20+Logs are in `~/.local/state/neferwl/runs/<backend>/`, with `latest.log` pointing at the newest run. `--debug=all` enables every category except the noisy `drm-flip`, `input-motion`, `input-keys`, `wayland-damage` (one `client damage` entry per client commit) and `render-damage` (one `frame damage` entry per rendered frame); name them to turn them on.
21+
22+## Frame stats
23+
24+Every 10 s, each output logs a `stats` entry:
25+
26+| Field | Meaning |
27+|---|---|
28+| `missed_vblanks` | Frames that flipped more than 1.5 refresh periods late (not counted under VRR) |
29+| `max_flip_interval_ms` | Longest gap between two flips |
30+| `max_commit_delay_ms` | Longest wait from "frame due" to commit |
31+| `late_fences` | Missed vblanks where the GPU finished too late (needs `--debug=drm-flip`) |
32+| `redrawn_pixels` | Pixels drawn |
33+
34+If outputs report flips faster than the display loop reads them, wayland merges them and logs a warning with `merged` at most every 10 s.
35+
36+Missed vblanks with `late_fences` mean the GPU was late. Without `late_fences` but with a large `max_commit_delay_ms`, the CPU was late.
A docs/index.md +10 -0
@@ -0,0 +1,10 @@
1+# NeferWL documentation
2+
3+NeferWL is a small Wayland compositor in Go. Start with Configuration to set up your keyboard, outputs and workflows, then use the guides below for desktop integration, screen capture and performance.
4+
5+- [Configuration](config.md): keys, actions, default bindings, layouts and HDR.
6+- [Desktop integration](desktop.md): sessions, clipboard, input methods, X11 apps, session locking and scripting.
7+- [Screen capture](capture.md): protocols, indicator, executable allowlist and portals.
8+- [Performance](performance.md): resource use, VRR and real-time priority.
9+
10+Developer documentation: [docs/dev](dev/index.md)
@@ -0,0 +1,10 @@
1+# NeferWL documentation
2+
3+NeferWL is a small Wayland compositor in Go. Start with Configuration to set up your keyboard, outputs and workflows, then use the guides below for desktop integration, screen capture and performance.
4+
5+- [Configuration](config.md): keys, actions, default bindings, layouts and HDR.
6+- [Desktop integration](desktop.md): sessions, clipboard, input methods, X11 apps, session locking and scripting.
7+- [Screen capture](capture.md): protocols, indicator, executable allowlist and portals.
8+- [Performance](performance.md): resource use, VRR and real-time priority.
9+
10+Developer documentation: [docs/dev](dev/index.md)
A docs/media/demo.mp4 +0 -0 binary file: no text diff

binary file: no text diff

A docs/media/demo.png +0 -0 binary file: no text diff

binary file: no text diff

M docs/performance.md +1 -36
@@ -22,39 +22,4 @@ sudo setcap cap_sys_nice+ep bin/neferwl
2222
2323 `performance.realtime = off` turns it off. With the capability set, the loader ignores `LD_PRELOAD` and `LD_LIBRARY_PATH`, and debuggers need root to attach.
2424
25-## Profiling
26-
27-Run these from another TTY or over SSH:
28-
29-```sh
30-# CPU, Go and C frames
31-perf record -g -p (pgrep -f bin/neferwl) -- sleep 8
32-perf report --children --percent-limit 1
33-
34-# Allocations
35-neferwl --backend=drm --pprof=localhost:6060
36-go tool pprof -sample_index=alloc_objects -top http://localhost:6060/debug/pprof/heap
37-
38-# One line per GC cycle
39-GODEBUG=gctrace=1 neferwl --backend=drm
40-```
41-
42-`make tty` serves pprof on `localhost:6060` by default (`make tty PPROF=` turns it off).
43-
44-Logs are in `~/.local/state/neferwl/runs/<backend>/`, with `latest.log` pointing at the newest run. `--debug=all` enables every category except the noisy `drm-flip`, `input-motion`, `input-keys`, `wayland-damage` (one `client damage` entry per client commit) and `render-damage` (one `frame damage` entry per rendered frame); name them to turn them on.
45-
46-## Frame stats
47-
48-Every 10 s, each output logs a `stats` entry:
49-
50-| Field | Meaning |
51-|---|---|
52-| `missed_vblanks` | Frames that flipped more than 1.5 refresh periods late (not counted under VRR) |
53-| `max_flip_interval_ms` | Longest gap between two flips |
54-| `max_commit_delay_ms` | Longest wait from "frame due" to commit |
55-| `late_fences` | Missed vblanks where the GPU finished too late (needs `--debug=drm-flip`) |
56-| `redrawn_pixels` | Pixels drawn |
57-
58-If outputs report flips faster than the display loop reads them, wayland merges them and logs a warning with `merged` at most every 10 s.
59-
60-Missed vblanks with `late_fences` mean the GPU was late. Without `late_fences` but with a large `max_commit_delay_ms`, the CPU was late.
25+For profiling tools and frame statistics, see [Profiling](dev/profiling.md).
@@ -22,39 +22,4 @@ sudo setcap cap_sys_nice+ep bin/neferwl
22 22
23 `performance.realtime = off` turns it off. With the capability set, the loader ignores `LD_PRELOAD` and `LD_LIBRARY_PATH`, and debuggers need root to attach.23 `performance.realtime = off` turns it off. With the capability set, the loader ignores `LD_PRELOAD` and `LD_LIBRARY_PATH`, and debuggers need root to attach.
24 24
25-## Profiling25+For profiling tools and frame statistics, see [Profiling](dev/profiling.md).
26-
27-Run these from another TTY or over SSH:
28-
29-```sh
30-# CPU, Go and C frames
31-perf record -g -p (pgrep -f bin/neferwl) -- sleep 8
32-perf report --children --percent-limit 1
33-
34-# Allocations
35-neferwl --backend=drm --pprof=localhost:6060
36-go tool pprof -sample_index=alloc_objects -top http://localhost:6060/debug/pprof/heap
37-
38-# One line per GC cycle
39-GODEBUG=gctrace=1 neferwl --backend=drm
40-```
41-
42-`make tty` serves pprof on `localhost:6060` by default (`make tty PPROF=` turns it off).
43-
44-Logs are in `~/.local/state/neferwl/runs/<backend>/`, with `latest.log` pointing at the newest run. `--debug=all` enables every category except the noisy `drm-flip`, `input-motion`, `input-keys`, `wayland-damage` (one `client damage` entry per client commit) and `render-damage` (one `frame damage` entry per rendered frame); name them to turn them on.
45-
46-## Frame stats
47-
48-Every 10 s, each output logs a `stats` entry:
49-
50-| Field | Meaning |
51-|---|---|
52-| `missed_vblanks` | Frames that flipped more than 1.5 refresh periods late (not counted under VRR) |
53-| `max_flip_interval_ms` | Longest gap between two flips |
54-| `max_commit_delay_ms` | Longest wait from "frame due" to commit |
55-| `late_fences` | Missed vblanks where the GPU finished too late (needs `--debug=drm-flip`) |
56-| `redrawn_pixels` | Pixels drawn |
57-
58-If outputs report flips faster than the display loop reads them, wayland merges them and logs a warning with `merged` at most every 10 s.
59-
60-Missed vblanks with `late_fences` mean the GPU was late. Without `late_fences` but with a large `max_commit_delay_ms`, the CPU was late.
D docs/session-lock.md +0 -26
@@ -1,26 +0,0 @@
1-# Session locking
2-
3-NeferWL supports `ext-session-lock-v1` with an external locker such as swaylock or hyprlock. The locker checks the password; NeferWL hides the desktop and blocks input.
4-
5-## While locked
6-
7-- Only the locker gets input. Binds, clipboard requests and new captures are refused.
8-- Outputs show black and the locker's surfaces only.
9-- Running captures stop.
10-- `Ctrl+Alt+Backspace` is disabled. VT switching still works.
11-- A locker that uses a screenshot as background (hyprlock `screenshot`, swaylock-effects `--screenshots`) must be in the [capture allowlist](capture.md#the-allowlist).
12-
13-The locker is told `locked` only once every output shows a protected frame or is off.
14-
15-## If the locker dies
16-
17-The session stays locked and black. Start a new locker to take over. There is no emergency unlock key and no timeout.
18-
19-A locker that exits right after unlocking must do a Wayland roundtrip first, or its exit counts as a crash and the session stays locked.
20-
21-## Limits
22-
23-- NeferWL does not authenticate: no PAM, no stored credentials.
24-- Any process running as your user can start its own locker and unlock.
25-- Clipboard data and window contents a client received before the lock stay with that client.
26-- The [state file](desktop.md#state-for-scripts) is not cleared while locked.
@@ -1,26 +0,0 @@
1-# Session locking
2-
3-NeferWL supports `ext-session-lock-v1` with an external locker such as swaylock or hyprlock. The locker checks the password; NeferWL hides the desktop and blocks input.
4-
5-## While locked
6-
7-- Only the locker gets input. Binds, clipboard requests and new captures are refused.
8-- Outputs show black and the locker's surfaces only.
9-- Running captures stop.
10-- `Ctrl+Alt+Backspace` is disabled. VT switching still works.
11-- A locker that uses a screenshot as background (hyprlock `screenshot`, swaylock-effects `--screenshots`) must be in the [capture allowlist](capture.md#the-allowlist).
12-
13-The locker is told `locked` only once every output shows a protected frame or is off.
14-
15-## If the locker dies
16-
17-The session stays locked and black. Start a new locker to take over. There is no emergency unlock key and no timeout.
18-
19-A locker that exits right after unlocking must do a Wayland roundtrip first, or its exit counts as a crash and the session stays locked.
20-
21-## Limits
22-
23-- NeferWL does not authenticate: no PAM, no stored credentials.
24-- Any process running as your user can start its own locker and unlock.
25-- Clipboard data and window contents a client received before the lock stay with that client.
26-- The [state file](desktop.md#state-for-scripts) is not cleared while locked.
M internal/adapters/headless/output.go +60 -8
@@ -1,6 +1,7 @@
11 package headless
22
33 import (
4+ "bufio"
45 "context"
56 "fmt"
67 "image"
@@ -96,6 +97,20 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
9697 var scene ports.Scene
9798 haveScene, dirty := false, false
9899 frame := 0
100+ var index *os.File
101+ var indexWriter *bufio.Writer
102+ var lastShot time.Time
103+ var lastShotName string
104+ defer func() {
105+ if index != nil {
106+ // A following file makes concat honor the final held interval.
107+ if !lastShot.IsZero() {
108+ _, _ = fmt.Fprintf(indexWriter, "duration %.6f\nfile '%s'\n", time.Since(lastShot).Seconds(), lastShotName)
109+ _ = indexWriter.Flush()
110+ }
111+ _ = index.Close()
112+ }
113+ }()
99114 var requests capture.Requests
100115 ctx, cancelCaptures := context.WithCancel(ctx)
101116 requests.Init(ctx, opts.Captured)
@@ -556,12 +571,40 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
556571 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
557572 continue
558573 }
559- if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), shot, opts.Security, scene.Security); err != nil {
574+ name := fmt.Sprintf("frame-%06d.png", frame)
575+ written, err := writePNGSecureResult(filepath.Join(opts.ScreenshotDir, name), shot, opts.Security, scene.Security)
576+ if err != nil {
560577 return fmt.Errorf("screenshot: %w", err)
561578 }
562- if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security); err != nil {
579+ if !written {
580+ continue
581+ }
582+ written, err = writePNGSecureResult(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security)
583+ if err != nil {
563584 return fmt.Errorf("latest screenshot: %w", err)
564585 }
586+ if !written {
587+ continue
588+ }
589+ now := time.Now()
590+ if index == nil {
591+ index, err = os.OpenFile(filepath.Join(opts.ScreenshotDir, "frames.ffconcat"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY|os.O_APPEND, 0o600)
592+ if err != nil {
593+ return fmt.Errorf("frame index: %w", err)
594+ }
595+ indexWriter = bufio.NewWriter(index)
596+ _, _ = fmt.Fprintln(indexWriter, "ffconcat version 1.0")
597+ }
598+ // Complete the previous frame's duration before starting this one.
599+ // An idle scene leaves that frame displayed and adds no entries.
600+ if !lastShot.IsZero() {
601+ _, _ = fmt.Fprintf(indexWriter, "duration %.6f\n", now.Sub(lastShot).Seconds())
602+ }
603+ _, _ = fmt.Fprintf(indexWriter, "file '%s'\n", name)
604+ if err := indexWriter.Flush(); err != nil {
605+ return fmt.Errorf("frame index: %w", err)
606+ }
607+ lastShot, lastShotName = now, name
565608 if opts.RawHDR && opts.HDR {
566609 // No cursor on the raw image. Only a confirmed HDR output
567610 // has a readable PQ target; else HDRPixels is nil.
@@ -589,25 +632,34 @@ func (opts Options) protected() bool {
589632 }
590633
591634 func writePNGSecure(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) error {
635+ _, err := writePNGSecureResult(path, img, security, state)
636+ return err
637+}
638+
639+// writePNGSecureResult distinguishes a security skip from a published PNG.
640+func writePNGSecureResult(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) {
592641 if security != nil && (state.Protected || security.Snapshot() != state) {
593- return nil
642+ return false, nil
594643 }
595644 f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")
596645 if err != nil {
597- return err
646+ return false, err
598647 }
599648 defer os.Remove(f.Name())
600649 if err := png.Encode(f, img); err != nil {
601650 _ = f.Close()
602- return err
651+ return false, err
603652 }
604653 if err := f.Close(); err != nil {
605- return err
654+ return false, err
606655 }
607656 if security != nil && security.Snapshot() != state {
608- return nil
657+ return false, nil
658+ }
659+ if err := os.Rename(f.Name(), path); err != nil {
660+ return false, err
609661 }
610- return os.Rename(f.Name(), path)
662+ return true, nil
611663 }
612664
613665 // report queues what the output read, with flip for a drawn frame, and
@@ -1,6 +1,7 @@
1 package headless1 package headless
2 2
3 import (3 import (
4+ "bufio"
4 "context"5 "context"
5 "fmt"6 "fmt"
6 "image"7 "image"
@@ -96,6 +97,20 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
96 var scene ports.Scene97 var scene ports.Scene
97 haveScene, dirty := false, false98 haveScene, dirty := false, false
98 frame := 099 frame := 0
100+ var index *os.File
101+ var indexWriter *bufio.Writer
102+ var lastShot time.Time
103+ var lastShotName string
104+ defer func() {
105+ if index != nil {
106+ // A following file makes concat honor the final held interval.
107+ if !lastShot.IsZero() {
108+ _, _ = fmt.Fprintf(indexWriter, "duration %.6f\nfile '%s'\n", time.Since(lastShot).Seconds(), lastShotName)
109+ _ = indexWriter.Flush()
110+ }
111+ _ = index.Close()
112+ }
113+ }()
99 var requests capture.Requests114 var requests capture.Requests
100 ctx, cancelCaptures := context.WithCancel(ctx)115 ctx, cancelCaptures := context.WithCancel(ctx)
101 requests.Init(ctx, opts.Captured)116 requests.Init(ctx, opts.Captured)
@@ -556,12 +571,40 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
556 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {571 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
557 continue572 continue
558 }573 }
559- if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), shot, opts.Security, scene.Security); err != nil {574+ name := fmt.Sprintf("frame-%06d.png", frame)
575+ written, err := writePNGSecureResult(filepath.Join(opts.ScreenshotDir, name), shot, opts.Security, scene.Security)
576+ if err != nil {
560 return fmt.Errorf("screenshot: %w", err)577 return fmt.Errorf("screenshot: %w", err)
561 }578 }
562- if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security); err != nil {579+ if !written {
580+ continue
581+ }
582+ written, err = writePNGSecureResult(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security)
583+ if err != nil {
563 return fmt.Errorf("latest screenshot: %w", err)584 return fmt.Errorf("latest screenshot: %w", err)
564 }585 }
586+ if !written {
587+ continue
588+ }
589+ now := time.Now()
590+ if index == nil {
591+ index, err = os.OpenFile(filepath.Join(opts.ScreenshotDir, "frames.ffconcat"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY|os.O_APPEND, 0o600)
592+ if err != nil {
593+ return fmt.Errorf("frame index: %w", err)
594+ }
595+ indexWriter = bufio.NewWriter(index)
596+ _, _ = fmt.Fprintln(indexWriter, "ffconcat version 1.0")
597+ }
598+ // Complete the previous frame's duration before starting this one.
599+ // An idle scene leaves that frame displayed and adds no entries.
600+ if !lastShot.IsZero() {
601+ _, _ = fmt.Fprintf(indexWriter, "duration %.6f\n", now.Sub(lastShot).Seconds())
602+ }
603+ _, _ = fmt.Fprintf(indexWriter, "file '%s'\n", name)
604+ if err := indexWriter.Flush(); err != nil {
605+ return fmt.Errorf("frame index: %w", err)
606+ }
607+ lastShot, lastShotName = now, name
565 if opts.RawHDR && opts.HDR {608 if opts.RawHDR && opts.HDR {
566 // No cursor on the raw image. Only a confirmed HDR output609 // No cursor on the raw image. Only a confirmed HDR output
567 // has a readable PQ target; else HDRPixels is nil.610 // has a readable PQ target; else HDRPixels is nil.
@@ -589,25 +632,34 @@ func (opts Options) protected() bool {
589 }632 }
590 633
591 func writePNGSecure(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) error {634 func writePNGSecure(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) error {
635+ _, err := writePNGSecureResult(path, img, security, state)
636+ return err
637+}
638+
639+// writePNGSecureResult distinguishes a security skip from a published PNG.
640+func writePNGSecureResult(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) {
592 if security != nil && (state.Protected || security.Snapshot() != state) {641 if security != nil && (state.Protected || security.Snapshot() != state) {
593- return nil642+ return false, nil
594 }643 }
595 f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")644 f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")
596 if err != nil {645 if err != nil {
597- return err646+ return false, err
598 }647 }
599 defer os.Remove(f.Name())648 defer os.Remove(f.Name())
600 if err := png.Encode(f, img); err != nil {649 if err := png.Encode(f, img); err != nil {
601 _ = f.Close()650 _ = f.Close()
602- return err651+ return false, err
603 }652 }
604 if err := f.Close(); err != nil {653 if err := f.Close(); err != nil {
605- return err654+ return false, err
606 }655 }
607 if security != nil && security.Snapshot() != state {656 if security != nil && security.Snapshot() != state {
608- return nil657+ return false, nil
658+ }
659+ if err := os.Rename(f.Name(), path); err != nil {
660+ return false, err
609 }661 }
610- return os.Rename(f.Name(), path)662+ return true, nil
611 }663 }
612 664
613 // report queues what the output read, with flip for a drawn frame, and665 // report queues what the output read, with flip for a drawn frame, and
M internal/adapters/headless/output_test.go +68 -0
@@ -8,8 +8,10 @@ import (
88 "image/color"
99 "image/png"
1010 "maps"
11+ "math"
1112 "os"
1213 "path/filepath"
14+ "strconv"
1315 "strings"
1416 "sync"
1517 "testing"
@@ -118,6 +120,29 @@ func TestRun(t *testing.T) {
118120 t.Fatal(err)
119121 }
120122 }
123+ index, err := os.ReadFile(filepath.Join(dir, "frames.ffconcat"))
124+ if err != nil {
125+ t.Fatal(err)
126+ }
127+ lines := strings.Split(strings.TrimSpace(string(index)), "\n")
128+ if len(lines) != 8 || lines[0] != "ffconcat version 1.0" {
129+ t.Fatalf("unexpected index: %s", index)
130+ }
131+ for i, want := range []string{"file 'frame-000001.png'", "file 'frame-000002.png'", "file 'frame-000003.png'"} {
132+ if lines[1+2*i] != want {
133+ t.Fatalf("frame %d: %s", i+1, lines[1+2*i])
134+ }
135+ if i < 3 {
136+ line := lines[2+2*i]
137+ value, err := strconv.ParseFloat(strings.TrimPrefix(line, "duration "), 64)
138+ if !strings.HasPrefix(line, "duration ") || err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
139+ t.Fatalf("invalid duration: %q", line)
140+ }
141+ }
142+ }
143+ if lines[7] != lines[5] {
144+ t.Fatalf("missing final repeated frame: %s", index)
145+ }
121146 }
122147
123148 // A scene whose Seq the output already holds is not drawn again.
@@ -508,3 +533,46 @@ func TestRunAppliesWholeBatch(t *testing.T) {
508533 t.Error("batch returned more than once")
509534 }
510535 }
536+
537+func TestRunFinalizesLastFrameDuration(t *testing.T) {
538+ dir := t.TempDir()
539+ scenes := make(chan ports.Scene, 1)
540+ scenes <- ports.Scene{Seq: 1}
541+ r, _ := recordingRenderer(t, nil)
542+ ctx, cancel := context.WithCancel(context.Background())
543+ defer cancel()
544+ done := make(chan error, 1)
545+ go func() {
546+ done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil)
547+ }()
548+ deadline := time.Now().Add(3 * time.Second)
549+ for {
550+ data, _ := os.ReadFile(filepath.Join(dir, "frames.ffconcat"))
551+ if strings.Contains(string(data), "file 'frame-000001.png'") {
552+ break
553+ }
554+ if time.Now().After(deadline) {
555+ cancel()
556+ <-done
557+ t.Fatal("frame index not written")
558+ }
559+ time.Sleep(time.Millisecond)
560+ }
561+ time.Sleep(50 * time.Millisecond)
562+ cancel()
563+ if err := <-done; err != nil {
564+ t.Fatal(err)
565+ }
566+ data, err := os.ReadFile(filepath.Join(dir, "frames.ffconcat"))
567+ if err != nil {
568+ t.Fatal(err)
569+ }
570+ lines := strings.Split(strings.TrimSpace(string(data)), "\n")
571+ if len(lines) != 4 || lines[0] != "ffconcat version 1.0" || lines[1] != "file 'frame-000001.png'" || lines[3] != lines[1] {
572+ t.Fatalf("unexpected finalized index: %s", data)
573+ }
574+ duration, err := strconv.ParseFloat(strings.TrimPrefix(lines[2], "duration "), 64)
575+ if err != nil || duration < 0.05 || math.IsNaN(duration) || math.IsInf(duration, 0) {
576+ t.Fatalf("final duration: %q (%v)", lines[2], err)
577+ }
578+}
@@ -8,8 +8,10 @@ import (
8 "image/color"8 "image/color"
9 "image/png"9 "image/png"
10 "maps"10 "maps"
11+ "math"
11 "os"12 "os"
12 "path/filepath"13 "path/filepath"
14+ "strconv"
13 "strings"15 "strings"
14 "sync"16 "sync"
15 "testing"17 "testing"
@@ -118,6 +120,29 @@ func TestRun(t *testing.T) {
118 t.Fatal(err)120 t.Fatal(err)
119 }121 }
120 }122 }
123+ index, err := os.ReadFile(filepath.Join(dir, "frames.ffconcat"))
124+ if err != nil {
125+ t.Fatal(err)
126+ }
127+ lines := strings.Split(strings.TrimSpace(string(index)), "\n")
128+ if len(lines) != 8 || lines[0] != "ffconcat version 1.0" {
129+ t.Fatalf("unexpected index: %s", index)
130+ }
131+ for i, want := range []string{"file 'frame-000001.png'", "file 'frame-000002.png'", "file 'frame-000003.png'"} {
132+ if lines[1+2*i] != want {
133+ t.Fatalf("frame %d: %s", i+1, lines[1+2*i])
134+ }
135+ if i < 3 {
136+ line := lines[2+2*i]
137+ value, err := strconv.ParseFloat(strings.TrimPrefix(line, "duration "), 64)
138+ if !strings.HasPrefix(line, "duration ") || err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
139+ t.Fatalf("invalid duration: %q", line)
140+ }
141+ }
142+ }
143+ if lines[7] != lines[5] {
144+ t.Fatalf("missing final repeated frame: %s", index)
145+ }
121 }146 }
122 147
123 // A scene whose Seq the output already holds is not drawn again.148 // A scene whose Seq the output already holds is not drawn again.
@@ -508,3 +533,46 @@ func TestRunAppliesWholeBatch(t *testing.T) {
508 t.Error("batch returned more than once")533 t.Error("batch returned more than once")
509 }534 }
510 }535 }
536+
537+func TestRunFinalizesLastFrameDuration(t *testing.T) {
538+ dir := t.TempDir()
539+ scenes := make(chan ports.Scene, 1)
540+ scenes <- ports.Scene{Seq: 1}
541+ r, _ := recordingRenderer(t, nil)
542+ ctx, cancel := context.WithCancel(context.Background())
543+ defer cancel()
544+ done := make(chan error, 1)
545+ go func() {
546+ done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil)
547+ }()
548+ deadline := time.Now().Add(3 * time.Second)
549+ for {
550+ data, _ := os.ReadFile(filepath.Join(dir, "frames.ffconcat"))
551+ if strings.Contains(string(data), "file 'frame-000001.png'") {
552+ break
553+ }
554+ if time.Now().After(deadline) {
555+ cancel()
556+ <-done
557+ t.Fatal("frame index not written")
558+ }
559+ time.Sleep(time.Millisecond)
560+ }
561+ time.Sleep(50 * time.Millisecond)
562+ cancel()
563+ if err := <-done; err != nil {
564+ t.Fatal(err)
565+ }
566+ data, err := os.ReadFile(filepath.Join(dir, "frames.ffconcat"))
567+ if err != nil {
568+ t.Fatal(err)
569+ }
570+ lines := strings.Split(strings.TrimSpace(string(data)), "\n")
571+ if len(lines) != 4 || lines[0] != "ffconcat version 1.0" || lines[1] != "file 'frame-000001.png'" || lines[3] != lines[1] {
572+ t.Fatalf("unexpected finalized index: %s", data)
573+ }
574+ duration, err := strconv.ParseFloat(strings.TrimPrefix(lines[2], "duration "), 64)
575+ if err != nil || duration < 0.05 || math.IsNaN(duration) || math.IsInf(duration, 0) {
576+ t.Fatalf("final duration: %q (%v)", lines[2], err)
577+ }
578+}
M internal/adapters/headless/security_test.go +41 -0
@@ -464,3 +464,44 @@ func TestSecurityCompletionLeaseRecycledBeforeProtectionRenderFailure(t *testing
464464 // don't let testify format that expired borrowed memory at cleanup.
465465 frame.Calls = nil
466466 }
467+
468+func TestSecuritySkippedPNGHasNoFrameIndex(t *testing.T) {
469+ dir := t.TempDir()
470+ var state atomic.Value
471+ state.Store(ports.SecurityState{})
472+ protected := ports.SecurityState{Generation: 1, Protected: true}
473+ gate := portsmocks.NewMockSessionSecurity(t)
474+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
475+ return state.Load().(ports.SecurityState)
476+ })
477+ r := portsmocks.NewMockRenderer(t)
478+ black := make(chan struct{}, 1)
479+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
480+ if s.Security.Protected {
481+ black <- struct{}{}
482+ }
483+ return nil, nil
484+ }).Twice()
485+ r.EXPECT().Pixels().RunAndReturn(func() *image.RGBA {
486+ state.Store(protected)
487+ return image.NewRGBA(image.Rect(0, 0, 2, 2))
488+ }).Once()
489+ r.EXPECT().Close().Return().Once()
490+ scenes := make(chan ports.Scene, 1)
491+ scenes <- ports.Scene{Seq: 1}
492+ ctx, cancel := context.WithCancel(context.Background())
493+ defer cancel()
494+ done := make(chan error, 1)
495+ go func() {
496+ done <- Run(ctx, Options{Security: gate, Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil)
497+ }()
498+ securityReceive(t, black)
499+ cancel()
500+ if err := securityReceive(t, done); err != nil {
501+ t.Fatal(err)
502+ }
503+ files, err := os.ReadDir(dir)
504+ if err != nil || len(files) != 0 {
505+ t.Fatalf("security-skipped frame produced PNG or index: %v (%v)", files, err)
506+ }
507+}
@@ -464,3 +464,44 @@ func TestSecurityCompletionLeaseRecycledBeforeProtectionRenderFailure(t *testing
464 // don't let testify format that expired borrowed memory at cleanup.464 // don't let testify format that expired borrowed memory at cleanup.
465 frame.Calls = nil465 frame.Calls = nil
466 }466 }
467+
468+func TestSecuritySkippedPNGHasNoFrameIndex(t *testing.T) {
469+ dir := t.TempDir()
470+ var state atomic.Value
471+ state.Store(ports.SecurityState{})
472+ protected := ports.SecurityState{Generation: 1, Protected: true}
473+ gate := portsmocks.NewMockSessionSecurity(t)
474+ gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
475+ return state.Load().(ports.SecurityState)
476+ })
477+ r := portsmocks.NewMockRenderer(t)
478+ black := make(chan struct{}, 1)
479+ r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
480+ if s.Security.Protected {
481+ black <- struct{}{}
482+ }
483+ return nil, nil
484+ }).Twice()
485+ r.EXPECT().Pixels().RunAndReturn(func() *image.RGBA {
486+ state.Store(protected)
487+ return image.NewRGBA(image.Rect(0, 0, 2, 2))
488+ }).Once()
489+ r.EXPECT().Close().Return().Once()
490+ scenes := make(chan ports.Scene, 1)
491+ scenes <- ports.Scene{Seq: 1}
492+ ctx, cancel := context.WithCancel(context.Background())
493+ defer cancel()
494+ done := make(chan error, 1)
495+ go func() {
496+ done <- Run(ctx, Options{Security: gate, Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil)
497+ }()
498+ securityReceive(t, black)
499+ cancel()
500+ if err := securityReceive(t, done); err != nil {
501+ t.Fatal(err)
502+ }
503+ files, err := os.ReadDir(dir)
504+ if err != nil || len(files) != 0 {
505+ t.Fatalf("security-skipped frame produced PNG or index: %v (%v)", files, err)
506+ }
507+}
A tools/demo/Dockerfile +26 -0
@@ -0,0 +1,26 @@
1+# syntax=docker/dockerfile:1
2+FROM archlinux:latest
3+RUN --mount=type=cache,target=/var/cache/pacman/pkg,sharing=locked \
4+ pacman -Sy --noconfirm archlinux-keyring \
5+ && pacman -Syu --noconfirm --needed \
6+ firefox foot btop fastfetch eza mousepad \
7+ ttf-jetbrains-mono-nerd noto-fonts noto-fonts-emoji dbus mesa vulkan-swrast
8+# Arch's btop carries file capabilities that rootless Docker cannot grant.
9+RUN install -m 0755 /usr/bin/btop /usr/local/bin/btop
10+RUN useradd -m -u 1000 demo \
11+ && mkdir -p /home/demo/firefox /home/demo/.config/btop /run/wl \
12+ && chmod 0700 /run/wl
13+COPY firefox/user.js /home/demo/firefox/user.js
14+COPY firefox/userChrome.css /home/demo/firefox/chrome/userChrome.css
15+COPY foot.ini /home/demo/.config/foot/foot.ini
16+COPY btop.conf /home/demo/.config/btop/btop.conf
17+COPY btop-vesper.theme /home/demo/.config/btop/themes/vesper.theme
18+COPY site /home/demo/site
19+COPY config /home/demo/config
20+RUN chown -R demo:demo /home/demo /run/wl
21+ENV HOME=/home/demo XDG_RUNTIME_DIR=/run/wl WAYLAND_DISPLAY=wayland-0 \
22+ GTK_THEME=Adwaita:dark ADW_DEBUG_COLOR_SCHEME=prefer-dark \
23+ MOZ_ENABLE_WAYLAND=1 LIBGL_ALWAYS_SOFTWARE=1 COLORTERM=truecolor
24+WORKDIR /home/demo
25+# The recorder grants demo access to only the disposable headless socket.
26+CMD ["sleep", "infinity"]
@@ -0,0 +1,26 @@
1+# syntax=docker/dockerfile:1
2+FROM archlinux:latest
3+RUN --mount=type=cache,target=/var/cache/pacman/pkg,sharing=locked \
4+ pacman -Sy --noconfirm archlinux-keyring \
5+ && pacman -Syu --noconfirm --needed \
6+ firefox foot btop fastfetch eza mousepad \
7+ ttf-jetbrains-mono-nerd noto-fonts noto-fonts-emoji dbus mesa vulkan-swrast
8+# Arch's btop carries file capabilities that rootless Docker cannot grant.
9+RUN install -m 0755 /usr/bin/btop /usr/local/bin/btop
10+RUN useradd -m -u 1000 demo \
11+ && mkdir -p /home/demo/firefox /home/demo/.config/btop /run/wl \
12+ && chmod 0700 /run/wl
13+COPY firefox/user.js /home/demo/firefox/user.js
14+COPY firefox/userChrome.css /home/demo/firefox/chrome/userChrome.css
15+COPY foot.ini /home/demo/.config/foot/foot.ini
16+COPY btop.conf /home/demo/.config/btop/btop.conf
17+COPY btop-vesper.theme /home/demo/.config/btop/themes/vesper.theme
18+COPY site /home/demo/site
19+COPY config /home/demo/config
20+RUN chown -R demo:demo /home/demo /run/wl
21+ENV HOME=/home/demo XDG_RUNTIME_DIR=/run/wl WAYLAND_DISPLAY=wayland-0 \
22+ GTK_THEME=Adwaita:dark ADW_DEBUG_COLOR_SCHEME=prefer-dark \
23+ MOZ_ENABLE_WAYLAND=1 LIBGL_ALWAYS_SOFTWARE=1 COLORTERM=truecolor
24+WORKDIR /home/demo
25+# The recorder grants demo access to only the disposable headless socket.
26+CMD ["sleep", "infinity"]
A tools/demo/btop-vesper.theme +43 -0
@@ -0,0 +1,43 @@
1+# Vesper-inspired warm monochrome, with peach highlights.
2+theme[main_bg]="#101010"
3+theme[main_fg]="#ffffff"
4+theme[title]="#ffc799"
5+theme[hi_fg]="#ffc799"
6+theme[selected_bg]="#282828"
7+theme[selected_fg]="#ffffff"
8+theme[inactive_fg]="#7e7e7e"
9+theme[proc_misc]="#ffc799"
10+theme[cpu_box]="#7e7e7e"
11+theme[mem_box]="#7e7e7e"
12+theme[net_box]="#7e7e7e"
13+theme[proc_box]="#7e7e7e"
14+theme[div_line]="#282828"
15+theme[meter_bg]="#282828"
16+theme[graph_text]="#a0a0a0"
17+theme[temp_start]="#7e7e7e"
18+theme[temp_mid]="#e6b99d"
19+theme[temp_end]="#ffc799"
20+theme[cpu_start]="#7e7e7e"
21+theme[cpu_mid]="#e6b99d"
22+theme[cpu_end]="#ffc799"
23+theme[free_start]="#7e7e7e"
24+theme[free_mid]="#e6b99d"
25+theme[free_end]="#ffc799"
26+theme[cached_start]="#7e7e7e"
27+theme[cached_mid]="#e6b99d"
28+theme[cached_end]="#ffc799"
29+theme[available_start]="#7e7e7e"
30+theme[available_mid]="#e6b99d"
31+theme[available_end]="#ffc799"
32+theme[used_start]="#7e7e7e"
33+theme[used_mid]="#e6b99d"
34+theme[used_end]="#ffc799"
35+theme[download_start]="#7e7e7e"
36+theme[download_mid]="#e6b99d"
37+theme[download_end]="#ffc799"
38+theme[upload_start]="#7e7e7e"
39+theme[upload_mid]="#e6b99d"
40+theme[upload_end]="#ffc799"
41+theme[process_start]="#7e7e7e"
42+theme[process_mid]="#e6b99d"
43+theme[process_end]="#ffc799"
@@ -0,0 +1,43 @@
1+# Vesper-inspired warm monochrome, with peach highlights.
2+theme[main_bg]="#101010"
3+theme[main_fg]="#ffffff"
4+theme[title]="#ffc799"
5+theme[hi_fg]="#ffc799"
6+theme[selected_bg]="#282828"
7+theme[selected_fg]="#ffffff"
8+theme[inactive_fg]="#7e7e7e"
9+theme[proc_misc]="#ffc799"
10+theme[cpu_box]="#7e7e7e"
11+theme[mem_box]="#7e7e7e"
12+theme[net_box]="#7e7e7e"
13+theme[proc_box]="#7e7e7e"
14+theme[div_line]="#282828"
15+theme[meter_bg]="#282828"
16+theme[graph_text]="#a0a0a0"
17+theme[temp_start]="#7e7e7e"
18+theme[temp_mid]="#e6b99d"
19+theme[temp_end]="#ffc799"
20+theme[cpu_start]="#7e7e7e"
21+theme[cpu_mid]="#e6b99d"
22+theme[cpu_end]="#ffc799"
23+theme[free_start]="#7e7e7e"
24+theme[free_mid]="#e6b99d"
25+theme[free_end]="#ffc799"
26+theme[cached_start]="#7e7e7e"
27+theme[cached_mid]="#e6b99d"
28+theme[cached_end]="#ffc799"
29+theme[available_start]="#7e7e7e"
30+theme[available_mid]="#e6b99d"
31+theme[available_end]="#ffc799"
32+theme[used_start]="#7e7e7e"
33+theme[used_mid]="#e6b99d"
34+theme[used_end]="#ffc799"
35+theme[download_start]="#7e7e7e"
36+theme[download_mid]="#e6b99d"
37+theme[download_end]="#ffc799"
38+theme[upload_start]="#7e7e7e"
39+theme[upload_mid]="#e6b99d"
40+theme[upload_end]="#ffc799"
41+theme[process_start]="#7e7e7e"
42+theme[process_mid]="#e6b99d"
43+theme[process_end]="#ffc799"
A tools/demo/btop.conf +9 -0
@@ -0,0 +1,9 @@
1+color_theme = "/home/demo/.config/btop/themes/vesper.theme"
2+theme_background = False
3+truecolor = True
4+update_ms = 500
5+custom_cpu_name = "CPU"
6+show_cpu_freq = False
7+shown_boxes = "cpu mem proc"
8+show_battery = False
9+proc_tree = False
@@ -0,0 +1,9 @@
1+color_theme = "/home/demo/.config/btop/themes/vesper.theme"
2+theme_background = False
3+truecolor = True
4+update_ms = 500
5+custom_cpu_name = "CPU"
6+show_cpu_freq = False
7+shown_boxes = "cpu mem proc"
8+show_battery = False
9+proc_tree = False
A tools/demo/config +25 -0
@@ -0,0 +1,25 @@
1+# NeferWL · a little space to think
2+animations = on
3+layout.gaps = 0
4+layout.max-columns = 2
5+layout.overflow = cascade
6+border.width = 2
7+border.active = #ffc799
8+border.inactive = #282828
9+background = #101010
10+terminal.auto-open = off
11+stash.spawn-terminal = off
12+stash.capture = off
13+keyboard.layout = us
14+
15+# Columns fill a band; extra windows flow below.
16+# Super+Up / Down travel between bands.
17+# Super+Shift+s sets a window aside.
18+# Super+s brings your stash into view.
19+# Super+o opens the overview.
20+bind.cmd+code:3 = focus-workspace 2
21+bind.cmd+code:2 = focus-workspace 1
22+
23+# Warm up btop off-screen before bringing its real samples into band 2.
24+rule.monitor.app-id = demo-monitor
25+rule.monitor.workspace = 2
@@ -0,0 +1,25 @@
1+# NeferWL · a little space to think
2+animations = on
3+layout.gaps = 0
4+layout.max-columns = 2
5+layout.overflow = cascade
6+border.width = 2
7+border.active = #ffc799
8+border.inactive = #282828
9+background = #101010
10+terminal.auto-open = off
11+stash.spawn-terminal = off
12+stash.capture = off
13+keyboard.layout = us
14+
15+# Columns fill a band; extra windows flow below.
16+# Super+Up / Down travel between bands.
17+# Super+Shift+s sets a window aside.
18+# Super+s brings your stash into view.
19+# Super+o opens the overview.
20+bind.cmd+code:3 = focus-workspace 2
21+bind.cmd+code:2 = focus-workspace 1
22+
23+# Warm up btop off-screen before bringing its real samples into band 2.
24+rule.monitor.app-id = demo-monitor
25+rule.monitor.workspace = 2
A tools/demo/firefox/user.js +15 -0
@@ -0,0 +1,15 @@
1+user_pref("ui.systemUsesDarkTheme", 1);
2+user_pref("layout.css.prefers-color-scheme.content-override", 0);
3+user_pref("browser.theme.content-theme", 0);
4+user_pref("browser.theme.toolbar-theme", 0);
5+user_pref("extensions.activeThemeID", "firefox-compact-dark@mozilla.org");
6+user_pref("browser.shell.checkDefaultBrowser", false);
7+user_pref("datareporting.policy.dataSubmissionPolicyBypassNotification", true);
8+user_pref("browser.startup.homepage_override.mstone", "ignore");
9+user_pref("browser.aboutwelcome.enabled", false);
10+user_pref("toolkit.telemetry.reportingpolicy.firstRun", false);
11+user_pref("browser.toolbars.bookmarks.visibility", "never");
12+user_pref("widget.gtk.overlay-scrollbars.enabled", true);
13+user_pref("browser.tabs.warnOnClose", false);
14+// Keep browser chrome neutral rather than Firefox's purple dark-theme tint.
15+user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);
@@ -0,0 +1,15 @@
1+user_pref("ui.systemUsesDarkTheme", 1);
2+user_pref("layout.css.prefers-color-scheme.content-override", 0);
3+user_pref("browser.theme.content-theme", 0);
4+user_pref("browser.theme.toolbar-theme", 0);
5+user_pref("extensions.activeThemeID", "firefox-compact-dark@mozilla.org");
6+user_pref("browser.shell.checkDefaultBrowser", false);
7+user_pref("datareporting.policy.dataSubmissionPolicyBypassNotification", true);
8+user_pref("browser.startup.homepage_override.mstone", "ignore");
9+user_pref("browser.aboutwelcome.enabled", false);
10+user_pref("toolkit.telemetry.reportingpolicy.firstRun", false);
11+user_pref("browser.toolbars.bookmarks.visibility", "never");
12+user_pref("widget.gtk.overlay-scrollbars.enabled", true);
13+user_pref("browser.tabs.warnOnClose", false);
14+// Keep browser chrome neutral rather than Firefox's purple dark-theme tint.
15+user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);
A tools/demo/firefox/userChrome.css +18 -0
@@ -0,0 +1,18 @@
1+/* Vesper chrome: neutral near-black surfaces and one peach selection accent. */
2+:root {
3+ --toolbar-bgcolor: #161616 !important;
4+ --toolbar-color: #ffffff !important;
5+ --lwt-accent-color: #101010 !important;
6+ --lwt-text-color: #ffffff !important;
7+ --lwt-selected-tab-background-color: #282828 !important;
8+ --toolbar-field-background-color: #101010 !important;
9+ --toolbar-field-color: #ffffff !important;
10+ --toolbar-field-focus-border-color: #ffc799 !important;
11+}
12+#navigator-toolbox, #TabsToolbar, #nav-bar {
13+ background: #161616 !important;
14+ background-image: none !important;
15+ color: #ffffff !important;
16+}
17+.tab-background[selected] { background: #282828 !important; outline-color: #ffc799 !important; }
18+#urlbar-background { background: #101010 !important; border-color: #282828 !important; }
@@ -0,0 +1,18 @@
1+/* Vesper chrome: neutral near-black surfaces and one peach selection accent. */
2+:root {
3+ --toolbar-bgcolor: #161616 !important;
4+ --toolbar-color: #ffffff !important;
5+ --lwt-accent-color: #101010 !important;
6+ --lwt-text-color: #ffffff !important;
7+ --lwt-selected-tab-background-color: #282828 !important;
8+ --toolbar-field-background-color: #101010 !important;
9+ --toolbar-field-color: #ffffff !important;
10+ --toolbar-field-focus-border-color: #ffc799 !important;
11+}
12+#navigator-toolbox, #TabsToolbar, #nav-bar {
13+ background: #161616 !important;
14+ background-image: none !important;
15+ color: #ffffff !important;
16+}
17+.tab-background[selected] { background: #282828 !important; outline-color: #ffc799 !important; }
18+#urlbar-background { background: #101010 !important; border-color: #282828 !important; }
A tools/demo/foot.ini +24 -0
@@ -0,0 +1,24 @@
1+[main]
2+font=JetBrainsMono Nerd Font:size=15
3+pad=20x20
4+
5+[colors-dark]
6+cursor=101010 ffc799
7+background=101010
8+foreground=ffffff
9+regular0=101010
10+regular1=f5a191
11+regular2=90b99f
12+regular3=e6b99d
13+regular4=aca1cf
14+regular5=e29eca
15+regular6=ea83a5
16+regular7=a0a0a0
17+bright0=7e7e7e
18+bright1=ff8080
19+bright2=99ffe4
20+bright3=ffc799
21+bright4=b9aeda
22+bright5=ecaad6
23+bright6=f591b2
24+bright7=ffffff
@@ -0,0 +1,24 @@
1+[main]
2+font=JetBrainsMono Nerd Font:size=15
3+pad=20x20
4+
5+[colors-dark]
6+cursor=101010 ffc799
7+background=101010
8+foreground=ffffff
9+regular0=101010
10+regular1=f5a191
11+regular2=90b99f
12+regular3=e6b99d
13+regular4=aca1cf
14+regular5=e29eca
15+regular6=ea83a5
16+regular7=a0a0a0
17+bright0=7e7e7e
18+bright1=ff8080
19+bright2=99ffe4
20+bright3=ffc799
21+bright4=b9aeda
22+bright5=ecaad6
23+bright6=f591b2
24+bright7=ffffff
A tools/demo/input.txt +43 -0
@@ -0,0 +1,43 @@
1+move 1918 1078
2+sleep 12s
3+key Super+Up
4+sleep 1500ms
5+key Super+Down
6+sleep 1500ms
7+key Super+Shift+s
8+sleep 500ms
9+key Super+s
10+sleep 1500ms
11+key Super+2
12+key Super+Shift+1
13+key Super+1
14+sleep 3s
15+key Super+Shift+s
16+sleep 500ms
17+key Super+s
18+sleep 1500ms
19+key Super+s
20+key Super+Left
21+sleep 2s
22+key Super+Right
23+sleep 2s
24+key Super+Left
25+sleep 1s
26+key Super+s
27+sleep 2s
28+key Super+2
29+sleep 4s
30+key Super+1
31+sleep 1500ms
32+key Super+o
33+sleep 1500ms
34+key Left
35+sleep 1s
36+key Down
37+sleep 1500ms
38+key Right
39+sleep 1500ms
40+key Up
41+sleep 1s
42+key Return
43+sleep 4s
@@ -0,0 +1,43 @@
1+move 1918 1078
2+sleep 12s
3+key Super+Up
4+sleep 1500ms
5+key Super+Down
6+sleep 1500ms
7+key Super+Shift+s
8+sleep 500ms
9+key Super+s
10+sleep 1500ms
11+key Super+2
12+key Super+Shift+1
13+key Super+1
14+sleep 3s
15+key Super+Shift+s
16+sleep 500ms
17+key Super+s
18+sleep 1500ms
19+key Super+s
20+key Super+Left
21+sleep 2s
22+key Super+Right
23+sleep 2s
24+key Super+Left
25+sleep 1s
26+key Super+s
27+sleep 2s
28+key Super+2
29+sleep 4s
30+key Super+1
31+sleep 1500ms
32+key Super+o
33+sleep 1500ms
34+key Left
35+sleep 1s
36+key Down
37+sleep 1500ms
38+key Right
39+sleep 1500ms
40+key Up
41+sleep 1s
42+key Return
43+sleep 4s
A tools/demo/record.sh +114 -0
@@ -0,0 +1,114 @@
1+#!/usr/bin/env bash
2+# Requires Go 1.27, rootless Docker, bash and ffmpeg. Run from any directory.
3+# KEEP_CAPTURE=1 preserves temporary captures on success; failures always clean up.
4+set -euo pipefail
5+root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
6+cd "$root"
7+for command in docker go ffmpeg; do
8+ command -v "$command" >/dev/null || { echo "Missing required command: $command" >&2; exit 1; }
9+done
10+docker info >/dev/null 2>&1 || { echo 'Docker daemon unavailable; start rootless Docker first.' >&2; exit 1; }
11+work=$(mktemp -d "${TMPDIR:-/tmp}/neferwl-demo.XXXXXX")
12+compositor='' launcher='' feeder='' success=''
13+container=neferwl-demo-$$
14+cleanup() {
15+ local status=$?
16+ trap - EXIT
17+ # launcher and feeder run with job control (set -m) in their own process
18+ # groups, so killing the group also stops their sleep/docker exec children.
19+ for pid in "$launcher" "$feeder"; do
20+ if [[ -n $pid ]]; then kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true; fi
21+ done
22+ if [[ -n $compositor ]]; then kill "$compositor" 2>/dev/null || true; fi
23+ docker rm -f "$container" >/dev/null 2>&1 || true
24+ for pid in "$launcher" "$feeder" "$compositor"; do
25+ if [[ -n $pid ]]; then wait "$pid" 2>/dev/null || true; fi
26+ done
27+ exec 3>&-
28+ if [[ $success != yes || ${KEEP_CAPTURE:-0} != 1 ]]; then
29+ rm -rf -- "$work"
30+ fi
31+ exit "$status"
32+}
33+trap cleanup EXIT
34+trap 'exit 130' INT
35+trap 'exit 143' TERM
36+printf 'Capture directory: %s\n' "$work"
37+mkdir -p "$work/shots" docs/media
38+docker build -t neferwl-demo -f tools/demo/Dockerfile tools/demo
39+CGO_ENABLED=0 go build -o "$work/neferwl" ./cmd/neferwl
40+state=$work/state
41+log=$state/neferwl/runs/headless/latest.log
42+mkfifo "$work/input"
43+# Hold both ends open so the FIFO cannot block compositor startup or produce
44+# premature EOF. No input is fed until Docker and the first app are ready.
45+exec 3<>"$work/input"
46+env -u NEFERWL_STATE -u NEFERWL_SLOT -u WAYLAND_DISPLAY XDG_STATE_HOME="$state" "$work/neferwl" \
47+ --backend=headless --size 1920x1080 --config tools/demo/config \
48+ --no-xwayland --screenshot "$work/shots" --input - --timeout 75s <"$work/input" &
49+compositor=$!
50+socket=''
51+for ((attempt = 0; attempt < 150; attempt++)); do
52+ kill -0 "$compositor" 2>/dev/null || { echo "Compositor exited before readiness; see $log" >&2; exit 1; }
53+ if [[ -f $log ]]; then
54+ display=$(sed -n '/"message":"listening"/s/.*"WAYLAND_DISPLAY":"\([^" ]*\)".*/\1/p' "$log" | tail -1)
55+ if [[ -n $display && -S $XDG_RUNTIME_DIR/${display##*/} ]]; then
56+ socket=$XDG_RUNTIME_DIR/${display##*/}
57+ break
58+ fi
59+ fi
60+ sleep 0.1
61+done
62+[[ -n $socket ]] || { echo "Timed out after 15s waiting for compositor socket; see $log" >&2; exit 1; }
63+# Container root maps to the host uid under rootless Docker. Grant demo access
64+# to this disposable socket only; Firefox rejects root with a demo-owned HOME.
65+docker run -d --name "$container" --hostname neferwl-demo --network none \
66+ -v "$socket:/run/wl/wayland-0" neferwl-demo
67+docker exec "$container" chmod 666 /run/wl/wayland-0
68+at() { local delay=$(($1 - SECONDS)); if ((delay > 0)); then sleep "$delay"; fi; }
69+launch_apps() {
70+ # This shell timeline shares t=0 with input.txt. Only keys live in input.txt;
71+ # Docker launches cannot be invoked by compositor input scripts.
72+ at 3
73+ docker exec -d --user demo "$container" dbus-run-session -- firefox --no-remote \
74+ --profile /home/demo/firefox file:///home/demo/site/index.html
75+ # btop's first per-core sample is bogus in this container. Let it collect
76+ # real deltas on workspace 2; input.txt brings it into view at t=17.
77+ at 5
78+ docker exec -d --user demo "$container" foot --app-id demo-monitor \
79+ --title 'Monitor / btop' /usr/local/bin/btop
80+ at 9
81+ docker exec -d --user demo "$container" dbus-run-session -- bash -c \
82+ 'gsettings set org.xfce.mousepad.preferences.view use-default-monospace-font false; gsettings set org.xfce.mousepad.preferences.view font-name "JetBrainsMono Nerd Font 15"; gsettings set org.xfce.mousepad.preferences.window menubar-visible false; mousepad /home/demo/config'
83+ at 30
84+ docker exec -d --user demo "$container" foot --title 'Fonts / workspace 2' bash -c \
85+ 'eza --tree --level=2 --icons --color=always /usr/share/fonts | head -30; exec sleep infinity'
86+}
87+docker exec -d --user demo "$container" foot --title 'System / NeferWL' bash -c \
88+ 'fastfetch --logo small --structure OS:Kernel:WM:Terminal:Memory:Colors; eza -la --no-user --icons --color=always /etc | head -24; exec sleep infinity'
89+# The first app is launched before t=0. FIFO input and shell launches now
90+# share this epoch, independent of compositor/image/container startup time.
91+SECONDS=0
92+set -m
93+{ cat tools/demo/input.txt; printf '\nkey Ctrl+Alt+BackSpace\n'; } >&3 &
94+feeder=$!
95+launch_apps &
96+launcher=$!
97+set +m
98+# --timeout exits nonzero; accept only its explicit timeout log entry.
99+if ! wait "$compositor"; then
100+ grep -q '"reason":"timeout"' "$log" || exit 1
101+fi
102+compositor=
103+wait "$feeder"
104+feeder=
105+wait "$launcher"
106+launcher=
107+ffmpeg -hide_banner -loglevel warning -f concat -safe 0 \
108+ -i "$work/shots/frames.ffconcat" -vf 'fps=30,format=yuv420p' \
109+ -c:v libx264 -crf 23 -movflags +faststart docs/media/demo.mp4 -y
110+ffmpeg -hide_banner -loglevel warning -ss 7 -i docs/media/demo.mp4 \
111+ -frames:v 1 -update 1 docs/media/demo.png -y
112+success=yes
113+if [[ ${KEEP_CAPTURE:-0} == 1 ]]; then printf 'Captures kept: %s\n' "$work"; fi
114+printf 'Recorded: docs/media/demo.mp4 and docs/media/demo.png\n'
@@ -0,0 +1,114 @@
1+#!/usr/bin/env bash
2+# Requires Go 1.27, rootless Docker, bash and ffmpeg. Run from any directory.
3+# KEEP_CAPTURE=1 preserves temporary captures on success; failures always clean up.
4+set -euo pipefail
5+root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
6+cd "$root"
7+for command in docker go ffmpeg; do
8+ command -v "$command" >/dev/null || { echo "Missing required command: $command" >&2; exit 1; }
9+done
10+docker info >/dev/null 2>&1 || { echo 'Docker daemon unavailable; start rootless Docker first.' >&2; exit 1; }
11+work=$(mktemp -d "${TMPDIR:-/tmp}/neferwl-demo.XXXXXX")
12+compositor='' launcher='' feeder='' success=''
13+container=neferwl-demo-$$
14+cleanup() {
15+ local status=$?
16+ trap - EXIT
17+ # launcher and feeder run with job control (set -m) in their own process
18+ # groups, so killing the group also stops their sleep/docker exec children.
19+ for pid in "$launcher" "$feeder"; do
20+ if [[ -n $pid ]]; then kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true; fi
21+ done
22+ if [[ -n $compositor ]]; then kill "$compositor" 2>/dev/null || true; fi
23+ docker rm -f "$container" >/dev/null 2>&1 || true
24+ for pid in "$launcher" "$feeder" "$compositor"; do
25+ if [[ -n $pid ]]; then wait "$pid" 2>/dev/null || true; fi
26+ done
27+ exec 3>&-
28+ if [[ $success != yes || ${KEEP_CAPTURE:-0} != 1 ]]; then
29+ rm -rf -- "$work"
30+ fi
31+ exit "$status"
32+}
33+trap cleanup EXIT
34+trap 'exit 130' INT
35+trap 'exit 143' TERM
36+printf 'Capture directory: %s\n' "$work"
37+mkdir -p "$work/shots" docs/media
38+docker build -t neferwl-demo -f tools/demo/Dockerfile tools/demo
39+CGO_ENABLED=0 go build -o "$work/neferwl" ./cmd/neferwl
40+state=$work/state
41+log=$state/neferwl/runs/headless/latest.log
42+mkfifo "$work/input"
43+# Hold both ends open so the FIFO cannot block compositor startup or produce
44+# premature EOF. No input is fed until Docker and the first app are ready.
45+exec 3<>"$work/input"
46+env -u NEFERWL_STATE -u NEFERWL_SLOT -u WAYLAND_DISPLAY XDG_STATE_HOME="$state" "$work/neferwl" \
47+ --backend=headless --size 1920x1080 --config tools/demo/config \
48+ --no-xwayland --screenshot "$work/shots" --input - --timeout 75s <"$work/input" &
49+compositor=$!
50+socket=''
51+for ((attempt = 0; attempt < 150; attempt++)); do
52+ kill -0 "$compositor" 2>/dev/null || { echo "Compositor exited before readiness; see $log" >&2; exit 1; }
53+ if [[ -f $log ]]; then
54+ display=$(sed -n '/"message":"listening"/s/.*"WAYLAND_DISPLAY":"\([^" ]*\)".*/\1/p' "$log" | tail -1)
55+ if [[ -n $display && -S $XDG_RUNTIME_DIR/${display##*/} ]]; then
56+ socket=$XDG_RUNTIME_DIR/${display##*/}
57+ break
58+ fi
59+ fi
60+ sleep 0.1
61+done
62+[[ -n $socket ]] || { echo "Timed out after 15s waiting for compositor socket; see $log" >&2; exit 1; }
63+# Container root maps to the host uid under rootless Docker. Grant demo access
64+# to this disposable socket only; Firefox rejects root with a demo-owned HOME.
65+docker run -d --name "$container" --hostname neferwl-demo --network none \
66+ -v "$socket:/run/wl/wayland-0" neferwl-demo
67+docker exec "$container" chmod 666 /run/wl/wayland-0
68+at() { local delay=$(($1 - SECONDS)); if ((delay > 0)); then sleep "$delay"; fi; }
69+launch_apps() {
70+ # This shell timeline shares t=0 with input.txt. Only keys live in input.txt;
71+ # Docker launches cannot be invoked by compositor input scripts.
72+ at 3
73+ docker exec -d --user demo "$container" dbus-run-session -- firefox --no-remote \
74+ --profile /home/demo/firefox file:///home/demo/site/index.html
75+ # btop's first per-core sample is bogus in this container. Let it collect
76+ # real deltas on workspace 2; input.txt brings it into view at t=17.
77+ at 5
78+ docker exec -d --user demo "$container" foot --app-id demo-monitor \
79+ --title 'Monitor / btop' /usr/local/bin/btop
80+ at 9
81+ docker exec -d --user demo "$container" dbus-run-session -- bash -c \
82+ 'gsettings set org.xfce.mousepad.preferences.view use-default-monospace-font false; gsettings set org.xfce.mousepad.preferences.view font-name "JetBrainsMono Nerd Font 15"; gsettings set org.xfce.mousepad.preferences.window menubar-visible false; mousepad /home/demo/config'
83+ at 30
84+ docker exec -d --user demo "$container" foot --title 'Fonts / workspace 2' bash -c \
85+ 'eza --tree --level=2 --icons --color=always /usr/share/fonts | head -30; exec sleep infinity'
86+}
87+docker exec -d --user demo "$container" foot --title 'System / NeferWL' bash -c \
88+ 'fastfetch --logo small --structure OS:Kernel:WM:Terminal:Memory:Colors; eza -la --no-user --icons --color=always /etc | head -24; exec sleep infinity'
89+# The first app is launched before t=0. FIFO input and shell launches now
90+# share this epoch, independent of compositor/image/container startup time.
91+SECONDS=0
92+set -m
93+{ cat tools/demo/input.txt; printf '\nkey Ctrl+Alt+BackSpace\n'; } >&3 &
94+feeder=$!
95+launch_apps &
96+launcher=$!
97+set +m
98+# --timeout exits nonzero; accept only its explicit timeout log entry.
99+if ! wait "$compositor"; then
100+ grep -q '"reason":"timeout"' "$log" || exit 1
101+fi
102+compositor=
103+wait "$feeder"
104+feeder=
105+wait "$launcher"
106+launcher=
107+ffmpeg -hide_banner -loglevel warning -f concat -safe 0 \
108+ -i "$work/shots/frames.ffconcat" -vf 'fps=30,format=yuv420p' \
109+ -c:v libx264 -crf 23 -movflags +faststart docs/media/demo.mp4 -y
110+ffmpeg -hide_banner -loglevel warning -ss 7 -i docs/media/demo.mp4 \
111+ -frames:v 1 -update 1 docs/media/demo.png -y
112+success=yes
113+if [[ ${KEEP_CAPTURE:-0} == 1 ]]; then printf 'Captures kept: %s\n' "$work"; fi
114+printf 'Recorded: docs/media/demo.mp4 and docs/media/demo.png\n'
A tools/demo/site/index.html +13 -0

Comments0 open / 0

no comments yet.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
l
merge request: the list of files (j k move, enter opens, esc back)
M
merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges
R
merge request: mark the head read
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close