#!/bin/bash # SPDX-License-Identifier: MIT # SPDX-FileCopyrightText: 2026 Martino Ferrari # # Regenerates the screenshots the Mallard manual carries (help/C//figures/*.png). # # It runs udpscope headlessly under its own Xvfb, against tools/mock_udpstreamer.py, drives it # into a state with the same `UDPSCOPE_*` probes the self-check uses, grabs the window with # ImageMagick's `import`, and draws numbered annotation boxes on a borderless crop with # `magick`. # # Two things about a headless box, both measured rather than assumed: # # * GTK4 picks the Vulkan renderer by default, and under Xvfb that surface is not readable # back through X11 -- `import` sees a window with no pixels (standard deviation 0). Forcing # the Cairo renderer (`GSK_RENDERER=cairo`, with `LIBGL_ALWAYS_SOFTWARE=1`) makes GTK paint # with XPutImage, which `import` can read. # * `xdotool search --name udpscope` finds nothing: GTK4 sets no WM_NAME on the X window it # creates, so windows are found by their geometry, not their name. # # Box provenance: the sidebar width is read from the app's own probe dump (`side=NNNx`), so the # side-panel box tracks the real widget; the header and status bands are the table in # `window_boxes` below, each named where it is used, because the dump reports neither. # # Usage: tools/make-help-shots.sh [OUTPUT_DIR] (default help/C//figures) set -euo pipefail here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) root=$(dirname -- "$here") app=${UDPSCOPE_SHOTS_APP:-$root/build/udpscope} mock=$here/mock_udpstreamer.py help_id=org.udpscope.app out=${1:-$root/help/C/$help_id/figures} port=18391 for tool in Xvfb import magick xdotool python3; do command -v "$tool" >/dev/null || { echo "make-help-shots: $tool is required" >&2; exit 1; } done [ -x "$app" ] || { echo "make-help-shots: $app is missing; build it first" >&2; exit 1; } [ -f "$mock" ] || { echo "make-help-shots: $mock is missing" >&2; exit 1; } mkdir -p "$out" # --- the window's own geometry, and the boxes it is annotated with ------------- # main-window.ui asks for 1280x800, and the sidebar is a fixed 320 px # (sidebar-width-unit=px, min=max=320). The header bar is the window's titlebar and the status # row sits at the foot of the plot column; neither reports a pixel height, so their bands are # the third measurement -- a pixel scan of a grab, 51 px and 43 px on the current theme -- and # are refreshed by re-measuring if the styling changes. WIN_W=1280 WIN_H=800 HEADER_H=51 STATUS_H=43 PLOT_W=952 boxes_main="header bar:0:0:$WIN_W:$HEADER_H side panel:SIDE_X:$HEADER_H:SIDE_W:706 plot:0:$HEADER_H:$PLOT_W:706 status bar:0:757:$PLOT_W:$STATUS_H" boxes_trigger="event held, axis pinned to the event:0:757:$PLOT_W:$STATUS_H the trigger marker at x = 0:0:$HEADER_H:$PLOT_W:706" boxes_math="the graph editor:8:8:EW-24:EH-24" display= xvfb_pid= app_pid= mock_pid= cleanup() { [ -n "$app_pid" ] && kill "$app_pid" 2>/dev/null || true [ -n "$mock_pid" ] && kill "$mock_pid" 2>/dev/null || true [ -n "$xvfb_pid" ] && kill "$xvfb_pid" 2>/dev/null || true } trap cleanup EXIT # A display of our own, so nothing on the developer's screen is grabbed or disturbed. for n in $(seq 210 240); do if Xvfb ":$n" -screen 0 1600x1000x24 -nolisten tcp >/dev/null 2>&1 & then xvfb_pid=$! sleep 1 if kill -0 "$xvfb_pid" 2>/dev/null; then display=":$n"; break; fi fi done [ -n "$display" ] || { echo "make-help-shots: no free Xvfb display in :210..:240" >&2; exit 1; } export DISPLAY="$display" export GDK_BACKEND=x11 GSK_RENDERER=cairo LIBGL_ALWAYS_SOFTWARE=1 # A throwaway config dir: the app restores the last session for a source, and a restored # layout would change the very arrangement the screenshot is meant to show. tmp=$(mktemp -d) trap 'cleanup; rm -rf "$tmp"' EXIT export XDG_CONFIG_HOME="$tmp/config" XDG_CACHE_HOME="$tmp/cache" XDG_DATA_HOME="$tmp/data" python3 "$mock" --port "$port" --rate 10000 --batch 100 >"$tmp/mock.log" 2>&1 & mock_pid=$! sleep 0.5 # The largest window that is not the 1600x1000 X root; that is the app's 1280x800 one (a math # editor opened on top is smaller, and X may also expose unnamed GTK helper windows). pick_window() { local id geo w best= best_w=0 for id in $(xdotool search --onlyvisible "" 2>/dev/null); do geo=$(xdotool getwindowgeometry "$id" 2>/dev/null | awk '/Geometry/{print $2}') [ "$geo" = "1600x1000" ] && continue w=${geo%x*} case "$w" in ''|*[!0-9]*) continue;; esac if [ "$w" -gt "$best_w" ]; then best=$id; best_w=$w; fi done [ -n "$best" ] && echo "$best" } # The math editor is a window of its own (a `GtkWindow`, presented on the main one), so it is # the largest window that is neither the X root nor the app's 1280x800 one and that has a real # size (X exposes 1x1 helper windows too). pick_other_window() { local id geo w best= best_w=0 for id in $(xdotool search --onlyvisible "" 2>/dev/null); do geo=$(xdotool getwindowgeometry "$id" 2>/dev/null | awk '/Geometry/{print $2}') case "$geo" in 1600x1000|"${WIN_W}x${WIN_H}") continue;; esac w=${geo%x*} case "$w" in ''|*[!0-9]*) continue;; esac [ "$w" -le 50 ] && continue if [ "$w" -gt "$best_w" ]; then best=$id; best_w=$w; fi done [ -n "$best" ] && echo "$best" } start_app() { # LOGFILE ENVS... ; extra args via APP_ARGS local log=$1; shift # shellcheck disable=SC2086 env "$@" "$app" --server 127.0.0.1 --port "$port" --buffer-samples 512k ${APP_ARGS:-} >"$log" 2>&1 & app_pid=$! } stop_app() { [ -n "$app_pid" ] && kill "$app_pid" 2>/dev/null || true wait "$app_pid" 2>/dev/null || true app_pid= } side_of_dump() { # LOGFILE -> the sidebar width the app reports, else the table's 320 local s s=$(grep -o 'side=[0-9]*x' "$1" 2>/dev/null | head -1 | grep -o '[0-9]*' || true) echo "${s:-320}" } # annotate NAME RAW LOG BOXES W H -> the figure, with a numbered box per line of BOXES. # A box is `label:x:y:w:h`; `SIDE_X`/`SIDE_W` are the sidebar's own numbers, `EW`/`EH` the # grabbed window's width and height. annotate() { local name=$1 raw=$2 log=$3 boxes=$4 win_w=$5 win_h=$6 local side_w side_x i=0 draw=() line label x y w h side_w=$(side_of_dump "$log") side_x=$((win_w - side_w)) while IFS= read -r line; do [ -n "$line" ] || continue IFS=: read -r label x y w h <<<"$line" x=${x/SIDE_X/$side_x}; w=${w/SIDE_W/$side_w} w=${w/EW/$win_w}; h=${h/EH/$win_h} i=$((i + 1)) draw+=(-stroke '#e01b24' -strokewidth 3 -fill none -draw "rectangle $x,$y $((x + w)),$((y + h))" -stroke none -fill '#e01b24' -draw "circle $((x + 17)),$((y + 17)) $((x + 17)),$((y + 33))" -fill white -stroke none -pointsize 22 -draw "text $((x + 10)),$((y + 25)) '$i'") done <<<"$boxes" magick "$raw" -crop "${win_w}x${win_h}+0+0" +repage "${draw[@]}" "$out/$name.png" local sd sd=$(magick identify -format '%[standard-deviation]' "$out/$name.png") awk -v s="$sd" 'BEGIN{exit !(s+0 > 0)}' || { echo "make-help-shots: the '$name' figure is blank (stddev=$sd)" >&2; return 1; } echo "make-help-shots: wrote $out/$name.png ($(magick identify -format '%wx%h' "$out/$name.png"))" # Print what each number marks, so the script is self-documenting in CI. local n=0 while IFS= read -r line; do [ -n "$line" ] || continue n=$((n + 1)) printf ' %s = %s\n' "$n" "${line%%:*}" done <<<"$boxes" } # grab NAME LOG BOXES [PICKER] -- grab the picked window, fail if it is blank, stop the app, # annotate it at its own size. grab() { local name=$1 log=$2 boxes=$3 picker=${4:-pick_window} win geo win_w win_h raw sd win=$($picker) [ -n "$win" ] || { echo "make-help-shots: no window for '$name'" >&2; stop_app; return 1; } geo=$(xdotool getwindowgeometry "$win" 2>/dev/null | awk '/Geometry/{print $2}') win_w=${geo%x*}; win_h=${geo#*x} raw="$tmp/$name.raw.png" import -window "$win" "$raw" sd=$(magick identify -format '%[standard-deviation]' "$raw") awk -v s="$sd" 'BEGIN{exit !(s+0 > 0)}' || { echo "make-help-shots: '$name' grabbed a blank window (stddev=$sd)" >&2; stop_app; return 1; } stop_app annotate "$name" "$raw" "$log" "$boxes" "$win_w" "$win_h" } # --- scene 1: the live window ------------------------------------------------- echo "make-help-shots: scene 'main-window'" APP_ARGS="--plots 3" start_app "$tmp/main-window.log" UDPSCOPE_ENABLE_ALL_MS=2500 sleep 8 grab main-window "$tmp/main-window.log" "$boxes_main" # --- scene 2: a held capture -------------------------------------------------- echo "make-help-shots: scene 'trigger-capture'" APP_ARGS="--plots 2" start_app "$tmp/trigger-capture.log" \ UDPSCOPE_ENABLE_ALL_MS=1200 UDPSCOPE_ARM_TRIGGER_MS=1500 sleep 9 grab trigger-capture "$tmp/trigger-capture.log" "$boxes_trigger" # --- scene 3: the math editor ------------------------------------------------- # The editor is a window of its own, presented on the main one, and `math_probe` # (`UDPSCOPE_PROBE_MATH_MS`) opens it -- "editor opened" in its log is the reading. No clicks # are needed: the probe presents it and reopens it after a structural edit, so by the time the # wait ends it is up. It is grabbed on its own rather than through the main window, which is # the whole point of the figure. echo "make-help-shots: scene 'math-editor'" APP_ARGS="--plots 1" start_app "$tmp/math-editor.log" \ UDPSCOPE_ENABLE_ALL_MS=1500 UDPSCOPE_PROBE_MATH_MS=2500 sleep 10 grab math-editor "$tmp/math-editor.log" "$boxes_math" pick_other_window # The last step, and it is not cosmetic: `import` records the root window's background, so the # PNG leaves ImageMagick with a `bKGD` chunk -- forbidden by the PNG specification on an image # with an alpha channel (colour types 4 and 6), which these are. ImageMagick writes it and reads # it back happily, so a round-trip through `magick` proves nothing; a stricter decoder refuses # the file. In Yelp that showed as a figure that never loaded, and over the `help:` URI as a page # that spun forever, because the load event never fired. Normalise every figure to the chunks a # decoder is guaranteed to accept, whatever the grab produced. for f in "$out"/*.png; do magick "$f" -strip -define png:exclude-chunk=bKGD,cHRM,tIME,tEXt,tXMP,exif \ -define png:compression-level=9 "$f" done echo "make-help-shots: done"