feat: the manual is a Mallard set opened in Yelp, not a GResource in the binary
The help was 14 Pango-markup `.txt` bodies in a second GResource, drawn into a paged `GtkWindow` built in `help_window.cpp`. That made F1 a viewer of ours with no search index, no cross-references and no translation, and it kept the manual in the *binary* when every other desktop application's is a document on disk. It is now a real Mallard set: help/C/org.udpscope.app/ index.page + 14 topic pages + figures/*.png and `show_help()` hands `help:org.udpscope.app` to whatever the desktop registered for the `help:` scheme -- Yelp on GNOME -- through a `GtkUriLauncher`, reporting a launch failure with a `GtkAlertDialog` rather than a click that silently does nothing. The paged window's builder, the page table and `help_self_check()`'s markup validation are gone with the resource. The document identifier is the desktop file's own basename (its desktop-file id), derived in CMakeLists from `data/org.udpscope.app.desktop.in`, so the same string is the `.desktop` name, the GApplication id and the install directory at once. The set is installed by a new `install()` rule to `<datadir>/help/C/org.udpscope.app/` -- where the freedesktop Help System Specification's `help:` URI resolves `help:<id>` -- so the manual is read from a datadir by another process even though the binary has no install step. `UDPSCOPE_HELP_NAME` and `UDPSCOPE_HELP_DIR` are compiled in from that one place. The 14 pages were migrated fact for fact, but restructured the Mallard way: a `<desc>` per page (what a search result shows), `<steps>` for procedures, `<terms>` for option lists, `<gui>`/`<key>`/`<keyseq>` for interface words and keys, `<note>` for the asides, and a topic graph via `<link type="guide" xref="index#main"/>`. The figures are annotated screenshots produced by `tools/make-help-shots.sh`, which drives the app headlessly against `tools/mock_udpstreamer.py` and draws the boxes with ImageMagick; the side panel's box is measured from the app's own `side=NNNx` dump reading, the header and status bands come from a documented table, and a crop whose standard deviation is 0 fails the script. `cmake --build build --target test-help` checks the set the way `test-ui` checks the `.ui` files: `yelp-check`'s offline subcommands (links, media, orphans, ids) and a `yelp-build html` render. `yelp-check validate` is deliberately absent -- it fetches the Mallard RelaxNG schema over the network and fails offline for that reason, not for a fault in the pages. The `[help]` probe reading changes with the resource it was written for. `parsed == pages` over in-binary markup cannot exist now, so `help_self_check()` scans `UDPSCOPE_HELP_DIR` and the line reads `topics=14 index=1 figures=3`; `topics=0 index=0` is the manual missing from the tree, which is the finding the old count named. Verified: clean build with the documented three warnings (the help bundle's `-Woverlength-strings` is gone, so the baseline moves from four to three); test-ui 14/14; test-help clean (the four subcommands and 15 pages rendered); a temp-prefix install lands 15 `.page` files and 3 figures at `share/help/C/org.udpscope.app/`; the headless run reads `[help] topics=14 index=1 figures=3 dir=...` with 0 Gtk-CRITICAL. Yelp's own window could not be exercised on this box -- it starts under Xvfb, activates its portals and never maps, the same WebKit/portal limitation that stops a grab -- so its rendering and its search index are unverified here; the content pipeline was (`yelp-build` renders the whole set). Docs: the manual's build/install/open story in `build-and-run.md` and README; the warning baseline in `verification.md`/CLAUDE.md; the help bullets in `invariants-gtk-and-chrome.md`; the `[help]` line in `probe-readings.md`.
This commit is contained in:
1 parent
620473a5be
commit
c1460a6977
47 files changed
+1910
-1056
No files matched your search
Executable
+224
@@ -0,0 +1,224 @@
|
||||
#!/bin/bash
|
||||
# SPDX-License-Identifier: MIT
|
||||
# SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
#
|
||||
# Regenerates the screenshots the Mallard manual carries (help/C/<id>/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/<id>/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-mb 16 ${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
|
||||
|
||||
echo "make-help-shots: done"
|
||||
Reference in new issue
Block a user