mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 00:00:07 +02:00
- get.sh: curl ... | bash asks for stable (main) or experimental, clones or updates ~/frametop, and runs install.sh; run it again to update or switch - install.sh offers gaze mode (step 8, yes by default) and notes what to do if an SSH connection drops - Input Settings' Gaze page says when the gaze service isn't installed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1149 lines
59 KiB
QML
1149 lines
59 KiB
QML
// Frametop Input Settings (Kirigami). Backend: ft_input_settings.py ("backend").
|
|
import QtQuick
|
|
import QtQuick.Controls as Controls
|
|
import QtQuick.Layouts
|
|
import org.kde.kirigami as Kirigami
|
|
|
|
Kirigami.ApplicationWindow {
|
|
id: root
|
|
title: "Frametop Input Settings"
|
|
width: Kirigami.Units.gridUnit * 44
|
|
height: Kirigami.Units.gridUnit * 34
|
|
|
|
globalDrawer: Kirigami.GlobalDrawer {
|
|
isMenu: false
|
|
modal: false
|
|
collapsible: true
|
|
collapsed: root.width < Kirigami.Units.gridUnit * 30
|
|
actions: [
|
|
Kirigami.Action { text: "Devices"; icon.name: "input-mouse"; onTriggered: root.show(devicesPage) },
|
|
Kirigami.Action { text: "Buttons"; icon.name: "input-keyboard"; onTriggered: root.show(buttonsPage) },
|
|
Kirigami.Action { text: "Controllers"; icon.name: "input-gamepad"; onTriggered: root.show(controllersPage) },
|
|
Kirigami.Action { text: "Keyboard"; icon.name: "input-keyboard-virtual"; onTriggered: root.show(keyboardPage) },
|
|
Kirigami.Action { text: "Pointer"; icon.name: "transform-move"; onTriggered: root.show(pointerPage) },
|
|
Kirigami.Action { text: "Ignored panels"; icon.name: "view-hidden"; onTriggered: root.show(ignorePage) },
|
|
Kirigami.Action { text: "Gaze"; icon.name: "view-visible"; onTriggered: root.show(gazePage) },
|
|
Kirigami.Action { text: "Bluetooth"; icon.name: "preferences-system-bluetooth"; onTriggered: root.show(bluetoothPage) }
|
|
]
|
|
}
|
|
|
|
// Why SteamVR has no ft_pointer driver, and how to get it back. Clicks, scrolling and mapped
|
|
// actions all go through that driver, so every page shows this as (part of) its header.
|
|
component DriverWarning: Kirigami.InlineMessage {
|
|
readonly property string fix: "SteamVR Settings > Startup / Shutdown > Manage Add-Ons"
|
|
readonly property string restart: ", then restart SteamVR (or reboot the headset)."
|
|
visible: backend.driverBlock !== ""
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: backend.driverBlock === "unloaded" ? Kirigami.MessageType.Warning : Kirigami.MessageType.Error
|
|
text: ({
|
|
blocked: "SteamVR blocked the Frametop pointer driver (ft_pointer) after a crash, so mouse clicks "
|
|
+ "and scrolling do nothing (the cursor still moves). To fix it, open " + fix
|
|
+ ", press Unblock next to ft_pointer" + restart,
|
|
disabled: "The Frametop pointer driver (ft_pointer) is turned off in SteamVR, so mouse clicks and "
|
|
+ "scrolling do nothing (the cursor still moves). To fix it, open " + fix
|
|
+ ", turn ft_pointer on" + restart,
|
|
safemode: "SteamVR is in safe mode, so it loads no add-ons, the Frametop pointer driver (ft_pointer) "
|
|
+ "included: mouse clicks and scrolling do nothing (the cursor still moves). To fix it, turn "
|
|
+ "safe mode off in SteamVR and check " + fix + restart,
|
|
unloaded: "SteamVR is running without the Frametop pointer driver (ft_pointer), so mouse clicks and "
|
|
+ "scrolling do nothing. If you just unblocked it, restart SteamVR (or reboot the headset). "
|
|
+ "Otherwise check " + fix + ", or reinstall it with pointer/driver/install.sh."
|
|
})[backend.driverBlock] || ""
|
|
}
|
|
|
|
function show(page) {
|
|
pageStack.clear()
|
|
pageStack.push(page)
|
|
}
|
|
|
|
// FT_INPUT_PAGE=buttons|controllers|keyboard|pointer|ignore|gaze|bluetooth opens the app on that page.
|
|
pageStack.initialPage: ({ buttons: buttonsPage, controllers: controllersPage, keyboard: keyboardPage,
|
|
pointer: pointerPage, ignore: ignorePage, gaze: gazePage,
|
|
bluetooth: bluetoothPage })[startPage] || devicesPage
|
|
|
|
Connections {
|
|
target: backend
|
|
function onMessage(text, isError) {
|
|
root.showPassiveNotification(text, isError ? "long" : "short")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Devices
|
|
Component {
|
|
id: devicesPage
|
|
Kirigami.ScrollablePage {
|
|
title: "Devices"
|
|
|
|
header: ColumnLayout {
|
|
spacing: 0
|
|
DriverWarning { Layout.fillWidth: true }
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: !backend.relayRunning || !backend.pointerMode
|
|
position: Kirigami.InlineMessage.Position.Header
|
|
type: backend.relayRunning ? Kirigami.MessageType.Information : Kirigami.MessageType.Error
|
|
text: !backend.relayRunning
|
|
? "The input relay isn't running (frametop-input-relay.service)."
|
|
: "Pointer mode is off (POINTER=0): pointer devices act as a plain mouse."
|
|
}
|
|
}
|
|
|
|
ListView {
|
|
model: backend.devices
|
|
spacing: Kirigami.Units.smallSpacing
|
|
|
|
Kirigami.PlaceholderMessage {
|
|
anchors.centerIn: parent
|
|
visible: parent.count === 0
|
|
text: "No USB or Bluetooth mice or keyboards connected"
|
|
explanation: "Connect or wake one; it appears here within a second."
|
|
}
|
|
|
|
delegate: Controls.ItemDelegate {
|
|
id: row
|
|
required property var modelData
|
|
width: ListView.view.width
|
|
hoverEnabled: false
|
|
down: false
|
|
|
|
contentItem: RowLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
|
|
// Activity light: flashes when the device sends input.
|
|
Rectangle {
|
|
id: light
|
|
implicitWidth: Kirigami.Units.gridUnit * 0.8
|
|
implicitHeight: implicitWidth
|
|
radius: width / 2
|
|
color: Kirigami.Theme.disabledTextColor
|
|
opacity: 0.35
|
|
Connections {
|
|
target: backend
|
|
function onActivity(id) {
|
|
if (id === row.modelData.id) {
|
|
light.color = Kirigami.Theme.positiveTextColor
|
|
light.opacity = 1
|
|
fade.restart()
|
|
}
|
|
}
|
|
}
|
|
Timer {
|
|
id: fade
|
|
interval: 350
|
|
onTriggered: { light.color = Kirigami.Theme.disabledTextColor; light.opacity = 0.35 }
|
|
}
|
|
}
|
|
|
|
Kirigami.Icon {
|
|
source: row.modelData.kinds.indexOf("mouse") >= 0 ? "input-mouse" : "input-keyboard"
|
|
implicitWidth: Kirigami.Units.iconSizes.medium
|
|
implicitHeight: implicitWidth
|
|
}
|
|
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 0
|
|
Controls.Label {
|
|
text: row.modelData.name
|
|
font.bold: true
|
|
Layout.fillWidth: true
|
|
elide: Text.ElideRight
|
|
}
|
|
Controls.Label {
|
|
text: row.modelData.connected
|
|
? row.modelData.bus + " · " + row.modelData.kinds.join(" + ") + " · " + row.modelData.id
|
|
+ (row.modelData.grabbed ? " · grabbed" : "")
|
|
: "not connected · saved settings · " + row.modelData.id
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
Layout.fillWidth: true
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
|
|
Controls.ComboBox {
|
|
model: backend.roles
|
|
textRole: "text"
|
|
valueRole: "value"
|
|
Component.onCompleted: currentIndex = indexOfValue(row.modelData.role)
|
|
onActivated: backend.setRole(row.modelData.id, currentValue, row.modelData.name)
|
|
}
|
|
Controls.Button {
|
|
visible: !row.modelData.connected
|
|
text: "Forget"
|
|
icon.name: "edit-delete-remove"
|
|
onClicked: backend.forgetDevice(row.modelData.id)
|
|
}
|
|
Controls.ToolButton {
|
|
icon.name: "edit-undo"
|
|
visible: row.modelData.explicit
|
|
display: Controls.AbstractButton.IconOnly
|
|
text: "Use the default role"
|
|
Controls.ToolTip.text: text
|
|
Controls.ToolTip.visible: hovered
|
|
onClicked: backend.resetRole(row.modelData.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
footer: Controls.Label {
|
|
padding: Kirigami.Units.largeSpacing
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "Move or press a device to see which row it is. 3D pointer: grabbed, drives the SteamVR pointer. "
|
|
+ "Pass through: left alone (a Meta tap toggles the dashboard if META_DASHBOARD=1). Ignore: left alone."
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Buttons
|
|
Component {
|
|
id: buttonsPage
|
|
Kirigami.ScrollablePage {
|
|
id: bpage
|
|
title: "Buttons"
|
|
header: DriverWarning {}
|
|
actions: [
|
|
Kirigami.Action {
|
|
text: "Remove all"
|
|
icon.name: "edit-clear-all"
|
|
tooltip: "Remove every binding you added for this device; built-in buttons go back to their defaults"
|
|
enabled: bpage.rows.some(r => r.custom)
|
|
onTriggered: backend.clearMappings(bpage.deviceId)
|
|
}
|
|
]
|
|
property string deviceId: pointerDevices.length > 0 ? pointerDevices[Math.max(0, deviceBox.currentIndex)].id : ""
|
|
property var pointerDevices: backend.devices.filter(d => d.role === "pointer")
|
|
property int capturedCode: -1
|
|
property string capturedName: ""
|
|
property bool capturing: false
|
|
property var rows: deviceId ? backend.mappings(deviceId) : []
|
|
|
|
Connections {
|
|
target: backend
|
|
function onCaptured(code, name) { bpage.capturedCode = code; bpage.capturedName = name; bpage.capturing = false }
|
|
function onMappingsChanged() { bpage.rows = bpage.deviceId ? backend.mappings(bpage.deviceId) : [] }
|
|
}
|
|
|
|
ColumnLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
|
|
Kirigami.PlaceholderMessage {
|
|
Layout.fillWidth: true
|
|
visible: bpage.pointerDevices.length === 0
|
|
text: "No 3D pointer devices"
|
|
explanation: "Set a device's role to 3D pointer on the Devices page."
|
|
}
|
|
|
|
Kirigami.FormLayout {
|
|
Layout.fillWidth: true
|
|
visible: bpage.pointerDevices.length > 0
|
|
|
|
Controls.ComboBox {
|
|
id: deviceBox
|
|
Kirigami.FormData.label: "Device:"
|
|
model: bpage.pointerDevices.map(d => ({ name: d.name + (d.connected ? "" : " (not connected)"), id: d.id }))
|
|
textRole: "name"
|
|
onActivated: { bpage.capturedCode = -1; bpage.rows = backend.mappings(bpage.deviceId) }
|
|
}
|
|
|
|
RowLayout {
|
|
Kirigami.FormData.label: "New mapping:"
|
|
enabled: bpage.pointerDevices.length > 0 && bpage.pointerDevices[Math.max(0, deviceBox.currentIndex)].connected
|
|
Controls.Button {
|
|
text: bpage.capturing ? "Press a button or key on the device…" : "Capture a button"
|
|
icon.name: "input-mouse-click-left"
|
|
highlighted: bpage.capturing
|
|
onClicked: {
|
|
if (bpage.capturing) { backend.cancelCapture(); bpage.capturing = false }
|
|
else { bpage.capturedCode = -1; bpage.capturing = true; backend.startCapture(bpage.deviceId) }
|
|
}
|
|
}
|
|
Controls.Label {
|
|
visible: bpage.capturedCode >= 0
|
|
text: bpage.capturedName
|
|
font.bold: true
|
|
}
|
|
Controls.ComboBox {
|
|
id: newAction
|
|
visible: bpage.capturedCode >= 0
|
|
model: backend.actions
|
|
textRole: "text"
|
|
valueRole: "value"
|
|
}
|
|
Controls.Button {
|
|
visible: bpage.capturedCode >= 0
|
|
text: "Map"
|
|
icon.name: "dialog-ok-apply"
|
|
onClicked: { backend.setMapping(bpage.deviceId, bpage.capturedCode, newAction.currentValue); bpage.capturedCode = -1 }
|
|
}
|
|
}
|
|
}
|
|
|
|
Kirigami.Heading {
|
|
visible: bpage.rows.length > 0
|
|
level: 3
|
|
text: "Current mappings"
|
|
}
|
|
|
|
Repeater {
|
|
model: bpage.rows
|
|
delegate: RowLayout {
|
|
required property var modelData
|
|
Layout.fillWidth: true
|
|
spacing: Kirigami.Units.largeSpacing
|
|
Controls.Label {
|
|
text: modelData.name
|
|
font.family: "monospace"
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 10
|
|
}
|
|
Controls.ComboBox {
|
|
model: backend.actions
|
|
textRole: "text"
|
|
valueRole: "value"
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 13
|
|
Component.onCompleted: currentIndex = indexOfValue(modelData.action)
|
|
onActivated: backend.setMapping(bpage.deviceId, modelData.code, currentValue)
|
|
}
|
|
Controls.Label {
|
|
text: modelData.custom ? (modelData.isDefault ? "changed" : "custom") : "default"
|
|
opacity: 0.6
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 4
|
|
}
|
|
// Remove: your own binding goes away (the button passes through again).
|
|
// Reset: a changed built-in binding goes back to its default.
|
|
// Unbind: an untouched built-in binding is set to "Do nothing".
|
|
Controls.Button {
|
|
text: modelData.custom ? (modelData.isDefault ? "Reset" : "Remove") : "Unbind"
|
|
icon.name: modelData.custom ? (modelData.isDefault ? "edit-undo" : "edit-delete-remove")
|
|
: "list-remove"
|
|
onClicked: modelData.custom ? backend.removeMapping(bpage.deviceId, modelData.code)
|
|
: backend.unbind(bpage.deviceId, modelData.code)
|
|
}
|
|
Item { Layout.fillWidth: true }
|
|
}
|
|
}
|
|
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "Buttons without a mapping pass through (mouse buttons as clicks, keys as keys). "
|
|
+ "The Z3's extra buttons show up as keys from its keyboard node. "
|
|
+ "The Frame controllers' buttons are on the Controllers page."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Controllers
|
|
Component {
|
|
id: controllersPage
|
|
Kirigami.ScrollablePage {
|
|
id: cpage
|
|
title: "Controllers"
|
|
header: DriverWarning {}
|
|
actions: [
|
|
Kirigami.Action {
|
|
text: "Remove all"
|
|
icon.name: "edit-clear-all"
|
|
tooltip: "Give every controller button back to games"
|
|
enabled: backend.controllerMappings.length > 0
|
|
onTriggered: backend.clearControllerMappings()
|
|
}
|
|
]
|
|
property string capturedButton: ""
|
|
property string capturedLabel: ""
|
|
property bool capturing: false
|
|
property var status: backend.controllerStatus
|
|
Component.onDestruction: backend.cancelControllerCapture()
|
|
|
|
Connections {
|
|
target: backend
|
|
function onCapturedController(button, label) {
|
|
cpage.capturedButton = button; cpage.capturedLabel = label; cpage.capturing = false
|
|
buttonBox.currentIndex = buttonBox.indexOfValue(button)
|
|
}
|
|
}
|
|
Timer {
|
|
// The relay takes every button for 30 s while capturing.
|
|
running: cpage.capturing
|
|
interval: 30000
|
|
onTriggered: { backend.cancelControllerCapture(); cpage.capturing = false }
|
|
}
|
|
|
|
ColumnLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: !cpage.status.helper
|
|
type: Kirigami.MessageType.Error
|
|
text: "The pointer helper isn't answering (frametop-pointer.service, needs SteamVR). "
|
|
+ "It reads the controller buttons."
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: cpage.status.helper && !cpage.status.manifest
|
|
type: Kirigami.MessageType.Error
|
|
text: "The pointer helper couldn't set up SteamVR input (pointer/helper/actions). See its log."
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: cpage.status.helper && !cpage.status.global && backend.controllerMappings.length > 0
|
|
type: Kirigami.MessageType.Warning
|
|
text: "Global input is off, so the mapped buttons only reach Frametop when nothing else has "
|
|
+ "focus, if at all."
|
|
}
|
|
|
|
Kirigami.FormLayout {
|
|
Layout.fillWidth: true
|
|
|
|
Controls.Switch {
|
|
Kirigami.FormData.label: "Global input:"
|
|
text: "SteamVR's \"Enable global input from overlays (Experimental)\", which mapped buttons need"
|
|
checked: cpage.status.global
|
|
enabled: cpage.status.helper
|
|
onToggled: backend.setGlobalInput(checked)
|
|
}
|
|
Controls.Switch {
|
|
Kirigami.FormData.label: "In games:"
|
|
text: "Mapped buttons work while a game is running too (the game loses them)"
|
|
checked: backend.controllerInGames
|
|
onToggled: backend.setControllerInGames(checked)
|
|
}
|
|
RowLayout {
|
|
Kirigami.FormData.label: "New mapping:"
|
|
Controls.Button {
|
|
text: cpage.capturing ? "Press a button on a controller…" : "Capture a button"
|
|
icon.name: "input-gamepad"
|
|
highlighted: cpage.capturing
|
|
enabled: cpage.status.helper
|
|
onClicked: {
|
|
if (cpage.capturing) { backend.cancelControllerCapture(); cpage.capturing = false }
|
|
else { cpage.capturedButton = ""; cpage.capturing = true; backend.startControllerCapture() }
|
|
}
|
|
}
|
|
Controls.Label { text: "or" ; opacity: 0.7 }
|
|
Controls.ComboBox {
|
|
id: buttonBox
|
|
model: backend.controllerButtons
|
|
textRole: "text"
|
|
valueRole: "value"
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 10
|
|
}
|
|
Controls.ComboBox {
|
|
id: newControllerAction
|
|
model: backend.controllerActions
|
|
textRole: "text"
|
|
valueRole: "value"
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 13
|
|
}
|
|
Controls.Button {
|
|
text: "Map"
|
|
icon.name: "dialog-ok-apply"
|
|
onClicked: { backend.setControllerMapping(buttonBox.currentValue, newControllerAction.currentValue); cpage.capturedButton = "" }
|
|
}
|
|
}
|
|
}
|
|
|
|
Kirigami.Heading {
|
|
visible: backend.controllerMappings.length > 0
|
|
level: 3
|
|
text: "Current mappings"
|
|
}
|
|
|
|
Repeater {
|
|
model: backend.controllerMappings
|
|
delegate: RowLayout {
|
|
id: crow
|
|
required property var modelData
|
|
Layout.fillWidth: true
|
|
spacing: Kirigami.Units.largeSpacing
|
|
Controls.Label {
|
|
text: crow.modelData.label
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 10
|
|
}
|
|
Controls.ComboBox {
|
|
model: backend.controllerActions
|
|
textRole: "text"
|
|
valueRole: "value"
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 13
|
|
Component.onCompleted: currentIndex = indexOfValue(crow.modelData.action)
|
|
onActivated: backend.setControllerMapping(crow.modelData.button, currentValue)
|
|
}
|
|
Controls.Label {
|
|
// Active: SteamVR delivers it to Frametop now (a controller is on).
|
|
text: cpage.status.active.indexOf(crow.modelData.button) >= 0 ? "active"
|
|
: cpage.status.inGame && !backend.controllerInGames ? "game's" : "waiting"
|
|
opacity: 0.6
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 4
|
|
Controls.ToolTip.text: text === "active" ? "SteamVR gives this button to Frametop"
|
|
: text === "game's" ? "A game is running: the button is the game's until it quits"
|
|
: "No controller with this button is on, or SteamVR doesn't give it to Frametop"
|
|
Controls.ToolTip.visible: hover.hovered
|
|
HoverHandler { id: hover }
|
|
}
|
|
Controls.Button {
|
|
text: "Remove"
|
|
icon.name: "edit-delete-remove"
|
|
onClicked: backend.removeControllerMapping(crow.modelData.button)
|
|
}
|
|
Item { Layout.fillWidth: true }
|
|
}
|
|
}
|
|
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "Outside games, a mapped button is Frametop's; while a game runs it's the game's, unless "
|
|
+ "\"In games\" is on. Unmapped buttons are left alone, and the system button stays SteamVR's. "
|
|
+ "Clicks and scrolling go to the 3D pointer. The trigger and grip also move SteamVR's laser "
|
|
+ "to that controller, so they're better left unmapped."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Keyboard
|
|
Component {
|
|
id: keyboardPage
|
|
Kirigami.ScrollablePage {
|
|
id: kpage
|
|
title: "Keyboard"
|
|
// Pass-through keyboards connected now: with "no_keyboard", the keyboard waits for none.
|
|
// A program's uinput keyboard (frame-voice's, say) doesn't count.
|
|
property var keyboards: backend.devices.filter(d => d.connected && d.role === "passthrough"
|
|
&& d.kinds.indexOf("keyboard") >= 0 && !d.uinput)
|
|
|
|
Kirigami.FormLayout {
|
|
Controls.ComboBox {
|
|
Kirigami.FormData.label: "Show the keyboard:"
|
|
model: backend.vrKeyboardModes
|
|
textRole: "text"
|
|
valueRole: "value"
|
|
Component.onCompleted: currentIndex = indexOfValue(backend.vrKeyboard)
|
|
onActivated: backend.setVrKeyboard(currentValue)
|
|
}
|
|
Controls.Switch {
|
|
Kirigami.FormData.label: "Keep it open:"
|
|
text: "Until you press its Close key or your keyboard button, not only while the text field has focus"
|
|
checked: backend.vrKeyboardPersist
|
|
enabled: backend.vrKeyboard !== "never"
|
|
onToggled: backend.setVrKeyboardPersist(checked)
|
|
}
|
|
Controls.Label {
|
|
Kirigami.FormData.label: "Keyboards connected:"
|
|
text: kpage.keyboards.length ? kpage.keyboards.map(d => d.name).join(", ") : "none"
|
|
}
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Key combinations" }
|
|
|
|
Repeater {
|
|
model: backend.keyShortcuts
|
|
delegate: RowLayout {
|
|
required property var modelData
|
|
Kirigami.FormData.label: modelData.label + ":"
|
|
Controls.Label { text: modelData.actionLabel }
|
|
Controls.ToolButton {
|
|
icon.name: "edit-delete"
|
|
display: Controls.AbstractButton.IconOnly
|
|
text: "Remove"
|
|
Controls.ToolTip.text: text
|
|
Controls.ToolTip.visible: hovered
|
|
onClicked: backend.removeShortcut(modelData.combo)
|
|
}
|
|
}
|
|
}
|
|
RowLayout {
|
|
Kirigami.FormData.label: "New:"
|
|
Controls.ComboBox {
|
|
id: shortcutAction
|
|
model: backend.shortcutActions
|
|
textRole: "text"
|
|
valueRole: "value"
|
|
Component.onCompleted: currentIndex = indexOfValue("float_toggle")
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 16
|
|
}
|
|
Controls.Button {
|
|
text: backend.capturingShortcut ? "Press the keys… (Cancel)" : "Set keys…"
|
|
onClicked: backend.capturingShortcut ? backend.cancelShortcutCapture()
|
|
: backend.startShortcutCapture(shortcutAction.currentValue)
|
|
}
|
|
}
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 30
|
|
wrapMode: Text.WordWrap
|
|
text: "Hold the modifiers (Ctrl, Alt, Shift, Meta), then press the key, on any keyboard. The "
|
|
+ "combination's last key isn't typed; the modifiers still reach the app. Meta+Shift+F floats "
|
|
+ "the desktop window under the pointer in VR, or puts it back, until you remove or change it."
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
}
|
|
}
|
|
|
|
footer: Controls.Label {
|
|
padding: Kirigami.Units.largeSpacing
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "Frametop's keyboard opens in front of you, below your eyes, and closes with its Close key or a layout reset "
|
|
+ "(or when the text field loses focus, with Keep it open off). It steps aside while the Steam "
|
|
+ "menu or Steam's own keyboard is up. Type on it with a laser or the 3D mouse. It types into any "
|
|
+ "app, but only Qt, GTK and Firefox apps say when a text field is selected; for the rest "
|
|
+ "(Chromium, Electron and X11 apps), map Open/close keyboard to a button on the Buttons or "
|
|
+ "Controllers page. Keyboards set to Ignore, and keyboards other programs make (like "
|
|
+ "frame-voice's), don't count as connected."
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Pointer
|
|
Component {
|
|
id: pointerPage
|
|
Kirigami.ScrollablePage {
|
|
title: "Pointer"
|
|
header: DriverWarning {}
|
|
actions: [
|
|
Kirigami.Action {
|
|
text: "Recenter"
|
|
icon.name: "zoom-fit-best"
|
|
onTriggered: backend.recenter()
|
|
}
|
|
]
|
|
|
|
Kirigami.FormLayout {
|
|
Controls.Switch {
|
|
Kirigami.FormData.label: "Head follow:"
|
|
text: "Pointer follows your head (experimental; leash below, 0° locks it to your view)"
|
|
checked: backend.pointerFollow
|
|
onToggled: backend.setPointerFollow(checked)
|
|
}
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 30
|
|
visible: true
|
|
type: Kirigami.MessageType.Warning
|
|
text: "Head follow is experimental. It's only lightly tested and not finished: the head "
|
|
+ "follow settings below are a starting point, and polishing how it feels is left open "
|
|
+ "for anyone who wants to take it further."
|
|
}
|
|
Repeater {
|
|
model: backend.pointerSettings
|
|
delegate: RowLayout {
|
|
required property var modelData
|
|
Kirigami.FormData.label: modelData.label + ":"
|
|
Controls.Slider {
|
|
id: slider
|
|
from: modelData.min
|
|
to: modelData.max
|
|
stepSize: modelData.step
|
|
value: modelData.value
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 14
|
|
onMoved: backend.setPointerSetting(modelData.key, value)
|
|
}
|
|
Controls.Label {
|
|
text: (modelData.step < 1 ? slider.value.toFixed(modelData.step < 0.01 ? 3 : 2) : Math.round(slider.value))
|
|
+ (modelData.unit ? " " + modelData.unit : "")
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 5
|
|
}
|
|
Controls.ToolButton {
|
|
icon.name: "edit-undo"
|
|
display: Controls.AbstractButton.IconOnly
|
|
text: "Default (" + modelData.default + ")"
|
|
Controls.ToolTip.text: text
|
|
Controls.ToolTip.visible: hovered
|
|
onClicked: { slider.value = modelData.default; backend.setPointerSetting(modelData.key, modelData.default) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
footer: Controls.Label {
|
|
padding: Kirigami.Units.largeSpacing
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "Changes apply live. SteamVR dot shrink: how close to the target the pointer's laser starts; "
|
|
+ "higher makes SteamVR's own blue dot smaller (max 0.98)."
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Ignored panels
|
|
Component {
|
|
id: ignorePage
|
|
Kirigami.ScrollablePage {
|
|
id: ipage
|
|
title: "Ignored panels"
|
|
header: DriverWarning {}
|
|
|
|
Timer {
|
|
// The helper lists SteamVR's panels again for each request.
|
|
running: true
|
|
repeat: true
|
|
triggeredOnStart: true
|
|
interval: 3000
|
|
onTriggered: backend.refreshPanels()
|
|
}
|
|
|
|
ColumnLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
visible: !backend.controllerStatus.helper
|
|
type: Kirigami.MessageType.Error
|
|
text: "The pointer helper isn't answering (frametop-pointer.service, needs SteamVR). "
|
|
+ "It lists SteamVR's panels and does the ignoring."
|
|
}
|
|
Controls.Label {
|
|
Layout.fillWidth: true
|
|
wrapMode: Text.Wrap
|
|
text: "The mouse pointer passes through the panels ticked here to whatever is behind them, "
|
|
+ "as if they weren't there. Use it for panels you only look at, like a performance "
|
|
+ "overlay that follows your view. Ignore a whole app, or only some of its panels. "
|
|
+ "Controllers aren't affected."
|
|
}
|
|
Controls.Switch {
|
|
id: showHidden
|
|
text: "Also list panels that aren't showing now"
|
|
}
|
|
Controls.Label {
|
|
visible: backend.controllerStatus.helper && !backend.panelsLoaded
|
|
text: "Asking the pointer helper for SteamVR's panels…"
|
|
opacity: 0.7
|
|
}
|
|
|
|
Repeater {
|
|
model: backend.panelGroups
|
|
delegate: ColumnLayout {
|
|
id: grp
|
|
required property var modelData
|
|
Layout.fillWidth: true
|
|
visible: showHidden.checked || modelData.anyVisible || modelData.anyIgnored
|
|
spacing: 0
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Kirigami.Heading {
|
|
level: 4
|
|
text: grp.modelData.title
|
|
}
|
|
Controls.Label {
|
|
text: grp.modelData.app
|
|
opacity: 0.6
|
|
}
|
|
Item { Layout.fillWidth: true }
|
|
Controls.CheckBox {
|
|
text: "Ignore the whole app"
|
|
checked: grp.modelData.appIgnored
|
|
onToggled: backend.setAppIgnored(grp.modelData.app, checked)
|
|
}
|
|
}
|
|
Repeater {
|
|
model: grp.modelData.panels
|
|
delegate: RowLayout {
|
|
id: prow
|
|
required property var modelData
|
|
// Ignored by the whole app, or by a pattern written in frametop.conf.
|
|
readonly property bool byOther: modelData.ignoredBy !== "" && modelData.ignoredBy !== modelData.key
|
|
visible: showHidden.checked || modelData.visible || modelData.ignoredBy !== ""
|
|
Layout.leftMargin: Kirigami.Units.gridUnit
|
|
Controls.CheckBox {
|
|
text: prow.modelData.name
|
|
checked: prow.modelData.ignoredBy !== ""
|
|
enabled: !prow.byOther
|
|
onToggled: backend.setPanelIgnored(prow.modelData.key, checked)
|
|
}
|
|
Controls.Label {
|
|
text: prow.modelData.key
|
|
opacity: 0.6
|
|
}
|
|
Controls.Label {
|
|
text: (prow.modelData.visible ? "showing" : "hidden")
|
|
+ (!prow.byOther ? ""
|
|
: prow.modelData.ignoredBy === grp.modelData.app + "*" ? ", whole app ignored"
|
|
: ", ignored by " + prow.modelData.ignoredBy)
|
|
opacity: 0.6
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Kirigami.Heading {
|
|
visible: backend.ignoreOrphans.length > 0
|
|
level: 3
|
|
text: "Ignored, not open now"
|
|
}
|
|
Repeater {
|
|
model: backend.ignoreOrphans
|
|
delegate: RowLayout {
|
|
id: orow
|
|
required property string modelData
|
|
Controls.Label {
|
|
text: orow.modelData
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 16
|
|
}
|
|
Controls.Button {
|
|
text: "Remove"
|
|
icon.name: "edit-delete-remove"
|
|
onClicked: backend.removeIgnore(orow.modelData)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
footer: Controls.Label {
|
|
padding: Kirigami.Units.largeSpacing
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "Saved as POINTER_IGNORE in ~/.config/frametop.conf, and applied at once. A whole app is "
|
|
+ "its key followed by *, which also covers panels it opens later. Frametop's own screens "
|
|
+ "aren't listed."
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Gaze
|
|
Component {
|
|
id: gazePage
|
|
Kirigami.ScrollablePage {
|
|
id: gpage
|
|
title: "Gaze"
|
|
header: DriverWarning {}
|
|
property var status: backend.gazeStatus
|
|
actions: [
|
|
Kirigami.Action {
|
|
text: "Quick check"
|
|
icon.name: "crosshairs"
|
|
enabled: backend.gazeServiceRunning
|
|
tooltip: "One dot in front of you in the headset: look at it, and the gaze tracker relearns where it sits"
|
|
onTriggered: backend.gazeQuickCheck()
|
|
},
|
|
Kirigami.Action {
|
|
text: "Calibrate"
|
|
icon.name: "crosshairs"
|
|
enabled: backend.gazeServiceRunning
|
|
tooltip: "The full calibration in the headset: dots in three rounds, dark to bright"
|
|
onTriggered: backend.gazeCalibrate()
|
|
},
|
|
Kirigami.Action {
|
|
text: "Check headset fit"
|
|
icon.name: "view-visible"
|
|
enabled: backend.gazeServiceRunning
|
|
tooltip: "In the headset: how well the eye tracker sees each eye, live, while you adjust it"
|
|
onTriggered: backend.gazeFitCheck()
|
|
},
|
|
Kirigami.Action {
|
|
text: "Reload calibration"
|
|
icon.name: "view-refresh"
|
|
enabled: backend.gazeServiceRunning
|
|
onTriggered: backend.reloadGazeCalibration()
|
|
},
|
|
Kirigami.Action {
|
|
text: "Forget nudges"
|
|
icon.name: "edit-clear-history"
|
|
enabled: backend.gazeServiceRunning
|
|
tooltip: "Drop what your mouse nudges taught; the calibration stays"
|
|
onTriggered: backend.forgetGazeLessons()
|
|
},
|
|
Kirigami.Action {
|
|
// A development tool: the checks and the calibration run in the headset panel.
|
|
text: "Gaze probe (development)…"
|
|
icon.name: "tools"
|
|
displayHint: Kirigami.DisplayHint.AlwaysHide
|
|
tooltip: "The gaze tracking's lab tool, for developing it: experiments, accuracy tests, practice"
|
|
onTriggered: backend.openGazeProbe()
|
|
}
|
|
]
|
|
|
|
Kirigami.FormLayout {
|
|
Kirigami.InlineMessage {
|
|
Layout.fillWidth: true
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 30
|
|
visible: true
|
|
type: Kirigami.MessageType.Warning
|
|
text: "Gaze mode is experimental. The pointer goes where you look and the mouse does the last bit; "
|
|
+ "moving the mouse takes over, looking well away hands it back. A small nudge and a click "
|
|
+ "teach the gaze service where it was off."
|
|
}
|
|
Controls.Switch {
|
|
Kirigami.FormData.label: "Gaze pointer:"
|
|
text: backend.gazeMode < 0 ? "Pointer helper not running" : "Pointer goes where you look"
|
|
enabled: backend.gazeMode >= 0
|
|
checked: backend.gazeMode > 0
|
|
onToggled: backend.setGazeMode(checked)
|
|
}
|
|
Controls.Label {
|
|
visible: backend.gazeMode >= 0 && (backend.gazeMode > 0) !== backend.gazeDefault
|
|
text: "Toggled by a button; it starts " + (backend.gazeDefault ? "on" : "off") + " after a restart."
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
}
|
|
ColumnLayout {
|
|
Kirigami.FormData.label: "Mouse left button:"
|
|
Repeater {
|
|
model: backend.gazeMouseChoices
|
|
delegate: Controls.RadioButton {
|
|
required property var modelData
|
|
text: modelData.text
|
|
checked: backend.gazeMouse === modelData.value
|
|
onToggled: if (checked) backend.setGazeMouse(modelData.value)
|
|
}
|
|
}
|
|
}
|
|
Controls.Switch {
|
|
Kirigami.FormData.label: "Mouse movement:"
|
|
text: "Only while a button is held (recommended)"
|
|
checked: backend.gazeMouseHeld
|
|
onToggled: backend.setGazeMouseHeld(checked)
|
|
Controls.ToolTip.visible: hovered
|
|
Controls.ToolTip.delay: Kirigami.Units.toolTipDelay
|
|
Controls.ToolTip.text: checked
|
|
? "Your eyes move the pointer, so moving the mouse on its own does nothing. Hold a button "
|
|
+ "and the pointer stops where you look: move onto the target and let go to click there "
|
|
+ "(left or right). To drag after moving, press right while holding left. It's on because a "
|
|
+ "bumped mouse can't pull the pointer away, and every mouse move is a real correction the "
|
|
+ "eye tracker learns from. If the eye tracker stops, the mouse works as usual."
|
|
: "The mouse moves the pointer any time, as without gaze. Moves that weren't corrections can "
|
|
+ "teach the eye tracker the wrong thing."
|
|
}
|
|
Controls.Switch {
|
|
Kirigami.FormData.label: "Gaze dot:"
|
|
text: "Always shown (off: only while the mouse moves it)"
|
|
checked: backend.gazeDotAlways
|
|
onToggled: backend.setGazeDotAlways(checked)
|
|
}
|
|
Controls.Label {
|
|
Layout.maximumWidth: Kirigami.Units.gridUnit * 30
|
|
wrapMode: Text.WordWrap
|
|
text: "Gaze works with the mouse and the keyboard. Keyboard clicks (Meta+J left, Meta+K right by "
|
|
+ "default): tap to click where you look, or hold, turn your head until the dot sits on the "
|
|
+ "target, and let go; hold still to drag. The Frame controllers don't take part, and moving "
|
|
+ "one hands the pointer back to them. Gaze pointer on/off, Gaze precision, and Gaze drag can "
|
|
+ "also go on a mouse button (Buttons page) or another key combination (Keyboard page)."
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
}
|
|
RowLayout {
|
|
Kirigami.FormData.label: "Eye tracker:"
|
|
Controls.RadioButton {
|
|
text: "SteamVR"
|
|
checked: backend.gazeTracker === "steam"
|
|
onToggled: if (checked) backend.setGazeTracker("steam")
|
|
}
|
|
Controls.RadioButton {
|
|
text: "Own tracker"
|
|
checked: backend.gazeTracker === "own"
|
|
onToggled: if (checked) backend.setGazeTracker("own")
|
|
}
|
|
}
|
|
Controls.Label {
|
|
// The gaze service runs ours (gaze/tracker/ft-eyes); it needs the frame grabber
|
|
// (gaze/tracker/install.sh, root) and keeps its own calibration.
|
|
visible: backend.gazeTracker === "own" && backend.gazeServiceRunning
|
|
&& (!gpage.status.own_running || gpage.status.own_reseat || !gpage.status.calibration_samples)
|
|
text: !gpage.status.eyegrab ? "Needs its frame grabber: run gaze/tracker/install.sh (asks for sudo)"
|
|
: !gpage.status.own_running ? "Starting…"
|
|
: !gpage.status.calibration_samples ? "Not calibrated: use Calibrate"
|
|
: "The headset was off: your first nudge and click resets where it sits"
|
|
color: gpage.status.own_running ? Kirigami.Theme.neutralTextColor : Kirigami.Theme.negativeTextColor
|
|
font: Kirigami.Theme.smallFont
|
|
}
|
|
RowLayout {
|
|
Kirigami.FormData.label: "Eye bias:"
|
|
Controls.RadioButton {
|
|
text: "Auto"
|
|
checked: backend.gazeEye === "auto"
|
|
onToggled: if (checked) backend.setGazeEye("auto")
|
|
}
|
|
Controls.RadioButton {
|
|
text: "Left"
|
|
checked: backend.gazeEye === "left"
|
|
onToggled: if (checked) backend.setGazeEye("left")
|
|
}
|
|
Controls.RadioButton {
|
|
text: "Right"
|
|
checked: backend.gazeEye === "right"
|
|
onToggled: if (checked) backend.setGazeEye("right")
|
|
}
|
|
}
|
|
Controls.Label {
|
|
// What the bias comes to now; on auto, from how far off each eye was at the last nudges.
|
|
property var w: gpage.status.eye_weights
|
|
property var n: gpage.status.eye_misses || [0, 0]
|
|
property var rms: gpage.status.eye_rms || [null, null]
|
|
visible: backend.gazeServiceRunning && w !== undefined
|
|
text: w === undefined ? "" : "Left " + Math.round(w[0] * 100) + "% · right " + Math.round(w[1] * 100) + "%"
|
|
+ (backend.gazeEye !== "auto" ? ""
|
|
: n[0] >= 5 && n[1] >= 5
|
|
? " (off by " + rms[0].toFixed(1) + "° and " + rms[1].toFixed(1) + "° at your last nudges)"
|
|
: " (even until each eye has 5 nudges: " + n[0] + " and " + n[1] + " so far)")
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
}
|
|
Repeater {
|
|
model: backend.gazeSettings
|
|
delegate: RowLayout {
|
|
required property var modelData
|
|
Kirigami.FormData.label: modelData.label + ":"
|
|
Controls.Slider {
|
|
id: gslider
|
|
from: modelData.min
|
|
to: modelData.max
|
|
stepSize: modelData.step
|
|
value: modelData.value
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 14
|
|
onMoved: backend.setPointerSetting(modelData.key, value)
|
|
}
|
|
Controls.Label {
|
|
text: gslider.value.toFixed(modelData.step < 0.1 ? 2 : 1) + " " + modelData.unit
|
|
Layout.preferredWidth: Kirigami.Units.gridUnit * 5
|
|
}
|
|
Controls.ToolButton {
|
|
icon.name: "edit-undo"
|
|
display: Controls.AbstractButton.IconOnly
|
|
text: "Default (" + modelData.default + ")"
|
|
Controls.ToolTip.text: text
|
|
Controls.ToolTip.visible: hovered
|
|
onClicked: { gslider.value = modelData.default; backend.setPointerSetting(modelData.key, modelData.default) }
|
|
}
|
|
}
|
|
}
|
|
|
|
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Gaze service" }
|
|
|
|
Controls.Label {
|
|
Kirigami.FormData.label: "Service:"
|
|
text: backend.gazeServiceRunning
|
|
? (gpage.status.ft_gaze ? "running" : "running, eye tracker reader restarting")
|
|
: backend.gazeServiceInstalled ? "not running (frametop-gaze.service, starts with SteamVR)"
|
|
: "not installed: run gaze/run.sh install in the Frametop folder, in a terminal"
|
|
color: backend.gazeServiceRunning ? Kirigami.Theme.textColor : Kirigami.Theme.negativeTextColor
|
|
}
|
|
Controls.Label {
|
|
visible: backend.gazeServiceRunning
|
|
Kirigami.FormData.label: "Headset:"
|
|
text: gpage.status.headset_on ? "on" : "off"
|
|
}
|
|
Controls.Label {
|
|
visible: backend.gazeServiceRunning
|
|
Kirigami.FormData.label: "Tracking:"
|
|
text: gpage.status.rate === undefined || gpage.status.rate === null ? "…"
|
|
: Math.round(gpage.status.rate) + " samples/s"
|
|
+ (gpage.status.one_eye_share > 0.5 ? " · only one eye tracked" : "")
|
|
color: gpage.status.one_eye_share > 0.5 ? Kirigami.Theme.neutralTextColor : Kirigami.Theme.textColor
|
|
Controls.ToolTip.text: "Only one eye tracked: the gaze comes from the other eye, a little less "
|
|
+ "precisely. Check headset fit shows where the tracker loses it."
|
|
Controls.ToolTip.visible: gpage.status.one_eye_share > 0.5 && ghover.hovered
|
|
HoverHandler { id: ghover }
|
|
}
|
|
Controls.Label {
|
|
// Share of the last second's samples where the tracker had lost each eye.
|
|
property real lostL: gpage.status.lost_left_share || 0
|
|
property real lostR: gpage.status.lost_right_share || 0
|
|
visible: backend.gazeServiceRunning && gpage.status.lost_left !== undefined
|
|
Kirigami.FormData.label: "Eyes:"
|
|
text: lostL < 0.05 && lostR < 0.05 ? "both tracked"
|
|
: (lostL >= 0.05 ? "left eye lost " + Math.round(lostL * 100) + "%" : "")
|
|
+ (lostL >= 0.05 && lostR >= 0.05 ? " · " : "")
|
|
+ (lostR >= 0.05 ? "right eye lost " + Math.round(lostR * 100) + "%" : "")
|
|
+ ((lostL >= 0.05) !== (lostR >= 0.05) ? " (the other eye stands in)" : "")
|
|
color: lostL >= 0.05 || lostR >= 0.05 ? Kirigami.Theme.neutralTextColor : Kirigami.Theme.textColor
|
|
}
|
|
Controls.Label {
|
|
visible: backend.gazeServiceRunning
|
|
Kirigami.FormData.label: "Calibration:"
|
|
text: gpage.status.calibration_samples > 0
|
|
? gpage.status.calibration_samples + " points ("
|
|
+ (gpage.status.tracker === "own" ? "Own tracker, " + gpage.status.calibration_made
|
|
: gpage.status.kind === "eyes" ? gpage.status.model + ", each eye" : gpage.status.model) + ")"
|
|
: "none yet: use Calibrate"
|
|
}
|
|
Controls.Label {
|
|
visible: backend.gazeServiceRunning
|
|
Kirigami.FormData.label: "Learned from nudges:"
|
|
text: gpage.status.lessons + (gpage.status.lessons === 1 ? " nudge" : " nudges")
|
|
+ (gpage.status.refused > 0 ? " (" + gpage.status.refused + " too far off, ignored)" : "")
|
|
}
|
|
}
|
|
|
|
footer: Controls.Label {
|
|
padding: Kirigami.Units.largeSpacing
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "Map a mouse button (Buttons) or a key combination (Keyboard) to \"Gaze pointer on/off\" "
|
|
+ "to switch it on the fly. A click waits for the release: if the gaze is off, drag onto the target "
|
|
+ "with the button held and let go there. Hold still to drag: hold a press this long without moving "
|
|
+ "to drag something instead. Look away to hand back: how far from the pointer you look before the "
|
|
+ "gaze takes it back from the mouse. Largest correction to learn: a click corrected further than "
|
|
+ "this isn't learned; the tracker is that far off, so the quick check runs instead. Eye tracker: SteamVR's, or our own "
|
|
+ "(gaze/tracker), which keeps its own calibration. Eye bias: the gaze "
|
|
+ "combines both eyes, since their errors partly cancel; Left or Right counts that eye twice as "
|
|
+ "much, and Auto weights each by how far off it was at your recent nudges."
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Bluetooth
|
|
Component {
|
|
id: bluetoothPage
|
|
Kirigami.ScrollablePage {
|
|
title: "Bluetooth"
|
|
header: DriverWarning {}
|
|
actions: [
|
|
Kirigami.Action { text: "Refresh"; icon.name: "view-refresh"; onTriggered: backend.refreshBluetooth() },
|
|
Kirigami.Action {
|
|
text: "Apply Bluetooth fixes"
|
|
icon.name: "tools-wizard"
|
|
tooltip: "Privacy off, and address resolution on bonded LE devices (asks for your password)"
|
|
onTriggered: backend.applyBluetoothFixes()
|
|
}
|
|
]
|
|
|
|
ListView {
|
|
model: backend.bluetooth
|
|
Kirigami.PlaceholderMessage {
|
|
anchors.centerIn: parent
|
|
visible: parent.count === 0
|
|
text: "No paired Bluetooth devices"
|
|
explanation: "Pair in Steam: Settings → Bluetooth."
|
|
}
|
|
delegate: Controls.ItemDelegate {
|
|
required property var modelData
|
|
width: ListView.view.width
|
|
contentItem: RowLayout {
|
|
spacing: Kirigami.Units.largeSpacing
|
|
Kirigami.Icon {
|
|
source: "preferences-system-bluetooth"
|
|
implicitWidth: Kirigami.Units.iconSizes.medium
|
|
implicitHeight: implicitWidth
|
|
opacity: modelData.connected ? 1 : 0.4
|
|
}
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 0
|
|
Controls.Label { text: modelData.name; font.bold: true }
|
|
Controls.Label {
|
|
text: modelData.address + " · " + (modelData.connected ? "connected" : "not connected")
|
|
+ (modelData.battery >= 0 ? " · battery " + modelData.battery + "%" : "")
|
|
opacity: 0.7
|
|
font: Kirigami.Theme.smallFont
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
footer: Controls.Label {
|
|
padding: Kirigami.Units.largeSpacing
|
|
wrapMode: Text.Wrap
|
|
opacity: 0.7
|
|
text: "Pair new devices in Steam (Settings → Bluetooth). After pairing an LE mouse or keyboard, "
|
|
+ "use Apply Bluetooth fixes once so it reconnects on its own."
|
|
}
|
|
}
|
|
}
|
|
}
|