detached d5c3b34

feat(demo): show hint mode on real sites with the compositor in the image

commit
d5c3b348cf59f2a5b1e60316d21d13fd66b24a48
author
brice · · 2026-10-21 14:09 UTC
committer
brice · · 2026-10-22 04:47 UTC
parent
c64260b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +221 -75
M docs/dev/headless.md +6 -4
@@ -25,10 +25,12 @@ ffmpeg -f concat -safe 0 -i /tmp/shots/frames.ffconcat -vf "fps=60,format=yuv420
2525 For a GIF, convert `demo.mp4` with ffmpeg's `palettegen` and `paletteuse` filters.
2626
2727 ### 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.
28+Run `tools/demo/record.sh` with Go 1.27, rootless Docker, ffmpeg and network access installed.
29+It builds the Arch image (apps, tesseract, AT-SPI) and the compositor binary, then records the 1080p README demo.
30+The compositor runs headless inside a disposable container, with the binary and a work directory bind-mounted and `/dev/dri` passed for the GPU.
31+Firefox opens a live DuckDuckGo page, so the recording needs the network and its content varies.
32+A FIFO feeds `input.txt` while the script launches the apps on the same clock.
33+The hint step labels the page with `hint`, then `hint-click <word>` reads the scanned frame and types the label of the link showing that word.
3234 Temporary captures are removed by default; set `KEEP_CAPTURE=1` to keep successful runs.
3335 The recording and poster replace `docs/media/demo.mp4` and `docs/media/demo.png`.
3436
@@ -25,10 +25,12 @@ ffmpeg -f concat -safe 0 -i /tmp/shots/frames.ffconcat -vf "fps=60,format=yuv420
25 For a GIF, convert `demo.mp4` with ffmpeg's `palettegen` and `paletteuse` filters.25 For a GIF, convert `demo.mp4` with ffmpeg's `palettegen` and `paletteuse` filters.
26 26
27 ### Demo recording27 ### Demo recording
28-Run `tools/demo/record.sh` with Go 1.27, rootless Docker and ffmpeg installed.28+Run `tools/demo/record.sh` with Go 1.27, rootless Docker, ffmpeg and network access installed.
29-It builds an Arch app image and the host compositor, then records the 1080p README demo.29+It builds the Arch image (apps, tesseract, AT-SPI) and the compositor binary, then records the 1080p README demo.
30-Apps run in a disposable, offline container; only the headless Wayland socket is mounted.30+The compositor runs headless inside a disposable container, with the binary and a work directory bind-mounted and `/dev/dri` passed for the GPU.
31-A FIFO synchronizes `input.txt` with app launches after container startup.31+Firefox opens a live DuckDuckGo page, so the recording needs the network and its content varies.
32+A FIFO feeds `input.txt` while the script launches the apps on the same clock.
33+The hint step labels the page with `hint`, then `hint-click <word>` reads the scanned frame and types the label of the link showing that word.
32 Temporary captures are removed by default; set `KEEP_CAPTURE=1` to keep successful runs.34 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`.35 The recording and poster replace `docs/media/demo.mp4` and `docs/media/demo.png`.
34 36
M docs/media/demo.mp4 +0 -0 binary file: no text diff

binary file: no text diff

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

binary file: no text diff

M tools/demo/Dockerfile +23 -15
@@ -4,23 +4,31 @@ RUN --mount=type=cache,target=/var/cache/pacman/pkg,sharing=locked \
44 pacman -Sy --noconfirm archlinux-keyring \
55 && pacman -Syu --noconfirm --needed \
66 firefox foot btop fastfetch eza mousepad \
7- ttf-jetbrains-mono-nerd noto-fonts noto-fonts-emoji dbus mesa vulkan-swrast
7+ ttf-jetbrains-mono-nerd noto-fonts noto-fonts-emoji dbus mesa vulkan-radeon \
8+ tesseract tesseract-data-eng at-spi2-core python
89 # Arch's btop carries file capabilities that rootless Docker cannot grant.
910 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
11+# Mousepad warns about running as root, so it runs as demo (uid 1000).
12+RUN useradd -m -u 1000 demo && mkdir -p /root/firefox /root/.config/btop /run/wl && chmod 0755 /run/wl
13+COPY firefox/user.js /root/firefox/user.js
14+COPY firefox/userChrome.css /root/firefox/chrome/userChrome.css
15+COPY foot.ini /root/.config/foot/foot.ini
16+COPY btop.conf /root/.config/btop/btop.conf
17+COPY btop-vesper.theme /root/.config/btop/themes/vesper.theme
18+COPY demo-entry demo-hint-label /usr/local/bin/
19+COPY config /root/config
1920 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 \
21+COPY mousepad.keyfile /home/demo/.config/glib-2.0/settings/keyfile
22+RUN chown -R demo:demo /home/demo
23+ENV HOME=/root XDG_RUNTIME_DIR=/run/wl WAYLAND_DISPLAY=wayland-0 \
2224 GTK_THEME=Adwaita:dark ADW_DEBUG_COLOR_SCHEME=prefer-dark \
25+ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/wl/bus \
2326 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"]
27+WORKDIR /root
28+# Everything runs as the container's root, which rootless Docker maps to the
29+# host user, so the bind-mounted work directory stays owned by the host user.
30+# The compositor (bind-mounted at /usr/local/bin/neferwl) and the apps share one
31+# session bus, which AT-SPI needs. The compositor renders on the host GPU through
32+# /dev/dri: vulkan-radeon is its Mesa driver here (swap in vulkan-intel for an
33+# Intel GPU). Software Vulkan (lvp) cannot export the sync files hint scans need.
34+ENTRYPOINT ["/usr/local/bin/demo-entry"]
@@ -4,23 +4,31 @@ RUN --mount=type=cache,target=/var/cache/pacman/pkg,sharing=locked \
4 pacman -Sy --noconfirm archlinux-keyring \4 pacman -Sy --noconfirm archlinux-keyring \
5 && pacman -Syu --noconfirm --needed \5 && pacman -Syu --noconfirm --needed \
6 firefox foot btop fastfetch eza mousepad \6 firefox foot btop fastfetch eza mousepad \
7- ttf-jetbrains-mono-nerd noto-fonts noto-fonts-emoji dbus mesa vulkan-swrast7+ ttf-jetbrains-mono-nerd noto-fonts noto-fonts-emoji dbus mesa vulkan-radeon \
8+ tesseract tesseract-data-eng at-spi2-core python
8 # Arch's btop carries file capabilities that rootless Docker cannot grant.9 # Arch's btop carries file capabilities that rootless Docker cannot grant.
9 RUN install -m 0755 /usr/bin/btop /usr/local/bin/btop10 RUN install -m 0755 /usr/bin/btop /usr/local/bin/btop
10-RUN useradd -m -u 1000 demo \11+# Mousepad warns about running as root, so it runs as demo (uid 1000).
11- && mkdir -p /home/demo/firefox /home/demo/.config/btop /run/wl \12+RUN useradd -m -u 1000 demo && mkdir -p /root/firefox /root/.config/btop /run/wl && chmod 0755 /run/wl
12- && chmod 0700 /run/wl13+COPY firefox/user.js /root/firefox/user.js
13-COPY firefox/user.js /home/demo/firefox/user.js14+COPY firefox/userChrome.css /root/firefox/chrome/userChrome.css
14-COPY firefox/userChrome.css /home/demo/firefox/chrome/userChrome.css15+COPY foot.ini /root/.config/foot/foot.ini
15-COPY foot.ini /home/demo/.config/foot/foot.ini16+COPY btop.conf /root/.config/btop/btop.conf
16-COPY btop.conf /home/demo/.config/btop/btop.conf17+COPY btop-vesper.theme /root/.config/btop/themes/vesper.theme
17-COPY btop-vesper.theme /home/demo/.config/btop/themes/vesper.theme18+COPY demo-entry demo-hint-label /usr/local/bin/
18-COPY site /home/demo/site19+COPY config /root/config
19 COPY config /home/demo/config20 COPY config /home/demo/config
20-RUN chown -R demo:demo /home/demo /run/wl21+COPY mousepad.keyfile /home/demo/.config/glib-2.0/settings/keyfile
21-ENV HOME=/home/demo XDG_RUNTIME_DIR=/run/wl WAYLAND_DISPLAY=wayland-0 \22+RUN chown -R demo:demo /home/demo
23+ENV HOME=/root XDG_RUNTIME_DIR=/run/wl WAYLAND_DISPLAY=wayland-0 \
22 GTK_THEME=Adwaita:dark ADW_DEBUG_COLOR_SCHEME=prefer-dark \24 GTK_THEME=Adwaita:dark ADW_DEBUG_COLOR_SCHEME=prefer-dark \
25+ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/wl/bus \
23 MOZ_ENABLE_WAYLAND=1 LIBGL_ALWAYS_SOFTWARE=1 COLORTERM=truecolor26 MOZ_ENABLE_WAYLAND=1 LIBGL_ALWAYS_SOFTWARE=1 COLORTERM=truecolor
24-WORKDIR /home/demo27+WORKDIR /root
25-# The recorder grants demo access to only the disposable headless socket.28+# Everything runs as the container's root, which rootless Docker maps to the
26-CMD ["sleep", "infinity"]29+# host user, so the bind-mounted work directory stays owned by the host user.
30+# The compositor (bind-mounted at /usr/local/bin/neferwl) and the apps share one
31+# session bus, which AT-SPI needs. The compositor renders on the host GPU through
32+# /dev/dri: vulkan-radeon is its Mesa driver here (swap in vulkan-intel for an
33+# Intel GPU). Software Vulkan (lvp) cannot export the sync files hint scans need.
34+ENTRYPOINT ["/usr/local/bin/demo-entry"]
M tools/demo/config +4 -0
@@ -11,12 +11,16 @@ terminal.auto-open = off
1111 stash.spawn-terminal = off
1212 stash.capture = off
1313 keyboard.layout = us
14+hint.enable = on
15+hint.targets = links
1416
1517 # Columns fill a band; extra windows flow below.
1618 # Super+Up / Down travel between bands.
1719 # Super+Shift+s sets a window aside.
1820 # Super+s brings your stash into view.
1921 # Super+o opens the overview.
22+# Super+g labels the links; type one to click it.
23+bind.cmd+g = hint
2024 bind.cmd+code:3 = focus-workspace 2
2125 bind.cmd+code:2 = focus-workspace 1
2226
@@ -11,12 +11,16 @@ terminal.auto-open = off
11 stash.spawn-terminal = off11 stash.spawn-terminal = off
12 stash.capture = off12 stash.capture = off
13 keyboard.layout = us13 keyboard.layout = us
14+hint.enable = on
15+hint.targets = links
14 16
15 # Columns fill a band; extra windows flow below.17 # Columns fill a band; extra windows flow below.
16 # Super+Up / Down travel between bands.18 # Super+Up / Down travel between bands.
17 # Super+Shift+s sets a window aside.19 # Super+Shift+s sets a window aside.
18 # Super+s brings your stash into view.20 # Super+s brings your stash into view.
19 # Super+o opens the overview.21 # Super+o opens the overview.
22+# Super+g labels the links; type one to click it.
23+bind.cmd+g = hint
20 bind.cmd+code:3 = focus-workspace 224 bind.cmd+code:3 = focus-workspace 2
21 bind.cmd+code:2 = focus-workspace 125 bind.cmd+code:2 = focus-workspace 1
22 26
A tools/demo/demo-entry +9 -0
@@ -0,0 +1,9 @@
1+#!/bin/sh
2+# Starts the session bus shared by the compositor and the apps, turns
3+# accessibility on before any app starts, then runs the compositor as the
4+# container's main process: the container exits with it.
5+set -eu
6+dbus-daemon --session --fork --address="$DBUS_SESSION_BUS_ADDRESS"
7+dbus-send --session --print-reply --dest=org.a11y.Bus /org/a11y/bus \
8+ org.freedesktop.DBus.Properties.Set string:org.a11y.Status string:IsEnabled variant:boolean:true >/dev/null
9+exec /usr/local/bin/neferwl "$@"
@@ -0,0 +1,9 @@
1+#!/bin/sh
2+# Starts the session bus shared by the compositor and the apps, turns
3+# accessibility on before any app starts, then runs the compositor as the
4+# container's main process: the container exits with it.
5+set -eu
6+dbus-daemon --session --fork --address="$DBUS_SESSION_BUS_ADDRESS"
7+dbus-send --session --print-reply --dest=org.a11y.Bus /org/a11y/bus \
8+ org.freedesktop.DBus.Properties.Set string:org.a11y.Status string:IsEnabled variant:boolean:true >/dev/null
9+exec /usr/local/bin/neferwl "$@"
A tools/demo/demo-hint-label +61 -0
@@ -0,0 +1,61 @@
1+#!/usr/bin/env python3
2+"""Print the hint label of the link under a word of the last hint scan.
3+
4+usage: demo-hint-label <since-epoch> <word>...
5+
6+Waits for a hint dump (NEFERWL_HINT_DEBUG=1) written after <since-epoch>, reads
7+the scanned frame with tesseract, finds the largest occurrence of the first of
8+the words that is on screen and returns the label of the link target under it.
9+Labels follow internal/core/hint.go: targets sorted by top then left edge, one
10+letter each from the home-row alphabet, two letters past 26 targets.
11+"""
12+import csv, glob, io, json, os, re, subprocess, sys, time
13+
14+ALPHABET = "fjdkslaghrueiwoqptyvbcnxmz"
15+DUMPS = os.path.join(os.environ.get("XDG_STATE_HOME", "/work/state"), "neferwl", "hints")
16+
17+
18+def newest_dump(since):
19+ deadline = time.time() + 15
20+ while time.time() < deadline:
21+ for path in sorted(glob.glob(os.path.join(DUMPS, "*.json")), reverse=True):
22+ png = path[:-5] + ".png"
23+ if os.path.getmtime(path) >= since and os.path.exists(png):
24+ return path, png
25+ time.sleep(0.2)
26+ sys.exit("no hint scan dump after the bind")
27+
28+
29+def words(png):
30+ out = subprocess.run(["tesseract", png, "stdout", "--psm", "11", "tsv"],
31+ capture_output=True, text=True, check=True).stdout
32+ for row in csv.DictReader(io.StringIO(out), delimiter="\t", quoting=csv.QUOTE_NONE):
33+ text = re.sub(r"\W", "", row["text"] or "").lower()
34+ if text and float(row["conf"]) > 30:
35+ x, y, w, h = (int(row[k]) for k in ("left", "top", "width", "height"))
36+ yield text, x + w / 2, y + h / 2, h
37+
38+
39+def main():
40+ since, wanted = float(sys.argv[1]), [w.lower() for w in sys.argv[2:]]
41+ path, png = newest_dump(since)
42+ meta = json.load(open(path))
43+ # Core keeps the targets whose centre is on the output, then sorts them.
44+ width, height = meta["width"], meta["height"]
45+ targets = [t for t in meta["targets"]
46+ if 0 <= (t["rect"][0] + t["rect"][2]) / 2 < width and 0 <= (t["rect"][1] + t["rect"][3]) / 2 < height]
47+ targets.sort(key=lambda t: (t["rect"][1], t["rect"][0]))
48+ found = list(words(png))
49+ for word in wanted:
50+ hits = [(h, cx, cy) for t, cx, cy, h in found if t == word]
51+ for _, cx, cy in sorted(hits, reverse=True):
52+ for i, t in enumerate(targets):
53+ x0, y0, x1, y1 = t["rect"]
54+ if t["kind"] == "link" and x0 <= cx < x1 and y0 <= cy < y1:
55+ n = len(ALPHABET)
56+ print(ALPHABET[i] if len(targets) <= n else ALPHABET[i // n] + ALPHABET[i % n])
57+ return
58+ sys.exit("none of %s is under a link target (%d targets)" % (wanted, len(targets)))
59+
60+
61+main()
@@ -0,0 +1,61 @@
1+#!/usr/bin/env python3
2+"""Print the hint label of the link under a word of the last hint scan.
3+
4+usage: demo-hint-label <since-epoch> <word>...
5+
6+Waits for a hint dump (NEFERWL_HINT_DEBUG=1) written after <since-epoch>, reads
7+the scanned frame with tesseract, finds the largest occurrence of the first of
8+the words that is on screen and returns the label of the link target under it.
9+Labels follow internal/core/hint.go: targets sorted by top then left edge, one
10+letter each from the home-row alphabet, two letters past 26 targets.
11+"""
12+import csv, glob, io, json, os, re, subprocess, sys, time
13+
14+ALPHABET = "fjdkslaghrueiwoqptyvbcnxmz"
15+DUMPS = os.path.join(os.environ.get("XDG_STATE_HOME", "/work/state"), "neferwl", "hints")
16+
17+
18+def newest_dump(since):
19+ deadline = time.time() + 15
20+ while time.time() < deadline:
21+ for path in sorted(glob.glob(os.path.join(DUMPS, "*.json")), reverse=True):
22+ png = path[:-5] + ".png"
23+ if os.path.getmtime(path) >= since and os.path.exists(png):
24+ return path, png
25+ time.sleep(0.2)
26+ sys.exit("no hint scan dump after the bind")
27+
28+
29+def words(png):
30+ out = subprocess.run(["tesseract", png, "stdout", "--psm", "11", "tsv"],
31+ capture_output=True, text=True, check=True).stdout
32+ for row in csv.DictReader(io.StringIO(out), delimiter="\t", quoting=csv.QUOTE_NONE):
33+ text = re.sub(r"\W", "", row["text"] or "").lower()
34+ if text and float(row["conf"]) > 30:
35+ x, y, w, h = (int(row[k]) for k in ("left", "top", "width", "height"))
36+ yield text, x + w / 2, y + h / 2, h
37+
38+
39+def main():
40+ since, wanted = float(sys.argv[1]), [w.lower() for w in sys.argv[2:]]
41+ path, png = newest_dump(since)
42+ meta = json.load(open(path))
43+ # Core keeps the targets whose centre is on the output, then sorts them.
44+ width, height = meta["width"], meta["height"]
45+ targets = [t for t in meta["targets"]
46+ if 0 <= (t["rect"][0] + t["rect"][2]) / 2 < width and 0 <= (t["rect"][1] + t["rect"][3]) / 2 < height]
47+ targets.sort(key=lambda t: (t["rect"][1], t["rect"][0]))
48+ found = list(words(png))
49+ for word in wanted:
50+ hits = [(h, cx, cy) for t, cx, cy, h in found if t == word]
51+ for _, cx, cy in sorted(hits, reverse=True):
52+ for i, t in enumerate(targets):
53+ x0, y0, x1, y1 = t["rect"]
54+ if t["kind"] == "link" and x0 <= cx < x1 and y0 <= cy < y1:
55+ n = len(ALPHABET)
56+ print(ALPHABET[i] if len(targets) <= n else ALPHABET[i // n] + ALPHABET[i % n])
57+ return
58+ sys.exit("none of %s is under a link target (%d targets)" % (wanted, len(targets)))
59+
60+
61+main()
M tools/demo/input.txt +31 -1
@@ -40,4 +40,34 @@ sleep 1500ms
4040 key Up
4141 sleep 1s
4242 key Return
43-sleep 4s
43+sleep 2s
44+# Back on workspace 1. Firefox builds its accessibility tree for page content
45+# only after a few scans; a hint bind cancelled at once still runs its scan
46+# without drawing labels, so these warm it up unseen.
47+key Super+1
48+sleep 2s
49+key Super+g
50+key Escape
51+sleep 2s
52+key Super+g
53+key Escape
54+sleep 2s
55+key Super+g
56+key Escape
57+sleep 2s
58+# Clear the terminal's listing (OCR would take file names for links) and print
59+# a URL, which hint mode labels like Firefox's links. foot opens a URL only from
60+# its own keyboard mode, so the terminal's label is shown, not clicked.
61+move 400 600
62+click
63+type clear; echo https://github.com/bnema/neferwl
64+key Return
65+sleep 500ms
66+move 1918 1078
67+sleep 2s
68+# Hint mode: Firefox's links come from the accessibility tree, the URL from OCR.
69+key Super+g
70+sleep 6s
71+# Types the label of the Wikipedia result: Firefox opens it.
72+hint-click Wikipedia
73+sleep 26s
@@ -40,4 +40,34 @@ sleep 1500ms
40 key Up40 key Up
41 sleep 1s41 sleep 1s
42 key Return42 key Return
43-sleep 4s43+sleep 2s
44+# Back on workspace 1. Firefox builds its accessibility tree for page content
45+# only after a few scans; a hint bind cancelled at once still runs its scan
46+# without drawing labels, so these warm it up unseen.
47+key Super+1
48+sleep 2s
49+key Super+g
50+key Escape
51+sleep 2s
52+key Super+g
53+key Escape
54+sleep 2s
55+key Super+g
56+key Escape
57+sleep 2s
58+# Clear the terminal's listing (OCR would take file names for links) and print
59+# a URL, which hint mode labels like Firefox's links. foot opens a URL only from
60+# its own keyboard mode, so the terminal's label is shown, not clicked.
61+move 400 600
62+click
63+type clear; echo https://github.com/bnema/neferwl
64+key Return
65+sleep 500ms
66+move 1918 1078
67+sleep 2s
68+# Hint mode: Firefox's links come from the accessibility tree, the URL from OCR.
69+key Super+g
70+sleep 6s
71+# Types the label of the Wikipedia result: Firefox opens it.
72+hint-click Wikipedia
73+sleep 26s
A tools/demo/mousepad.keyfile +6 -0
@@ -0,0 +1,6 @@
1+[org/xfce/mousepad/preferences/view]
2+use-default-monospace-font=false
3+font-name='JetBrainsMono Nerd Font 15'
4+
5+[org/xfce/mousepad/preferences/window]
6+menubar-visible=false
@@ -0,0 +1,6 @@
1+[org/xfce/mousepad/preferences/view]
2+use-default-monospace-font=false
3+font-name='JetBrainsMono Nerd Font 15'
4+
5+[org/xfce/mousepad/preferences/window]
6+menubar-visible=false
M tools/demo/record.sh +81 -42
@@ -1,5 +1,7 @@
11 #!/usr/bin/env bash
2-# Requires Go 1.27, rootless Docker, bash and ffmpeg. Run from any directory.
2+# Requires Go 1.27, rootless Docker, bash, ffmpeg and network access.
3+# Run from any directory. The image holds the compositor's runtime (tesseract,
4+# AT-SPI, a Mesa Vulkan driver) and the apps; the host builds the binary.
35 # KEEP_CAPTURE=1 preserves temporary captures on success; failures always clean up.
46 set -euo pipefail
57 root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
@@ -9,7 +11,7 @@ for command in docker go ffmpeg; do
911 done
1012 docker info >/dev/null 2>&1 || { echo 'Docker daemon unavailable; start rootless Docker first.' >&2; exit 1; }
1113 work=$(mktemp -d "${TMPDIR:-/tmp}/neferwl-demo.XXXXXX")
12-compositor='' launcher='' feeder='' success=''
14+launcher='' feeder='' success=''
1315 container=neferwl-demo-$$
1416 cleanup() {
1517 local status=$?
@@ -19,9 +21,8 @@ cleanup() {
1921 for pid in "$launcher" "$feeder"; do
2022 if [[ -n $pid ]]; then kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true; fi
2123 done
22- if [[ -n $compositor ]]; then kill "$compositor" 2>/dev/null || true; fi
2324 docker rm -f "$container" >/dev/null 2>&1 || true
24- for pid in "$launcher" "$feeder" "$compositor"; do
25+ for pid in "$launcher" "$feeder"; do
2526 if [[ -n $pid ]]; then wait "$pid" 2>/dev/null || true; fi
2627 done
2728 exec 3>&-
@@ -34,80 +35,118 @@ trap cleanup EXIT
3435 trap 'exit 130' INT
3536 trap 'exit 143' TERM
3637 printf 'Capture directory: %s\n' "$work"
37-mkdir -p "$work/shots" docs/media
38+mkdir -p "$work/shots" "$work/state" docs/media
3839 docker build -t neferwl-demo -f tools/demo/Dockerfile tools/demo
3940 CGO_ENABLED=0 go build -o "$work/neferwl" ./cmd/neferwl
40-state=$work/state
41-log=$state/neferwl/runs/headless/latest.log
41+log=$work/state/neferwl/runs/headless/latest.log
4242 mkfifo "$work/input"
4343 # 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.
44+# premature EOF. No input is fed until the first app is ready.
4545 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; }
46+# The compositor is the container's main process, so the container ends with
47+# it. Root in a rootless container is the host user: the bind-mounted work
48+# directory (binary, FIFO, frames, log, hint dumps) stays owned by the host user.
49+# The GPU render node gives the compositor the sync files that hint scans need.
50+gpu=()
51+if [[ -d /dev/dri ]]; then gpu=(--device /dev/dri); else echo 'No /dev/dri: hint scans will fail.' >&2; fi
52+docker run -d --name "$container" --hostname neferwl-demo "${gpu[@]}" \
53+ -e XDG_STATE_HOME=/work/state -e NEFERWL_HINT_DEBUG=1 \
54+ -v "$work:/work" -v "$work/neferwl:/usr/local/bin/neferwl:ro" neferwl-demo \
55+ --backend=headless --size 1920x1080 --config /root/config --no-xwayland \
56+ --screenshot /work/shots --input /work/input --timeout 120s >/dev/null
57+display=''
58+for ((attempt = 0; attempt < 300; attempt++)); do
59+ [[ $(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null) == true ]] ||
60+ { echo "Compositor exited before readiness; see $log" >&2; docker logs "$container" >&2 || true; exit 1; }
5361 if [[ -f $log ]]; then
5462 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
63+ [[ -n $display ]] && break
5964 fi
6065 sleep 0.1
6166 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
67+[[ -n $display ]] || { echo "Timed out after 30s waiting for the compositor; see $log" >&2; exit 1; }
68+# Mousepad runs as another user (see launch_apps) and needs the socket.
69+docker exec "$container" chmod 666 "/run/wl/${display##*/}"
70+app() { docker exec -d -e "WAYLAND_DISPLAY=${display##*/}" "$container" "$@"; }
6871 at() { local delay=$(($1 - SECONDS)); if ((delay > 0)); then sleep "$delay"; fi; }
6972 launch_apps() {
7073 # 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.
74+ # app launches cannot be invoked by compositor input scripts.
7275 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
76+ app firefox --no-remote --profile /root/firefox 'https://duckduckgo.com/?q=wayland+compositor'
7577 # btop's first per-core sample is bogus in this container. Let it collect
7678 # real deltas on workspace 2; input.txt brings it into view at t=17.
7779 at 5
78- docker exec -d --user demo "$container" foot --app-id demo-monitor \
79- --title 'Monitor / btop' /usr/local/bin/btop
80+ app foot --app-id demo-monitor --title 'Monitor / btop' /usr/local/bin/btop
8081 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'
82+ # Mousepad runs as demo (it warns about root), with a file-backed
83+ # GSettings and no session bus (it needs the root-owned bus otherwise).
84+ app setpriv --reuid=1000 --regid=1000 --clear-groups env HOME=/home/demo \
85+ GSETTINGS_BACKEND=keyfile DBUS_SESSION_BUS_ADDRESS=disabled: \
86+ mousepad --disable-server /home/demo/config
8387 at 30
84- docker exec -d --user demo "$container" foot --title 'Fonts / workspace 2' bash -c \
88+ app foot --title 'Fonts / workspace 2' bash -c \
8589 'eza --tree --level=2 --icons --color=always /usr/share/fonts | head -30; exec sleep infinity'
8690 }
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'
91+feed() {
92+ # Forwards input.txt to the compositor in real time. `sleep` lines are
93+ # served here (not forwarded), so a host-side step can sit between two compositor steps:
94+ # `hint-click <word>...` types the label that hint mode gave the link
95+ # showing one of the words, from the dump of the scan the last `hint` bind
96+ # started (demo-hint-label reproduces the labelling of internal/core/hint.go).
97+ local line cmd d mark label
98+ while IFS= read -r line || [[ -n $line ]]; do
99+ cmd=${line%% *}
100+ case $cmd in
101+ '' | '#'*) ;;
102+ sleep)
103+ d=${line#sleep }
104+ if [[ $d == *ms ]]; then d=$(printf '%d.%03d' $((${d%ms} / 1000)) $((${d%ms} % 1000))); fi
105+ sleep "$d" ;;
106+ hint-click)
107+ # shellcheck disable=SC2086
108+ label=$(docker exec "$container" demo-hint-label "$mark" ${line#hint-click }) ||
109+ { echo "No hint label for: ${line#hint-click }" >&2; docker kill "$container" >/dev/null; return 1; }
110+ echo "Hint label for ${line#hint-click }: $label" >&2
111+ printf 'type %s\n' "$label" ;;
112+ *)
113+ [[ $line == 'key Super+g' ]] && mark=$(date +%s.%N)
114+ printf '%s\n' "$line" ;;
115+ esac
116+ done <tools/demo/input.txt
117+ printf 'key Ctrl+Alt+BackSpace\n'
118+}
119+app foot --title 'System / NeferWL' bash -c \
120+ 'fastfetch --logo small --structure OS:Kernel:WM:Terminal:Memory:Colors; eza -la --no-user --icons --color=always /etc | head -24
121+ # A bare shell for the end of input.txt: no prompt, no echo, no line editing.
122+ stty -echo; exec env PS1= bash --norc --noediting'
89123 # The first app is launched before t=0. FIFO input and shell launches now
90124 # share this epoch, independent of compositor/image/container startup time.
91125 SECONDS=0
92126 set -m
93-{ cat tools/demo/input.txt; printf '\nkey Ctrl+Alt+BackSpace\n'; } >&3 &
127+feed >&3 &
94128 feeder=$!
95129 launch_apps &
96130 launcher=$!
97131 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
132+# The compositor ends with the quit key at the end of input.txt, or at --timeout.
133+status=$(docker wait "$container")
134+if [[ $status != 0 ]]; then
135+ grep -q '"reason":"timeout"' "$log" || { echo "Compositor failed (exit $status); see $log" >&2; docker logs "$container" >&2 || true; exit 1; }
101136 fi
102-compositor=
103-wait "$feeder"
137+wait "$feeder" || { echo 'Scripted input failed.' >&2; exit 1; }
104138 feeder=
105139 wait "$launcher"
106140 launcher=
141+# Hint mode needs both sources: the accessibility tree (Firefox) and OCR (terminal).
142+if grep -qE 'OCR unavailable|English OCR data missing' "$log"; then echo 'tesseract is missing in the image.' >&2; exit 1; fi
143+grep -qE '"targets":[1-9][0-9]*,.*"message":"hint scan"' "$log" ||
144+ { echo 'No hint scan found targets:' >&2; grep -E '"component":"(hints|atspi)"' "$log" | cut -c1-300 >&2; exit 1; }
145+grep '"message":"hint scan"' "$log" | sed -E 's/.*"atspi_targets":([0-9]+).*"ocr_links":([0-9]+).*"targets":([0-9]+).*/hint scan: \1 accessibility + \2 OCR links = \3 targets/' | tail -3
107146 ffmpeg -hide_banner -loglevel warning -f concat -safe 0 \
108147 -i "$work/shots/frames.ffconcat" -vf 'fps=30,format=yuv420p' \
109148 -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 \
149+ffmpeg -hide_banner -loglevel warning -ss 8 -i docs/media/demo.mp4 \
111150 -frames:v 1 -update 1 docs/media/demo.png -y
112151 success=yes
113152 if [[ ${KEEP_CAPTURE:-0} == 1 ]]; then printf 'Captures kept: %s\n' "$work"; fi
@@ -1,5 +1,7 @@
1 #!/usr/bin/env bash1 #!/usr/bin/env bash
2-# Requires Go 1.27, rootless Docker, bash and ffmpeg. Run from any directory.2+# Requires Go 1.27, rootless Docker, bash, ffmpeg and network access.
3+# Run from any directory. The image holds the compositor's runtime (tesseract,
4+# AT-SPI, a Mesa Vulkan driver) and the apps; the host builds the binary.
3 # KEEP_CAPTURE=1 preserves temporary captures on success; failures always clean up.5 # KEEP_CAPTURE=1 preserves temporary captures on success; failures always clean up.
4 set -euo pipefail6 set -euo pipefail
5 root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)7 root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
@@ -9,7 +11,7 @@ for command in docker go ffmpeg; do
9 done11 done
10 docker info >/dev/null 2>&1 || { echo 'Docker daemon unavailable; start rootless Docker first.' >&2; exit 1; }12 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")13 work=$(mktemp -d "${TMPDIR:-/tmp}/neferwl-demo.XXXXXX")
12-compositor='' launcher='' feeder='' success=''14+launcher='' feeder='' success=''
13 container=neferwl-demo-$$15 container=neferwl-demo-$$
14 cleanup() {16 cleanup() {
15 local status=$?17 local status=$?
@@ -19,9 +21,8 @@ cleanup() {
19 for pid in "$launcher" "$feeder"; do21 for pid in "$launcher" "$feeder"; do
20 if [[ -n $pid ]]; then kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true; fi22 if [[ -n $pid ]]; then kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true; fi
21 done23 done
22- if [[ -n $compositor ]]; then kill "$compositor" 2>/dev/null || true; fi
23 docker rm -f "$container" >/dev/null 2>&1 || true24 docker rm -f "$container" >/dev/null 2>&1 || true
24- for pid in "$launcher" "$feeder" "$compositor"; do25+ for pid in "$launcher" "$feeder"; do
25 if [[ -n $pid ]]; then wait "$pid" 2>/dev/null || true; fi26 if [[ -n $pid ]]; then wait "$pid" 2>/dev/null || true; fi
26 done27 done
27 exec 3>&-28 exec 3>&-
@@ -34,80 +35,118 @@ trap cleanup EXIT
34 trap 'exit 130' INT35 trap 'exit 130' INT
35 trap 'exit 143' TERM36 trap 'exit 143' TERM
36 printf 'Capture directory: %s\n' "$work"37 printf 'Capture directory: %s\n' "$work"
37-mkdir -p "$work/shots" docs/media38+mkdir -p "$work/shots" "$work/state" docs/media
38 docker build -t neferwl-demo -f tools/demo/Dockerfile tools/demo39 docker build -t neferwl-demo -f tools/demo/Dockerfile tools/demo
39 CGO_ENABLED=0 go build -o "$work/neferwl" ./cmd/neferwl40 CGO_ENABLED=0 go build -o "$work/neferwl" ./cmd/neferwl
40-state=$work/state41+log=$work/state/neferwl/runs/headless/latest.log
41-log=$state/neferwl/runs/headless/latest.log
42 mkfifo "$work/input"42 mkfifo "$work/input"
43 # Hold both ends open so the FIFO cannot block compositor startup or produce43 # 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.44+# premature EOF. No input is fed until the first app is ready.
45 exec 3<>"$work/input"45 exec 3<>"$work/input"
46-env -u NEFERWL_STATE -u NEFERWL_SLOT -u WAYLAND_DISPLAY XDG_STATE_HOME="$state" "$work/neferwl" \46+# The compositor is the container's main process, so the container ends with
47- --backend=headless --size 1920x1080 --config tools/demo/config \47+# it. Root in a rootless container is the host user: the bind-mounted work
48- --no-xwayland --screenshot "$work/shots" --input - --timeout 75s <"$work/input" &48+# directory (binary, FIFO, frames, log, hint dumps) stays owned by the host user.
49-compositor=$!49+# The GPU render node gives the compositor the sync files that hint scans need.
50-socket=''50+gpu=()
51-for ((attempt = 0; attempt < 150; attempt++)); do51+if [[ -d /dev/dri ]]; then gpu=(--device /dev/dri); else echo 'No /dev/dri: hint scans will fail.' >&2; fi
52- kill -0 "$compositor" 2>/dev/null || { echo "Compositor exited before readiness; see $log" >&2; exit 1; }52+docker run -d --name "$container" --hostname neferwl-demo "${gpu[@]}" \
53+ -e XDG_STATE_HOME=/work/state -e NEFERWL_HINT_DEBUG=1 \
54+ -v "$work:/work" -v "$work/neferwl:/usr/local/bin/neferwl:ro" neferwl-demo \
55+ --backend=headless --size 1920x1080 --config /root/config --no-xwayland \
56+ --screenshot /work/shots --input /work/input --timeout 120s >/dev/null
57+display=''
58+for ((attempt = 0; attempt < 300; attempt++)); do
59+ [[ $(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null) == true ]] ||
60+ { echo "Compositor exited before readiness; see $log" >&2; docker logs "$container" >&2 || true; exit 1; }
53 if [[ -f $log ]]; then61 if [[ -f $log ]]; then
54 display=$(sed -n '/"message":"listening"/s/.*"WAYLAND_DISPLAY":"\([^" ]*\)".*/\1/p' "$log" | tail -1)62 display=$(sed -n '/"message":"listening"/s/.*"WAYLAND_DISPLAY":"\([^" ]*\)".*/\1/p' "$log" | tail -1)
55- if [[ -n $display && -S $XDG_RUNTIME_DIR/${display##*/} ]]; then63+ [[ -n $display ]] && break
56- socket=$XDG_RUNTIME_DIR/${display##*/}
57- break
58- fi
59 fi64 fi
60 sleep 0.165 sleep 0.1
61 done66 done
62-[[ -n $socket ]] || { echo "Timed out after 15s waiting for compositor socket; see $log" >&2; exit 1; }67+[[ -n $display ]] || { echo "Timed out after 30s waiting for the compositor; see $log" >&2; exit 1; }
63-# Container root maps to the host uid under rootless Docker. Grant demo access68+# Mousepad runs as another user (see launch_apps) and needs the socket.
64-# to this disposable socket only; Firefox rejects root with a demo-owned HOME.69+docker exec "$container" chmod 666 "/run/wl/${display##*/}"
65-docker run -d --name "$container" --hostname neferwl-demo --network none \70+app() { docker exec -d -e "WAYLAND_DISPLAY=${display##*/}" "$container" "$@"; }
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; }71 at() { local delay=$(($1 - SECONDS)); if ((delay > 0)); then sleep "$delay"; fi; }
69 launch_apps() {72 launch_apps() {
70 # This shell timeline shares t=0 with input.txt. Only keys live in input.txt;73 # 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.74+ # app launches cannot be invoked by compositor input scripts.
72 at 375 at 3
73- docker exec -d --user demo "$container" dbus-run-session -- firefox --no-remote \76+ app firefox --no-remote --profile /root/firefox 'https://duckduckgo.com/?q=wayland+compositor'
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 collect77 # 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.78 # real deltas on workspace 2; input.txt brings it into view at t=17.
77 at 579 at 5
78- docker exec -d --user demo "$container" foot --app-id demo-monitor \80+ app foot --app-id demo-monitor --title 'Monitor / btop' /usr/local/bin/btop
79- --title 'Monitor / btop' /usr/local/bin/btop
80 at 981 at 9
81- docker exec -d --user demo "$container" dbus-run-session -- bash -c \82+ # Mousepad runs as demo (it warns about root), with a file-backed
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+ # GSettings and no session bus (it needs the root-owned bus otherwise).
84+ app setpriv --reuid=1000 --regid=1000 --clear-groups env HOME=/home/demo \
85+ GSETTINGS_BACKEND=keyfile DBUS_SESSION_BUS_ADDRESS=disabled: \
86+ mousepad --disable-server /home/demo/config
83 at 3087 at 30
84- docker exec -d --user demo "$container" foot --title 'Fonts / workspace 2' bash -c \88+ app foot --title 'Fonts / workspace 2' bash -c \
85 'eza --tree --level=2 --icons --color=always /usr/share/fonts | head -30; exec sleep infinity'89 'eza --tree --level=2 --icons --color=always /usr/share/fonts | head -30; exec sleep infinity'
86 }90 }
87-docker exec -d --user demo "$container" foot --title 'System / NeferWL' bash -c \91+feed() {
88- 'fastfetch --logo small --structure OS:Kernel:WM:Terminal:Memory:Colors; eza -la --no-user --icons --color=always /etc | head -24; exec sleep infinity'92+ # Forwards input.txt to the compositor in real time. `sleep` lines are
93+ # served here (not forwarded), so a host-side step can sit between two compositor steps:
94+ # `hint-click <word>...` types the label that hint mode gave the link
95+ # showing one of the words, from the dump of the scan the last `hint` bind
96+ # started (demo-hint-label reproduces the labelling of internal/core/hint.go).
97+ local line cmd d mark label
98+ while IFS= read -r line || [[ -n $line ]]; do
99+ cmd=${line%% *}
100+ case $cmd in
101+ '' | '#'*) ;;
102+ sleep)
103+ d=${line#sleep }
104+ if [[ $d == *ms ]]; then d=$(printf '%d.%03d' $((${d%ms} / 1000)) $((${d%ms} % 1000))); fi
105+ sleep "$d" ;;
106+ hint-click)
107+ # shellcheck disable=SC2086
108+ label=$(docker exec "$container" demo-hint-label "$mark" ${line#hint-click }) ||
109+ { echo "No hint label for: ${line#hint-click }" >&2; docker kill "$container" >/dev/null; return 1; }
110+ echo "Hint label for ${line#hint-click }: $label" >&2
111+ printf 'type %s\n' "$label" ;;
112+ *)
113+ [[ $line == 'key Super+g' ]] && mark=$(date +%s.%N)
114+ printf '%s\n' "$line" ;;
115+ esac
116+ done <tools/demo/input.txt
117+ printf 'key Ctrl+Alt+BackSpace\n'
118+}
119+app foot --title 'System / NeferWL' bash -c \
120+ 'fastfetch --logo small --structure OS:Kernel:WM:Terminal:Memory:Colors; eza -la --no-user --icons --color=always /etc | head -24
121+ # A bare shell for the end of input.txt: no prompt, no echo, no line editing.
122+ stty -echo; exec env PS1= bash --norc --noediting'
89 # The first app is launched before t=0. FIFO input and shell launches now123 # 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.124 # share this epoch, independent of compositor/image/container startup time.
91 SECONDS=0125 SECONDS=0
92 set -m126 set -m
93-{ cat tools/demo/input.txt; printf '\nkey Ctrl+Alt+BackSpace\n'; } >&3 &127+feed >&3 &
94 feeder=$!128 feeder=$!
95 launch_apps &129 launch_apps &
96 launcher=$!130 launcher=$!
97 set +m131 set +m
98-# --timeout exits nonzero; accept only its explicit timeout log entry.132+# The compositor ends with the quit key at the end of input.txt, or at --timeout.
99-if ! wait "$compositor"; then133+status=$(docker wait "$container")
100- grep -q '"reason":"timeout"' "$log" || exit 1134+if [[ $status != 0 ]]; then
135+ grep -q '"reason":"timeout"' "$log" || { echo "Compositor failed (exit $status); see $log" >&2; docker logs "$container" >&2 || true; exit 1; }
101 fi136 fi
102-compositor=137+wait "$feeder" || { echo 'Scripted input failed.' >&2; exit 1; }
103-wait "$feeder"
104 feeder=138 feeder=
105 wait "$launcher"139 wait "$launcher"
106 launcher=140 launcher=
141+# Hint mode needs both sources: the accessibility tree (Firefox) and OCR (terminal).
142+if grep -qE 'OCR unavailable|English OCR data missing' "$log"; then echo 'tesseract is missing in the image.' >&2; exit 1; fi
143+grep -qE '"targets":[1-9][0-9]*,.*"message":"hint scan"' "$log" ||
144+ { echo 'No hint scan found targets:' >&2; grep -E '"component":"(hints|atspi)"' "$log" | cut -c1-300 >&2; exit 1; }
145+grep '"message":"hint scan"' "$log" | sed -E 's/.*"atspi_targets":([0-9]+).*"ocr_links":([0-9]+).*"targets":([0-9]+).*/hint scan: \1 accessibility + \2 OCR links = \3 targets/' | tail -3
107 ffmpeg -hide_banner -loglevel warning -f concat -safe 0 \146 ffmpeg -hide_banner -loglevel warning -f concat -safe 0 \
108 -i "$work/shots/frames.ffconcat" -vf 'fps=30,format=yuv420p' \147 -i "$work/shots/frames.ffconcat" -vf 'fps=30,format=yuv420p' \
109 -c:v libx264 -crf 23 -movflags +faststart docs/media/demo.mp4 -y148 -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 \149+ffmpeg -hide_banner -loglevel warning -ss 8 -i docs/media/demo.mp4 \
111 -frames:v 1 -update 1 docs/media/demo.png -y150 -frames:v 1 -update 1 docs/media/demo.png -y
112 success=yes151 success=yes
113 if [[ ${KEEP_CAPTURE:-0} == 1 ]]; then printf 'Captures kept: %s\n' "$work"; fi152 if [[ ${KEEP_CAPTURE:-0} == 1 ]]; then printf 'Captures kept: %s\n' "$work"; fi
D tools/demo/site/index.html +0 -13
@@ -1,13 +0,0 @@
1-<!doctype html>
2-<html lang="en">
3-<meta charset="utf-8">
4-<meta name="color-scheme" content="dark">
5-<title>NeferWL — Space to think</title>
6-<style>
7-*{box-sizing:border-box}body{margin:0;background:#101010;color:#ffffff;font:18px/1.6 sans-serif}main{max-width:960px;margin:auto;padding:60px 38px}header{color:#ffc799;letter-spacing:.2em;font:700 18px monospace}small{color:#ffc799}h1{font-size:54px;line-height:1.12;letter-spacing:-2px;margin:40px 0 24px}p{color:#a0a0a0}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:36px 0}.card{padding:18px;background:#161616;border:1px solid #282828;border-radius:12px}.card b{color:#ffc799}.card p{font-size:15px;margin-bottom:0}pre{background:#161616;border-left:3px solid #ffc799;border-radius:6px;padding:24px;font:16px/1.8 monospace;color:#ffc799}footer{color:#a0a0a0;font:14px monospace}
8-</style>
9-<main><header>◈ NEFERWL</header><h1>Your desktop.<br>A little more focused.</h1><p>A Wayland compositor with room for your work.<br>Tile naturally, set things aside, see the whole picture.</p><div class="cards"><section class="card"><b>01 / Performance</b><p>Small, responsive.<br>Built in Go. Made to stay out of your way.</p></section><section class="card"><b>02 / Stash</b><p>Keep tools close.<br>Set aside a window. Bring it back in a keystroke.</p></section><section class="card"><b>03 / Overview</b><p>Find your focus.<br>Every workspace and stashed window, at a glance.</p></section></div><pre># A desktop that flows with you
10-layout.max-columns = 2
11-layout.overflow = cascade
12-animations = on</pre><footer>SUPER + ↑ / ↓ &nbsp; bands &nbsp; · &nbsp; SUPER + S &nbsp; stash &nbsp; · &nbsp; SUPER + O &nbsp; overview</footer></main>
13-</html>
@@ -1,13 +0,0 @@
1-<!doctype html>
2-<html lang="en">
3-<meta charset="utf-8">
4-<meta name="color-scheme" content="dark">
5-<title>NeferWL — Space to think</title>
6-<style>
7-*{box-sizing:border-box}body{margin:0;background:#101010;color:#ffffff;font:18px/1.6 sans-serif}main{max-width:960px;margin:auto;padding:60px 38px}header{color:#ffc799;letter-spacing:.2em;font:700 18px monospace}small{color:#ffc799}h1{font-size:54px;line-height:1.12;letter-spacing:-2px;margin:40px 0 24px}p{color:#a0a0a0}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:36px 0}.card{padding:18px;background:#161616;border:1px solid #282828;border-radius:12px}.card b{color:#ffc799}.card p{font-size:15px;margin-bottom:0}pre{background:#161616;border-left:3px solid #ffc799;border-radius:6px;padding:24px;font:16px/1.8 monospace;color:#ffc799}footer{color:#a0a0a0;font:14px monospace}
8-</style>
9-<main><header>◈ NEFERWL</header><h1>Your desktop.<br>A little more focused.</h1><p>A Wayland compositor with room for your work.<br>Tile naturally, set things aside, see the whole picture.</p><div class="cards"><section class="card"><b>01 / Performance</b><p>Small, responsive.<br>Built in Go. Made to stay out of your way.</p></section><section class="card"><b>02 / Stash</b><p>Keep tools close.<br>Set aside a window. Bring it back in a keystroke.</p></section><section class="card"><b>03 / Overview</b><p>Find your focus.<br>Every workspace and stashed window, at a glance.</p></section></div><pre># A desktop that flows with you
10-layout.max-columns = 2
11-layout.overflow = cascade
12-animations = on</pre><footer>SUPER + ↑ / ↓ &nbsp; bands &nbsp; · &nbsp; SUPER + S &nbsp; stash &nbsp; · &nbsp; SUPER + O &nbsp; overview</footer></main>
13-</html>

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
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close