src/ui/cursors-panel.ui holds the panel's shape -- the header with the mode dropdown and the reset button, and the grid the two cursor times are read in. What is not in it is the grid's contents, which follow the cursor mode and the rows and are rebuilt whenever either changes. Panels need a builder that outlives them, unlike the dialogs, which are rebuilt on every opening: a widget belongs to the builder that built it, so App::ui_builders holds each panel's for the life of the window. ui_load() is that, in ui_widgets.cpp. make test-ui also checks that udpscope-ui.gresource.xml lists every .ui file now. That was the third trap of this refactor -- a file on disk but not in the resource is a Gtk-ERROR at load, which aborts -- and it is a compile-time mistake that nothing else in the build could see. Verified by unlisting the new file and watching the check fail, then restoring it. [cursors-probe] reads as it did: all four modes, pane=1, the timings and the drag cases.
136 lines
5.6 KiB
Python
Executable File
136 lines
5.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: MIT
|
|
# SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
"""Checks the interface files: `make test-ui`.
|
|
|
|
The interface is hand-written GtkBuilder XML, and nothing in the build can see a mistake in it. A
|
|
property a class does not have, or a child a container does not accept, is a `Gtk-ERROR` when the
|
|
window is built — and `Gtk-ERROR` aborts the process, so a typo in a `.ui` takes the application
|
|
down rather than failing to open a dialog. `preprocess="xml-stripblanks"` catches malformed XML and
|
|
nothing else, and the probes catch the rest only for the windows they open.
|
|
|
|
blueprint-compiler can see it: it type-checks every property and child type against the GIR. It reads
|
|
blueprint rather than XML — but it reads XML too, because `decompile` turns a `.ui` back into
|
|
blueprint. So the check is a round trip, and the XML stays the source of truth:
|
|
|
|
$ make test-ui
|
|
check_ui: 5 files in src/ui
|
|
ok help.ui
|
|
ok main-window.ui
|
|
ok preferences.ui
|
|
ok shortcuts.ui
|
|
ok source-form.ui
|
|
check_ui: 5 ok, 0 failed
|
|
check_ui: 23 pieces of advice — not failures; --lint shows them
|
|
|
|
A file that fails prints what the compiler said, with the line, the caret and its suggestion:
|
|
|
|
FAIL preferences.ui
|
|
error: Class Adw.EntryRow does not have a property called subtitle
|
|
at preferences.blp line 24 column 9:
|
|
24 | subtitle: "empty = unicast";
|
|
| ^^^^^^^^
|
|
hint: Did you mean `title`?
|
|
|
|
`lint` runs too and its findings are printed as advice rather than failures: a linter has opinions
|
|
this project does not have to share (it wants "udpscope" marked translatable, which it is not), and a
|
|
check that fails on advice is a check people stop running.
|
|
|
|
A missing `blueprint-compiler` is a *failure* and not a skip. This target exists to be run, and
|
|
answering "fine" without having looked is the one answer it must not give.
|
|
"""
|
|
|
|
import argparse
|
|
import pathlib
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
TOOL = "blueprint-compiler"
|
|
|
|
|
|
def check(tool: str, ui: pathlib.Path, scratch: pathlib.Path) -> tuple[bool, list[str]]:
|
|
"""Round-trips one `.ui` through blueprint and returns (ok, advice)."""
|
|
blp = scratch / (ui.stem + ".blp")
|
|
|
|
# `decompile` only transcribes: it is the *compile* of its output that checks the properties, so
|
|
# both exit codes matter -- a file the decompiler cannot read at all is just as broken.
|
|
round_trip = subprocess.run(
|
|
[tool, "decompile", str(ui), "--output", str(blp)], capture_output=True, text=True
|
|
)
|
|
if round_trip.returncode != 0 or not blp.exists():
|
|
return False, [line for line in round_trip.stderr.splitlines() if line]
|
|
|
|
compiled = subprocess.run(
|
|
[tool, "compile", str(blp)], capture_output=True, text=True
|
|
)
|
|
if compiled.returncode != 0:
|
|
return False, [line for line in compiled.stderr.splitlines() if line]
|
|
|
|
linted = subprocess.run([tool, "lint", str(blp)], capture_output=True, text=True)
|
|
advice = [line for line in linted.stderr.splitlines() if line]
|
|
return True, advice
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("directory", nargs="?", default="src/ui",
|
|
help="where the .ui files are (default: src/ui)")
|
|
parser.add_argument("--lint", action="store_true",
|
|
help="print the linter's advice as well as the failures")
|
|
args = parser.parse_args()
|
|
|
|
tool = shutil.which(TOOL)
|
|
if tool is None:
|
|
print(f"check_ui: {TOOL} is not installed, so the UI files were NOT checked", file=sys.stderr)
|
|
print(" install it (Arch: pacman -S blueprint-compiler) and run this again",
|
|
file=sys.stderr)
|
|
return 2
|
|
|
|
directory = pathlib.Path(args.directory)
|
|
files = sorted(directory.glob("*.ui"))
|
|
if not files:
|
|
print(f"check_ui: no .ui files in {directory}", file=sys.stderr)
|
|
return 2
|
|
print(f"check_ui: {len(files)} files in {directory}")
|
|
|
|
failed = 0
|
|
advice_count = 0
|
|
with tempfile.TemporaryDirectory(prefix="check-ui-") as scratch_name:
|
|
scratch = pathlib.Path(scratch_name)
|
|
for ui in files:
|
|
ok, messages = check(tool, ui, scratch)
|
|
print(f" {'ok ' if ok else 'FAIL'} {ui.name}")
|
|
if ok:
|
|
advice_count += len([m for m in messages if m.startswith("warning:")])
|
|
else:
|
|
failed += 1
|
|
# A failure is always printed; advice only when it was asked for. Twenty lines of opinion
|
|
# on every run is how a check's output stops being read.
|
|
if not ok or args.lint:
|
|
for line in messages:
|
|
print(f" {line}")
|
|
|
|
# Every file has to be in the GResource. One that is not is a `Gtk-ERROR` at load -- an abort rather
|
|
# than a window that fails to open -- and nothing else in the build notices, because the xml is what
|
|
# `glib-compile-resources` reads and the file on disk is not it.
|
|
gresource = directory / "udpscope-ui.gresource.xml"
|
|
if gresource.exists():
|
|
listed = set(re.findall(r"<file[^>]*>([^<]+)</file>", gresource.read_text()))
|
|
for ui in files:
|
|
if ui.name not in listed:
|
|
print(f" FAIL {ui.name} is not listed in udpscope-ui.gresource.xml")
|
|
failed += 1
|
|
|
|
print(f"check_ui: {len(files) - failed} ok, {failed} failed")
|
|
if advice_count:
|
|
print(f"check_ui: {advice_count} pieces of advice — not failures; --lint shows them")
|
|
return 1 if failed else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|