Files
insta360-link-tools/tests/lint_qml.py
brad 1f0d5ec0f4 Fix the QML scope error that disabled all camera movement
The joystick, the keyboard and every movement command did nothing. The cause
was in the journal all along:

    main.qml: ReferenceError: stick is not defined

`stick` is the id of the Joystick inside the `fullRepresentation` Component.
Ids declared inside a Component are not visible from the enclosing scope, so
the inNx, inNy and inEngaged properties declared on the root object could not
resolve it. Those bindings threw, inEngaged stayed false, the tick timer never
ran, and no input of any kind produced a command. Everything else kept
working, which is why status, zoom and binary resolution all looked healthy in
the log while dragging did nothing.

The three properties now live inside the fullRepresentation, where both
`stick` and the root object are in scope.

Also fixed from the same log: the configuration page logged "does not have a
property called cfg_<name>Default" for all eight keys plus `title`, because
Plasma assigns those when it instantiates a config page and they were never
declared. They are declared now, so the Defaults button has something to
compare against.

Startup probed for the binary twice, because Component.onCompleted and
onConfiguredBinChanged both fired while the component was still being set up,
duplicating every follow-up query. The second path is now guarded.

The diagnostics readout is one label built by one guarded function instead of
a Repeater over an array of expressions. In that earlier shape a single
throwing sub-expression discards the whole model and the panel renders empty,
which is precisely the failure mode it is supposed to diagnose, and is what it
did.

The real repair is to the verification, in tests/lint_qml.py. qmllint had
flagged `stick`, and a filter discarded it. Without the KDE QML modules
present qmllint cannot resolve PlasmoidItem, so nothing inside it has a known
scope and all 87 identifiers in a clean file are reported as unqualified
access. Filtering that category wholesale was the only way to get a readable
result, and it threw away the one finding that mattered.

tests/qmlstubs now holds minimal stand-ins for org.kde.plasma.plasmoid,
org.kde.plasma.components, org.kde.plasma.plasma5support and
org.kde.kirigami, so the types resolve and the scope analysis is meaningful.
With the stubs in place the only remaining unqualified accesses are `root`,
`sh` and `modelData`, each allowlisted with a reason, so anything else is a
genuine finding. Confirmed by reintroducing the bug: the linter fails and
names the exact lines and identifier.

Tested: 169 checks across seven suites plus the new linter, all passing.
shellcheck clean.
2026-10-07 23:29:08 -07:00

155 lines
5.0 KiB
Python

#!/usr/bin/env python3
"""Lint the applet's QML with the KDE types resolved.
Why this exists
---------------
Running qmllint on the applet without the KDE QML modules present reports
every single identifier inside `PlasmoidItem` as "Unqualified access",
because the type cannot be resolved and so nothing inside it has a known
scope. That is 87 warnings of pure noise on a clean file, which is why they
were previously filtered out wholesale, and that filter hid a real one:
ReferenceError: stick is not defined
`stick` is an id declared inside the `fullRepresentation` Component, and a
property on the root object cannot see into that scope. The binding threw at
runtime, so the movement timer never ran and every form of input silently
stopped working. qmllint had flagged it; the filter discarded it.
tests/qmlstubs holds minimal stand-ins for the KDE modules so qmllint can
resolve the types and reason about scope properly. With those in place the
only remaining unqualified accesses are the handful listed in ALLOWED below,
so anything new is a genuine finding rather than noise.
Run: python3 tests/lint_qml.py
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.normpath(os.path.join(HERE, ".."))
STUBS = os.path.join(HERE, "qmlstubs")
TARGETS = [
"plasmoid/package/contents/ui/main.qml",
"plasmoid/package/contents/ui/Joystick.qml",
]
# Identifiers that are legitimately unqualified, with the reason. Keep this
# list short and specific: a broad rule here is what let the scope bug
# through last time.
ALLOWED = {
# Ids declared on the outer component and used inside the nested
# fullRepresentation. QML resolves these at runtime under the default
# component behaviour; qmllint asks for "pragma ComponentBehavior: Bound"
# to make it explicit, which would change runtime semantics and is left
# as a separate decision.
"root",
"sh",
# Implicit property supplied by Repeater to its delegate.
"modelData",
}
CANDIDATES = [
shutil.which("qmllint"),
shutil.which("pyside6-qmllint"),
"/tmp/pyside/PySide6/qmllint",
]
def find_qmllint() -> str | None:
for c in CANDIDATES:
if c and os.path.exists(c):
return c
return None
def parse(output: str) -> tuple[list[tuple[str, str, str]], list[str]]:
"""Return (unqualified findings, hard errors).
A finding is (file, line, symbol). The symbol is recovered from the caret
line qmllint prints beneath the offending source.
"""
findings: list[tuple[str, str, str]] = []
errors: list[str] = []
lines = output.split("\n")
for i, line in enumerate(lines):
if line.startswith("Error:"):
errors.append(line)
continue
m = re.match(r"Warning: (.+?):(\d+):(\d+): Unqualified access", line)
if not m or i + 2 >= len(lines):
continue
src, caret = lines[i + 1], lines[i + 2]
col = caret.find("^")
if col < 0:
continue
end = col
while end < len(caret) and caret[end] == "^":
end += 1
findings.append((m.group(1), m.group(2), src[col:end]))
return findings, errors
def main() -> int:
qmllint = find_qmllint()
if not qmllint:
print("SKIP: qmllint not available "
"(pip install PySide6-Essentials provides one)")
return 0
env = dict(os.environ, LC_ALL="C.UTF-8")
failures = 0
checked = 0
for target in TARGETS:
path = os.path.join(ROOT, target)
if not os.path.exists(path):
print(f"FAIL {target}: missing")
failures += 1
continue
proc = subprocess.run(
[qmllint, "-I", STUBS, path],
capture_output=True, text=True, env=env, cwd=ROOT,
)
output = proc.stdout + proc.stderr
findings, errors = parse(output)
checked += 1
for err in errors:
print(f"FAIL {err}")
failures += 1
unexpected = [f for f in findings if f[2] not in ALLOWED]
if unexpected:
for _, line, sym in unexpected:
print(f"FAIL {target}:{line}: unqualified access to "
f"'{sym}'. If this is an id from another component, "
f"QML cannot resolve it from here.")
failures += 1
else:
allowed_count = len(findings)
print(f"ok {target} "
f"({allowed_count} allowed unqualified, 0 unexpected)")
# An unresolved import means the stubs have drifted and the scope
# analysis below it is worthless, so treat it as a failure.
if "was not found" in output:
for line in output.split("\n"):
if "was not found" in line:
print(f"FAIL unresolved type in {target}: {line.strip()}")
failures += 1
print(f"\n{checked} files linted, {failures} failures")
return 0 if failures == 0 else 1
if __name__ == "__main__":
sys.exit(main())