diff --git a/docs/manual/media/compose.gif b/docs/manual/media/compose.gif index 0140574..4ea749c 100644 --- a/docs/manual/media/compose.gif +++ b/docs/manual/media/compose.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:181ec1ed2d3de8cdf8dfb4dbb059e55ea1c8231d46b58c75b6eeea430b58ade5 -size 1921098 +oid sha256:de3b5d0e540f295e2d15e8578bb856398f21bb45834592b93bfed499760b70d1 +size 2799326 diff --git a/docs/manual/media/develop-groups.gif b/docs/manual/media/develop-groups.gif index 13ea9c2..403f82f 100644 --- a/docs/manual/media/develop-groups.gif +++ b/docs/manual/media/develop-groups.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:89df272b7ea2fd9d987f9743429a1ce9bb963d39d508b844757771334bc55157 -size 345057 +oid sha256:b8b03cdc279e3b7a3366dad0829b192a9161a23d7c6fc85a262c5d45a694f8c5 +size 343592 diff --git a/docs/manual/media/develop-light.gif b/docs/manual/media/develop-light.gif index b6a322c..de5d98e 100644 --- a/docs/manual/media/develop-light.gif +++ b/docs/manual/media/develop-light.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1887f1640e2dca650ef344cde39f7adbc98196588f96a757cca912954b9c41e8 -size 2925741 +oid sha256:731469768b156c80696d8e6c5cfca2427591fddc5d60b3d0eb220a7e9a88bc65 +size 3136229 diff --git a/docs/manual/media/develop-wb.gif b/docs/manual/media/develop-wb.gif index 327bb03..726eaea 100644 --- a/docs/manual/media/develop-wb.gif +++ b/docs/manual/media/develop-wb.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:57e9b181a783b2b00bfd5f1295de7bfcbddb05eaf5826a6a553161d8bb29e28a -size 1801325 +oid sha256:523baf0c5cad46c3874c9fb7a18c3fb47259430ec35ac033e7acebb3e70015eb +size 1797569 diff --git a/docs/manual/media/develop-zoom.gif b/docs/manual/media/develop-zoom.gif index 5ce4cd2..baee305 100644 --- a/docs/manual/media/develop-zoom.gif +++ b/docs/manual/media/develop-zoom.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7ffb8620f6874034345ba2d5d9262f5657da02119b634fa2fb46a11bac106246 -size 3559669 +oid sha256:45e275fc7165f25d2a9aea317bc76dc35c7ef2d56dfa00bf5a7b3ae325c12fc9 +size 7778853 diff --git a/docs/manual/media/develop.png b/docs/manual/media/develop.png index 69c67c7..f665a7a 100644 --- a/docs/manual/media/develop.png +++ b/docs/manual/media/develop.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ee6b5672974ec0d7bd2177ae925b60493aac59585f9a8403479e11a819a296e9 -size 1564867 +oid sha256:c2b89da21d4903073db17249106d8ae199e8f8be7d46de9b6e71560f3f6e72fa +size 1566725 diff --git a/docs/manual/media/film.gif b/docs/manual/media/film.gif index 4cf369a..025737a 100644 --- a/docs/manual/media/film.gif +++ b/docs/manual/media/film.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:57ed26e716c17a28d8c82b4d4bab2a07c4987e0672ee45c45d6a9b9145c41ea0 -size 723666 +oid sha256:e713496df0b0bd85ed0df6f56c013eb55d519eeeb2a2f6e1a6f2aee8bd345473 +size 1089689 diff --git a/docs/manual/media/library-collection-menu.png b/docs/manual/media/library-collection-menu.png index 5e233ea..3ad14fe 100644 --- a/docs/manual/media/library-collection-menu.png +++ b/docs/manual/media/library-collection-menu.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d39e4d07042839516b1a6eed7a1c126fb7058a9f394f23a3bf019f5209ad01d7 -size 216510 +oid sha256:9d2ffcac1fd94552cc0f5df7fe9a99bfe2313775564f8b25e5b419f9fe328e39 +size 217552 diff --git a/docs/manual/media/library-collections.gif b/docs/manual/media/library-collections.gif index bd071d1..014fb47 100644 --- a/docs/manual/media/library-collections.gif +++ b/docs/manual/media/library-collections.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6eb6c54ac139ee07ba616e99ca672c5b3ee4e4bff472ccc6a1299a05bb488246 -size 701171 +oid sha256:f9a454098b5e6a373a0cfc5f092b84a4cd124c7e2ede463042c17af33189b275 +size 825837 diff --git a/docs/manual/media/library-keywords.gif b/docs/manual/media/library-keywords.gif index 87f5962..eeba4f6 100644 --- a/docs/manual/media/library-keywords.gif +++ b/docs/manual/media/library-keywords.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ee20fd9cdb62734d057a4379821599b17779521073cb116ba19af9b8688e249c -size 401768 +oid sha256:daffda4b7fcdfaa97958bbe1ef498058037d04c39689c6d3a8c8483ee71183d2 +size 613141 diff --git a/docs/manual/media/library-nesting.gif b/docs/manual/media/library-nesting.gif index 2e29e87..0b41dbd 100644 --- a/docs/manual/media/library-nesting.gif +++ b/docs/manual/media/library-nesting.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:306b0d30a9b7a92dd409b6d1a0f973aa0ce7bfeb5a1e31fc1394761c8b967c5a -size 920770 +oid sha256:21a39e445e785c3f347ee9edbc3936b9454482d451e4bf0a4c7c7f5ca4127206 +size 1523363 diff --git a/docs/manual/media/library-nesting.png b/docs/manual/media/library-nesting.png index dbc2411..d172425 100644 --- a/docs/manual/media/library-nesting.png +++ b/docs/manual/media/library-nesting.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b479c2fcf4ba5641accf06b0cee67cbbd406f5905de59ab6f53de137fec2476f -size 357747 +oid sha256:f3f185b442814e968f9626e2e0d7890b9d534c13b4e1a11c425641b6106f3adc +size 358956 diff --git a/docs/manual/media/library-rating.gif b/docs/manual/media/library-rating.gif index a8f8200..e11ce50 100644 --- a/docs/manual/media/library-rating.gif +++ b/docs/manual/media/library-rating.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c7ff6dd0d8a953240ae4c27613c81d9cfd8555d401f12b9e0345808bfd61b48d -size 462229 +oid sha256:2934b4d0812895b485b1b17d290c7fde2506f731121b1f98f425abcf553e9a5c +size 487865 diff --git a/docs/manual/media/library-selection.png b/docs/manual/media/library-selection.png index a1c89db..1fcf492 100644 --- a/docs/manual/media/library-selection.png +++ b/docs/manual/media/library-selection.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e7e9c740ede38211c3ce0d70f008a517f82daa13adbc16639b56aef31dc8aad9 -size 994301 +oid sha256:688cec488f5784f27cd9b15b5e2c606f1706d0af5caf59694f15b8eca01f3231 +size 1005493 diff --git a/docs/manual/media/library-thumbsize.gif b/docs/manual/media/library-thumbsize.gif index 0b8ca53..791cfb0 100644 --- a/docs/manual/media/library-thumbsize.gif +++ b/docs/manual/media/library-thumbsize.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0b67b43fffdbc590334aabf8555eb82a8061583f8320b001a5cfdb591ec6dd90 -size 1570486 +oid sha256:4e7cffc7b96de83a6102b0c43330b0fcebbb1fecd87d6008644d52ef75d5fae8 +size 2447955 diff --git a/docs/manual/media/library-timeline.gif b/docs/manual/media/library-timeline.gif index 478d95a..5d07f43 100644 --- a/docs/manual/media/library-timeline.gif +++ b/docs/manual/media/library-timeline.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d8f8ff892fc717ac3a01cbff2ee755c5c6a6e0dc80bf43286a9443ee07814f79 -size 926338 +oid sha256:468cc626dd52b3ff715f6750697f1d03b30d6fe4e898a14c2a4d5c341f6c9457 +size 981099 diff --git a/docs/manual/media/library.png b/docs/manual/media/library.png index 0f5db50..3379607 100644 --- a/docs/manual/media/library.png +++ b/docs/manual/media/library.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f65289c8e7054109a5869be200b6c97fe855a31ae3ade36ffe0504f8010cb115 -size 1037382 +oid sha256:fabc074b493fe14ebfb76035e39a8129601784cf3953842bd2c463576c040fd9 +size 1034640 diff --git a/docs/manual/media/local-categories.png b/docs/manual/media/local-categories.png index f9a69a4..f4631e2 100644 --- a/docs/manual/media/local-categories.png +++ b/docs/manual/media/local-categories.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5e7890066d24ea7f045e92632bdcd238f74b92896390b802cdd0515bf9bcb2d5 -size 1346470 +oid sha256:d375af25451e665cbd351678efa9b37c4d887ad2d2aad20d66bb1ed868f3a4f2 +size 1348547 diff --git a/docs/manual/media/local-paint.gif b/docs/manual/media/local-paint.gif index 93a9476..2c7cea5 100644 --- a/docs/manual/media/local-paint.gif +++ b/docs/manual/media/local-paint.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:645b45b39722f0cc352289c521f03b5053b55cc61eb776c979f8fa7fa518ad47 -size 399814 +oid sha256:a47508cb130980a3fff9210aec51803646712ee345758caffd99807466fa881d +size 1114099 diff --git a/docs/manual/media/local-segment.png b/docs/manual/media/local-segment.png index cf4502e..5b2d3d8 100644 --- a/docs/manual/media/local-segment.png +++ b/docs/manual/media/local-segment.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2fd560acc54eaf9f537f9b13423a9325c2b2441027ed237f6bbd4da0a7a279b5 -size 1357900 +oid sha256:8e51c942217cd7f0ac791fbb1e01af884dd9ea4465777d97c9480c850b22222b +size 1359531 diff --git a/docs/manual/media/panorama-filled.png b/docs/manual/media/panorama-filled.png index 7974967..278aba9 100644 --- a/docs/manual/media/panorama-filled.png +++ b/docs/manual/media/panorama-filled.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:65c97e0424fa36f32a77dcc789108d92f1e42526e8d887b4864ff0a219299d18 -size 1046096 +oid sha256:66c985ff59d67f4f9761b2563008fa184c2f616a18750f671e7993194812c09d +size 1045211 diff --git a/docs/manual/media/panorama.gif b/docs/manual/media/panorama.gif index b68b49a..772169d 100644 --- a/docs/manual/media/panorama.gif +++ b/docs/manual/media/panorama.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5867f182c9cd8156328710ab21e0ad6b549c1fa7839d472922dba427b6f95b38 -size 902017 +oid sha256:b18f91fbd8a4e7293a881691d518631c94eb2f2b370b221eb1d550de45be4cb2 +size 1322779 diff --git a/docs/manual/media/presets.png b/docs/manual/media/presets.png index 510727c..98638d5 100644 --- a/docs/manual/media/presets.png +++ b/docs/manual/media/presets.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a8685d7aebd9abace5fb5247105939fc1d4563fea7cb3a9fd4fec7b900eab7c0 -size 736006 +oid sha256:8874ddc135f359998dcb7b3a047166425f2625027a35c1af878dbbf9c49235c4 +size 720725 diff --git a/docs/manual/media/repair.gif b/docs/manual/media/repair.gif index 458556f..1151d94 100644 --- a/docs/manual/media/repair.gif +++ b/docs/manual/media/repair.gif @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f88bf5622ae40c61d284c741b4f4ecda56c84bc6b7e22371877410c6c231c7a2 -size 249346 +oid sha256:0684dc43c970d320d58f7bf460636783e4d1a8d431783878eec40a7f9f03d650 +size 325697 diff --git a/docs/manual/media/settings-export.png b/docs/manual/media/settings-export.png index 8148b91..e455ee6 100644 --- a/docs/manual/media/settings-export.png +++ b/docs/manual/media/settings-export.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b4db0c848b4dec5b6779a83b3b51b18593f079fb6e128f8d559ee0d10ad14f89 -size 89992 +oid sha256:fdb4c6190111c31e7cf57b76ef134eca36f64bfbcc3ef26c59b1ca79ecf70f4b +size 89970 diff --git a/docs/manual/media/settings.png b/docs/manual/media/settings.png index 0ad5936..e37c0f6 100644 --- a/docs/manual/media/settings.png +++ b/docs/manual/media/settings.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9f37fc703b03e8251facde23098cd78c6e7c41be677491bc1b06604821e58ec8 -size 108703 +oid sha256:ad6972c7e921463fa78313523fbcfa22bdb8e875193228648847b4383227f606 +size 107714 diff --git a/tools/manual/README.md b/tools/manual/README.md index b0eda89..8267b61 100644 --- a/tools/manual/README.md +++ b/tools/manual/README.md @@ -1,20 +1,51 @@ # tools/manual — the pictures in docs/manual -`record.sh ` drives the desktop build on a private X server and -records every scene in `scenes.py` into `docs/manual/media/`. `drive.py` is -the puppeteer underneath — launch, click, drag, type, screenshot, record — -and is usable on its own to look at a panel after changing it: +`record.sh` drives the desktop build on a private X server and records the +scenes in `scenes.py` into `docs/manual/media/`: ```bash -DR_HOME=/var/tmp/x tools/manual/drive.py launch /some/folder -tools/manual/drive.py click 1543 22 # Settings -tools/manual/drive.py shot /tmp/settings.png +tools/manual/record.sh /var/tmp/dr-demo/library # every scene +tools/manual/record.sh /var/tmp/dr-demo/library develop_zoom # one, and what it needs +tools/manual/record.sh --changed /var/tmp/dr-demo/library # those whose sources moved +tools/manual/record.sh --check # the manual and the scenes agree +tools/manual/record.sh --list +``` + +Set `DR_LIBRARY_SNAPSHOT` to a pristine copy of the library: a recording +rates, files and merges, and the snapshot is copied over the library first. +Each run starts from a fresh profile under `DR_HOME`. + +## Controls by name + +The binary is built with `--features automation`, which lets the app answer +"where is the control labelled *Exposure*?" over the Unix socket named by +`DR_AUTOMATION` (`ui/dr-ui/src/automation.rs`). No other build has the +feature, and a build that has it listens only when the variable is set. The +names are the accessible labels a screen reader reads, so a scene says +`click_on('Select@Button')` or `slide('Exposure', 24)` rather than a pixel, +and survives a panel that moves. The pointer itself is still xdotool. + +```bash +DR_HOME=/var/tmp/x tools/manual/drive.py launch /some/folder # a feature build in DR_BIN +tools/manual/drive.py labels # every name on screen +tools/manual/drive.py click-on 'Settings@Button' +tools/manual/drive.py ids canvas # things with no name: id:canvas-image tools/manual/drive.py stop ``` -The demo library is the author's: seventy frames with no people in them, -the `fixtures/pano` set among them. The scenes' cell coordinates are for -that grid, at 1600×1100; the panel coordinates hold for any library. +Grid cells are named by file (`_MG_8393@ListItem`), rating stars `1 star` +to `5 stars`. What a scene cannot name — a point on the photograph, the crop +rectangle's corner — it places relative to something it can +(`drive.photo(fx, fy)`, `id:move-area`). + +## Scenes and the manual + +Each scene in `scenes.py` declares the pictures it makes, as the manual names +them, and the globs of the sources that could change them. `--check` fails +when the manual shows a picture no scene makes, or a scene makes one the +manual does not show; it reads two files and runs in CI. `--changed` +re-records a scene when one of its sources, or its own code, has changed +since the commit that last touched its pictures. Two things the scripts know that are not obvious: a Slint `TouchArea` wants a held press, not xdotool's `click`; and the app is driven on Xvfb rather diff --git a/tools/manual/drive.py b/tools/manual/drive.py index 682db07..a1cc9d1 100755 --- a/tools/manual/drive.py +++ b/tools/manual/drive.py @@ -31,8 +31,10 @@ Suffixes narrow it — `@ROLE` takes only elements of that role (`Button`, `#-1` the last (a sheet is drawn after what it covers). A leading `id:` names an element by its id in the markup or its component type instead (`id:canvas-image`, `id:move-area`, `id:Timeline`), for the few things a -scene aims at that are not controls. Only what Slint draws is answered: -nothing hidden, nothing scrolled out of its list. +scene aims at that are not controls. Only what Slint draws is answered, +and only where its centre is in the window: nothing hidden, nothing +scrolled out of its list. An element half under the edge of a scrolled +list still counts, so a scene that needs all of one scrolls it into view. The input itself is still xdotool — a real pointer, pressed and held — so a scene exercises exactly what a hand would. The hook only says where to aim. @@ -229,6 +231,17 @@ def wait_ready(timeout=120): time.sleep(0.5) +_window = None + + +def window_size(): + global _window + if _window is None: + w = ask('window') + _window = (w['w'], w['h']) + return _window + + def parse_name(name): """`LABEL`, `id:ID`, each with optional `@ROLE` and `#N`.""" m = re.match(r'^(.*?)(?:@(\w+))?(?:#(-?\d+))?$', name, re.S) @@ -244,7 +257,9 @@ def matches(name, within=None): hits = ask(f'locate-id {what[3:]}') else: hits = ask(f'locate {what}') - hits = [e for e in hits if e['w'] > 0 and e['h'] > 0 and e['opacity'] > 0] + W, H = window_size() + hits = [e for e in hits if e['w'] > 0 and e['h'] > 0 and e['opacity'] > 0 + and 0 <= e['x'] + e['w'] / 2 < W and 0 <= e['y'] + e['h'] / 2 < H] if role: hits = [e for e in hits if (e['role'] or '').lower() == role.lower()] if within: @@ -351,7 +366,11 @@ def rec_start(out): def rec_stop(): - pid = int(open(f'{HOME}/rec.pid').read()) + """Stop the recording `rec_start` began. The pid file goes with it, so + a second stop — or one after a scene failed — signals nothing.""" + path = f'{HOME}/rec.pid' + pid = int(open(path).read()) + os.remove(path) os.kill(pid, 2) for _ in range(50): try: diff --git a/tools/manual/record.sh b/tools/manual/record.sh index d1cd52f..8c83961 100755 --- a/tools/manual/record.sh +++ b/tools/manual/record.sh @@ -1,57 +1,145 @@ #!/usr/bin/env bash # Re-record the manual: docs/manual/README.md's pictures, from the app itself. # -# tools/manual/record.sh [scene...] +# tools/manual/record.sh LIBRARY [SCENE...] every scene, or those named +# tools/manual/record.sh --changed LIBRARY the scenes whose sources +# changed since their pictures +# tools/manual/record.sh --check the manual and the scenes +# agree (no app, no display) +# tools/manual/record.sh --list the scenes and their pictures # -# Needs Xvfb, xdotool, ImageMagick's `import` and ffmpeg. Builds the desktop -# binary if it is not there, starts a private X server, opens the folder as -# a library in a throwaway profile, runs the scenes (all of them by default) -# and writes PNGs and GIFs into docs/manual/media/. +# Needs Xvfb, xdotool, ImageMagick's `import`, ffmpeg and optipng. Builds the +# desktop binary with the `automation` feature — the hook scenes.py finds +# controls by name through — unless DR_BIN names one. Starts a private X +# server, opens LIBRARY in a fresh throwaway profile under DR_HOME, runs the +# scenes and writes PNGs and GIFs into docs/manual/media/. # -# The library folder is the author's demo set — seventy face-free frames, -# the twelve-frame panorama from fixtures/pano among them — and scenes.py's -# cell coordinates assume its grid. Another folder needs those looked at. +# LIBRARY is the author's demo set — seventy face-free frames, the +# twelve-frame panorama from fixtures/pano among them — and the scenes open +# its photographs by file name. Recording changes it (ratings, collections, +# the merged DNG), so DR_LIBRARY_SNAPSHOT, if set, is copied over it first. +# +# Environment: DR_HOME (/var/tmp/dr-manual), DR_DISPLAY (:7), DR_BIN, +# DR_LIBRARY_SNAPSHOT, DR_EXPORT_DIR (LIBRARY/../export), DR_INFERENCE +# (`cpu`, the default, pins inference to the CPU so a recording does not +# fight a training run for the GPU; `probe` lets the app choose), CARGO +# (cargo; a wrapper taking cargo's arguments works too). set -euo pipefail here="$(cd "$(dirname "$0")" && pwd)" repo="$(cd "$here/../.." && pwd)" -library="${1:?library folder}" -shift || true +scenes="$here/scenes.py" + +mode=record +case "${1:-}" in + --check) exec python3 "$scenes" check ;; + --list) exec python3 "$scenes" list ;; + --changed) mode=changed; shift ;; + ""|-h|--help) sed -n '2,/^set -e/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;; +esac + +library="$(cd "${1:?library folder}" && pwd)" +shift + +if [ "$mode" = changed ]; then + python3 "$scenes" changed | tee /dev/stderr > /dev/null + mapfile -t chosen < <(python3 "$scenes" changed | cut -f1) + if [ ${#chosen[@]} -eq 0 ]; then + echo "nothing to re-record: every picture is newer than what it depends on" + exit 0 + fi + set -- "${chosen[@]}" +fi export DR_HOME="${DR_HOME:-/var/tmp/dr-manual}" export DR_DISPLAY="${DR_DISPLAY:-:7}" -export DR_BIN="${DR_BIN:-$repo/target/release/darkroom-desktop}" export DR_LIBRARY="$library" +export DR_AUTOMATION="$DR_HOME/automation.sock" media="$repo/docs/manual/media" -mkdir -p "$media" "$DR_HOME/xdg/config/darkroom" "$DR_HOME/xdg/data/darkroom" -[ -x "$DR_BIN" ] || (cd "$repo" && cargo build --release -p darkroom-desktop) +if [ -z "${DR_BIN:-}" ]; then + (cd "$repo" && ${CARGO:-cargo} build --release -p darkroom-desktop --features automation) + DR_BIN="$repo/target/release/darkroom-desktop" +fi +export DR_BIN + +if [ -n "${DR_LIBRARY_SNAPSHOT:-}" ]; then + rsync -a --delete "$DR_LIBRARY_SNAPSHOT/" "$library/" +fi + +# A fresh profile every time, so a scene sees the library and not whatever +# the last run left in the catalog. The inference cache is kept — it is +# minutes of compiling — and so are the links to the models. The marker is +# what makes the wipe safe: nothing without it is deleted. +profile="$DR_HOME/xdg" +if [ -d "$profile" ] && [ ! -e "$DR_HOME/.manual-profile" ]; then + echo "$profile exists and was not made by record.sh; set DR_HOME elsewhere" >&2 + exit 1 +fi +mkdir -p "$DR_HOME" && touch "$DR_HOME/.manual-profile" +rm -rf "$profile/config" "$profile/state" +if [ -d "$profile/data/darkroom" ]; then + find "$profile/data/darkroom" -mindepth 1 -maxdepth 1 \ + ! -name inference ! -name models ! -name runtime -exec rm -rf {} + +fi +mkdir -p "$profile/config/darkroom" "$profile/data/darkroom" # The models and the runtime are shared with the real profile: a segmenter # that is not there makes "Find subjects" a picture of nothing. for d in models runtime; do src="${XDG_DATA_HOME:-$HOME/.local/share}/darkroom/$d" - [ -e "$src" ] && ln -sfn "$src" "$DR_HOME/xdg/data/darkroom/$d" + [ -e "$src" ] && ln -sfn "$src" "$profile/data/darkroom/$d" done +# Keep the inference choice the app last made, but on the CPU unless asked. +backend="$profile/data/darkroom/inference/backend.json" +if [ "${DR_INFERENCE:-cpu}" = cpu ] && [ -e "$backend" ]; then + python3 - "$backend" <<'PY' +import json, sys +p = sys.argv[1] +d = json.load(open(p)) +d['rung'], d['reason'] = 'Cpu', 'forced for the demo' +json.dump(d, open(p, 'w'), indent=2) +PY +fi + # A folder library, chosen: no launch screen on the way in. -cat > "$DR_HOME/xdg/config/darkroom/sessions.json" < "$profile/config/darkroom/sessions.json" < "$profile/config/darkroom/settings.json" </dev/null 2>&1; then Xvfb "$DR_DISPLAY" -screen 0 1920x1200x24 +extension GLX +render -noreset \ > "$DR_HOME/xvfb.log" 2>&1 & echo $! > "$DR_HOME/xvfb.pid" + started_x=1 sleep 2 fi +cleanup() { + python3 "$here/drive.py" stop || true + if [ -n "$started_x" ]; then kill "$(cat "$DR_HOME/xvfb.pid")" || true; fi +} +trap cleanup EXIT +stamp="$DR_HOME/.recording-started" +touch "$stamp" python3 "$here/drive.py" launch -sleep 12 -python3 "$here/scenes.py" "$media" "${@:-all}" -python3 "$here/drive.py" stop +DR_TOOLS="$here" python3 -c ' +import os, sys, time +sys.path.insert(0, os.environ["DR_TOOLS"]) +import drive as dr +dr.wait_ready() +dr.wait_for("id:grid-scroll", 120) +time.sleep(10) # the first thumbnails, and the scan settling +' +python3 "$scenes" record "$media" "${@:-all}" # GIFs for the page; the MP4s are working files and are not kept. for mp4 in "$media"/*.mp4; do @@ -59,4 +147,4 @@ for mp4 in "$media"/*.mp4; do "$here/gif.sh" "$mp4" "${mp4%.mp4}.gif" 960 10 rm "$mp4" done -optipng -quiet -o2 "$media"/*.png || true +find "$media" -name '*.png' -newer "$stamp" -exec optipng -quiet -o2 {} + || true diff --git a/tools/manual/scenes.py b/tools/manual/scenes.py index 17efd4e..d7467d7 100755 --- a/tools/manual/scenes.py +++ b/tools/manual/scenes.py @@ -1,28 +1,112 @@ #!/usr/bin/env python3 -"""The manual's scenes: `scenes.py OUT_DIR scene [scene...]`, or `all`. +"""The manual's scenes, and what each one is for. -Each scene drives the running app through `drive.py` and leaves a PNG or an -MP4 in OUT_DIR, named after itself. `record.sh` runs them in order and turns -the MP4s into GIFs. + scenes.py record OUT_DIR SCENE... drive the running app through the + scenes (`all` for every one) + scenes.py order SCENE... the scenes to run, prerequisites first + scenes.py check does the manual picture what the scenes + make, and only that? (no app needed) + scenes.py changed the scenes whose sources changed since + their pictures were last committed + scenes.py list every scene, its pictures and sources -Coordinates are window pixels for a 1600×1100 window over the manual's own -library (see record.sh): which cell holds which photograph is part of the -scene, so a different library needs the numbers looked at again. Panel -coordinates hold for any library. +Each scene drives the running app through `drive.py` and leaves the +pictures it names in OUT_DIR: PNGs, and MP4s that `record.sh` turns into +the GIFs the manual shows. A scene names its controls by the accessible +label a screen reader reads (see drive.py), so it survives a panel that +moves; what it cannot name — a point on the photograph, the crop's corner +— it places relative to something it can. + +Every scene starts from a known place (`at_library`, `at_develop`) and +leaves the photograph as it found it (`undo_all`), so one can be recorded +on its own. The few that continue another's work say so with `after`, and +running one runs its prerequisites first, into a scratch folder. + +`sources` are the globs of the files whose change could change the +picture; `changed` compares them, and the scene's own code here, against +the commit that last touched each of its pictures. + +The library is the author's demo set (see record.sh): seventy face-free +frames, the twelve-frame alpine panorama among them. The photographs a +scene opens are named by file. """ +import fnmatch +import inspect import os import re import subprocess import sys +import tempfile import time -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.abspath(os.path.join(HERE, '..', '..')) +MANUAL = 'docs/manual/README.md' +MEDIA = 'docs/manual/media' + +sys.path.insert(0, HERE) import drive as dr # noqa: E402 -OUT = sys.argv[1] if len(sys.argv) > 1 else '.' +OUT = '.' DEMO_LIBRARY = os.environ.get('DR_LIBRARY', '') # the folder the merge writes its DNG into +# The photographs the scenes open, by file. +CHINATOWN = '_MG_8393' # fire escapes, a white air conditioner, a strip of sky +TEAPOT = '_MG_4918' +PANO_FIRST, PANO_LAST = '_MG_8320', '_MG_8331' +NY_FIRST, NY_LAST = '_MG_8393', '_MG_8397' +ALPS_ROW = ['_MG_8322', '_MG_8328'] # the second row of the alps, first and last +TOWER = '_MG_8693' # towers shot looking up: verticals that converge +ROAD = '_MG_8672' # a road under a sky, with marks on it: masks and repair +ROAD_MARK = (0.305, 0.794) # a dark mark on its tarmac +# --- the registry ----------------------------------------------------------- +SCENES = {} + +# What almost every picture depends on: the shared controls and the palette. +COMMON = [ + 'ui/dr-ui/style.yaml', + 'ui/dr-ui/ui/widgets.slint', + 'ui/dr-ui/ui/controls.slint', + 'ui/dr-ui/ui/icons.slint', +] +LIBRARY_SRC = COMMON + [ + 'ui/dr-ui/ui/library.slint', + 'ui/dr-ui/ui/collections.slint', + 'ui/dr-ui/ui/labels.slint', + 'ui/dr-ui/src/library_ui/**', + 'ui/dr-ui/src/library/cells.rs', + 'ui/dr-ui/src/library/filters.rs', +] +DEVELOP_SRC = COMMON + [ + 'ui/dr-ui/ui/app.slint', + 'ui/dr-ui/ui/develop.slint', + 'ui/dr-ui/ui/adjust.slint', + 'ui/dr-ui/ui/toolrail.slint', + 'ui/dr-ui/ui/histogram.slint', + 'ui/dr-ui/src/develop_ui.rs', + 'ui/dr-ui/src/develop/rows.rs', + 'ui/dr-ui/src/develop/tabs.rs', +] +PIPELINE_SRC = ['core/dr-pipeline/src/**', 'core/dr-gpu/src/**'] + + +class Scene: + def __init__(self, fn, media, sources, after): + self.fn, self.name = fn, fn.__name__ + self.media, self.sources, self.after = list(media), list(sources), list(after) + + +def scene(media, sources=(), after=()): + """Register a scene: the pictures it leaves (as the manual names them), + the sources they depend on, and the scenes whose state it continues.""" + def register(fn): + SCENES[fn.__name__] = Scene(fn, media, sources, after) + return fn + return register + + +# --- small verbs ------------------------------------------------------------ def shot(name): dr.shot(f'{OUT}/{name}.png') @@ -39,20 +123,18 @@ def pause(s): time.sleep(s) -def hold(px, py, seconds): - dr.move(px, py) - pause(0.15) - dr.x('mousedown', 1) - pause(seconds) - dr.x('mouseup', 1) - - def wheel(n, px, py): + """`n` notches of the wheel at a point: positive scrolls down.""" dr.move(px, py) dr.x('click', '--repeat', abs(n), '--delay', 40, 5 if n > 0 else 4) def ctrl_wheel(n, px, py): + """`n` notches with Ctrl held: negative (up) makes the thumbnails + larger. The pointer is nudged first — a wheel with no motion before it + can reach the grid without the hover that routes it to the zoom.""" + dr.move(px + 2, py + 2) + pause(0.1) dr.move(px, py) dr.x('keydown', 'ctrl') for _ in range(abs(n)): @@ -61,270 +143,849 @@ def ctrl_wheel(n, px, py): dr.x('keyup', 'ctrl') -# --- places on the screen --------------------------------------------------- -LIBRARY = (55, 22) # ‹ Library, in the develop header -SELECT = (942, 22) # Select / Done, in the library header -SETTINGS_LIB = (1543, 22) -SETTINGS_DEV = (1343, 22) -EXPORT = (720, 22) -BACK = (60, 22) # ‹ Back, on the settings page -RAIL = {'photo': (30, 75), 'compose': (30, 130), 'local': (30, 185), 'repair': (30, 240)} -GROUPS = {'all': 1269, 'optics': 1313, 'light': 1359, 'colour': 1406, 'effects': 1457, 'detail': 1506} -BEFORE = (210, 1045) -RESET_ADJUST = (1565, 741) # "reset" in the Adjust heading, All group, scrolled to top -CELL_TEAPOT = (424, 165) -CELL_PANO_FIRST = (1264, 165) -CELL_CHINATOWN = (963, 525) -CELL_NY_LAST = (1502, 525) -COLLECTION_ROW = (60, 90) +def with_key(key, fn, *args): + dr.x('keydown', key) + try: + fn(*args) + finally: + dr.x('keyup', key) + + +def key(*keys): + dr.x('key', '--delay', 80, *keys) + + +def type_text(text): + dr.x('type', '--delay', 90, text) + + +def mid(r): + return int((r[0] + r[2]) / 2), int((r[1] + r[3]) / 2) + + +def enabled(name): + try: + return dr.find(name)['enabled'] is not False + except dr.NotFound: + return False + + +# --- where things are ------------------------------------------------------- +def at_library(): + """The grid, from wherever the app is: out of develop, settings or a + merge, and out of selecting.""" + for _ in range(6): + if dr.present('id:grid-scroll'): + break + for back in ('‹ Library@Button', '‹ Back@Button'): + if dr.present(back): + dr.click_on(back) + break + pause(1.5) + dr.wait_for('id:grid-scroll', 30) + if dr.present('Clear@Button'): # the photograph last opened stays selected + dr.click_on('Clear@Button') + pause(0.6) + stop_selecting() + + +HEADER = (0, 0, 10000, 44) + + +def selecting(): + """Whether the grid is selecting: `Select` in the header has become + `Done`.""" + return dr.present('Done@Button', within=HEADER) + + +def start_selecting(): + if not selecting(): + dr.click_on('Select@Button', within=HEADER) + dr.wait_for('Done@Button', 5, within=HEADER) + + +def stop_selecting(): + if selecting(): + dr.click_on('Done@Button', within=HEADER) + dr.wait_for('Select@Button', 5, within=HEADER) + + +def grid_top(): + wheel(-60, *mid(dr.rect('id:grid-scroll'))) + pause(0.6) + + +def grid_view(): + """The part of the grid a cell can be pressed in: not under the + selection bar.""" + x0, y0, x1, y1 = dr.rect('id:grid-scroll') + return x0, y0, x1, y1 - (50 if dr.present('Select all@Button') else 0) + + +def show_cell(name): + """Scroll the grid until the cell for file `name` is wholly in view, + and return its rectangle.""" + x0, y0, x1, y1 = grid_view() + for _ in range(60): + hits = dr.matches(f'{name}@ListItem') + if hits: + e = hits[0] + if e['y'] >= y0 and e['y'] + e['h'] <= y1: + return e['x'], e['y'], e['x'] + e['w'], e['y'] + e['h'] + step = 2 if e['y'] + e['h'] > y1 else -2 + else: + step = 3 + wheel(step, (x0 + x1) // 2, (y0 + y1) // 2) + pause(0.4) + raise dr.NotFound(f'no cell for {name}') + + +def cell(name): + """A point on `name`'s thumbnail, scrolled into view: above the middle, + clear of the star strip that a small cell draws across its centre.""" + x0, y0, x1, y1 = show_cell(name) + return (x0 + x1) // 2, int(y0 + (y1 - y0) * 0.35) + + +def cell_width(): + """How wide the grid's cells are drawn, from whichever is on screen.""" + return next(e['w'] for e in dr.ask('labels _MG_') if e['role'] == 'ListItem') + + +def at_develop(photo=CHINATOWN): + """`photo` open in develop, on the Photo tool, the All group, the + column at its top, and the edit as it was opened.""" + if dr.present('‹ Library@Button') and dr.present(f'{photo}.CR2'): + pass + else: + at_library() + grid_top() + dr.click(*cell(photo)) + dr.wait_for(f'{photo}.CR2', 30) + wait_canvas() + for done in ('Done Composing', 'Done Masking', 'Done Repairing'): + if dr.present(f'{done}@Button'): + dr.click_on(f'{done}@Button') + pause(0.8) + tool('Photo') + undo_all() + group('All') + column_top() + + +def wait_canvas(timeout=30): + """Until the canvas holds a picture.""" + t0 = time.time() + while time.time() - t0 < timeout: + if dr.present('id:canvas-image') and dr.ask('canvas')['w'] > 0: + pause(1.5) + return + pause(0.5) + raise TimeoutError('the photograph never arrived') + + +def undo_all(): + """Walk the history back to where the photograph was opened.""" + for _ in range(80): + if not enabled('Undo@Button#0'): + return + dr.click_on('Undo@Button#0') + pause(0.35) + + +def tool(name): + """A tool from the rail: Photo, Compose, Local, Repair.""" + dr.click_on(f'{name}@Button', within=dr.rect('id:rail')) + pause(1.2) def group(name): - dr.click(GROUPS[name], 67) + """A group from the strip at the head of the develop column.""" + dr.click_on(f'{name}@Text', within=dr.rect('id:GroupStrip')) pause(0.9) -def to_library(): - dr.click(*LIBRARY) - pause(2) +def column(): + return dr.rect('id:develop-column') -def open_chinatown(): - dr.click(*CELL_CHINATOWN) - pause(6) +def column_top(): + wheel(-60, *mid(column())) + pause(0.6) -def reset_edit(): - group('all') - wheel(-60, 1420, 700) - dr.click(*RESET_ADJUST) +def in_column(name, margin=40): + """Scroll the develop column until `name` is well inside it; its centre. + Looks where the column is first, then from the top down.""" + x0, y0, x1, y1 = column() + from_top = False + for _ in range(80): + hits = dr.matches(name, within=(x0, y0, x1, y1)) + if hits: + e = hits[parse_index(name)] + cy = e['y'] + e['h'] / 2 + if y0 + margin <= cy <= y1 - margin: + return int(e['x'] + e['w'] / 2), int(cy) + step = 2 if cy > y1 - margin else -2 + elif not from_top: + column_top() + from_top = True + continue + else: + step = 3 + wheel(step, (x0 + x1) // 2, (y0 + y1) // 2) + pause(0.3) + raise dr.NotFound(f'{name} is not in the develop column') + + +def parse_index(name): + return dr.parse_name(name)[2] + + +def handle(e): + """Where a slider's handle is: its value along the track, read from the + number its readout starts with and the range it reports. The middle + when the readout is not a number.""" + m = re.match(r'\s*(-?[\d.]+)', e['value'] or '') + lo, hi = e.get('min'), e.get('max') + f = 0.5 + if m and lo is not None and hi is not None and hi > lo: + f = min(max((float(m.group(1)) - lo) / (hi - lo), 0.0), 1.0) + return int(e['x'] + e['w'] * f), int(e['y'] + e['h'] / 2) + + +def slide(name, dx, steps=25): + """Drag slider `name` from its handle by `dx`.""" + in_column(f'{name}@Slider') + x0, y0, x1, y1 = column() + cx, cy = handle(dr.find(f'{name}@Slider', within=(x0, y0, x1, y1))) + dr.drag(cx, cy, cx + dx, cy, steps) + + +def hold(px, py, seconds): + dr.move(px, py) + pause(0.15) + dr.x('mousedown', 1) + pause(seconds) + dr.x('mouseup', 1) + + +def hold_before(seconds): + hold(*dr.centre('Before@Button'), seconds) + + +def photo_drag(a, b, steps=25): + dr.drag(*dr.photo(*a), *dr.photo(*b), steps) + + +def reset_adjust(): + in_column('Reset all adjustments') + dr.click_on('Reset all adjustments') pause(1) +# --- opening a library ------------------------------------------------------ +@scene(media=['launch.png', 'launch-folder.png'], + sources=COMMON + ['ui/dr-ui/ui/launch.slint', 'ui/dr-ui/src/launch_ui.rs', + 'ui/dr-ui/src/launch.rs']) +def launch(): + """The launch screen belongs to a profile with no library, so this one + is its own: the app is restarted on an empty profile beside the real + one, shown the demo folder, and put back.""" + empty = f'{dr.HOME}/launch-xdg' + subprocess.run(['rm', '-rf', empty], check=True) + os.makedirs(f'{empty}/config/darkroom', exist_ok=True) + dr.stop() + dr.launch([], xdg=empty) + dr.wait_ready() + dr.wait_for('Open folder@Button', 30) + pause(1) + shot('launch') + dr.click_on('Library folder@TextInput') + type_text(DEMO_LIBRARY or '/var/tmp/dr-demo/library') + dr.click_on('Open folder@Button') + dr.wait_for('Open library@Button', 30) + pause(1) + shot('launch-folder') + dr.stop() + dr.launch([]) + dr.wait_ready() + dr.wait_for('id:grid-scroll', 60) + pause(3) + + # --- library ---------------------------------------------------------------- +@scene(media=[], sources=[]) +def tidy(): + """Not a picture: the demo library's snapshot carries the collections + an earlier recording made, and the collection scenes make them again. + Delete them through the menu, the way anyone would.""" + at_library() + x0, y0, x1, y1 = dr.rect('id:CollectionsPanel') + for _ in range(12): + rows = [e for e in dr.ask('labels ') + if e['role'] == 'Text' and x0 <= e['x'] < x1 and 70 < e['y'] < y1 - 80 + and e['label'] in ('Alps', 'Trips', 'New York')] + if not rows: + return + dr.click(int(rows[0]['x'] + 20), int(rows[0]['y'] + rows[0]['h'] / 2), 3) + dr.wait_for('Delete@Button', 5) + dr.click_on('Delete@Button') + pause(0.8) + if dr.present('Delete anyway@Button'): + dr.click_on('Delete anyway@Button') + pause(1.2) + + +@scene(media=['library.png'], sources=LIBRARY_SRC, after=['tidy']) def library(): + at_library() + grid_top() + dr.move(*mid(dr.rect('id:CollectionsPanel'))) + pause(1) shot('library') +@scene(media=['library-rating.gif'], sources=LIBRARY_SRC) def library_rating(): + """Two photographs rated from the stars on their cells, then the grid + filtered to three stars and more, and back. Frames the demo library + leaves unrated, so a click sets a rating rather than clearing one.""" + at_library() + grid_top() + show_cell('_MG_8674') rec('library-rating') - dr.move(424, 900); pause(1.0) - dr.click(444, 939); pause(1.2) - dr.move(604, 900); pause(0.8) - dr.click(644, 939); pause(1.2) - dr.move(800, 700); pause(0.6) - dr.click(665, 61); pause(1.5) # 3+ - dr.click(384, 61); pause(1.2) # All + for name, stars in (('_MG_8672', '4 stars'), ('_MG_8674', '5 stars')): + c = show_cell(name) + dr.move(*mid(c)) + pause(1.0) + dr.click_on(f'{stars}@Button', within=c) + pause(1.2) + dr.move(*mid(dr.rect('id:CollectionsPanel'))) + pause(0.6) + dr.click_on('3+@Button') + pause(1.5) + dr.click_on('All@Button') + pause(1.2) cut() +@scene(media=['library-timeline.gif'], sources=LIBRARY_SRC) def library_timeline(): + at_library() + grid_top() + x0, y0, x1, y1 = dr.rect('id:Timeline') + x = (x0 + x1) // 2 rec('library-timeline') - dr.drag(300, 200, 300, 900, 40); pause(1.0) - dr.drag(300, 900, 300, 150, 40); pause(1.0) + dr.drag(x, y0 + 120, x, y1 - 120, 40) + pause(1.0) + dr.drag(x, y1 - 120, x, y0 + 70, 40) + pause(1.0) cut() +@scene(media=['library-thumbsize.gif'], sources=LIBRARY_SRC) def library_thumbsize(): + at_library() + grid_top() + centre = mid(dr.rect('id:grid-scroll')) + before = cell_width() rec('library-thumbsize') - ctrl_wheel(-4, 900, 500); pause(0.8) - ctrl_wheel(4, 900, 500); pause(0.8) + ctrl_wheel(-3, *centre) + pause(0.8) + ctrl_wheel(3, *centre) + pause(0.8) cut() + # A notch can go astray, and every scene after this one expects the + # cells the size they were. + for _ in range(12): + pause(0.8) + now = cell_width() + if now == before: + break + ctrl_wheel(-1 if now < before else 1, *centre) + grid_top() +def select_pano(): + """The twelve alpine frames: the first, shift to the last.""" + at_library() + grid_top() + start_selecting() + dr.click(*cell(PANO_FIRST)) + pause(0.3) + with_key('shift', dr.click, *cell(PANO_LAST)) + pause(0.8) + + +@scene(media=['library-selection.png'], sources=LIBRARY_SRC) def library_selection(): - # The grid remembers its scroll between launches; the cells below are - # the grid at its top. - wheel(-40, 900, 500); pause(0.6) - dr.click(*SELECT); pause(0.5) - dr.click(*CELL_PANO_FIRST); pause(0.3) - dr.x('keydown', 'shift'); dr.click(*CELL_NY_LAST); dr.x('keyup', 'shift'); pause(0.8) - for x in (963, 1143, 1323, 1502): - dr.x('keydown', 'ctrl'); dr.click(x, 525); dr.x('keyup', 'ctrl'); pause(0.3) + """The twelve alpine frames: a range with Shift, then the last three + added one at a time with Ctrl.""" + at_library() + grid_top() + start_selecting() + dr.click(*cell(PANO_FIRST)) + pause(0.3) + with_key('shift', dr.click, *cell(ALPS_ROW[1])) + pause(0.8) + for name in ('_MG_8329', '_MG_8330', '_MG_8331'): + with_key('ctrl', dr.click, *cell(name)) + pause(0.3) pause(0.5) shot('library-selection') + stop_selecting() +@scene(media=['library-keywords.gif'], sources=LIBRARY_SRC + ['ui/dr-ui/src/library_ui/ratings_keywords.rs']) def library_keywords(): - # Continues from library_selection: twelve frames selected. + select_pano() rec('library-keywords') - dr.click(1117, 1079); pause(1.2) + dr.click_on('Keywords@Button') + pause(1.2) for word in ('alps', 'panorama', 'summer'): - dr.x('type', '--delay', 90, word); dr.x('key', 'Return'); pause(0.7) + type_text(word) + dr.x('key', 'Return') + pause(0.7) pause(0.8) - dr.click(800, 673); pause(1.0) # Done + dr.click_on('Done@Button#-1') # the sheet's, not the header's + pause(1.0) cut() - dr.click(*SELECT); pause(0.5) # leave selecting + stop_selecting() + + +def collection_row(name): + """The middle of `name`'s row in the sidebar, once the tree has stopped + moving: it re-sorts after a drop or a rename a moment after the gesture, + and a row read mid-way is the neighbour's by the time it is pressed.""" + x0, y0, x1, y1 = dr.rect('id:CollectionsPanel') + dr.wait_for(f'{name}@Text', 15, within=(x0, y0 + 60, x1, y1 - 80)) + last = None + for _ in range(20): + now = dr.centre(f'{name}@Text', within=(x0, y0 + 60, x1, y1 - 80)) + if now == last: + return now + last = now + pause(0.4) + return last + + +def tree_drag(what, onto): + """Drag one sidebar row onto another, slowly enough for the tree's drag + to start under a loaded machine, and until the tree shows it nested.""" + a, b = collection_row(what), collection_row(onto) + dr.x('windowfocus', '--sync', dr.win(), check=False) + dr.move(*a) + pause(0.3) + dr.x('mousedown', 1) + pause(0.4) + for i in range(1, 41): + dr.move(a[0] + (b[0] - a[0]) * i // 40, a[1] + (b[1] - a[1]) * i // 40) + pause(0.03) + pause(0.5) + dr.x('mouseup', 1) + x0, y0, x1, y1 = dr.rect('id:CollectionsPanel') + panel = (x0, y0 + 60, x1, y1 - 80) + t0 = time.time() + while time.time() - t0 < 15: # until the tree draws it inside + if dr.find(f'{what}@Text', within=panel)['x'] > dr.find(f'{onto}@Text', within=panel)['x']: + break + pause(0.4) + pause(1.0) + collection_row(what) + + +def open_menu(name): + """The menu on collection `name`. A right-click that lands just after a + drag in the tree can open the menu of the row that was dragged, so the + sheet's heading is checked and the click tried again.""" + for _ in range(4): + pause(0.8) + dr.click(*collection_row(name), 3) + dr.wait_for('New collection inside@Button', 5) + pause(0.6) + x0, x1 = dr.rect('New collection inside@Button')[0::2] + heads = [e for e in dr.matches(f'{name}@Text') if x0 <= e['x'] < x1] + if heads: + return + dr.click_on('Cancel@Button') + raise dr.NotFound(f'the menu on {name} would not open') def new_collection(name): # `+` makes a collection inside whichever row is selected, so select the # top first for a top-level one. - dr.click(60, 48); pause(0.5) - dr.click(212, 22); pause(0.4) - dr.x('type', '--delay', 90, name); dr.x('key', 'Return'); pause(1.2) - - -# The menu a right-click on a collection opens, at 1600×1100. -MENU = {'rename': (800, 496), 'inside': (800, 536), 'top': (800, 576), - 'offline': (800, 616), 'delete': (800, 669), 'cancel': (800, 709)} + dr.click_on('All photographs@Text') + pause(0.5) + dr.click_on('New collection@Button') + pause(0.4) + type_text(name) + dr.x('key', 'Return') + pause(1.2) +@scene(media=['library-collections.gif'], sources=LIBRARY_SRC + ['ui/dr-ui/src/collections_ui/**'], + after=['tidy']) def library_collections(): + at_library() + grid_top() new_collection('Alps') rec('library-collections') - dr.drag(*CELL_PANO_FIRST, *COLLECTION_ROW, 40); pause(1.5) - dr.click(*SELECT); pause(0.5) - dr.click(424, 350); pause(0.3) - dr.x('keydown', 'shift'); dr.click(1502, 350); dr.x('keyup', 'shift'); pause(0.8) - dr.drag(963, 350, *COLLECTION_ROW, 40); pause(1.5) - dr.click(*SELECT); pause(0.5) - dr.click(*COLLECTION_ROW); pause(1.5) + dr.drag(*cell(PANO_FIRST), *collection_row('Alps'), 40) + pause(1.5) + start_selecting() + dr.click(*cell(ALPS_ROW[0])) + pause(0.3) + with_key('shift', dr.click, *cell(ALPS_ROW[1])) + pause(0.8) + dr.drag(*cell('_MG_8325'), *collection_row('Alps'), 40) + pause(1.5) + stop_selecting() + dr.click(*collection_row('Alps')) + pause(1.5) cut() - dr.click(60, 48); pause(1.2) # All photographs + dr.click_on('All photographs@Text') + pause(1.2) +@scene(media=['library-nesting.gif', 'library-nesting.png', 'library-collection-menu.png'], + sources=LIBRARY_SRC + ['ui/dr-ui/src/collections_ui/**'], after=['library_collections']) def library_nesting(): - # Continues from library_collections: "Alps" exists with photographs in - # it. Make "Trips", drag Alps into it, make "New York" inside Trips from - # the menu, file frames there, then open Trips to see it count both. + """Continues from library_collections: "Alps" exists with photographs + in it. Make "Trips", drag Alps into it, make "New York" inside Trips + from the menu, file frames there, then open Trips to see it count both.""" + at_library() + grid_top() new_collection('Trips') rec('library-nesting') pause(0.5) - dr.drag(60, 90, 60, 128, 30); pause(1.5) # Alps onto Trips - dr.click(60, 90, 3); pause(1.2) # the menu, on Trips - dr.click(*MENU['inside']); pause(0.6) - dr.x('type', '--delay', 90, 'New York'); dr.x('key', 'Return'); pause(1.2) - dr.click(*SELECT); pause(0.5) - dr.click(963, 525); pause(0.3) - dr.x('keydown', 'shift'); dr.click(1502, 525); dr.x('keyup', 'shift'); pause(0.8) - dr.drag_path([(1143, 525), (200, 525), (90, 146)], 25); pause(1.5) # four frames into New York - dr.click(*SELECT); pause(0.5) - dr.click(60, 90); pause(1.5) # Trips: both children + tree_drag('Alps', 'Trips') + open_menu('Trips') + dr.click_on('New collection inside@Button') + pause(0.6) + type_text('New York') + dr.x('key', 'Return') + pause(1.2) + start_selecting() + dr.click(*cell(NY_FIRST)) + pause(0.3) + with_key('shift', dr.click, *cell(NY_LAST)) + pause(0.8) + # Out of the grid sideways first, then to the row: a diagonal with much + # vertical in it is taken by the grid's Flickable as a scroll. + start = cell('_MG_8394') + gx0 = dr.rect('id:grid-scroll')[0] + dr.drag_path([start, (gx0 - 130, start[1]), collection_row('New York')], 25) + pause(1.5) + stop_selecting() + dr.click(*collection_row('Trips')) + pause(1.5) cut() shot('library-nesting') - dr.click(90, 146, 3); pause(1.2) # the menu, on New York + open_menu('New York') + pause(0.4) shot('library-collection-menu') - dr.click(*MENU['cancel']); pause(0.8) - dr.click(60, 48); pause(1.2) + dr.click_on('Cancel@Button') + pause(0.8) + dr.click_on('All photographs@Text') + pause(1.2) + + +@scene(media=['library-labels.gif'], sources=LIBRARY_SRC + ['ui/dr-ui/src/labels.rs']) +def library_labels(): + """Colour labels from the keyboard over four New York frames, then the + grid filtered to one of them, and back.""" + # The grid takes the keyboard when it is built, and a sheet or a rename + # field that had it since does not give it back; opening a photograph + # and coming back builds the grid again. + at_develop(TEAPOT) + at_library() + grid_top() + frames = ['_MG_8423', '_MG_8430', '_MG_8439', '_MG_8440'] + show_cell(frames[-1]) + rec('library-labels') + pause(0.5) + for f, k in zip(frames, ('6', '7', '8', '9')): + dr.move(*cell(f)) + pause(0.6) + key(k) + pause(0.8) + dr.move(*cell(frames[1])) + pause(0.4) + key('7') # the same key again takes it off + pause(1.0) + dr.move(*mid(dr.rect('id:CollectionsPanel'))) + dr.click_on('Green@Button') + pause(1.8) + dr.click_on('Green@Button') + pause(1.2) + cut() + for f, k in zip(frames, ('6', None, '8', '9')): # and take them off again + if k: + dr.move(*cell(f)) + pause(0.3) + key(k) + pause(0.4) # --- develop ---------------------------------------------------------------- +@scene(media=['develop.png'], sources=DEVELOP_SRC) def develop(): - open_chinatown() - group('all') + at_develop() + dr.move(*dr.photo(0.5, 0.5)) + pause(1) shot('develop') +@scene(media=['develop-groups.gif'], sources=DEVELOP_SRC) def develop_groups(): + at_develop() rec('develop-groups') - for g in ['optics', 'light', 'colour', 'effects', 'detail', 'all']: - group(g); pause(0.6) + for g in ['Optics', 'Light', 'Colour', 'Effects', 'Detail', 'All']: + group(g) + pause(0.6) cut() +@scene(media=['develop-light.gif'], sources=DEVELOP_SRC + PIPELINE_SRC) def develop_light(): - group('light') + at_develop() + group('Light') + in_column('Shadows@Slider') rec('develop-light') pause(0.5) - dr.drag(1398, 792, 1422, 792, 25); pause(0.8) # exposure up - dr.drag(1398, 918, 1320, 918, 25); pause(0.8) # highlights down - dr.drag(1398, 964, 1450, 964, 25); pause(0.8) # shadows up - hold(*BEFORE, 1.6); pause(1.0) + slide('Exposure', 24) + pause(0.8) + slide('Highlights', -78) + pause(0.8) + slide('Shadows', 52) + pause(0.8) + hold_before(1.6) + pause(1.0) cut() - reset_edit() + undo_all() +@scene(media=['develop-zoom.gif'], sources=DEVELOP_SRC + ['ui/dr-ui/src/develop/render.rs']) def develop_zoom(): + """1:1 with a double-click, a pan, and then on in with the wheel until + the file's own pixels are hard-edged blocks — which is where it ends.""" + at_develop() + a = dr.photo(0.62, 0.52) # the fire escape and the air conditioner rec('develop-zoom') - dr.move(650, 500); pause(0.3) - dr.x('click', '--repeat', 2, '--delay', 80, 1); pause(1.5) - dr.drag(650, 500, 900, 700, 30); pause(0.8) - dr.drag(900, 700, 500, 450, 30); pause(0.8) - dr.x('click', '--repeat', 2, '--delay', 80, 1); pause(1.2) + dr.move(*a) + pause(0.3) + dr.x('click', '--repeat', 2, '--delay', 80, 1) + pause(1.5) + dr.drag(a[0], a[1], a[0] + 250, a[1] + 200, 30) + pause(0.8) + dr.drag(a[0] + 250, a[1] + 200, a[0] - 150, a[1] - 50, 30) + pause(0.8) + for _ in range(6): + wheel(-1, *a) + pause(0.6) + pause(2.0) cut() + dr.move(*a) + dr.x('click', '--repeat', 2, '--delay', 80, 1) + pause(1.2) +@scene(media=['develop-wb.gif'], sources=DEVELOP_SRC + ['ui/dr-ui/src/develop/white_balance.rs']) def develop_wb(): - group('colour') + at_develop() + group('Colour') + in_column('Temperature@Slider') rec('develop-wb') pause(0.4) - dr.drag(1398, 826, 1330, 826, 25); pause(1.2) # temperature cold - dr.click(1487, 776); pause(0.8) # pick - dr.click(995, 635); pause(1.5) # the white air conditioner - hold(*BEFORE, 1.4); pause(0.8) + slide('Temperature', -68) + pause(1.2) + dr.click_on('Set from a neutral in the photograph') + pause(0.8) + dr.click(*dr.photo(0.8, 0.585)) # the white air conditioner + pause(1.5) + hold_before(1.4) + pause(0.8) cut() - reset_edit() + undo_all() +COMPOSE_SRC = DEVELOP_SRC + ['ui/dr-ui/ui/crop.slint', 'ui/dr-ui/src/develop/framing.rs'] + + +@scene(media=['compose.gif'], sources=COMPOSE_SRC) def compose(): - dr.click(*RAIL['compose']); pause(1.2) + at_develop() + tool('Compose') + x0, y0, x1, y1 = dr.rect('id:move-area') rec('compose') pause(0.4) - dr.drag(66, 182, 260, 330, 30); pause(0.8) - dr.drag(1236, 964, 1100, 900, 25); pause(0.8) - dr.drag(1420, 593, 1448, 593, 20); pause(1.0) # straighten - dr.click(1466, 647); pause(1.2) # 1:1 - dr.click(1378, 647); pause(1.0) # Original - dr.click(132, 1045); pause(1.2) # Done composing + dr.drag(x0 + 6, y0 + 3, x0 + 200, y0 + 151, 30) + pause(0.8) + x0, y0, x1, y1 = dr.rect('id:move-area') + dr.drag(x1 - 4, y1 - 1, x1 - 140, y1 - 65, 25) + pause(0.8) + slide('Straighten', 28, 20) + pause(1.0) + dr.click_on('1:1@RadioButton') + pause(1.2) + dr.click_on('Original@RadioButton') + pause(1.0) + dr.click_on('Done Composing@Button') + pause(1.2) cut() - shot('compose-done') - dr.click(1567, 510); pause(1.0) # reset compose - dr.click(115, 1045); pause(1.0) # refit + undo_all() +@scene(media=['compose-perspective.gif'], sources=COMPOSE_SRC) +def compose_perspective(): + """Towers shot looking up: their walls lean in. Vertical at about +58 + stands them and the lamp posts upright, and the crop refits to what + is left.""" + at_develop(TOWER) + tool('Compose') + in_column('Horizontal@Slider') + rec('compose-perspective') + pause(1.0) + slide('Vertical', 105, 30) + pause(2.0) + dr.click_on('Done Composing@Button') + pause(1.5) + hold_before(1.4) + pause(1.0) + cut() + undo_all() + + +@scene(media=['local-categories.png', 'local-segment.png'], + sources=DEVELOP_SRC + ['ui/dr-ui/ui/masks.slint', 'ui/dr-ui/src/masks_ui.rs', + 'ui/dr-ui/src/segmentation.rs', 'ui/dr-ui/src/develop/segmentation.rs']) def local_segment(): - dr.click(*RAIL['local']); pause(1.2) - dr.click(1420, 435); pause(14) # Find subjects + at_develop(ROAD) + tool('Local') + if dr.present('Find subjects@Button'): # not once the photograph has been looked at + dr.click(*in_column('Find subjects@Button')) + dr.wait_for('Sky@Text', 120) + pause(2) shot('local-categories') - dr.click(1266, 646); pause(2.5) # Sky + dr.click(*in_column('Sky@Text')) + pause(2.5) shot('local-segment') +MASK_SRC = DEVELOP_SRC + ['ui/dr-ui/ui/masks.slint', 'ui/dr-ui/src/masks_ui.rs', + 'ui/dr-ui/src/develop/masks.rs', 'ui/dr-ui/src/develop/mask_ops.rs'] + + +@scene(media=['local-paint.gif'], sources=MASK_SRC, after=['local_segment']) def local_paint(): - # Continues from local_segment: the sky mask selected and shown. + """Continues from local_segment: the sky mask selected and shown. The + three ways of looking at it, a stroke painted into it, then its edge + grown.""" rec('local-paint') - wheel(12, 1420, 800); pause(0.8) - shot('local-mask-row') + pause(0.5) + for how in ('Alpha', 'Edge', 'Tint'): + dr.click(*in_column(f'{how}@RadioButton')) + pause(1.4) + dr.click(*in_column('+ Add@Button')) + pause(1.0) + photo_drag((0.33, 0.28), (0.5, 0.36), 30) + pause(1.5) + dr.click(*in_column('category@Text')) # the sky's own part, whose edge it is + pause(0.8) + dr.click(*in_column('Grow@RadioButton')) + pause(2.0) cut() + tool('Photo') + undo_all() -def local_done(): - dr.click(62, 1045); pause(1.0) # Done masking - reset_edit() +@scene(media=['local-intersect.gif'], sources=MASK_SRC) +def local_intersect(): + """A gradient over the lower half, then `∩ Intersect` and two strokes: + the mask keeps only where the strokes and the gradient agree.""" + at_develop() + tool('Local') + rec('local-intersect') + pause(0.5) + dr.click(*in_column('Linear@Button')) + pause(1.8) + dr.click(*in_column('∩ Intersect@Button')) + pause(1.2) + photo_drag((0.6, 0.15), (0.6, 0.95), 30) + pause(0.8) + photo_drag((0.7, 0.95), (0.7, 0.15), 30) + pause(2.0) + cut() + tool('Photo') + undo_all() +@scene(media=['crop-orphan.gif'], sources=COMPOSE_SRC + MASK_SRC) +def crop_orphan(): + """A stroke in the top-left corner, then a crop that leaves it outside + the frame: the notice says so and offers the way back.""" + at_develop() + tool('Local') + dr.click(*in_column('Paint@Button')) + pause(1.0) + photo_drag((0.04, 0.07), (0.16, 0.13), 20) + pause(1.0) + tool('Compose') + x0, y0, x1, y1 = dr.rect('id:move-area') + rec('crop-orphan') + pause(0.8) + dr.drag(x0 + 3, y0 + 3, *dr.photo(0.3, 0.3), 30) + dr.wait_for('Undo crop@Button', 10) + pause(2.5) + dr.click_on('Undo crop@Button') + pause(2.0) + cut() + tool('Photo') + undo_all() + + +@scene(media=['repair.gif'], sources=DEVELOP_SRC + ['ui/dr-ui/ui/spots.slint', 'ui/dr-ui/src/spots_ui.rs', + 'ui/dr-ui/src/develop/repairs.rs']) def repair(): - dr.click(*RAIL['repair']); pause(1.2) + at_develop(ROAD) + tool('Repair') rec('repair') pause(0.4) - dr.click(700, 620); pause(3) - dr.drag(1264, 460, 1330, 460, 20); pause(1.5) # size - hold(*BEFORE, 1.4); pause(0.8) + dr.click(*dr.photo(*ROAD_MARK)) + pause(3) + slide('Size', 20, 20) + pause(1.5) + hold_before(1.4) + pause(0.8) cut() - dr.click(125, 1045); pause(1.0) # Done repairing - reset_edit() + tool('Photo') + undo_all() +@scene(media=['film.gif'], sources=DEVELOP_SRC + ['core/dr-film/src/**']) def film(): - group('all') + at_develop() rec('film') - dr.click(1420, 779); pause(1.5) # Film ▸ - dr.click(1420, 953); pause(3) # Velvia 100 - hold(*BEFORE, 1.4); pause(0.8) + dr.click(*in_column('Film@Text')) + pause(1.5) + dr.click(*in_column('Fujifilm Velvia 100@Text')) + pause(3) + hold_before(1.4) + pause(0.8) cut() - reset_edit() + undo_all() +@scene(media=['presets.png'], sources=DEVELOP_SRC + ['ui/dr-ui/ui/presets.slint', 'ui/dr-ui/src/presets.rs']) def presets(): - dr.click(1420, 692); pause(1.5) + at_develop() + dr.click_on('Presets…@Button') + dr.wait_for('CARRIES@Text', 5) + pause(1.5) shot('presets') - dr.click(800, 783); pause(1.0) # Done - - -def develop_export(): - dr.click(*EXPORT); pause(8) - shot('export-done') + dr.click_on('Done@Button#-1') + pause(1.0) # --- panorama --------------------------------------------------------------- @@ -369,54 +1030,220 @@ def join(name, parts): os.remove(f'{OUT}/{p}.mp4') +@scene(media=['panorama.gif', 'panorama-aligned.png', 'panorama-filled.png'], + sources=COMMON + ['ui/dr-ui/ui/merge.slint', 'ui/dr-ui/src/merge_ui.rs', 'ui/dr-ui/src/merge.rs', + 'core/dr-pano/src/**']) def panorama(): - to_library() - library_selection() + select_pano() rec('panorama-a') - dr.click(1325, 1079); pause(40) # Merge to panorama + dr.click_on('Merge to panorama@Button') + dr.wait_for('Fill the border@RadioButton', 600) + pause(3) shot('panorama-aligned') - dr.click(184, 637); pause(6) # Fill the border + for projection in ('Perspective', 'Spherical', 'Cylindrical'): + dr.click_on(f'{projection}@RadioButton') + pause(5) + dr.click_on('Fill the border@RadioButton') + pause(6) cut() # The preview fills at the working scale — minutes on the CPU rung — # so the film pauses until the border's corner has been painted. - wait_until_lit(60, 110, 400) + x0, y0, _, _ = dr.rect('id:preview-image') + wait_until_lit(x0 + 36, y0 + 30, 400) pause(3) shot('panorama-filled') rec('panorama-b') pause(2) started = time.time() - dr.click(1543, 22); pause(8) # Merge + dr.click_on('Merge@Button') + pause(8) cut() join('panorama', ['panorama-a', 'panorama-b']) - # The merge itself takes minutes too; the last picture is the - # composite in the grid. + # The merge itself takes minutes too; the scene is done when the DNG is. wait_for_new(DEMO_LIBRARY, '.dng', started, 600) if DEMO_LIBRARY else pause(60) pause(5) - shot('panorama-done') - dr.click(*BACK); pause(2) + at_library() # --- settings --------------------------------------------------------------- +@scene(media=['settings.png', 'settings-export.png'], + sources=COMMON + ['ui/dr-ui/ui/settings.slint', 'ui/dr-ui/src/settings_ui.rs']) def settings(): - dr.click(*SETTINGS_LIB); pause(2) + at_library() + dr.click_on('Settings@Button') + dr.wait_for('BACKGROUND ACTIVITY@Text', 10) + pause(2) shot('settings') - wheel(30, 700, 600); pause(1) + x0, y0, x1, y1 = dr.rect('id:SettingsPage') + for _ in range(40): + if dr.present('Strip location and personal metadata@Checkbox', within=(x0, y0, x1, y1 - 20)): + break + wheel(3, (x0 + x1) // 2, (y0 + y1) // 2) + pause(0.3) + wheel(10, (x0 + x1) // 2, (y0 + y1) // 2) + pause(1) shot('settings-export') - dr.click(*BACK); pause(1.5) + dr.click_on('‹ Back@Button') + pause(1.5) -ALL = [ - 'library', 'library_rating', 'library_timeline', 'library_thumbsize', - 'library_selection', 'library_keywords', 'library_collections', 'library_nesting', - 'develop', 'develop_groups', 'develop_light', 'develop_zoom', 'develop_wb', - 'compose', 'local_segment', 'local_paint', 'local_done', 'repair', 'film', - 'presets', 'panorama', 'settings', -] +# --- running them ----------------------------------------------------------- +def order(names): + """`names` with their prerequisites, each once, prerequisites first.""" + seen, out = set(), [] + + def visit(n, wanted): + if n not in SCENES: + sys.exit(f'no scene {n!r}; `scenes.py list` names them') + for a in SCENES[n].after: + visit(a, False) + if n not in seen: + seen.add(n) + out.append((n, wanted)) + elif wanted: + out[[m for m, _ in out].index(n)] = (n, True) + + # The snapshot carries collections an earlier recording made; every + # scene is drawn from an empty sidebar. + if 'tidy' not in names: + visit('tidy', False) + for n in names: + visit(n, True) + return out + + +def record(out_dir, names): + """Run `names`, prerequisites first. A prerequisite nobody asked for + records into a scratch folder, so its pictures are not replaced.""" + global OUT + scratch = tempfile.mkdtemp(prefix='dr-manual-') + try: + for name, wanted in order(names): + OUT = out_dir if wanted else scratch + print(f'-- {name}' + ('' if wanted else ' (to set up the next)'), flush=True) + try: + SCENES[name].fn() + except BaseException: + stop_recording() # a failed scene must not leave ffmpeg grabbing + raise + finally: + subprocess.run(['rm', '-rf', scratch]) + + +def stop_recording(): + if os.path.exists(f'{dr.HOME}/rec.pid'): + dr.rec_stop() + + +# --- the manual and the scenes, compared ----------------------------------- +def pictures(): + """Every picture a scene makes, as the manual names it.""" + return {m: s.name for s in SCENES.values() for m in s.media if '.' in m} + + +def referenced(): + text = open(os.path.join(REPO, MANUAL)).read() + return set(re.findall(r'\]\(media/([^)\s]+)\)', text)) + + +def check(): + """The manual shows what the scenes make, all of it and nothing else.""" + made, shown = set(pictures()), referenced() + faults = [] + for m in sorted(shown - made): + faults.append(f'{MANUAL} shows media/{m}, which no scene in tools/manual/scenes.py makes') + for m in sorted(made - shown): + faults.append(f'scene {pictures()[m]} makes media/{m}, which {MANUAL} does not show') + for s in SCENES.values(): + for g in s.sources: + if not glob_matches(g): + faults.append(f'scene {s.name} depends on {g}, which matches no tracked file') + for f in faults: + print(f) + if not faults: + print(f'{len(shown)} pictures in the manual, each made by one of {len(SCENES)} scenes') + return not faults + + +def git(*args): + return subprocess.run(['git', '-C', REPO, *args], capture_output=True, text=True, + check=True).stdout + + +_tracked = None + + +def glob_matches(pattern): + global _tracked + if _tracked is None: + _tracked = git('ls-files').split('\n') + return any(fnmatch.fnmatch(f, pattern) for f in _tracked) + + +def fn_lines(s): + lines, start = inspect.getsourcelines(s.fn) + return start, start + len(lines) + + +def own_code_changed(s, since): + """Whether the lines of `s`'s function in this file differ from `since`.""" + diff = git('diff', '-U0', since, '--', os.path.relpath(__file__, REPO)) + lo, hi = fn_lines(s) + for m in re.finditer(r'^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@', diff, re.M): + start, count = int(m.group(1)), int(m.group(2) if m.group(2) is not None else 1) + if start < hi and start + max(count, 1) > lo: + return True + return False + + +def changed(): + """The scenes whose pictures are older than their sources: for each + picture, the commit that last touched it, and whether any source — or + the scene's own code — has changed since (the working tree included). + A picture never committed is new, and so is its scene.""" + out = [] + for s in SCENES.values(): + pics = [m for m in s.media if '.' in m] + if not pics: + continue + reasons = [] + for m in pics: + since = git('log', '-1', '--format=%H', '--', f'{MEDIA}/{m}').strip() + if not since: + reasons.append(f'media/{m} was never committed') + continue + paths = git('diff', '--name-only', since, '--', *[f':(glob){g}' for g in s.sources]).split() + if paths: + reasons.append(f'{", ".join(sorted(set(paths))[:3])} changed since media/{m}') + elif own_code_changed(s, since): + reasons.append(f'its scene changed since media/{m}') + if reasons: + out.append((s.name, reasons[0])) + return out + + +def main(): + global OUT + cmd, *rest = sys.argv[1:] or ['help'] + if cmd == 'record': + OUT, names = rest[0], rest[1:] + if names in ([], ['all']): + names = list(SCENES) + record(OUT, names) + elif cmd == 'order': + print(' '.join(n for n, _ in order(rest))) + elif cmd == 'check': + sys.exit(0 if check() else 1) + elif cmd == 'changed': + for name, why in changed(): + print(f'{name}\t{why}') + elif cmd == 'list': + for s in SCENES.values(): + after = f' (after {", ".join(s.after)})' if s.after else '' + print(f'{s.name}: {" ".join(m for m in s.media if "." in m) or "-"}{after}') + else: + sys.exit(__doc__) + if __name__ == '__main__': - names = sys.argv[2:] - if names == ['all']: - names = ALL - for name in names: - print(f'-- {name}', flush=True) - globals()[name]() + main() diff --git a/ui/dr-ui/src/automation.rs b/ui/dr-ui/src/automation.rs index 33b0426..4b8fbfd 100644 --- a/ui/dr-ui/src/automation.rs +++ b/ui/dr-ui/src/automation.rs @@ -24,8 +24,9 @@ //! //! - `ping` — `"ok"`. //! - `locate LABEL` — every element whose accessible label is exactly -//! `LABEL`: `[{"label","role","x","y","w","h","opacity","enabled", -//! "checked","value"}, …]`, in physical window pixels, tree order. +//! `LABEL`: `[{"label","id","type","role","x","y","w","h","opacity", +//! "enabled","checked","value","min","max"}, …]`, in physical window +//! pixels, tree order. //! - `labels [SUBSTRING]` — every labelled element, or those whose label //! contains `SUBSTRING`; the same objects. For finding what a scene can //! name. @@ -180,5 +181,7 @@ fn describe(e: &ElementHandle, scale: f32) -> Value { "enabled": e.accessible_enabled(), "checked": e.accessible_checked(), "value": e.accessible_value().map(|s| s.to_string()), + "min": e.accessible_value_minimum(), + "max": e.accessible_value_maximum(), }) } diff --git a/ui/dr-ui/ui/merge.slint b/ui/dr-ui/ui/merge.slint index ac77150..de3b463 100644 --- a/ui/dr-ui/ui/merge.slint +++ b/ui/dr-ui/ui/merge.slint @@ -176,7 +176,7 @@ export component MergePage inherits Rectangle { if root.preview.width > 0: Rectangle { height: min(root.height * 0.55, parent.width * root.preview.height / max(1, root.preview.width)); background: Theme.rule; - Image { + preview-image := Image { source: root.preview; width: 100%; height: 100%;