mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 02:00:06 +02:00
The checklist page measures the light when it opens, starting ft-camd if nothing runs it (and stopping it on quit), instead of saying the cameras aren't running. The round's lighting defaults to what the cameras measure: daylight or indoor, from the mono cameras' ambient infrared. Lamps give off little infrared, so dim and normal rooms read alike; picking dim, room or daylight still overrides it. session.json gets source, measured and ambient_ir. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1781 lines
87 KiB
QML
1781 lines
87 KiB
QML
// Frametop Hand Recorder (Kirigami). Backend: ft_handrec.py ("backend"); frame sets come from
|
|
// its image provider (image://frames/...).
|
|
import QtQuick
|
|
import QtQuick.Controls as Controls
|
|
import QtQuick.Layouts
|
|
import org.kde.kirigami as Kirigami
|
|
|
|
Kirigami.ApplicationWindow {
|
|
id: root
|
|
title: "Frametop Hand Recorder"
|
|
width: Kirigami.Units.gridUnit * 50
|
|
height: Kirigami.Units.gridUnit * 36
|
|
|
|
// The session chosen on the Export page, for the Upload page.
|
|
property string chosenSession: ""
|
|
|
|
globalDrawer: Kirigami.GlobalDrawer {
|
|
isMenu: false
|
|
modal: false
|
|
collapsible: true
|
|
collapsed: root.width < Kirigami.Units.gridUnit * 34
|
|
actions: [
|
|
Kirigami.Action { text: "Welcome"; icon.name: "help-about"; onTriggered: root.show(welcomePage) },
|
|
Kirigami.Action {
|
|
text: "Before you start"; icon.name: "view-task"; enabled: !backend.needsConsent
|
|
onTriggered: root.show(checklistPage)
|
|
},
|
|
Kirigami.Action {
|
|
text: "Session"; icon.name: "media-record"; enabled: !backend.needsConsent
|
|
onTriggered: root.show(sessionPage)
|
|
},
|
|
Kirigami.Action { text: "Review"; icon.name: "view-preview"; onTriggered: root.show(reviewPage) },
|
|
Kirigami.Action {
|
|
text: "Export"; icon.name: "document-export"; enabled: !backend.needsConsent
|
|
onTriggered: root.show(exportPage)
|
|
},
|
|
Kirigami.Action {
|
|
text: "Upload"; icon.name: "cloud-upload"; enabled: !backend.needsConsent
|
|
onTriggered: root.show(uploadPage)
|
|
}
|
|
]
|
|
}
|
|
|
|
// The texts wait for a legal review; until then nobody should contribute.
|
|
component DraftBanner: Kirigami.InlineMessage {
|
|
visible: backend.textsDraft
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Warning
|
|
text: "Contributions aren't open yet. These texts are drafts waiting for a legal review: you can "
|
|
+ "record and review, but please don't upload anything until this message is gone."
|
|
}
|
|
|
|
// Read-only Markdown (CONSENT.md, UPLOAD.md), selectable for copying.
|
|
component MarkdownText: Controls.TextArea {
|
|
id: markdownText
|
|
property string markdown
|
|
// Links get the application palette's colour when the text is parsed, and Kirigami's theme
|
|
// doesn't set that palette: set it, then parse again (the theme resolves after creation).
|
|
readonly property color linkColor: Kirigami.Theme.linkColor
|
|
function parse() {
|
|
backend.setLinkColor(linkColor)
|
|
text = markdown
|
|
}
|
|
onLinkColorChanged: parse()
|
|
onMarkdownChanged: parse()
|
|
Component.onCompleted: parse()
|
|
textFormat: TextEdit.MarkdownText
|
|
readOnly: true
|
|
selectByMouse: true
|
|
wrapMode: Text.Wrap
|
|
background: null
|
|
padding: 0
|
|
onLinkActivated: link => Qt.openUrlExternally(link)
|
|
}
|
|
|
|
function plural(n, word) {
|
|
return n + " " + word + (n === 1 ? "" : "s")
|
|
}
|
|
|
|
function show(page) {
|
|
pageStack.clear()
|
|
pageStack.push(page)
|
|
}
|
|
|
|
// --page welcome|checklist|session|review|export|upload opens the app on that page; the
|
|
// Welcome page comes first until the consent is agreed to.
|
|
pageStack.initialPage: backend.needsConsent && startPage !== "review" ? welcomePage
|
|
: (({ welcome: welcomePage, checklist: checklistPage, session: sessionPage, review: reviewPage,
|
|
export: exportPage, upload: uploadPage })[startPage] || checklistPage)
|
|
|
|
Connections {
|
|
target: backend
|
|
function onMessage(text, isError) {
|
|
root.showPassiveNotification(text, isError ? "long" : "short")
|
|
}
|
|
}
|
|
|
|
// Asks before something is deleted for good: ask(title, text, function).
|
|
Kirigami.PromptDialog {
|
|
id: confirm
|
|
property var action: null
|
|
standardButtons: Controls.Dialog.Ok | Controls.Dialog.Cancel
|
|
onAccepted: if (action) action()
|
|
function ask(title, text, fn) {
|
|
confirm.title = title
|
|
confirm.subtitle = text
|
|
confirm.action = fn
|
|
confirm.open()
|
|
}
|
|
}
|
|
|
|
// Restart SteamVR (the camera check's fix). What it does to Frametop, read from the code
|
|
// (hands/README.md, "Camera check"): ft-screens quits when SteamVR does, which ends the
|
|
// desktop; its unit doesn't restart, and stopping it ends what was started in it.
|
|
function askRestartSteamVR() {
|
|
confirm.ask("Restart SteamVR?",
|
|
"SteamVR starts its cameras again, and with them the colour camera module. This closes every "
|
|
+ "VR app. It also closes the Frametop desktop with all its windows, this one too: Frametop's "
|
|
+ "screens don't come back by themselves. Save your work first; programs started from the "
|
|
+ "desktop, such as terminals and what runs in them, stop too.\n\nOnce SteamVR is back, start "
|
|
+ "the desktop again (Desktop in the library) and open the Hand recorder: it checks the cameras "
|
|
+ "again. If they're still off, restart the headset.",
|
|
() => backend.restartSteamVR())
|
|
}
|
|
|
|
// The camera check's problem, with its fix (a header message on the checklist and session pages).
|
|
component CameraMessage: Kirigami.InlineMessage {
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: backend.restartingSteamVR ? Kirigami.MessageType.Information : Kirigami.MessageType.Error
|
|
text: backend.restartingSteamVR ? "Restarting SteamVR, then checking the cameras again…" : backend.cameraText
|
|
actions: [
|
|
Kirigami.Action {
|
|
text: "Restart SteamVR…"
|
|
icon.name: "system-reboot"
|
|
enabled: !backend.restartingSteamVR && !backend.sessionActive
|
|
onTriggered: root.askRestartSteamVR()
|
|
},
|
|
Kirigami.Action {
|
|
text: "Check again"
|
|
icon.name: "view-refresh"
|
|
enabled: !backend.cameraBusy
|
|
onTriggered: backend.checkCameras()
|
|
}
|
|
]
|
|
}
|
|
|
|
// Closing during a session asks first. Quitting closes the window again (Qt 6), so the
|
|
// answer is remembered; the backend stops the session as the app quits.
|
|
property bool quitting: false
|
|
onClosing: close => {
|
|
if (backend.sessionActive && !quitting) {
|
|
close.accepted = false
|
|
confirm.ask("Stop the session?", "A session is recording. Closing stops it; what's recorded so far is kept.",
|
|
() => { root.quitting = true; Qt.quit() })
|
|
} else if (backend.uploading && !quitting) {
|
|
close.accepted = false
|
|
confirm.ask("Cancel the upload?", "An upload is running. Closing cancels it; you can start it again later.",
|
|
() => { root.quitting = true; Qt.quit() })
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Welcome
|
|
Component {
|
|
id: welcomePage
|
|
Kirigami.ScrollablePage {
|
|
title: "Welcome"
|
|
header: DraftBanner { }
|
|
|
|
ColumnLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
|
|
MarkdownText {
|
|
Layout.fillWidth: true
|
|
markdown: backend.consentText
|
|
}
|
|
|
|
Kirigami.Separator { Layout.fillWidth: true }
|
|
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: !backend.needsConsent
|
|
type: Kirigami.MessageType.Positive
|
|
text: "You agreed to this text (" + backend.consentAccepted + "). Your contributor id is "
|
|
+ backend.contributor + ": keep it if you might want to withdraw later."
|
|
}
|
|
Controls.Button {
|
|
visible: !backend.needsConsent
|
|
text: "Copy contributor id"
|
|
icon.name: "edit-copy"
|
|
onClicked: backend.copy(backend.contributor)
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: backend.needsConsent && backend.contributor !== ""
|
|
type: Kirigami.MessageType.Information
|
|
text: "This text changed since you last agreed to it. Please read it again; your contributor id stays the same."
|
|
}
|
|
|
|
Controls.CheckBox {
|
|
id: adult
|
|
visible: backend.needsConsent
|
|
text: "I'm 18 or older"
|
|
}
|
|
Controls.CheckBox {
|
|
id: agree
|
|
visible: backend.needsConsent
|
|
text: "I agree to the text above"
|
|
}
|
|
RowLayout {
|
|
visible: backend.needsConsent
|
|
Controls.Label { text: "Handedness (optional):" }
|
|
Controls.ComboBox {
|
|
id: handed
|
|
model: backend.handednessChoices
|
|
textRole: "text"
|
|
valueRole: "value"
|
|
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(backend.handedness))
|
|
}
|
|
}
|
|
Controls.Button {
|
|
visible: backend.needsConsent
|
|
enabled: adult.checked && agree.checked
|
|
text: "Agree and continue"
|
|
icon.name: "go-next"
|
|
onClicked: {
|
|
backend.acceptConsent(adult.checked, agree.checked, handed.currentValue)
|
|
if (!backend.needsConsent)
|
|
root.show(checklistPage)
|
|
}
|
|
}
|
|
Controls.Button {
|
|
visible: !backend.needsConsent
|
|
text: "Continue"
|
|
icon.name: "go-next"
|
|
onClicked: root.show(checklistPage)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Before you start
|
|
Component {
|
|
id: checklistPage
|
|
Kirigami.ScrollablePage {
|
|
id: checklist
|
|
title: "Before you start"
|
|
readonly property bool privacyOk: privacy1.checked && privacy2.checked && privacy3.checked
|
|
readonly property bool ready: privacyOk && backend.diskOk
|
|
&& backend.runnerError === "" && !backend.sessionActive
|
|
|
|
function answers() {
|
|
const objects = []
|
|
for (let i = 0; i < objectBoxes.count; ++i) {
|
|
const box = objectBoxes.itemAt(i)
|
|
if (box.checked)
|
|
objects.push(box.value)
|
|
}
|
|
return {
|
|
objects: objects,
|
|
own_objects: ownObjects.text.split(",").map(s => s.trim()).filter(s => s !== ""),
|
|
controllers: straps.checked ? "straps" : "none",
|
|
sleeves: sleeves.currentValue,
|
|
rings: rings.checked,
|
|
watch: watch.checked,
|
|
notes: notes.text,
|
|
privacy: privacyOk
|
|
}
|
|
}
|
|
|
|
header: ColumnLayout {
|
|
spacing: 0
|
|
DraftBanner { Layout.fillWidth: true }
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: backend.runnerError !== ""
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Error
|
|
text: backend.runnerError
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: backend.sessionActive
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Information
|
|
text: "A session is running."
|
|
actions: [ Kirigami.Action { text: "Go to it"; onTriggered: root.show(sessionPage) } ]
|
|
}
|
|
CameraMessage {
|
|
Layout.fillWidth: true
|
|
visible: !backend.sessionActive && (backend.cameraText !== "" || backend.restartingSteamVR)
|
|
}
|
|
}
|
|
Component.onCompleted: backend.checkCameras()
|
|
|
|
Kirigami.FormLayout {
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Within reach" }
|
|
Controls.Label {
|
|
Kirigami.FormData.label: ""
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
wrapMode: Text.Wrap
|
|
text: "Put these on the desk or table in front of you. Untick what you don't have; "
|
|
+ "the session skips those."
|
|
}
|
|
Repeater {
|
|
id: objectBoxes
|
|
model: backend.objects
|
|
Controls.CheckBox {
|
|
required property var modelData
|
|
required property int index
|
|
readonly property string value: modelData.value
|
|
Kirigami.FormData.label: index === 0 ? "Objects:" : ""
|
|
text: modelData.text
|
|
checked: true
|
|
}
|
|
}
|
|
Controls.TextField {
|
|
id: ownObjects
|
|
Kirigami.FormData.label: "Your own:"
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 20
|
|
placeholderText: "other things you use, separated by commas"
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Controllers" }
|
|
ColumnLayout {
|
|
Kirigami.FormData.label: "Ergonomic Kit straps:"
|
|
Controls.RadioButton { id: straps; text: "Yes, I have the controllers with the straps" }
|
|
Controls.RadioButton { text: "No"; checked: true }
|
|
}
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "With the straps, the controllers stay on your hands while your fingers move freely. "
|
|
+ "The controllers' tracking then says exactly where your hands are, which teaches the "
|
|
+ "model how far away a hand is. Without them, the two controller sections are skipped."
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Light" }
|
|
Controls.Label {
|
|
Kirigami.FormData.label: "The cameras see:"
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
wrapMode: Text.Wrap
|
|
text: backend.lightingMeasured || "Not measured yet"
|
|
}
|
|
Controls.ComboBox {
|
|
id: lighting
|
|
Kirigami.FormData.label: "Lighting this round:"
|
|
model: backend.lightingChoices
|
|
textRole: "text"
|
|
valueRole: "value"
|
|
currentIndex: 0
|
|
onActivated: backend.checkLighting(currentValue)
|
|
Component.onCompleted: backend.checkLighting(currentValue)
|
|
}
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "Each round in a different light helps the most: dim, a normal room, daylight. The cameras "
|
|
+ "tell daylight from indoor light themselves; to say dim or a normal room, pick it here."
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
Layout.fillWidth: true
|
|
visible: backend.lightingNote !== ""
|
|
Layout.preferredHeight: visible ? implicitHeight : 0
|
|
type: Kirigami.MessageType.Warning
|
|
text: backend.lightingNote
|
|
}
|
|
Controls.Button {
|
|
visible: backend.lightingNote !== "" || backend.lightingMeasured === ""
|
|
text: "Measure the light again"
|
|
icon.name: "view-refresh"
|
|
onClicked: backend.checkLighting(lighting.currentValue)
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "You" }
|
|
Controls.ComboBox {
|
|
id: sleeves
|
|
Kirigami.FormData.label: "Sleeves:"
|
|
model: backend.sleeveChoices
|
|
textRole: "text"
|
|
valueRole: "value"
|
|
}
|
|
Controls.CheckBox { id: rings; Kirigami.FormData.label: "Wearing:"; text: "Rings" }
|
|
Controls.CheckBox { id: watch; text: "A watch or bracelet" }
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "Wear what you normally do: the dataset needs hands with and without them. This only notes it."
|
|
}
|
|
Controls.TextField {
|
|
id: notes
|
|
Kirigami.FormData.label: "Notes (optional):"
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 20
|
|
placeholderText: "anything that helps, e.g. a bandage on a finger"
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Privacy" }
|
|
Controls.CheckBox {
|
|
id: privacy1
|
|
Kirigami.FormData.label: "I've checked:"
|
|
text: "I'm facing away from other people, mirrors and windows"
|
|
}
|
|
Controls.CheckBox {
|
|
id: privacy2
|
|
text: "No papers, photos, screens or other private things are in view"
|
|
}
|
|
Controls.CheckBox {
|
|
id: privacy3
|
|
text: "Nobody else's face or hands will be in view"
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Space" }
|
|
Controls.Label {
|
|
Kirigami.FormData.label: "Free space:"
|
|
text: backend.freeText + " (a round takes about 10 GB)"
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
Layout.fillWidth: true
|
|
visible: !backend.diskOk
|
|
type: Kirigami.MessageType.Error
|
|
text: "Not enough free space for a round. Export and delete earlier sessions (Review), "
|
|
+ "or free up space, first."
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Cameras" }
|
|
RowLayout {
|
|
Kirigami.FormData.label: "Tracking cameras:"
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 20
|
|
wrapMode: Text.Wrap
|
|
text: backend.camerasIgnored ? "Not checked (--ignore-cameras)"
|
|
: backend.cameraState === "ok" ? "All four are running."
|
|
: backend.cameraState === "degraded" ? "Not all running: see the message at the top."
|
|
: backend.cameraState === "unknown" ? "Couldn't tell (" + backend.cameraSummary.replace(/^unknown: /, "")
|
|
+ "). The session checks again when it starts."
|
|
: "Checking…"
|
|
}
|
|
Controls.ToolButton {
|
|
visible: backend.cameraEvidence !== ""
|
|
icon.name: "documentinfo"
|
|
text: "Details"
|
|
checkable: true
|
|
id: cameraDetails
|
|
display: Controls.AbstractButton.IconOnly
|
|
Controls.ToolTip.text: "What the check looked at"
|
|
Controls.ToolTip.visible: hovered
|
|
}
|
|
Controls.ToolButton {
|
|
icon.name: "view-refresh"
|
|
text: "Check again"
|
|
display: Controls.AbstractButton.IconOnly
|
|
enabled: !backend.cameraBusy
|
|
onClicked: backend.checkCameras()
|
|
Controls.ToolTip.text: text
|
|
Controls.ToolTip.visible: hovered
|
|
}
|
|
}
|
|
Controls.Label {
|
|
visible: cameraDetails.checked && backend.cameraEvidence !== ""
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 34
|
|
wrapMode: Text.WrapAnywhere
|
|
font: Kirigami.Theme.smallFont
|
|
opacity: 0.7
|
|
textFormat: Text.PlainText
|
|
text: backend.cameraSummary + "\n" + backend.cameraEvidence
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "What will happen" }
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
wrapMode: Text.Wrap
|
|
text: "Press Start, then put the headset on. A panel in the headset shows each step: a picture "
|
|
+ "of the hand pose, where to hold your hands, and what to do. The sections: hand poses, "
|
|
+ "gestures, typing and the mouse, your objects, touching a dot, and moves with the "
|
|
+ "controllers on and off. Each section is recorded as one take. In the pose steps your "
|
|
+ "hands keep moving while a row of pictures lights up one shape after another: follow it.\n\n"
|
|
+ "Each step waits until you're ready: press the button on the right side of the headset, "
|
|
+ "or Space or Next in this window. A 3-2-1 "
|
|
+ "countdown follows, then hold the pose until the bar runs out. Nothing is recorded while "
|
|
+ "a step waits. The headset button also pauses and resumes a recording. In this window P "
|
|
+ "pauses, R records the last step again, S skips a section and Esc stops.\n\n"
|
|
+ "Nothing leaves the headset. Afterwards you watch the takes in Review, delete anything "
|
|
+ "you don't want to share, and only then export."
|
|
}
|
|
ColumnLayout {
|
|
Kirigami.FormData.label: "Round:"
|
|
Controls.RadioButton {
|
|
id: fullRound
|
|
text: "Full session"
|
|
checked: !backend.hasFullSession
|
|
}
|
|
Controls.RadioButton {
|
|
id: quickRound
|
|
text: "Quick round (about 3 min): hand size, poses, the dot, no hands"
|
|
checked: backend.hasFullSession
|
|
}
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
Layout.fillWidth: true
|
|
visible: backend.hasFullSession
|
|
Layout.preferredHeight: visible ? implicitHeight : 0
|
|
type: Kirigami.MessageType.Information
|
|
text: "You've recorded a full session already. A quick round in a different light (dim, "
|
|
+ "daylight) adds the most now."
|
|
}
|
|
Controls.CheckBox {
|
|
id: autoAdvance
|
|
Kirigami.FormData.label: "Pace:"
|
|
text: "Advance by itself (no Next between steps)"
|
|
}
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: (autoAdvance.checked
|
|
? "Each step shows for a few seconds and the next follows by itself. Quicker if you "
|
|
+ "know the steps already. "
|
|
: "") + "Length: " + backend.planText(checklist.answers(), autoAdvance.checked, quickRound.checked) + "."
|
|
}
|
|
Controls.Button {
|
|
text: "Start"
|
|
icon.name: "media-record"
|
|
enabled: checklist.ready && !backend.camerasBlockStart && !backend.restartingSteamVR
|
|
onClicked: {
|
|
if (backend.startSession(checklist.answers(), lighting.currentValue, autoAdvance.checked,
|
|
quickRound.checked))
|
|
root.show(sessionPage)
|
|
}
|
|
}
|
|
Controls.Label {
|
|
visible: (!checklist.ready || backend.camerasBlockStart) && !backend.sessionActive
|
|
opacity: 0.7
|
|
text: !checklist.privacyOk ? "Tick the three privacy checks to start."
|
|
: backend.camerasBlockStart ? "The headset's cameras aren't all running: see the message at the top." : ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Session
|
|
Component {
|
|
id: sessionPage
|
|
Kirigami.Page {
|
|
id: sessionView
|
|
title: "Session"
|
|
readonly property var st: backend.status
|
|
readonly property string state: st.state || ""
|
|
readonly property bool waiting: !!st.waiting
|
|
readonly property bool stepMode: st.mode !== "auto"
|
|
readonly property var stateText: ({
|
|
starting: "Starting…", intro: "Get ready", ready: "Get ready", countdown: "Starting",
|
|
running: "Recording", paused: "Paused", between: "Between sections", done: "Done",
|
|
stopped: "Stopped", error: "Error", nohands: "No hands seen"
|
|
})
|
|
|
|
// The keys while the window has focus (the panel in the headset lists them too).
|
|
Shortcut {
|
|
sequence: "Space"
|
|
enabled: backend.sessionActive
|
|
onActivated: backend.nextStep()
|
|
}
|
|
Shortcut {
|
|
sequence: "P"
|
|
enabled: backend.sessionActive
|
|
onActivated: backend.togglePause()
|
|
}
|
|
Shortcut {
|
|
sequence: "R"
|
|
enabled: backend.sessionActive
|
|
onActivated: backend.redo()
|
|
}
|
|
Shortcut {
|
|
sequence: "S"
|
|
enabled: backend.sessionActive
|
|
onActivated: backend.skipSection()
|
|
}
|
|
Shortcut {
|
|
sequence: "Esc"
|
|
enabled: backend.sessionActive
|
|
onActivated: backend.stopSession()
|
|
}
|
|
|
|
// A hand chip like the panel's: seen (green), lost (orange) or hidden.
|
|
component HandChip: Controls.Label {
|
|
property var seen
|
|
visible: seen === true || seen === false || seen === "seen" || seen === "lost"
|
|
readonly property bool ok: seen === true || seen === "seen"
|
|
padding: Kirigami.Units.smallSpacing
|
|
leftPadding: Kirigami.Units.largeSpacing
|
|
rightPadding: Kirigami.Units.largeSpacing
|
|
color: "white"
|
|
background: Rectangle {
|
|
radius: height / 2
|
|
color: parent.ok ? Kirigami.Theme.positiveTextColor : Kirigami.Theme.neutralTextColor
|
|
}
|
|
}
|
|
|
|
// The pose picture as the panel shows it: a right hand as drawn, a left hand flipped,
|
|
// both hands as a flipped copy beside it (DESIGN.md "The pose pictures").
|
|
component PosePicture: Row {
|
|
property string path
|
|
property string mode
|
|
property real side: Kirigami.Units.gridUnit * 9
|
|
spacing: Kirigami.Units.smallSpacing
|
|
visible: path !== ""
|
|
Image {
|
|
visible: parent.mode === "both"
|
|
width: visible ? parent.side * 0.75 : 0
|
|
height: width
|
|
source: parent.path ? "file://" + parent.path : ""
|
|
fillMode: Image.PreserveAspectFit
|
|
mirror: true
|
|
smooth: true
|
|
mipmap: true
|
|
}
|
|
Image {
|
|
width: parent.mode === "both" ? parent.side * 0.75 : parent.side
|
|
height: width
|
|
source: parent.path ? "file://" + parent.path : ""
|
|
fillMode: Image.PreserveAspectFit
|
|
mirror: parent.mode === "mirror"
|
|
smooth: true
|
|
mipmap: true
|
|
}
|
|
}
|
|
|
|
// Where to hold the hands: a front view (left, centre, right, up, down; the push
|
|
// sections' chest, desk and eye) and how far out (near, mid, far), as on the panel.
|
|
component WhereDiagram: RowLayout {
|
|
id: where
|
|
property string position
|
|
property string distance
|
|
readonly property var cell: ({ centre: [1, 1], center: [1, 1], chest: [1, 1], left: [0, 1],
|
|
right: [2, 1], up: [1, 0], eye: [1, 0], down: [1, 2],
|
|
desk: [1, 2] })[position] || null
|
|
readonly property int step: ["near", "mid", "far"].indexOf(distance)
|
|
readonly property real unit: Kirigami.Units.gridUnit * 1.1
|
|
readonly property color accent: "#4cd9ff"
|
|
spacing: Kirigami.Units.gridUnit
|
|
visible: cell !== null || step >= 0
|
|
ColumnLayout {
|
|
visible: where.cell !== null
|
|
Grid {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
columns: 3
|
|
spacing: 2
|
|
Repeater {
|
|
model: 9
|
|
Rectangle {
|
|
required property int index
|
|
width: where.unit * 1.33
|
|
height: where.unit
|
|
radius: 3
|
|
readonly property bool on: where.cell !== null && index === where.cell[1] * 3 + where.cell[0]
|
|
color: on ? where.accent
|
|
: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
|
|
Kirigami.Theme.textColor.b, 0.12)
|
|
}
|
|
}
|
|
}
|
|
Controls.Label {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
font: Kirigami.Theme.smallFont
|
|
text: ({ left: "To your left", right: "To your right", up: "Up high", down: "Down low",
|
|
chest: "Chest height", desk: "Desk height", eye: "Eye level" })[where.position]
|
|
|| "In front"
|
|
}
|
|
}
|
|
ColumnLayout {
|
|
visible: where.step >= 0
|
|
Row {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
spacing: where.unit * 0.6
|
|
height: where.unit * 1.2
|
|
// the head, seen from the side, then the arm's reach
|
|
Rectangle {
|
|
width: where.unit
|
|
height: width
|
|
radius: width / 2
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
|
|
Kirigami.Theme.textColor.b, 0.6)
|
|
}
|
|
Repeater {
|
|
model: 3
|
|
Rectangle {
|
|
required property int index
|
|
readonly property bool on: index === where.step
|
|
width: on ? where.unit * 0.8 : where.unit * 0.35
|
|
height: width
|
|
radius: width / 2
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: on ? where.accent : Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
|
|
Kirigami.Theme.textColor.b, 0.35)
|
|
}
|
|
}
|
|
}
|
|
Controls.Label {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
font: Kirigami.Theme.smallFont
|
|
text: ["Close: a hand's length", "Halfway out", "Arm stretched out"][Math.max(0, where.step)]
|
|
}
|
|
}
|
|
}
|
|
|
|
header: ColumnLayout {
|
|
spacing: 0
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: backend.runnerError !== "" || (sessionView.state === "error" && sessionView.st.error !== backend.cameraText)
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Error
|
|
text: backend.runnerError || ("The session stopped with an error: " + (sessionView.st.error || "unknown"))
|
|
}
|
|
// The camera check stopped the session (at its start, or after a step with no hands).
|
|
CameraMessage {
|
|
Layout.fillWidth: true
|
|
visible: (!backend.sessionActive && sessionView.state !== "" && backend.cameraText !== "")
|
|
|| backend.restartingSteamVR
|
|
}
|
|
}
|
|
|
|
Kirigami.PlaceholderMessage {
|
|
anchors.centerIn: parent
|
|
width: parent.width - Kirigami.Units.gridUnit * 4
|
|
visible: sessionView.state === ""
|
|
icon.name: "media-record"
|
|
text: "No session yet"
|
|
explanation: "Go through Before you start, then press Start there."
|
|
helpfulAction: Kirigami.Action {
|
|
text: "Before you start"; icon.name: "view-task"
|
|
onTriggered: root.show(checklistPage)
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
anchors.fill: parent
|
|
anchors.margins: Kirigami.Units.gridUnit
|
|
visible: sessionView.state !== ""
|
|
spacing: Kirigami.Units.largeSpacing
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Kirigami.Heading {
|
|
level: 1
|
|
text: sessionView.stateText[sessionView.state] || sessionView.state
|
|
}
|
|
Item { Layout.fillWidth: true }
|
|
Controls.Label {
|
|
visible: !!sessionView.st.take && sessionView.state !== "paused"
|
|
&& (sessionView.state === "countdown" || sessionView.state === "running"
|
|
|| !sessionView.stepMode)
|
|
text: "● Recording"
|
|
color: Kirigami.Theme.negativeTextColor
|
|
}
|
|
}
|
|
Controls.Label {
|
|
visible: (sessionView.st.section_index || 0) > 0
|
|
text: "Section " + sessionView.st.section_index + " of " + sessionView.st.section_count
|
|
+ (sessionView.st.title ? ": " + sessionView.st.title : "")
|
|
+ ((sessionView.st.step_index || 0) > 0 && sessionView.stepMode
|
|
? " · step " + sessionView.st.step_index + " of " + sessionView.st.step_count : "")
|
|
font.bold: true
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: Kirigami.Units.gridUnit * 1.5
|
|
|
|
ColumnLayout {
|
|
visible: !!sessionView.st.image || !!sessionView.st.position || !!sessionView.st.distance
|
|
spacing: Kirigami.Units.largeSpacing
|
|
PosePicture {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
path: sessionView.st.image || ""
|
|
mode: sessionView.st.image_mode || ""
|
|
}
|
|
Controls.Label {
|
|
Layout.alignment: Qt.AlignHCenter
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 14
|
|
visible: !!sessionView.st.image && !!sessionView.st.caption
|
|
wrapMode: Text.Wrap
|
|
horizontalAlignment: Text.AlignHCenter
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
text: sessionView.st.caption || ""
|
|
}
|
|
WhereDiagram {
|
|
id: where
|
|
Layout.alignment: Qt.AlignHCenter
|
|
position: sessionView.st.position || ""
|
|
distance: sessionView.st.distance || ""
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
spacing: Kirigami.Units.largeSpacing
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
visible: !!sessionView.st.prompt
|
|
wrapMode: Text.Wrap
|
|
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 1.4
|
|
text: sessionView.st.prompt || ""
|
|
}
|
|
// A sweep's row of pictures, the lit one framed, as on the panel
|
|
Row {
|
|
visible: (sessionView.st.strip || []).length > 0
|
|
spacing: Kirigami.Units.largeSpacing
|
|
Repeater {
|
|
model: sessionView.st.strip || []
|
|
Column {
|
|
required property var modelData
|
|
required property int index
|
|
readonly property bool lit: index === sessionView.st.cue
|
|
spacing: Kirigami.Units.smallSpacing
|
|
Rectangle {
|
|
width: Kirigami.Units.gridUnit * 5.5
|
|
height: width
|
|
radius: Kirigami.Units.smallSpacing * 2
|
|
color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g,
|
|
Kirigami.Theme.textColor.b, 0.06)
|
|
border.width: parent.lit ? 3 : 0
|
|
border.color: "#4cd9ff"
|
|
Image {
|
|
anchors.fill: parent
|
|
anchors.margins: 4
|
|
source: modelData.image ? "file://" + modelData.image : ""
|
|
fillMode: Image.PreserveAspectFit
|
|
mirror: modelData.mode === "mirror"
|
|
opacity: parent.parent.lit ? 1 : 0.4
|
|
smooth: true
|
|
mipmap: true
|
|
}
|
|
}
|
|
Controls.Label {
|
|
anchors.horizontalCenter: parent.horizontalCenter
|
|
text: modelData.label || ""
|
|
color: parent.lit ? "#4cd9ff" : Kirigami.Theme.textColor
|
|
opacity: parent.lit ? 1 : 0.6
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// The countdown's 3, 2, 1, then the hold's word, big
|
|
Controls.Label {
|
|
visible: !!sessionView.st.big && sessionView.state !== "paused"
|
|
text: sessionView.st.big || ""
|
|
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 4
|
|
font.bold: true
|
|
color: Kirigami.Theme.highlightColor
|
|
}
|
|
Controls.Label {
|
|
visible: !sessionView.waiting && sessionView.state !== "countdown"
|
|
&& sessionView.state !== "ready" && sessionView.st.seconds_left !== undefined
|
|
&& sessionView.st.seconds_left !== null && sessionView.st.seconds_left > 0
|
|
text: Math.ceil(sessionView.st.seconds_left || 0) + " s left"
|
|
opacity: 0.7
|
|
}
|
|
Controls.Button {
|
|
visible: sessionView.waiting && sessionView.state !== "paused"
|
|
focusPolicy: Qt.NoFocus
|
|
text: sessionView.state === "nohands" ? "Try again" : "Next"
|
|
icon.name: sessionView.state === "nohands" ? "edit-undo" : "go-next"
|
|
font.pointSize: Kirigami.Theme.defaultFont.pointSize * 1.5
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 10
|
|
Layout.preferredHeight: Kirigami.Units.gridUnit * 3
|
|
onClicked: backend.nextStep()
|
|
}
|
|
Controls.Label {
|
|
visible: sessionView.waiting && sessionView.state !== "paused"
|
|
opacity: 0.7
|
|
text: sessionView.state === "nohands"
|
|
? "Try again starts the same step (Space or the headset button do too). Stop ends the session, "
|
|
+ "keeping what's recorded."
|
|
: (sessionView.st.ready_text || "Ready? Press Space or click Next")
|
|
+ ". A 3-2-1 countdown starts the recording."
|
|
wrapMode: Text.Wrap
|
|
Layout.fillWidth: true
|
|
}
|
|
}
|
|
}
|
|
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: !!sessionView.st.note
|
|
// (A hidden InlineMessage keeps its height in a layout.)
|
|
Layout.preferredHeight: visible ? implicitHeight : 0
|
|
type: Kirigami.MessageType.Warning
|
|
text: sessionView.st.note || ""
|
|
}
|
|
RowLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
HandChip { text: "Left hand"; seen: sessionView.st.hands ? sessionView.st.hands.left : undefined }
|
|
HandChip { text: "Right hand"; seen: sessionView.st.hands ? sessionView.st.hands.right : undefined }
|
|
}
|
|
Controls.Label {
|
|
visible: !!sessionView.st.take
|
|
text: "Take " + (sessionView.st.take || "")
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
}
|
|
|
|
Item { Layout.fillHeight: true }
|
|
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "The instructions appear in the headset. "
|
|
+ (sessionView.st.button ? "The button on the right side of the headset: "
|
|
+ (sessionView.stepMode ? "next, " : "") + "pause or resume. " : "")
|
|
+ "While this window has focus: "
|
|
+ (sessionView.stepMode ? "Space: next · " : "")
|
|
+ "P: pause or resume · R: record the last step again · S: skip section · Esc: stop. "
|
|
+ "Stopping keeps what's recorded so far."
|
|
}
|
|
RowLayout {
|
|
// No keyboard focus on the buttons, so Space always reaches the shortcut.
|
|
Controls.Button {
|
|
visible: !backend.sessionActive
|
|
focusPolicy: Qt.NoFocus
|
|
text: "New session…"
|
|
icon.name: "media-record"
|
|
onClicked: root.show(checklistPage)
|
|
}
|
|
Controls.Button {
|
|
visible: backend.sessionActive
|
|
focusPolicy: Qt.NoFocus
|
|
text: sessionView.state === "paused" ? "Resume" : "Pause"
|
|
icon.name: sessionView.state === "paused" ? "media-playback-start" : "media-playback-pause"
|
|
onClicked: backend.togglePause()
|
|
}
|
|
Controls.Button {
|
|
visible: backend.sessionActive
|
|
enabled: !!sessionView.st.can_redo
|
|
focusPolicy: Qt.NoFocus
|
|
text: "Redo step"
|
|
icon.name: "edit-undo"
|
|
onClicked: backend.redo()
|
|
}
|
|
Controls.Button {
|
|
visible: backend.sessionActive
|
|
focusPolicy: Qt.NoFocus
|
|
text: "Skip section"
|
|
icon.name: "media-skip-forward"
|
|
onClicked: backend.skipSection()
|
|
}
|
|
Controls.Button {
|
|
visible: backend.sessionActive
|
|
focusPolicy: Qt.NoFocus
|
|
text: "Stop"
|
|
icon.name: "media-playback-stop"
|
|
onClicked: backend.stopSession()
|
|
}
|
|
Controls.Button {
|
|
visible: !backend.sessionActive && backend.sessionId !== ""
|
|
focusPolicy: Qt.NoFocus
|
|
text: "Review this session"
|
|
icon.name: "view-preview"
|
|
onClicked: {
|
|
root.show(reviewPage)
|
|
pageStack.push(takesPage, { session: backend.sessionId })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Review
|
|
Component {
|
|
id: reviewPage
|
|
Kirigami.ScrollablePage {
|
|
title: "Review"
|
|
actions: [
|
|
Kirigami.Action { text: "Refresh"; icon.name: "view-refresh"; onTriggered: backend.refreshSessions() }
|
|
]
|
|
|
|
ListView {
|
|
model: backend.sessions
|
|
spacing: Kirigami.Units.smallSpacing
|
|
|
|
Kirigami.PlaceholderMessage {
|
|
anchors.centerIn: parent
|
|
width: parent.width - Kirigami.Units.gridUnit * 4
|
|
visible: parent.count === 0
|
|
text: "No sessions yet"
|
|
explanation: "Recorded sessions appear here, to watch and trim before you export them."
|
|
}
|
|
|
|
delegate: Controls.ItemDelegate {
|
|
id: sessionRow
|
|
required property var modelData
|
|
width: ListView.view.width
|
|
onClicked: pageStack.push(takesPage, { session: modelData.id })
|
|
|
|
contentItem: RowLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
Kirigami.Icon {
|
|
source: sessionRow.modelData.active ? "media-record" : "folder-videos"
|
|
implicitWidth: Kirigami.Units.iconSizes.medium
|
|
implicitHeight: implicitWidth
|
|
}
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 0
|
|
Controls.Label {
|
|
text: sessionRow.modelData.label + (sessionRow.modelData.active ? " (recording)" : "")
|
|
+ (sessionRow.modelData.dry_run ? " (dry run)" : "")
|
|
font.bold: true
|
|
}
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
elide: Text.ElideRight
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
text: root.plural(sessionRow.modelData.takes, "take") + " · " + sessionRow.modelData.sizeText
|
|
+ (sessionRow.modelData.statusText ? " · " + sessionRow.modelData.statusText : "")
|
|
+ (sessionRow.modelData.lightingText ? " · " + sessionRow.modelData.lightingText : "")
|
|
+ (sessionRow.modelData.export_stale ? " · changed since its export"
|
|
: sessionRow.modelData.exported ? " · exported (" + sessionRow.modelData.exportText + ")" : "")
|
|
}
|
|
}
|
|
Controls.ToolButton {
|
|
icon.name: "edit-delete"
|
|
text: "Delete session"
|
|
display: Controls.AbstractButton.IconOnly
|
|
enabled: !sessionRow.modelData.active
|
|
Controls.ToolTip.text: text
|
|
Controls.ToolTip.visible: hovered
|
|
onClicked: {
|
|
const sid = sessionRow.modelData.id
|
|
confirm.ask("Delete this session?",
|
|
"The session of " + sessionRow.modelData.label + " (" + root.plural(sessionRow.modelData.takes, "take")
|
|
+ ", " + sessionRow.modelData.sizeText + ") and its export are deleted from the "
|
|
+ "headset for good.",
|
|
() => { pageStack.pop(pageStack.items[0]); backend.deleteSession(sid) })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: takesPage
|
|
Kirigami.ScrollablePage {
|
|
id: takesView
|
|
property string session: ""
|
|
property var rows: backend.takeList(session)
|
|
title: "Takes"
|
|
|
|
Connections {
|
|
target: backend
|
|
function onSessionsChanged() { takesView.rows = backend.takeList(takesView.session) }
|
|
}
|
|
|
|
ListView {
|
|
model: takesView.rows
|
|
spacing: Kirigami.Units.smallSpacing
|
|
|
|
Kirigami.PlaceholderMessage {
|
|
anchors.centerIn: parent
|
|
width: parent.width - Kirigami.Units.gridUnit * 4
|
|
visible: parent.count === 0
|
|
text: "No takes in this session"
|
|
}
|
|
|
|
delegate: Controls.ItemDelegate {
|
|
id: takeRow
|
|
required property var modelData
|
|
width: ListView.view.width
|
|
onClicked: pageStack.push(viewerPage, { session: takesView.session, take: modelData.id })
|
|
|
|
contentItem: RowLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
Image {
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 5
|
|
Layout.preferredHeight: Kirigami.Units.gridUnit * 4.5
|
|
fillMode: Image.PreserveAspectFit
|
|
asynchronous: true
|
|
source: takeRow.modelData.sets > 0
|
|
? "image://frames/thumb/" + takesView.session + "/" + takeRow.modelData.id : ""
|
|
}
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 0
|
|
Controls.Label { text: takeRow.modelData.title; font.bold: true }
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
elide: Text.ElideRight
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
text: takeRow.modelData.status + " · " + takeRow.modelData.durationText + " · "
|
|
+ takeRow.modelData.sets + " sets"
|
|
+ (takeRow.modelData.deleted_sets ? " (" + takeRow.modelData.deleted_sets + " deleted)" : "")
|
|
+ " · " + takeRow.modelData.sizeText
|
|
}
|
|
}
|
|
Controls.ToolButton {
|
|
icon.name: "edit-delete"
|
|
text: "Delete take"
|
|
display: Controls.AbstractButton.IconOnly
|
|
Controls.ToolTip.text: text
|
|
Controls.ToolTip.visible: hovered
|
|
onClicked: {
|
|
const take = takeRow.modelData.id
|
|
confirm.ask("Delete this take?",
|
|
"\"" + takeRow.modelData.title + "\" (" + takeRow.modelData.durationText
|
|
+ ") is deleted from the headset for good.",
|
|
() => { pageStack.pop(takesView); backend.deleteTake(takesView.session, take) })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: viewerPage
|
|
Kirigami.Page {
|
|
id: viewer
|
|
property string session: ""
|
|
property string take: ""
|
|
property var info: backend.takeInfo(session, take)
|
|
property int index: 0
|
|
property int markStart: -1
|
|
property int markEnd: -1
|
|
title: info.title
|
|
readonly property bool deletedHere: isDeleted(index)
|
|
|
|
function step(n) {
|
|
index = Math.max(0, Math.min(info.count - 1, index + n))
|
|
}
|
|
function isDeleted(i) {
|
|
for (const r of info.ranges)
|
|
if (i >= r[0] && i <= r[1] && r[0] >= 0)
|
|
return true
|
|
return false
|
|
}
|
|
function deleteMarked() {
|
|
backend.deleteRange(session, take, markStart, markEnd)
|
|
markStart = markEnd = -1
|
|
info = backend.takeInfo(session, take)
|
|
}
|
|
|
|
Connections {
|
|
target: backend
|
|
function onSessionsChanged() { viewer.info = backend.takeInfo(viewer.session, viewer.take) }
|
|
}
|
|
|
|
// Keyboard stepping: arrows one set, Page Up/Down ten, Home/End, [ and ] mark a range,
|
|
// Delete deletes it.
|
|
Item {
|
|
id: keys
|
|
focus: true
|
|
Component.onCompleted: forceActiveFocus()
|
|
Keys.onPressed: event => {
|
|
const k = event.key
|
|
if (k === Qt.Key_Left) viewer.step(-1)
|
|
else if (k === Qt.Key_Right) viewer.step(1)
|
|
else if (k === Qt.Key_PageUp) viewer.step(-10)
|
|
else if (k === Qt.Key_PageDown) viewer.step(10)
|
|
else if (k === Qt.Key_Home) viewer.index = 0
|
|
else if (k === Qt.Key_End) viewer.index = Math.max(0, viewer.info.count - 1)
|
|
else if (k === Qt.Key_BracketLeft) viewer.markStart = viewer.index
|
|
else if (k === Qt.Key_BracketRight) viewer.markEnd = viewer.index
|
|
else if (k === Qt.Key_Delete && viewer.markStart >= 0 && viewer.markEnd >= 0) viewer.deleteMarked()
|
|
else return
|
|
event.accepted = true
|
|
}
|
|
}
|
|
|
|
Kirigami.PlaceholderMessage {
|
|
anchors.centerIn: parent
|
|
visible: viewer.info.count === 0
|
|
text: "This take has no frame sets"
|
|
}
|
|
|
|
ColumnLayout {
|
|
anchors.fill: parent
|
|
visible: viewer.info.count > 0
|
|
spacing: Kirigami.Units.smallSpacing
|
|
|
|
Image {
|
|
id: frame
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
fillMode: Image.PreserveAspectFit
|
|
asynchronous: true
|
|
cache: false
|
|
retainWhileLoading: true
|
|
source: viewer.info.count > 0
|
|
? "image://frames/set/" + viewer.session + "/" + viewer.take + "/" + viewer.index : ""
|
|
MouseArea { anchors.fill: parent; onClicked: keys.forceActiveFocus() }
|
|
|
|
Rectangle {
|
|
visible: viewer.deletedHere
|
|
x: (frame.width - frame.paintedWidth) / 2
|
|
y: (frame.height - frame.paintedHeight) / 2
|
|
width: frame.paintedWidth
|
|
height: frame.paintedHeight
|
|
color: Qt.rgba(0.8, 0, 0, 0.35)
|
|
Controls.Label {
|
|
anchors.centerIn: parent
|
|
text: "Deleted: not exported"
|
|
color: "white"
|
|
font.bold: true
|
|
}
|
|
}
|
|
}
|
|
|
|
// The slider, with the deleted ranges (red) and the marked range (blue) under it.
|
|
Item {
|
|
Layout.fillWidth: true
|
|
implicitHeight: slider.implicitHeight
|
|
Repeater {
|
|
model: viewer.info.ranges
|
|
Rectangle {
|
|
required property var modelData
|
|
visible: modelData[0] >= 0
|
|
readonly property real unit: (slider.availableWidth) / Math.max(1, viewer.info.count - 1)
|
|
x: slider.leftPadding + modelData[0] * unit - 2
|
|
width: Math.max(4, (modelData[1] - modelData[0]) * unit + 4)
|
|
y: slider.topPadding + slider.availableHeight / 2 + 4
|
|
height: 4
|
|
color: Kirigami.Theme.negativeTextColor
|
|
}
|
|
}
|
|
Rectangle {
|
|
visible: viewer.markStart >= 0
|
|
readonly property int last: viewer.markEnd >= 0 ? viewer.markEnd : viewer.index
|
|
readonly property real unit: (slider.availableWidth) / Math.max(1, viewer.info.count - 1)
|
|
x: slider.leftPadding + Math.min(viewer.markStart, last) * unit - 2
|
|
width: Math.abs(last - viewer.markStart) * unit + 4
|
|
y: slider.topPadding + slider.availableHeight / 2 - 8
|
|
height: 4
|
|
color: Kirigami.Theme.highlightColor
|
|
}
|
|
Controls.Slider {
|
|
id: slider
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
focusPolicy: Qt.NoFocus
|
|
from: 0
|
|
to: Math.max(0, viewer.info.count - 1)
|
|
stepSize: 1
|
|
snapMode: Controls.Slider.SnapAlways
|
|
value: viewer.index
|
|
onMoved: viewer.index = Math.round(value)
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Controls.Label {
|
|
text: "Set " + (viewer.index + 1) + " of " + viewer.info.count + " · "
|
|
+ backend.setTime(viewer.session, viewer.take, viewer.index)
|
|
}
|
|
Item { Layout.fillWidth: true }
|
|
Controls.Button {
|
|
focusPolicy: Qt.NoFocus
|
|
text: viewer.markStart >= 0 ? "Start: " + (viewer.markStart + 1) : "Mark start"
|
|
icon.name: "go-first"
|
|
onClicked: viewer.markStart = viewer.index
|
|
}
|
|
Controls.Button {
|
|
focusPolicy: Qt.NoFocus
|
|
text: viewer.markEnd >= 0 ? "End: " + (viewer.markEnd + 1) : "Mark end"
|
|
icon.name: "go-last"
|
|
onClicked: viewer.markEnd = viewer.index
|
|
}
|
|
Controls.Button {
|
|
focusPolicy: Qt.NoFocus
|
|
enabled: viewer.markStart >= 0 && viewer.markEnd >= 0
|
|
text: "Delete range"
|
|
icon.name: "edit-cut"
|
|
onClicked: viewer.deleteMarked()
|
|
}
|
|
Controls.Button {
|
|
focusPolicy: Qt.NoFocus
|
|
visible: viewer.markStart >= 0 || viewer.markEnd >= 0
|
|
text: "Clear marks"
|
|
icon.name: "edit-clear"
|
|
onClicked: viewer.markStart = viewer.markEnd = -1
|
|
}
|
|
}
|
|
|
|
Flow {
|
|
Layout.fillWidth: true
|
|
spacing: Kirigami.Units.smallSpacing
|
|
visible: viewer.info.ranges.length > 0
|
|
Controls.Label { text: "Deleted:"; topPadding: Kirigami.Units.smallSpacing }
|
|
Repeater {
|
|
model: viewer.info.ranges
|
|
Controls.Button {
|
|
required property var modelData
|
|
required property int index
|
|
focusPolicy: Qt.NoFocus
|
|
flat: true
|
|
text: (modelData[0] < 0 ? "a range with no sets"
|
|
: "sets " + (modelData[0] + 1) + " to " + (modelData[1] + 1)) + ": restore"
|
|
icon.name: "edit-undo"
|
|
onClicked: backend.restoreRange(viewer.session, viewer.take, index)
|
|
}
|
|
}
|
|
}
|
|
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
text: "← → step one set, Page Up/Down ten, Home/End. [ and ] mark a range's start and end, "
|
|
+ "Delete deletes it. Deleted sets stay on the headset until export, which leaves them out."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Export
|
|
Component {
|
|
id: exportPage
|
|
Kirigami.ScrollablePage {
|
|
id: exportView
|
|
title: "Export"
|
|
property bool worn: backend.headsetWorn()
|
|
readonly property var chosen: {
|
|
const list = backend.sessions
|
|
for (const s of list)
|
|
if (s.id === sessionBox.currentValue)
|
|
return s
|
|
return null
|
|
}
|
|
|
|
Timer {
|
|
interval: 5000
|
|
running: true
|
|
repeat: true
|
|
onTriggered: exportView.worn = backend.headsetWorn()
|
|
}
|
|
|
|
header: ColumnLayout {
|
|
spacing: 0
|
|
DraftBanner { Layout.fillWidth: true }
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: exportView.worn
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Warning
|
|
text: "Someone seems to be wearing the headset. Exporting keeps the processor busy for a "
|
|
+ "while, which can make VR stutter: export when the headset is off, or go ahead anyway."
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: !backend.zstdFound
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Error
|
|
text: "zstd isn't installed in the dev container, so nothing can be exported. Run setup/dev-container.sh."
|
|
}
|
|
}
|
|
|
|
Kirigami.FormLayout {
|
|
Controls.ComboBox {
|
|
id: sessionBox
|
|
Kirigami.FormData.label: "Session:"
|
|
model: backend.sessions
|
|
textRole: "label"
|
|
valueRole: "id"
|
|
enabled: !backend.exporting
|
|
Component.onCompleted: {
|
|
const i = indexOfValue(root.chosenSession)
|
|
currentIndex = i >= 0 ? i : (count > 0 ? 0 : -1)
|
|
}
|
|
onActivated: root.chosenSession = currentValue
|
|
}
|
|
Controls.Label {
|
|
Kirigami.FormData.label: "Recorded:"
|
|
visible: exportView.chosen !== null
|
|
text: exportView.chosen ? root.plural(exportView.chosen.takes, "take") + ", " + exportView.chosen.sizeText : ""
|
|
}
|
|
Controls.Label {
|
|
Kirigami.FormData.label: "Export:"
|
|
visible: exportView.chosen !== null
|
|
text: !exportView.chosen ? ""
|
|
: exportView.chosen.export_stale ? "changed since it was exported: export it again"
|
|
: exportView.chosen.exported ? "ready, " + exportView.chosen.exportText : "not exported yet"
|
|
}
|
|
RowLayout {
|
|
Controls.Button {
|
|
text: exportView.chosen && exportView.chosen.exported ? "Export again" : "Export"
|
|
icon.name: "document-export"
|
|
enabled: exportView.chosen !== null && !backend.exporting && backend.zstdFound
|
|
&& !exportView.chosen.active
|
|
onClicked: backend.exportSession(exportView.chosen.id, false)
|
|
}
|
|
Controls.Button {
|
|
visible: backend.exporting
|
|
text: "Cancel"
|
|
icon.name: "dialog-cancel"
|
|
onClicked: backend.cancelExport()
|
|
}
|
|
}
|
|
// Drawn here: the desktop style's ProgressBar paints nothing without a QApplication.
|
|
Rectangle {
|
|
Kirigami.FormData.label: "Progress:"
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 20
|
|
implicitHeight: Kirigami.Units.smallSpacing * 2
|
|
visible: backend.exporting
|
|
radius: height / 2
|
|
color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.15)
|
|
Rectangle {
|
|
width: parent.width * Math.max(0, Math.min(1, backend.exportFraction))
|
|
height: parent.height
|
|
radius: parent.radius
|
|
color: Kirigami.Theme.highlightColor
|
|
}
|
|
}
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
|
|
visible: backend.exportText !== "" && backend.exportSessionId === sessionBox.currentValue
|
|
wrapMode: Text.WrapAnywhere
|
|
text: backend.exportText
|
|
}
|
|
RowLayout {
|
|
visible: exportView.chosen !== null && exportView.chosen.exported && !backend.exporting
|
|
Controls.Button {
|
|
text: "Upload"
|
|
icon.name: "go-next"
|
|
onClicked: root.show(uploadPage)
|
|
}
|
|
Controls.Button {
|
|
text: "Delete export"
|
|
icon.name: "edit-delete"
|
|
onClicked: {
|
|
const sid = exportView.chosen.id
|
|
confirm.ask("Delete this export?", "The export is deleted; the session's recordings stay.",
|
|
() => backend.deleteExport(sid))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
footer: Controls.Label {
|
|
padding: Kirigami.Units.largeSpacing
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "Export leaves out the ranges you deleted, compresses the rest and adds a manifest and "
|
|
+ "checksums, in " + backend.exportsDir + ". It runs at the lowest priority "
|
|
+ "and takes a few minutes per round. Nothing is uploaded until you press Upload on the Upload page."
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Upload
|
|
Component {
|
|
id: uploadPage
|
|
Kirigami.ScrollablePage {
|
|
id: uploadView
|
|
title: "Upload"
|
|
readonly property var exported: backend.sessions.filter(s => s.exported)
|
|
readonly property string session: exportBox.currentIndex >= 0 ? exportBox.currentValue || "" : ""
|
|
readonly property var chosen: exportBox.currentIndex >= 0 && exportBox.currentIndex < exported.length
|
|
? exported[exportBox.currentIndex] : null
|
|
readonly property var up: backend.upload
|
|
readonly property var upError: up.error || ({})
|
|
readonly property var upResult: up.result || ({})
|
|
// The upload shown is this export's (the window keeps the last one's result).
|
|
readonly property bool mine: up.session === session
|
|
property var info: ({ previous: {}, uploads: 0 })
|
|
readonly property bool loggedIn: backend.login.state === "ok"
|
|
// Why Upload is off, or "" when it can go.
|
|
readonly property string blocked: {
|
|
if (session === "") return "Choose an export"
|
|
if (chosen && chosen.export_stale) return "Export the session again first"
|
|
if (backend.exporting) return "Wait for the export to finish"
|
|
if (backend.uploading) return ""
|
|
if (backend.hubDryRun) return ""
|
|
if (!backend.uploadAllowed) return "Contributions aren't open yet"
|
|
if (!loggedIn) return "Log in to Hugging Face first (below)"
|
|
return ""
|
|
}
|
|
|
|
function refreshInfo() { info = backend.uploadInfo(session) }
|
|
onSessionChanged: refreshInfo()
|
|
Connections {
|
|
target: backend
|
|
function onSessionsChanged() { uploadView.refreshInfo() }
|
|
}
|
|
Component.onCompleted: {
|
|
refreshInfo()
|
|
if (backend.login.state === "unknown") backend.checkLogin()
|
|
}
|
|
|
|
function startUpload() {
|
|
if (info.previous && info.previous.export_sha)
|
|
confirm.ask("Upload this export again?",
|
|
"It was uploaded on " + info.previous.uploaded + " (" + (info.previous.pr_url || "no link")
|
|
+ "). Uploading it again opens a second pull request.",
|
|
() => backend.startUpload(uploadView.session, true))
|
|
else
|
|
backend.startUpload(session, false)
|
|
}
|
|
|
|
header: ColumnLayout {
|
|
spacing: 0
|
|
DraftBanner { Layout.fillWidth: true }
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: backend.hubDryRun
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Information
|
|
text: "Dry run (--hub-dry-run): Upload checks the export and lists what it would send. "
|
|
+ "Nothing goes over the network."
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: backend.textsDraft && backend.allowUploadSet && !backend.hubDryRun
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: Kirigami.MessageType.Warning
|
|
text: "Rehearsal: FT_HANDREC_ALLOW_UPLOAD=1 allows uploads to " + backend.dataset
|
|
+ " while the texts are drafts."
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
|
|
Kirigami.PlaceholderMessage {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.exported.length === 0
|
|
icon.name: "document-export"
|
|
text: "Nothing exported yet"
|
|
explanation: "Review a session, then export it. It can be uploaded from here."
|
|
helpfulAction: Kirigami.Action {
|
|
text: "Export"; icon.name: "document-export"
|
|
onTriggered: root.show(exportPage)
|
|
}
|
|
}
|
|
|
|
Kirigami.FormLayout {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.exported.length > 0
|
|
Controls.ComboBox {
|
|
id: exportBox
|
|
Kirigami.FormData.label: "Export:"
|
|
model: uploadView.exported
|
|
textRole: "label"
|
|
valueRole: "id"
|
|
enabled: !backend.uploading
|
|
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(root.chosenSession))
|
|
onActivated: root.chosenSession = currentValue
|
|
}
|
|
Controls.Label {
|
|
Kirigami.FormData.label: "Size:"
|
|
text: uploadView.chosen ? uploadView.chosen.exportText : ""
|
|
}
|
|
Controls.Label {
|
|
Kirigami.FormData.label: "Goes to:"
|
|
textFormat: Text.StyledText
|
|
text: "<a href=\"" + backend.datasetUrl + "\">" + backend.dataset + "</a>, as a pull request"
|
|
onLinkActivated: link => Qt.openUrlExternally(link)
|
|
}
|
|
RowLayout {
|
|
Kirigami.FormData.label: "Hugging Face:"
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 22
|
|
wrapMode: Text.Wrap
|
|
text: backend.login.state === "dry" ? "not checked in a dry run"
|
|
: backend.login.state === "unknown" ? "not checked yet"
|
|
: backend.login.state === "none" ? "not logged in"
|
|
: backend.login.text || ""
|
|
}
|
|
Controls.Button {
|
|
visible: backend.login.state !== "dry"
|
|
text: "Check again"
|
|
icon.name: "view-refresh"
|
|
enabled: backend.login.state !== "checking"
|
|
onClicked: backend.checkLogin()
|
|
}
|
|
}
|
|
}
|
|
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.chosen !== null && uploadView.chosen.export_stale
|
|
type: Kirigami.MessageType.Warning
|
|
text: "This session changed since it was exported. Export it again so the upload has your latest deletions."
|
|
}
|
|
|
|
// Not logged in: how to log in, in a terminal. The token never goes into this window.
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.session !== "" && ["none", "read", "error", "missing"].indexOf(backend.login.state) >= 0
|
|
type: backend.login.state === "error" ? Kirigami.MessageType.Error : Kirigami.MessageType.Warning
|
|
text: backend.login.state === "none"
|
|
? "You're not logged in to Hugging Face. Open a terminal (Konsole) and run the command below. "
|
|
+ "Paste a token with the Write role when it asks (steps 1 to 3 below say how to make one). "
|
|
+ "The token goes only into that terminal, never into this window. Then press Check again."
|
|
: backend.login.text || ""
|
|
actions: backend.login.link ? [linkAction] : []
|
|
Kirigami.Action {
|
|
id: linkAction
|
|
text: "Open"
|
|
icon.name: "internet-services"
|
|
onTriggered: Qt.openUrlExternally(backend.login.link)
|
|
}
|
|
}
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.session !== "" && ["none", "read"].indexOf(backend.login.state) >= 0
|
|
Controls.TextField {
|
|
id: loginCommand
|
|
Layout.fillWidth: true
|
|
readOnly: true
|
|
font.family: "monospace"
|
|
text: backend.loginCommand + (backend.login.state === "read" ? " --force" : "")
|
|
}
|
|
Controls.Button {
|
|
text: "Copy command"
|
|
icon.name: "edit-copy"
|
|
onClicked: backend.copy(loginCommand.text)
|
|
}
|
|
}
|
|
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.session !== "" && uploadView.info.previous.export_sha !== undefined
|
|
type: Kirigami.MessageType.Information
|
|
text: "This export was uploaded on " + (uploadView.info.previous.uploaded || "") + ": "
|
|
+ (uploadView.info.previous.pr_url || "") + ". There's no need to upload it again."
|
|
}
|
|
|
|
RowLayout {
|
|
visible: uploadView.session !== ""
|
|
Controls.Button {
|
|
text: backend.hubDryRun ? "Upload (dry run)" : "Upload"
|
|
icon.name: "cloud-upload"
|
|
enabled: uploadView.blocked === "" && !backend.uploading
|
|
onClicked: uploadView.startUpload()
|
|
}
|
|
Controls.Button {
|
|
visible: backend.uploading
|
|
text: "Cancel"
|
|
icon.name: "dialog-cancel"
|
|
onClicked: backend.cancelUpload()
|
|
}
|
|
Controls.Label {
|
|
visible: !backend.uploading && uploadView.blocked !== ""
|
|
opacity: 0.7
|
|
text: uploadView.blocked
|
|
}
|
|
}
|
|
|
|
// Progress: a share while the export is checked, a sweeping bar while it's sent
|
|
// (huggingface_hub doesn't report progress). Drawn here, as on the Export page.
|
|
Rectangle {
|
|
id: track
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 20
|
|
implicitHeight: Kirigami.Units.smallSpacing * 2
|
|
visible: backend.uploading
|
|
radius: height / 2
|
|
clip: true
|
|
color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.15)
|
|
readonly property bool unknown: uploadView.up.fraction === undefined || uploadView.up.fraction < 0
|
|
Rectangle {
|
|
id: fill
|
|
height: parent.height
|
|
radius: parent.radius
|
|
color: Kirigami.Theme.highlightColor
|
|
width: track.unknown ? parent.width / 4 : parent.width * Math.max(0, Math.min(1, uploadView.up.fraction))
|
|
x: 0
|
|
SequentialAnimation on x {
|
|
running: track.visible && track.unknown
|
|
loops: Animation.Infinite
|
|
onRunningChanged: if (!running) fill.x = 0
|
|
NumberAnimation { from: -fill.width; to: track.width; duration: 1600 }
|
|
}
|
|
}
|
|
}
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.mine && (uploadView.up.text || "") !== ""
|
|
&& (backend.uploading || uploadView.up.phase === "failed" && uploadView.upError.kind === "cancelled")
|
|
wrapMode: Text.Wrap
|
|
text: uploadView.up.text || ""
|
|
}
|
|
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: !backend.uploading && uploadView.mine && uploadView.up.phase === "done"
|
|
type: Kirigami.MessageType.Positive
|
|
text: uploadView.upResult.dry_run
|
|
? "Dry run: the export passed its checks. It would go to " + uploadView.upResult.repo + "/"
|
|
+ uploadView.upResult.path_in_repo + " (" + uploadView.upResult.files + " files). Nothing was sent."
|
|
: "Uploaded. Your pull request: " + (uploadView.upResult.pr_url || "")
|
|
+ ". The maintainer reviews it before it joins the dataset."
|
|
actions: uploadView.upResult.pr_url ? [openPr, copyPr] : []
|
|
Kirigami.Action {
|
|
id: openPr
|
|
text: "Open"
|
|
icon.name: "internet-services"
|
|
onTriggered: Qt.openUrlExternally(uploadView.upResult.pr_url)
|
|
}
|
|
Kirigami.Action {
|
|
id: copyPr
|
|
text: "Copy link"
|
|
icon.name: "edit-copy"
|
|
onTriggered: backend.copy(uploadView.upResult.pr_url)
|
|
}
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: !backend.uploading && uploadView.mine && uploadView.up.phase === "failed"
|
|
&& uploadView.upError.kind !== "cancelled"
|
|
type: Kirigami.MessageType.Error
|
|
text: (uploadView.upError.text || "")
|
|
+ ((uploadView.upError.errors || []).length ? "\n\n• " + uploadView.upError.errors.join("\n• ") : "")
|
|
actions: uploadView.upError.link ? [openErrorLink] : []
|
|
Kirigami.Action {
|
|
id: openErrorLink
|
|
text: "Open"
|
|
icon.name: "internet-services"
|
|
onTriggered: Qt.openUrlExternally(uploadView.upError.link)
|
|
}
|
|
}
|
|
Controls.TextArea {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.mine && (uploadView.up.log || "") !== ""
|
|
readOnly: true
|
|
selectByMouse: true
|
|
wrapMode: Text.WrapAnywhere
|
|
font.family: "monospace"
|
|
text: uploadView.up.log || ""
|
|
}
|
|
|
|
Kirigami.Separator {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.session !== ""
|
|
}
|
|
Kirigami.Heading {
|
|
level: 3
|
|
visible: uploadView.session !== ""
|
|
text: "Or upload from a terminal"
|
|
}
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.session !== ""
|
|
Controls.TextArea {
|
|
id: command
|
|
Layout.fillWidth: true
|
|
readOnly: true
|
|
selectByMouse: true
|
|
wrapMode: Text.WrapAnywhere
|
|
font.family: "monospace"
|
|
text: backend.uploadCommand(uploadView.session)
|
|
}
|
|
Controls.Button {
|
|
text: "Copy command"
|
|
icon.name: "edit-copy"
|
|
onClicked: backend.copy(command.text)
|
|
}
|
|
}
|
|
MarkdownText {
|
|
Layout.fillWidth: true
|
|
visible: uploadView.session !== ""
|
|
markdown: backend.uploadText(uploadView.session)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|