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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 25 | 25 | For a GIF, convert `demo.mp4` with ffmpeg's `palettegen` and `paletteuse` filters. | |
| 26 | 26 | ||
| 27 | 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. | |
| 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. | |
| 32 | 34 | Temporary captures are removed by default; set `KEEP_CAPTURE=1` to keep successful runs. | |
| 33 | 35 | The recording and poster replace `docs/media/demo.mp4` and `docs/media/demo.png`. | |
| 34 | 36 | ||
| @@ -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 recording | 27 | ### 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 \ | |||
| 4 | 4 | pacman -Sy --noconfirm archlinux-keyring \ | |
| 5 | 5 | && pacman -Syu --noconfirm --needed \ | |
| 6 | 6 | 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 | |
| 8 | 9 | # Arch's btop carries file capabilities that rootless Docker cannot grant. | |
| 9 | 10 | 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 | |
| 19 | 20 | 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 \ | |
| 22 | 24 | GTK_THEME=Adwaita:dark ADW_DEBUG_COLOR_SCHEME=prefer-dark \ | |
| 25 | + | DBUS_SESSION_BUS_ADDRESS=unix:path=/run/wl/bus \ | |
| 23 | 26 | 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-swrast | 7 | + | 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/btop | 10 | 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/wl | 13 | + | COPY firefox/user.js /root/firefox/user.js |
| 13 | - | COPY firefox/user.js /home/demo/firefox/user.js | 14 | + | COPY firefox/userChrome.css /root/firefox/chrome/userChrome.css |
| 14 | - | COPY firefox/userChrome.css /home/demo/firefox/chrome/userChrome.css | 15 | + | COPY foot.ini /root/.config/foot/foot.ini |
| 15 | - | COPY foot.ini /home/demo/.config/foot/foot.ini | 16 | + | COPY btop.conf /root/.config/btop/btop.conf |
| 16 | - | COPY btop.conf /home/demo/.config/btop/btop.conf | 17 | + | COPY btop-vesper.theme /root/.config/btop/themes/vesper.theme |
| 17 | - | COPY btop-vesper.theme /home/demo/.config/btop/themes/vesper.theme | 18 | + | COPY demo-entry demo-hint-label /usr/local/bin/ |
| 18 | - | COPY site /home/demo/site | 19 | + | COPY config /root/config |
| 19 | COPY config /home/demo/config | 20 | COPY config /home/demo/config | ||
| 20 | - | RUN chown -R demo:demo /home/demo /run/wl | 21 | + | 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=truecolor | 26 | MOZ_ENABLE_WAYLAND=1 LIBGL_ALWAYS_SOFTWARE=1 COLORTERM=truecolor | ||
| 24 | - | WORKDIR /home/demo | 27 | + | 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 | |||
| 11 | 11 | stash.spawn-terminal = off | |
| 12 | 12 | stash.capture = off | |
| 13 | 13 | keyboard.layout = us | |
| 14 | + | hint.enable = on | |
| 15 | + | hint.targets = links | |
| 14 | 16 | ||
| 15 | 17 | # Columns fill a band; extra windows flow below. | |
| 16 | 18 | # Super+Up / Down travel between bands. | |
| 17 | 19 | # Super+Shift+s sets a window aside. | |
| 18 | 20 | # Super+s brings your stash into view. | |
| 19 | 21 | # Super+o opens the overview. | |
| 22 | + | # Super+g labels the links; type one to click it. | |
| 23 | + | bind.cmd+g = hint | |
| 20 | 24 | bind.cmd+code:3 = focus-workspace 2 | |
| 21 | 25 | bind.cmd+code:2 = focus-workspace 1 | |
| 22 | 26 | ||
| @@ -11,12 +11,16 @@ terminal.auto-open = off | |||||
| 11 | stash.spawn-terminal = off | 11 | stash.spawn-terminal = off | ||
| 12 | stash.capture = off | 12 | stash.capture = off | ||
| 13 | keyboard.layout = us | 13 | 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 2 | 24 | bind.cmd+code:3 = focus-workspace 2 | ||
| 21 | bind.cmd+code:2 = focus-workspace 1 | 25 | 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 | |||
| 40 | 40 | key Up | |
| 41 | 41 | sleep 1s | |
| 42 | 42 | 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 Up | 40 | key Up | ||
| 41 | sleep 1s | 41 | sleep 1s | ||
| 42 | key Return | 42 | 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 | |||
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 @@ | |||
| 1 | 1 | #!/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 | 5 | # KEEP_CAPTURE=1 preserves temporary captures on success; failures always clean up. | |
| 4 | 6 | set -euo pipefail | |
| 5 | 7 | root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) | |
| @@ -9,7 +11,7 @@ for command in docker go ffmpeg; do | |||
| 9 | 11 | done | |
| 10 | 12 | docker info >/dev/null 2>&1 || { echo 'Docker daemon unavailable; start rootless Docker first.' >&2; exit 1; } | |
| 11 | 13 | work=$(mktemp -d "${TMPDIR:-/tmp}/neferwl-demo.XXXXXX") | |
| 12 | - | compositor='' launcher='' feeder='' success='' | |
| 14 | + | launcher='' feeder='' success='' | |
| 13 | 15 | container=neferwl-demo-$$ | |
| 14 | 16 | cleanup() { | |
| 15 | 17 | local status=$? | |
| @@ -19,9 +21,8 @@ cleanup() { | |||
| 19 | 21 | for pid in "$launcher" "$feeder"; do | |
| 20 | 22 | if [[ -n $pid ]]; then kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true; fi | |
| 21 | 23 | done | |
| 22 | - | if [[ -n $compositor ]]; then kill "$compositor" 2>/dev/null || true; fi | |
| 23 | 24 | docker rm -f "$container" >/dev/null 2>&1 || true | |
| 24 | - | for pid in "$launcher" "$feeder" "$compositor"; do | |
| 25 | + | for pid in "$launcher" "$feeder"; do | |
| 25 | 26 | if [[ -n $pid ]]; then wait "$pid" 2>/dev/null || true; fi | |
| 26 | 27 | done | |
| 27 | 28 | exec 3>&- | |
| @@ -34,80 +35,118 @@ trap cleanup EXIT | |||
| 34 | 35 | trap 'exit 130' INT | |
| 35 | 36 | trap 'exit 143' TERM | |
| 36 | 37 | printf 'Capture directory: %s\n' "$work" | |
| 37 | - | mkdir -p "$work/shots" docs/media | |
| 38 | + | mkdir -p "$work/shots" "$work/state" docs/media | |
| 38 | 39 | docker build -t neferwl-demo -f tools/demo/Dockerfile tools/demo | |
| 39 | 40 | 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 | |
| 42 | 42 | mkfifo "$work/input" | |
| 43 | 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. | |
| 44 | + | # premature EOF. No input is fed until the first app is ready. | |
| 45 | 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; } | |
| 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; } | |
| 53 | 61 | if [[ -f $log ]]; then | |
| 54 | 62 | 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 | |
| 59 | 64 | fi | |
| 60 | 65 | sleep 0.1 | |
| 61 | 66 | 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" "$@"; } | |
| 68 | 71 | at() { local delay=$(($1 - SECONDS)); if ((delay > 0)); then sleep "$delay"; fi; } | |
| 69 | 72 | launch_apps() { | |
| 70 | 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 | 75 | 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' | |
| 75 | 77 | # btop's first per-core sample is bogus in this container. Let it collect | |
| 76 | 78 | # real deltas on workspace 2; input.txt brings it into view at t=17. | |
| 77 | 79 | 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 | |
| 80 | 81 | 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 | |
| 83 | 87 | 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 | 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 \ | |
| 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' | |
| 89 | 123 | # The first app is launched before t=0. FIFO input and shell launches now | |
| 90 | 124 | # share this epoch, independent of compositor/image/container startup time. | |
| 91 | 125 | SECONDS=0 | |
| 92 | 126 | set -m | |
| 93 | - | { cat tools/demo/input.txt; printf '\nkey Ctrl+Alt+BackSpace\n'; } >&3 & | |
| 127 | + | feed >&3 & | |
| 94 | 128 | feeder=$! | |
| 95 | 129 | launch_apps & | |
| 96 | 130 | launcher=$! | |
| 97 | 131 | 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; } | |
| 101 | 136 | fi | |
| 102 | - | compositor= | |
| 103 | - | wait "$feeder" | |
| 137 | + | wait "$feeder" || { echo 'Scripted input failed.' >&2; exit 1; } | |
| 104 | 138 | feeder= | |
| 105 | 139 | wait "$launcher" | |
| 106 | 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 | 146 | ffmpeg -hide_banner -loglevel warning -f concat -safe 0 \ | |
| 108 | 147 | -i "$work/shots/frames.ffconcat" -vf 'fps=30,format=yuv420p' \ | |
| 109 | 148 | -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 | 150 | -frames:v 1 -update 1 docs/media/demo.png -y | |
| 112 | 151 | success=yes | |
| 113 | 152 | if [[ ${KEEP_CAPTURE:-0} == 1 ]]; then printf 'Captures kept: %s\n' "$work"; fi | |
| @@ -1,5 +1,7 @@ | |||||
| 1 | #!/usr/bin/env bash | 1 | #!/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 pipefail | 6 | 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 | done | 11 | 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"; do | 21 | for pid in "$launcher" "$feeder"; do | ||
| 20 | if [[ -n $pid ]]; then kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true; fi | 22 | if [[ -n $pid ]]; then kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true; fi | ||
| 21 | done | 23 | done | ||
| 22 | - | if [[ -n $compositor ]]; then kill "$compositor" 2>/dev/null || true; fi | |||
| 23 | docker rm -f "$container" >/dev/null 2>&1 || true | 24 | docker rm -f "$container" >/dev/null 2>&1 || true | ||
| 24 | - | for pid in "$launcher" "$feeder" "$compositor"; do | 25 | + | for pid in "$launcher" "$feeder"; do |
| 25 | if [[ -n $pid ]]; then wait "$pid" 2>/dev/null || true; fi | 26 | if [[ -n $pid ]]; then wait "$pid" 2>/dev/null || true; fi | ||
| 26 | done | 27 | done | ||
| 27 | exec 3>&- | 28 | exec 3>&- | ||
| @@ -34,80 +35,118 @@ trap cleanup EXIT | |||||
| 34 | trap 'exit 130' INT | 35 | trap 'exit 130' INT | ||
| 35 | trap 'exit 143' TERM | 36 | trap 'exit 143' TERM | ||
| 36 | printf 'Capture directory: %s\n' "$work" | 37 | printf 'Capture directory: %s\n' "$work" | ||
| 37 | - | mkdir -p "$work/shots" docs/media | 38 | + | mkdir -p "$work/shots" "$work/state" docs/media |
| 38 | docker build -t neferwl-demo -f tools/demo/Dockerfile tools/demo | 39 | docker build -t neferwl-demo -f tools/demo/Dockerfile tools/demo | ||
| 39 | CGO_ENABLED=0 go build -o "$work/neferwl" ./cmd/neferwl | 40 | CGO_ENABLED=0 go build -o "$work/neferwl" ./cmd/neferwl | ||
| 40 | - | state=$work/state | 41 | + | 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 produce | 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. | 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++)); do | 51 | + | 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 ]]; then | 61 | 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##*/} ]]; then | 63 | + | [[ -n $display ]] && break |
| 56 | - | socket=$XDG_RUNTIME_DIR/${display##*/} | |||
| 57 | - | break | |||
| 58 | - | fi | |||
| 59 | fi | 64 | fi | ||
| 60 | sleep 0.1 | 65 | sleep 0.1 | ||
| 61 | done | 66 | 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 access | 68 | + | # 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 3 | 75 | 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 collect | 77 | # 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 5 | 79 | 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 9 | 81 | 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 30 | 87 | 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 now | 123 | # 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=0 | 125 | SECONDS=0 | ||
| 92 | set -m | 126 | 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 +m | 131 | 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"; then | 133 | + | status=$(docker wait "$container") |
| 100 | - | grep -q '"reason":"timeout"' "$log" || exit 1 | 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; } | |||
| 101 | fi | 136 | 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 -y | 148 | -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 -y | 150 | -frames:v 1 -update 1 docs/media/demo.png -y | ||
| 112 | success=yes | 151 | success=yes | ||
| 113 | if [[ ${KEEP_CAPTURE:-0} == 1 ]]; then printf 'Captures kept: %s\n' "$work"; fi | 152 | 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 + ↑ / ↓ bands · SUPER + S stash · SUPER + O 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 + ↑ / ↓ bands · SUPER + S stash · SUPER + O overview</footer></main> | |||
| 13 | - | </html> | |||