Hands: fix Export doing nothing, and show it's busy at once

af2ea7c put _stay_awake between exportSession and its @Slot, so the
window's Export button called a method QML couldn't see. A new test checks
every backend call in main.qml against Backend's slots and properties.
Export and Upload now say Exporting…/Uploading… with a spinner the moment
they're pressed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-10-03 09:29:04 -06:00
1 parent af2ea7c0ee
commit 8dbcdecf90
3 files changed
+61 -4

No files matched your search

+1 -1
View File
@@ -787,7 +787,6 @@ class Backend(QObject):
def exportSessionId(self): def exportSessionId(self):
return self._export_session return self._export_session
@Slot(str, bool)
def _stay_awake(self, why, on): def _stay_awake(self, why, on):
"""Hold off sleep while an export or upload runs, so the headset can be taken off and """Hold off sleep while an export or upload runs, so the headset can be taken off and
left plugged in: a host unit running systemd-inhibit, started with the first reason left plugged in: a host unit running systemd-inhibit, started with the first reason
@@ -812,6 +811,7 @@ class Backend(QObject):
print(f"keeping the Frame awake: {e}", file=sys.stderr, flush=True) print(f"keeping the Frame awake: {e}", file=sys.stderr, flush=True)
self._thread(run) self._thread(run)
@Slot(str, bool)
def exportSession(self, session, keep_notes): def exportSession(self, session, keep_notes):
if self.exporting or self._active_guard(session): if self.exporting or self._active_guard(session):
return return
+17 -3
View File
@@ -1396,12 +1396,20 @@ Kirigami.ApplicationWindow {
} }
RowLayout { RowLayout {
Controls.Button { Controls.Button {
text: exportView.chosen && exportView.chosen.exported ? "Export again" : "Export" text: backend.exporting ? "Exporting…"
: exportView.chosen && exportView.chosen.exported ? "Export again" : "Export"
icon.name: "document-export" icon.name: "document-export"
enabled: exportView.chosen !== null && !backend.exporting && backend.zstdFound enabled: exportView.chosen !== null && !backend.exporting && backend.zstdFound
&& !exportView.chosen.active && !exportView.chosen.active
onClicked: backend.exportSession(exportView.chosen.id, false) onClicked: backend.exportSession(exportView.chosen.id, false)
} }
// Shows the moment Export is pressed: the first progress can take a few seconds.
Controls.BusyIndicator {
visible: backend.exporting
running: visible
implicitWidth: Kirigami.Units.gridUnit * 1.5
implicitHeight: implicitWidth
}
Controls.Button { Controls.Button {
visible: backend.exporting visible: backend.exporting
text: "Cancel" text: "Cancel"
@@ -1426,7 +1434,7 @@ Kirigami.ApplicationWindow {
} }
Controls.Label { Controls.Label {
Layout.maximumWidth: Kirigami.Units.gridUnit * 26 Layout.maximumWidth: Kirigami.Units.gridUnit * 26
visible: backend.exportText !== "" && backend.exportSessionId === sessionBox.currentValue visible: backend.exportText !== "" && (backend.exporting || backend.exportSessionId === sessionBox.currentValue)
wrapMode: Text.WrapAnywhere wrapMode: Text.WrapAnywhere
text: backend.exportText text: backend.exportText
} }
@@ -1643,11 +1651,17 @@ Kirigami.ApplicationWindow {
RowLayout { RowLayout {
visible: uploadView.session !== "" visible: uploadView.session !== ""
Controls.Button { Controls.Button {
text: backend.hubDryRun ? "Upload (dry run)" : "Upload" text: backend.uploading ? "Uploading…" : backend.hubDryRun ? "Upload (dry run)" : "Upload"
icon.name: "cloud-upload" icon.name: "cloud-upload"
enabled: uploadView.blocked === "" && !backend.uploading enabled: uploadView.blocked === "" && !backend.uploading
onClicked: uploadView.startUpload() onClicked: uploadView.startUpload()
} }
Controls.BusyIndicator {
visible: backend.uploading
running: visible
implicitWidth: Kirigami.Units.gridUnit * 1.5
implicitHeight: implicitWidth
}
Controls.Button { Controls.Button {
visible: backend.uploading visible: backend.uploading
text: "Cancel" text: "Cancel"
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""main.qml against ft_handrec.Backend: every backend.name(...) the window calls is a slot, and
every backend.name it reads is a property or a slot. A method that lost its @Slot shows up in
QML only as "is not a function" when its button is pressed (2026-10-03: Export did nothing).
Needs PySide6 (the dev container); skipped without it.
python3 hands/rec/tests/test_qml_backend.py
"""
import os
import re
import sys
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
REC = os.path.dirname(HERE)
sys.path.insert(0, REC)
class QmlBackendTest(unittest.TestCase):
def setUp(self):
try:
import ft_handrec
except ImportError as e:
self.skipTest(f"no PySide6: {e}")
meta = ft_handrec.Backend.staticMetaObject
self.slots = {bytes(meta.method(i).name()).decode() for i in range(meta.methodCount())}
self.props = {meta.property(i).name() for i in range(meta.propertyCount())}
with open(os.path.join(REC, "main.qml")) as f:
self.qml = f.read()
def test_calls_are_slots(self):
called = set(re.findall(r"\bbackend\.(\w+)\s*\(", self.qml))
self.assertTrue(called)
self.assertEqual(sorted(called - self.slots), [], "called from main.qml but not a slot")
def test_reads_exist(self):
read = set(re.findall(r"\bbackend\.(\w+)\b(?!\s*\()", self.qml))
self.assertTrue(read)
self.assertEqual(sorted(read - self.props - self.slots), [], "read in main.qml but not on Backend")
if __name__ == "__main__":
unittest.main()