mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 02:00:06 +02:00
Map the Frame controllers' buttons; make gaze clicks correctable
The Frame controllers aren't input devices on the host, so the pointer helper reads them with SteamVR input (vrbuttons.h, actions/), one action set per button, and sends presses to the relay, which does the mapped action like for a mouse button. Only mapped buttons are taken, at an overlay-global priority (SteamVR's experimental "Enable global input from overlays"), and only outside games unless In games is on. Frametop Input Settings gets a Controllers page to map them. Gaze mode: a left press while the gaze has the pointer isn't sent at once. The pointer stops, you drag it onto what you meant with the button held, and the release clicks there; a press held still for POINTER_GAZE_HOLD (0.5 s) becomes a real press, for drags. The dot shows only while the mouse moves it (POINTER_GAZE_SHOW), while a press is held, and as a pulse per click. Outside games the pointer stays on while gaze mode is on. ft-gazed takes a third of each lesson's offset instead of all of it: in the first live test one 6 degree lesson moved everything and put the next target 7 degrees off. Frametop Input Settings gets a Gaze page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3070fd5776
commit
0d50efec45
13 files changed
+1426
-54
No files matched your search
+312
-3
@@ -18,7 +18,9 @@ Kirigami.ApplicationWindow {
|
||||
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: "Pointer"; icon.name: "transform-move"; onTriggered: root.show(pointerPage) },
|
||||
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) }
|
||||
]
|
||||
}
|
||||
@@ -28,8 +30,9 @@ Kirigami.ApplicationWindow {
|
||||
pageStack.push(page)
|
||||
}
|
||||
|
||||
// FT_INPUT_PAGE=buttons|pointer|bluetooth opens the app on that page.
|
||||
pageStack.initialPage: ({ buttons: buttonsPage, pointer: pointerPage, bluetooth: bluetoothPage })[startPage] || devicesPage
|
||||
// FT_INPUT_PAGE=buttons|controllers|pointer|gaze|bluetooth opens the app on that page.
|
||||
pageStack.initialPage: ({ buttons: buttonsPage, controllers: controllersPage, pointer: pointerPage, gaze: gazePage,
|
||||
bluetooth: bluetoothPage })[startPage] || devicesPage
|
||||
|
||||
Connections {
|
||||
target: backend
|
||||
@@ -294,7 +297,177 @@ Kirigami.ApplicationWindow {
|
||||
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 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"
|
||||
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
|
||||
type: Kirigami.MessageType.Warning
|
||||
text: "SteamVR's \"Enable global input from overlays (Experimental)\" is off. Without it the "
|
||||
+ "buttons only reach Frametop when nothing else has focus, if at all."
|
||||
actions: [
|
||||
Kirigami.Action {
|
||||
text: "Turn it on"
|
||||
icon.name: "dialog-ok-apply"
|
||||
onTriggered: backend.setGlobalInput(true)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Kirigami.FormLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,6 +543,142 @@ Kirigami.ApplicationWindow {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Gaze
|
||||
Component {
|
||||
id: gazePage
|
||||
Kirigami.ScrollablePage {
|
||||
id: gpage
|
||||
title: "Gaze"
|
||||
property var status: backend.gazeStatus
|
||||
actions: [
|
||||
Kirigami.Action {
|
||||
text: "Calibrate…"
|
||||
icon.name: "crosshairs"
|
||||
tooltip: "Open the gaze probe to calibrate (fullscreen on a Frametop screen)"
|
||||
onTriggered: backend.openGazeProbe()
|
||||
},
|
||||
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.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
|
||||
}
|
||||
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")
|
||||
: "not running (frametop-gaze.service, starts with SteamVR)"
|
||||
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: reseat the headset or check the lenses. It still works, "
|
||||
+ "probably less precisely."
|
||||
Controls.ToolTip.visible: gpage.status.one_eye_share > 0.5 && ghover.hovered
|
||||
HoverHandler { id: ghover }
|
||||
}
|
||||
Controls.Label {
|
||||
visible: backend.gazeServiceRunning
|
||||
Kirigami.FormData.label: "Calibration:"
|
||||
text: gpage.status.calibration_samples > 0
|
||||
? gpage.status.calibration_samples + " points (" + 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 controller button (Controllers) 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 nudge to learn: bigger mouse moves before a click "
|
||||
+ "are treated as using the mouse, not correcting the gaze."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Bluetooth
|
||||
Component {
|
||||
id: bluetoothPage
|
||||
|
||||
Reference in new issue
Block a user