feat(demo): open the hint scene on fullscreen Firefox
- commit
- 3a0a985de87a8a181a72d84e0797f921c65f252d
- author
- brice · · 2026-10-21 15:42 UTC
- committer
- brice · · 2026-10-22 04:47 UTC
- parent
- d5c3b34
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +105 -86
M docs/dev/headless.md +1 -1
| @@ -30,7 +30,7 @@ It builds the Arch image (apps, tesseract, AT-SPI) and the compositor binary, th | |||
| 30 | 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 | 31 | Firefox opens a live DuckDuckGo page, so the recording needs the network and its content varies. | |
| 32 | 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. | |
| 33 | + | The hint step makes Firefox fullscreen and labels the page with `hint`; `hint-click <words>` then types the label of the link that shows those words, found from the scan's dump and an OCR read of the still page (`hint-read`). | |
| 34 | 34 | Temporary captures are removed by default; set `KEEP_CAPTURE=1` to keep successful runs. | |
| 35 | 35 | The recording and poster replace `docs/media/demo.mp4` and `docs/media/demo.png`. | |
| 36 | 36 | ||
| @@ -30,7 +30,7 @@ It builds the Arch image (apps, tesseract, AT-SPI) and the compositor binary, th | |||||
| 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. | 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. | 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. | 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. | 33 | + | The hint step makes Firefox fullscreen and labels the page with `hint`; `hint-click <words>` then types the label of the link that shows those words, found from the scan's dump and an OCR read of the still page (`hint-read`). |
| 34 | 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. | ||
| 35 | 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`. | ||
| 36 | 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/config +1 -0
| @@ -19,6 +19,7 @@ hint.targets = links | |||
| 19 | 19 | # Super+Shift+s sets a window aside. | |
| 20 | 20 | # Super+s brings your stash into view. | |
| 21 | 21 | # Super+o opens the overview. | |
| 22 | + | # Super+Shift+f makes a window fullscreen. | |
| 22 | 23 | # Super+g labels the links; type one to click it. | |
| 23 | 24 | bind.cmd+g = hint | |
| 24 | 25 | bind.cmd+code:3 = focus-workspace 2 | |
| @@ -19,6 +19,7 @@ hint.targets = links | |||||
| 19 | # Super+Shift+s sets a window aside. | 19 | # Super+Shift+s sets a window aside. | ||
| 20 | # Super+s brings your stash into view. | 20 | # Super+s brings your stash into view. | ||
| 21 | # Super+o opens the overview. | 21 | # Super+o opens the overview. | ||
| 22 | + | # Super+Shift+f makes a window fullscreen. | |||
| 22 | # Super+g labels the links; type one to click it. | 23 | # Super+g labels the links; type one to click it. | ||
| 23 | bind.cmd+g = hint | 24 | bind.cmd+g = hint | ||
| 24 | bind.cmd+code:3 = focus-workspace 2 | 25 | bind.cmd+code:3 = focus-workspace 2 | ||
M tools/demo/demo-hint-label +53 -41
| @@ -1,61 +1,73 @@ | |||
| 1 | 1 | #!/usr/bin/env python3 | |
| 2 | - | """Print the hint label of the link under a word of the last hint scan. | |
| 2 | + | """Work out the hint label of a link, to type it the way a user reads it. | |
| 3 | 3 | ||
| 4 | - | usage: demo-hint-label <since-epoch> <word>... | |
| 4 | + | demo-hint-label ocr | |
| 5 | + | Reads the frame on screen now (the compositor's latest screenshot) with | |
| 6 | + | tesseract and keeps the words and their boxes in /tmp/hint-words.json. | |
| 7 | + | Run it while the page is still, before the hint bind, so that the | |
| 8 | + | lookup after the bind needs no OCR. | |
| 5 | 9 | ||
| 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. | |
| 10 | + | demo-hint-label label <since-epoch> <word>... | |
| 11 | + | Waits for the hint dump (NEFERWL_HINT_DEBUG=1) of the scan started after | |
| 12 | + | <since-epoch>, finds the link target that shows all the words and prints | |
| 13 | + | its label. Labels follow internal/core/hint.go: the targets whose centre | |
| 14 | + | is on the output, sorted by top then left edge, one letter each from the | |
| 15 | + | home-row alphabet, two letters past 26 targets. | |
| 11 | 16 | """ | |
| 12 | - | import csv, glob, io, json, os, re, subprocess, sys, time | |
| 17 | + | import csv, glob, io, json, os, re, shutil, subprocess, sys, time | |
| 13 | 18 | ||
| 14 | 19 | ALPHABET = "fjdkslaghrueiwoqptyvbcnxmz" | |
| 15 | - | DUMPS = os.path.join(os.environ.get("XDG_STATE_HOME", "/work/state"), "neferwl", "hints") | |
| 20 | + | STATE = os.environ.get("XDG_STATE_HOME", "/work/state") | |
| 21 | + | DUMPS = os.path.join(STATE, "neferwl", "hints") | |
| 22 | + | SHOT = "/work/shots/latest.png" | |
| 23 | + | WORDS = "/tmp/hint-words.json" | |
| 16 | 24 | ||
| 17 | 25 | ||
| 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"], | |
| 26 | + | def ocr(): | |
| 27 | + | shutil.copyfile(SHOT, "/tmp/hint-ocr.png") | |
| 28 | + | out = subprocess.run(["tesseract", "/tmp/hint-ocr.png", "stdout", "--psm", "11", "tsv"], | |
| 31 | 29 | capture_output=True, text=True, check=True).stdout | |
| 30 | + | words = [] | |
| 32 | 31 | for row in csv.DictReader(io.StringIO(out), delimiter="\t", quoting=csv.QUOTE_NONE): | |
| 33 | 32 | text = re.sub(r"\W", "", row["text"] or "").lower() | |
| 34 | 33 | if text and float(row["conf"]) > 30: | |
| 35 | 34 | x, y, w, h = (int(row[k]) for k in ("left", "top", "width", "height")) | |
| 36 | - | yield text, x + w / 2, y + h / 2, h | |
| 35 | + | words.append([text, x + w / 2, y + h / 2]) | |
| 36 | + | json.dump(words, open(WORDS, "w")) | |
| 37 | + | ||
| 38 | + | ||
| 39 | + | def newest_dump(since): | |
| 40 | + | deadline = time.time() + 15 | |
| 41 | + | while time.time() < deadline: | |
| 42 | + | for path in sorted(glob.glob(os.path.join(DUMPS, "*.json")), reverse=True): | |
| 43 | + | if os.path.getmtime(path) >= since: | |
| 44 | + | return path | |
| 45 | + | time.sleep(0.05) | |
| 46 | + | sys.exit("no hint scan dump after the bind") | |
| 37 | 47 | ||
| 38 | 48 | ||
| 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)) | |
| 49 | + | def label(since, wanted): | |
| 50 | + | meta = json.load(open(newest_dump(since))) | |
| 51 | + | words = json.load(open(WORDS)) | |
| 43 | 52 | # Core keeps the targets whose centre is on the output, then sorts them. | |
| 44 | 53 | width, height = meta["width"], meta["height"] | |
| 45 | 54 | targets = [t for t in meta["targets"] | |
| 46 | 55 | if 0 <= (t["rect"][0] + t["rect"][2]) / 2 < width and 0 <= (t["rect"][1] + t["rect"][3]) / 2 < height] | |
| 47 | 56 | 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() | |
| 57 | + | for i, t in enumerate(targets): | |
| 58 | + | x0, y0, x1, y1 = t["rect"] | |
| 59 | + | inside = {w for w, cx, cy in words if x0 <= cx < x1 and y0 <= cy < y1} | |
| 60 | + | if t["kind"] == "link" and set(wanted) <= inside: | |
| 61 | + | n = len(ALPHABET) | |
| 62 | + | print("link %d of %d shows %s" % (i + 1, len(targets), " ".join(wanted)), file=sys.stderr) | |
| 63 | + | print(ALPHABET[i] if len(targets) <= n else ALPHABET[i // n] + ALPHABET[i % n]) | |
| 64 | + | return | |
| 65 | + | sys.exit("no link shows %s (%d targets, %d words read)" % (" ".join(wanted), len(targets), len(words))) | |
| 66 | + | ||
| 67 | + | ||
| 68 | + | if sys.argv[1:2] == ["ocr"]: | |
| 69 | + | ocr() | |
| 70 | + | elif sys.argv[1:2] == ["label"] and len(sys.argv) > 3: | |
| 71 | + | label(float(sys.argv[2]), [w.lower() for w in sys.argv[3:]]) | |
| 72 | + | else: | |
| 73 | + | sys.exit(__doc__) | |
| @@ -1,61 +1,73 @@ | |||||
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 | ||
| 2 | - | """Print the hint label of the link under a word of the last hint scan. | 2 | + | """Work out the hint label of a link, to type it the way a user reads it. |
| 3 | 3 | ||||
| 4 | - | usage: demo-hint-label <since-epoch> <word>... | 4 | + | demo-hint-label ocr |
| 5 | + | Reads the frame on screen now (the compositor's latest screenshot) with | |||
| 6 | + | tesseract and keeps the words and their boxes in /tmp/hint-words.json. | |||
| 7 | + | Run it while the page is still, before the hint bind, so that the | |||
| 8 | + | lookup after the bind needs no OCR. | |||
| 5 | 9 | ||||
| 6 | - | Waits for a hint dump (NEFERWL_HINT_DEBUG=1) written after <since-epoch>, reads | 10 | + | demo-hint-label label <since-epoch> <word>... |
| 7 | - | the scanned frame with tesseract, finds the largest occurrence of the first of | 11 | + | Waits for the hint dump (NEFERWL_HINT_DEBUG=1) of the scan started after |
| 8 | - | the words that is on screen and returns the label of the link target under it. | 12 | + | <since-epoch>, finds the link target that shows all the words and prints |
| 9 | - | Labels follow internal/core/hint.go: targets sorted by top then left edge, one | 13 | + | its label. Labels follow internal/core/hint.go: the targets whose centre |
| 10 | - | letter each from the home-row alphabet, two letters past 26 targets. | 14 | + | is on the output, sorted by top then left edge, one letter each from the |
| 15 | + | home-row alphabet, two letters past 26 targets. | |||
| 11 | """ | 16 | """ | ||
| 12 | - | import csv, glob, io, json, os, re, subprocess, sys, time | 17 | + | import csv, glob, io, json, os, re, shutil, subprocess, sys, time |
| 13 | 18 | ||||
| 14 | ALPHABET = "fjdkslaghrueiwoqptyvbcnxmz" | 19 | ALPHABET = "fjdkslaghrueiwoqptyvbcnxmz" | ||
| 15 | - | DUMPS = os.path.join(os.environ.get("XDG_STATE_HOME", "/work/state"), "neferwl", "hints") | 20 | + | STATE = os.environ.get("XDG_STATE_HOME", "/work/state") |
| 21 | + | DUMPS = os.path.join(STATE, "neferwl", "hints") | |||
| 22 | + | SHOT = "/work/shots/latest.png" | |||
| 23 | + | WORDS = "/tmp/hint-words.json" | |||
| 16 | 24 | ||||
| 17 | 25 | ||||
| 18 | - | def newest_dump(since): | 26 | + | def ocr(): |
| 19 | - | deadline = time.time() + 15 | 27 | + | shutil.copyfile(SHOT, "/tmp/hint-ocr.png") |
| 20 | - | while time.time() < deadline: | 28 | + | out = subprocess.run(["tesseract", "/tmp/hint-ocr.png", "stdout", "--psm", "11", "tsv"], |
| 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 | 29 | capture_output=True, text=True, check=True).stdout | ||
| 30 | + | words = [] | |||
| 32 | for row in csv.DictReader(io.StringIO(out), delimiter="\t", quoting=csv.QUOTE_NONE): | 31 | for row in csv.DictReader(io.StringIO(out), delimiter="\t", quoting=csv.QUOTE_NONE): | ||
| 33 | text = re.sub(r"\W", "", row["text"] or "").lower() | 32 | text = re.sub(r"\W", "", row["text"] or "").lower() | ||
| 34 | if text and float(row["conf"]) > 30: | 33 | if text and float(row["conf"]) > 30: | ||
| 35 | x, y, w, h = (int(row[k]) for k in ("left", "top", "width", "height")) | 34 | x, y, w, h = (int(row[k]) for k in ("left", "top", "width", "height")) | ||
| 36 | - | yield text, x + w / 2, y + h / 2, h | 35 | + | words.append([text, x + w / 2, y + h / 2]) |
| 36 | + | json.dump(words, open(WORDS, "w")) | |||
| 37 | + | ||||
| 38 | + | ||||
| 39 | + | def newest_dump(since): | |||
| 40 | + | deadline = time.time() + 15 | |||
| 41 | + | while time.time() < deadline: | |||
| 42 | + | for path in sorted(glob.glob(os.path.join(DUMPS, "*.json")), reverse=True): | |||
| 43 | + | if os.path.getmtime(path) >= since: | |||
| 44 | + | return path | |||
| 45 | + | time.sleep(0.05) | |||
| 46 | + | sys.exit("no hint scan dump after the bind") | |||
| 37 | 47 | ||||
| 38 | 48 | ||||
| 39 | - | def main(): | 49 | + | def label(since, wanted): |
| 40 | - | since, wanted = float(sys.argv[1]), [w.lower() for w in sys.argv[2:]] | 50 | + | meta = json.load(open(newest_dump(since))) |
| 41 | - | path, png = newest_dump(since) | 51 | + | words = json.load(open(WORDS)) |
| 42 | - | meta = json.load(open(path)) | |||
| 43 | # Core keeps the targets whose centre is on the output, then sorts them. | 52 | # Core keeps the targets whose centre is on the output, then sorts them. | ||
| 44 | width, height = meta["width"], meta["height"] | 53 | width, height = meta["width"], meta["height"] | ||
| 45 | targets = [t for t in meta["targets"] | 54 | 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] | 55 | 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])) | 56 | targets.sort(key=lambda t: (t["rect"][1], t["rect"][0])) | ||
| 48 | - | found = list(words(png)) | 57 | + | for i, t in enumerate(targets): |
| 49 | - | for word in wanted: | 58 | + | x0, y0, x1, y1 = t["rect"] |
| 50 | - | hits = [(h, cx, cy) for t, cx, cy, h in found if t == word] | 59 | + | inside = {w for w, cx, cy in words if x0 <= cx < x1 and y0 <= cy < y1} |
| 51 | - | for _, cx, cy in sorted(hits, reverse=True): | 60 | + | if t["kind"] == "link" and set(wanted) <= inside: |
| 52 | - | for i, t in enumerate(targets): | 61 | + | n = len(ALPHABET) |
| 53 | - | x0, y0, x1, y1 = t["rect"] | 62 | + | print("link %d of %d shows %s" % (i + 1, len(targets), " ".join(wanted)), file=sys.stderr) |
| 54 | - | if t["kind"] == "link" and x0 <= cx < x1 and y0 <= cy < y1: | 63 | + | print(ALPHABET[i] if len(targets) <= n else ALPHABET[i // n] + ALPHABET[i % n]) |
| 55 | - | n = len(ALPHABET) | 64 | + | return |
| 56 | - | print(ALPHABET[i] if len(targets) <= n else ALPHABET[i // n] + ALPHABET[i % n]) | 65 | + | sys.exit("no link shows %s (%d targets, %d words read)" % (" ".join(wanted), len(targets), len(words))) |
| 57 | - | return | 66 | + | |
| 58 | - | sys.exit("none of %s is under a link target (%d targets)" % (wanted, len(targets))) | 67 | + | |
| 59 | - | 68 | + | if sys.argv[1:2] == ["ocr"]: | |
| 60 | - | 69 | + | ocr() | |
| 61 | - | main() | 70 | + | elif sys.argv[1:2] == ["label"] and len(sys.argv) > 3: |
| 71 | + | label(float(sys.argv[2]), [w.lower() for w in sys.argv[3:]]) | |||
| 72 | + | else: | |||
| 73 | + | sys.exit(__doc__) | |||
M tools/demo/input.txt +32 -31
| @@ -1,8 +1,17 @@ | |||
| 1 | 1 | move 1918 1078 | |
| 2 | - | sleep 12s | |
| 2 | + | sleep 5s | |
| 3 | + | # Unseen warm-up while Firefox sits beside the terminal: a hint bind cancelled | |
| 4 | + | # at once still runs its scan, without drawing labels. It connects to the | |
| 5 | + | # accessibility bus early; more of them below (each right after a key press, in | |
| 6 | + | # an existing pause) let Firefox expose its page content by the end. | |
| 7 | + | key Super+g | |
| 8 | + | key Escape | |
| 9 | + | sleep 7s | |
| 3 | 10 | key Super+Up | |
| 4 | 11 | sleep 1500ms | |
| 5 | 12 | key Super+Down | |
| 13 | + | key Super+g | |
| 14 | + | key Escape | |
| 6 | 15 | sleep 1500ms | |
| 7 | 16 | key Super+Shift+s | |
| 8 | 17 | sleep 500ms | |
| @@ -11,6 +20,8 @@ sleep 1500ms | |||
| 11 | 20 | key Super+2 | |
| 12 | 21 | key Super+Shift+1 | |
| 13 | 22 | key Super+1 | |
| 23 | + | key Super+g | |
| 24 | + | key Escape | |
| 14 | 25 | sleep 3s | |
| 15 | 26 | key Super+Shift+s | |
| 16 | 27 | sleep 500ms | |
| @@ -18,8 +29,12 @@ key Super+s | |||
| 18 | 29 | sleep 1500ms | |
| 19 | 30 | key Super+s | |
| 20 | 31 | key Super+Left | |
| 32 | + | key Super+g | |
| 33 | + | key Escape | |
| 21 | 34 | sleep 2s | |
| 22 | 35 | key Super+Right | |
| 36 | + | key Super+g | |
| 37 | + | key Escape | |
| 23 | 38 | sleep 2s | |
| 24 | 39 | key Super+Left | |
| 25 | 40 | sleep 1s | |
| @@ -40,34 +55,20 @@ sleep 1500ms | |||
| 40 | 55 | key Up | |
| 41 | 56 | sleep 1s | |
| 42 | 57 | key Return | |
| 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. | |
| 58 | + | sleep 700ms | |
| 59 | + | # Back on workspace 1: focus Firefox and fullscreen it. Once the page is still, | |
| 60 | + | # read it, then hint mode starts and the label of the Wikipedia result | |
| 61 | + | # ("Wayland (protocol) - Wikipedia") is typed. | |
| 62 | + | key Super+Right | |
| 63 | + | sleep 600ms | |
| 64 | + | key Super+Shift+f | |
| 65 | + | sleep 900ms | |
| 66 | + | hint-read | |
| 67 | + | sleep 200ms | |
| 69 | 68 | key Super+g | |
| 70 | - | sleep 6s | |
| 71 | - | # Types the label of the Wikipedia result: Firefox opens it. | |
| 72 | - | hint-click Wikipedia | |
| 73 | - | sleep 26s | |
| 69 | + | sleep 1500ms | |
| 70 | + | hint-click wikipedia protocol | |
| 71 | + | # Firefox loads the page; the compositor writes no frame while it is still, so | |
| 72 | + | # a pointer move marks the end of the hold. | |
| 73 | + | sleep 4s | |
| 74 | + | move 1500 900 | |
| @@ -1,8 +1,17 @@ | |||||
| 1 | move 1918 1078 | 1 | move 1918 1078 | ||
| 2 | - | sleep 12s | 2 | + | sleep 5s |
| 3 | + | # Unseen warm-up while Firefox sits beside the terminal: a hint bind cancelled | |||
| 4 | + | # at once still runs its scan, without drawing labels. It connects to the | |||
| 5 | + | # accessibility bus early; more of them below (each right after a key press, in | |||
| 6 | + | # an existing pause) let Firefox expose its page content by the end. | |||
| 7 | + | key Super+g | |||
| 8 | + | key Escape | |||
| 9 | + | sleep 7s | |||
| 3 | key Super+Up | 10 | key Super+Up | ||
| 4 | sleep 1500ms | 11 | sleep 1500ms | ||
| 5 | key Super+Down | 12 | key Super+Down | ||
| 13 | + | key Super+g | |||
| 14 | + | key Escape | |||
| 6 | sleep 1500ms | 15 | sleep 1500ms | ||
| 7 | key Super+Shift+s | 16 | key Super+Shift+s | ||
| 8 | sleep 500ms | 17 | sleep 500ms | ||
| @@ -11,6 +20,8 @@ sleep 1500ms | |||||
| 11 | key Super+2 | 20 | key Super+2 | ||
| 12 | key Super+Shift+1 | 21 | key Super+Shift+1 | ||
| 13 | key Super+1 | 22 | key Super+1 | ||
| 23 | + | key Super+g | |||
| 24 | + | key Escape | |||
| 14 | sleep 3s | 25 | sleep 3s | ||
| 15 | key Super+Shift+s | 26 | key Super+Shift+s | ||
| 16 | sleep 500ms | 27 | sleep 500ms | ||
| @@ -18,8 +29,12 @@ key Super+s | |||||
| 18 | sleep 1500ms | 29 | sleep 1500ms | ||
| 19 | key Super+s | 30 | key Super+s | ||
| 20 | key Super+Left | 31 | key Super+Left | ||
| 32 | + | key Super+g | |||
| 33 | + | key Escape | |||
| 21 | sleep 2s | 34 | sleep 2s | ||
| 22 | key Super+Right | 35 | key Super+Right | ||
| 36 | + | key Super+g | |||
| 37 | + | key Escape | |||
| 23 | sleep 2s | 38 | sleep 2s | ||
| 24 | key Super+Left | 39 | key Super+Left | ||
| 25 | sleep 1s | 40 | sleep 1s | ||
| @@ -40,34 +55,20 @@ sleep 1500ms | |||||
| 40 | key Up | 55 | key Up | ||
| 41 | sleep 1s | 56 | sleep 1s | ||
| 42 | key Return | 57 | key Return | ||
| 43 | - | sleep 2s | 58 | + | sleep 700ms |
| 44 | - | # Back on workspace 1. Firefox builds its accessibility tree for page content | 59 | + | # Back on workspace 1: focus Firefox and fullscreen it. Once the page is still, |
| 45 | - | # only after a few scans; a hint bind cancelled at once still runs its scan | 60 | + | # read it, then hint mode starts and the label of the Wikipedia result |
| 46 | - | # without drawing labels, so these warm it up unseen. | 61 | + | # ("Wayland (protocol) - Wikipedia") is typed. |
| 47 | - | key Super+1 | 62 | + | key Super+Right |
| 48 | - | sleep 2s | 63 | + | sleep 600ms |
| 49 | - | key Super+g | 64 | + | key Super+Shift+f |
| 50 | - | key Escape | 65 | + | sleep 900ms |
| 51 | - | sleep 2s | 66 | + | hint-read |
| 52 | - | key Super+g | 67 | + | sleep 200ms |
| 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 | 68 | key Super+g | ||
| 70 | - | sleep 6s | 69 | + | sleep 1500ms |
| 71 | - | # Types the label of the Wikipedia result: Firefox opens it. | 70 | + | hint-click wikipedia protocol |
| 72 | - | hint-click Wikipedia | 71 | + | # Firefox loads the page; the compositor writes no frame while it is still, so |
| 73 | - | sleep 26s | 72 | + | # a pointer move marks the end of the hold. |
| 73 | + | sleep 4s | |||
| 74 | + | move 1500 900 | |||
M tools/demo/record.sh +18 -13
| @@ -53,7 +53,7 @@ docker run -d --name "$container" --hostname neferwl-demo "${gpu[@]}" \ | |||
| 53 | 53 | -e XDG_STATE_HOME=/work/state -e NEFERWL_HINT_DEBUG=1 \ | |
| 54 | 54 | -v "$work:/work" -v "$work/neferwl:/usr/local/bin/neferwl:ro" neferwl-demo \ | |
| 55 | 55 | --backend=headless --size 1920x1080 --config /root/config --no-xwayland \ | |
| 56 | - | --screenshot /work/shots --input /work/input --timeout 120s >/dev/null | |
| 56 | + | --screenshot /work/shots --input /work/input --timeout 75s >/dev/null | |
| 57 | 57 | display='' | |
| 58 | 58 | for ((attempt = 0; attempt < 300; attempt++)); do | |
| 59 | 59 | [[ $(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null) == true ]] || | |
| @@ -90,11 +90,14 @@ launch_apps() { | |||
| 90 | 90 | } | |
| 91 | 91 | feed() { | |
| 92 | 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 | |
| 93 | + | # served here (not forwarded), so host-side steps can sit between two | |
| 94 | + | # compositor steps: | |
| 95 | + | # hint-read reads the still page with OCR, in the background | |
| 96 | + | # hint-click <word>... types the label of the link that shows all the | |
| 97 | + | # words, from the dump of the scan the last | |
| 98 | + | # `hint` bind started (demo-hint-label reproduces | |
| 99 | + | # the labelling of internal/core/hint.go) | |
| 100 | + | local line cmd d mark label reader='' | |
| 98 | 101 | while IFS= read -r line || [[ -n $line ]]; do | |
| 99 | 102 | cmd=${line%% *} | |
| 100 | 103 | case $cmd in | |
| @@ -103,9 +106,13 @@ feed() { | |||
| 103 | 106 | d=${line#sleep } | |
| 104 | 107 | if [[ $d == *ms ]]; then d=$(printf '%d.%03d' $((${d%ms} / 1000)) $((${d%ms} % 1000))); fi | |
| 105 | 108 | sleep "$d" ;; | |
| 109 | + | hint-read) | |
| 110 | + | docker exec "$container" demo-hint-label ocr & | |
| 111 | + | reader=$! ;; | |
| 106 | 112 | hint-click) | |
| 113 | + | [[ -n $reader ]] && wait "$reader" | |
| 107 | 114 | # shellcheck disable=SC2086 | |
| 108 | - | label=$(docker exec "$container" demo-hint-label "$mark" ${line#hint-click }) || | |
| 115 | + | label=$(docker exec "$container" demo-hint-label label "$mark" ${line#hint-click }) || | |
| 109 | 116 | { echo "No hint label for: ${line#hint-click }" >&2; docker kill "$container" >/dev/null; return 1; } | |
| 110 | 117 | echo "Hint label for ${line#hint-click }: $label" >&2 | |
| 111 | 118 | printf 'type %s\n' "$label" ;; | |
| @@ -117,9 +124,7 @@ feed() { | |||
| 117 | 124 | printf 'key Ctrl+Alt+BackSpace\n' | |
| 118 | 125 | } | |
| 119 | 126 | 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' | |
| 127 | + | 'fastfetch --logo small --structure OS:Kernel:WM:Terminal:Memory:Colors; eza -la --no-user --icons --color=always /etc | head -24; exec sleep infinity' | |
| 123 | 128 | # The first app is launched before t=0. FIFO input and shell launches now | |
| 124 | 129 | # share this epoch, independent of compositor/image/container startup time. | |
| 125 | 130 | SECONDS=0 | |
| @@ -138,10 +143,10 @@ wait "$feeder" || { echo 'Scripted input failed.' >&2; exit 1; } | |||
| 138 | 143 | feeder= | |
| 139 | 144 | wait "$launcher" | |
| 140 | 145 | launcher= | |
| 141 | - | # Hint mode needs both sources: the accessibility tree (Firefox) and OCR (terminal). | |
| 146 | + | # Firefox's links come from the accessibility tree; OCR (tesseract) must still load. | |
| 142 | 147 | 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; } | |
| 148 | + | grep -qE '"atspi_targets":[1-9][0-9]*,.*"message":"hint scan"' "$log" || | |
| 149 | + | { echo 'No hint scan found accessibility targets:' >&2; grep -E '"component":"(hints|atspi)"' "$log" | cut -c1-300 >&2; exit 1; } | |
| 145 | 150 | 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 | |
| 146 | 151 | ffmpeg -hide_banner -loglevel warning -f concat -safe 0 \ | |
| 147 | 152 | -i "$work/shots/frames.ffconcat" -vf 'fps=30,format=yuv420p' \ | |
| @@ -53,7 +53,7 @@ docker run -d --name "$container" --hostname neferwl-demo "${gpu[@]}" \ | |||||
| 53 | -e XDG_STATE_HOME=/work/state -e NEFERWL_HINT_DEBUG=1 \ | 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 \ | 54 | -v "$work:/work" -v "$work/neferwl:/usr/local/bin/neferwl:ro" neferwl-demo \ | ||
| 55 | --backend=headless --size 1920x1080 --config /root/config --no-xwayland \ | 55 | --backend=headless --size 1920x1080 --config /root/config --no-xwayland \ | ||
| 56 | - | --screenshot /work/shots --input /work/input --timeout 120s >/dev/null | 56 | + | --screenshot /work/shots --input /work/input --timeout 75s >/dev/null |
| 57 | display='' | 57 | display='' | ||
| 58 | for ((attempt = 0; attempt < 300; attempt++)); do | 58 | for ((attempt = 0; attempt < 300; attempt++)); do | ||
| 59 | [[ $(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null) == true ]] || | 59 | [[ $(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null) == true ]] || | ||
| @@ -90,11 +90,14 @@ launch_apps() { | |||||
| 90 | } | 90 | } | ||
| 91 | feed() { | 91 | feed() { | ||
| 92 | # Forwards input.txt to the compositor in real time. `sleep` lines are | 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: | 93 | + | # served here (not forwarded), so host-side steps can sit between two |
| 94 | - | # `hint-click <word>...` types the label that hint mode gave the link | 94 | + | # compositor steps: |
| 95 | - | # showing one of the words, from the dump of the scan the last `hint` bind | 95 | + | # hint-read reads the still page with OCR, in the background |
| 96 | - | # started (demo-hint-label reproduces the labelling of internal/core/hint.go). | 96 | + | # hint-click <word>... types the label of the link that shows all the |
| 97 | - | local line cmd d mark label | 97 | + | # words, from the dump of the scan the last |
| 98 | + | # `hint` bind started (demo-hint-label reproduces | |||
| 99 | + | # the labelling of internal/core/hint.go) | |||
| 100 | + | local line cmd d mark label reader='' | |||
| 98 | while IFS= read -r line || [[ -n $line ]]; do | 101 | while IFS= read -r line || [[ -n $line ]]; do | ||
| 99 | cmd=${line%% *} | 102 | cmd=${line%% *} | ||
| 100 | case $cmd in | 103 | case $cmd in | ||
| @@ -103,9 +106,13 @@ feed() { | |||||
| 103 | d=${line#sleep } | 106 | d=${line#sleep } | ||
| 104 | if [[ $d == *ms ]]; then d=$(printf '%d.%03d' $((${d%ms} / 1000)) $((${d%ms} % 1000))); fi | 107 | if [[ $d == *ms ]]; then d=$(printf '%d.%03d' $((${d%ms} / 1000)) $((${d%ms} % 1000))); fi | ||
| 105 | sleep "$d" ;; | 108 | sleep "$d" ;; | ||
| 109 | + | hint-read) | |||
| 110 | + | docker exec "$container" demo-hint-label ocr & | |||
| 111 | + | reader=$! ;; | |||
| 106 | hint-click) | 112 | hint-click) | ||
| 113 | + | [[ -n $reader ]] && wait "$reader" | |||
| 107 | # shellcheck disable=SC2086 | 114 | # shellcheck disable=SC2086 | ||
| 108 | - | label=$(docker exec "$container" demo-hint-label "$mark" ${line#hint-click }) || | 115 | + | label=$(docker exec "$container" demo-hint-label label "$mark" ${line#hint-click }) || |
| 109 | { echo "No hint label for: ${line#hint-click }" >&2; docker kill "$container" >/dev/null; return 1; } | 116 | { 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 | 117 | echo "Hint label for ${line#hint-click }: $label" >&2 | ||
| 111 | printf 'type %s\n' "$label" ;; | 118 | printf 'type %s\n' "$label" ;; | ||
| @@ -117,9 +124,7 @@ feed() { | |||||
| 117 | printf 'key Ctrl+Alt+BackSpace\n' | 124 | printf 'key Ctrl+Alt+BackSpace\n' | ||
| 118 | } | 125 | } | ||
| 119 | app foot --title 'System / NeferWL' bash -c \ | 126 | 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 | 127 | + | 'fastfetch --logo small --structure OS:Kernel:WM:Terminal:Memory:Colors; eza -la --no-user --icons --color=always /etc | head -24; exec sleep infinity' |
| 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' | |||
| 123 | # The first app is launched before t=0. FIFO input and shell launches now | 128 | # The first app is launched before t=0. FIFO input and shell launches now | ||
| 124 | # share this epoch, independent of compositor/image/container startup time. | 129 | # share this epoch, independent of compositor/image/container startup time. | ||
| 125 | SECONDS=0 | 130 | SECONDS=0 | ||
| @@ -138,10 +143,10 @@ wait "$feeder" || { echo 'Scripted input failed.' >&2; exit 1; } | |||||
| 138 | feeder= | 143 | feeder= | ||
| 139 | wait "$launcher" | 144 | wait "$launcher" | ||
| 140 | launcher= | 145 | launcher= | ||
| 141 | - | # Hint mode needs both sources: the accessibility tree (Firefox) and OCR (terminal). | 146 | + | # Firefox's links come from the accessibility tree; OCR (tesseract) must still load. |
| 142 | if grep -qE 'OCR unavailable|English OCR data missing' "$log"; then echo 'tesseract is missing in the image.' >&2; exit 1; fi | 147 | 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" || | 148 | + | grep -qE '"atspi_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; } | 149 | + | { echo 'No hint scan found accessibility 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 | 150 | 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 | ||
| 146 | ffmpeg -hide_banner -loglevel warning -f concat -safe 0 \ | 151 | ffmpeg -hide_banner -loglevel warning -f concat -safe 0 \ | ||
| 147 | -i "$work/shots/frames.ffconcat" -vf 'fps=30,format=yuv420p' \ | 152 | -i "$work/shots/frames.ffconcat" -vf 'fps=30,format=yuv420p' \ | ||