Files
DeeJanuz--frametop/display-settings/main.qml
T
DeeJanuzandClaude Opus 5.5 0ac2b10cd7 Remove leftovers before a release
- gaze/tracker/lab/eyes_track.py: nothing used it
- Input Settings: the pointer role backend, whose page was removed
- ft-screens: no log line for every floating-window resize
- hands: ft-hands --help gives the palm-down default (1: off), the
  uninstall removes ft-handsctl's link, .frame-job is ignored
- Display Settings: "Save as profile…", not "Save current arrangement"
- two stale comments (ft-pointer's grabprobe, ft-gaze)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:47:53 -06:00

1074 lines
57 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Frametop Display Settings (Kirigami). Backend: ft_display_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 Display Settings"
width: Kirigami.Units.gridUnit * 46
height: Kirigami.Units.gridUnit * 36
// Pages as tabs across the top (a side drawer was easy to miss).
readonly property var pages: backend.backend === "screens"
? [{ name: "screens", text: "Screens", icon: "video-display", page: screensPage },
{ name: "layout", text: "Layout", icon: "view-grid", page: layoutPage },
{ name: "visibility", text: "Visibility & pins", icon: "view-visible", page: visibilityPage },
{ name: "power", text: "Power", icon: "preferences-system-power-management", page: powerPage }]
: [{ name: "screens", text: "Screens", icon: "video-display", page: screensPage },
{ name: "layout", text: "Layout", icon: "view-grid", page: layoutPage },
{ name: "power", text: "Power", icon: "preferences-system-power-management", page: powerPage }]
header: Controls.TabBar {
id: tabs
Repeater {
model: root.pages
Controls.TabButton {
required property var modelData
text: modelData.text
icon.name: modelData.icon
onClicked: root.show(modelData.page)
}
}
Component.onCompleted: currentIndex = Math.max(0, root.pages.findIndex(p => p.name === startPage))
}
function show(page) {
pageStack.clear()
pageStack.push(page)
}
// FT_DISPLAY_PAGE=layout|visibility|power opens the app on that page.
pageStack.initialPage: ({ layout: layoutPage, visibility: visibilityPage, power: powerPage })[startPage] || screensPage
// "1 hour", "15 minutes", "30 seconds".
function duration(seconds) {
const unit = (n, word) => n + " " + word + (n === 1 ? "" : "s")
if (seconds >= 3600 && seconds % 3600 === 0) return unit(seconds / 3600, "hour")
if (seconds >= 60 && seconds % 60 === 0) return unit(seconds / 60, "minute")
return unit(seconds, "second")
}
Connections {
target: backend
function onMessage(text, isError) {
root.showPassiveNotification(text, isError ? "long" : "short")
}
}
Kirigami.PromptDialog {
id: restartDialog
title: "Restart the desktop?"
subtitle: "Every window on the desktop closes, this app too. It starts again with the current settings."
standardButtons: Kirigami.Dialog.NoButton
customFooterActions: [
Kirigami.Action {
text: "Restart"
icon.name: "view-refresh"
onTriggered: { restartDialog.close(); backend.restartDesktop() }
},
Kirigami.Action {
text: "Cancel"
icon.name: "dialog-cancel"
onTriggered: restartDialog.close()
}
]
}
// Save the arrangement under a name, or rename a saved layout.
Kirigami.PromptDialog {
id: nameDialog
property string mode: "save" // save | rename
property string oldName: ""
readonly property var names: backend.layoutNames
readonly property string name: nameField.text.trim().split(/\s+/).join(" ")
readonly property bool taken: name !== oldName && names.indexOf(name) >= 0
readonly property bool ok: name !== "" && !(mode === "rename" && taken)
title: mode === "save" ? "Save the arrangement" : "Rename " + oldName
standardButtons: Kirigami.Dialog.NoButton
function openFor(m, text) {
mode = m
oldName = m === "rename" ? text : ""
nameField.text = text
open()
nameField.forceActiveFocus()
nameField.selectAll()
}
function accept() {
if (!ok) return
close()
if (mode === "save") backend.saveLayout(name)
else if (name !== oldName) backend.renameLayout(oldName, name)
}
ColumnLayout {
Controls.Label {
Layout.fillWidth: true
wrapMode: Text.Wrap
text: nameDialog.mode === "save"
? "Where the screens are now, with their sizes, curves, and pins, under this name:"
: "New name:"
}
Controls.TextField {
id: nameField
Layout.fillWidth: true
maximumLength: 40
onAccepted: nameDialog.accept()
}
Controls.Label {
visible: nameDialog.taken
opacity: 0.7
text: nameDialog.mode === "save" ? "Replaces the saved layout with that name."
: "There's already a layout with that name."
}
}
customFooterActions: [
Kirigami.Action {
text: nameDialog.mode === "save" ? "Save" : "Rename"
icon.name: nameDialog.mode === "save" ? "document-save" : "edit-rename"
enabled: nameDialog.ok
onTriggered: nameDialog.accept()
},
Kirigami.Action {
text: "Cancel"
icon.name: "dialog-cancel"
onTriggered: nameDialog.close()
}
]
}
Kirigami.PromptDialog {
id: deleteDialog
property string name: ""
title: "Delete " + name + "?"
subtitle: "The screens stay where they are; only the saved layout goes."
standardButtons: Kirigami.Dialog.NoButton
customFooterActions: [
Kirigami.Action {
text: "Delete"
icon.name: "edit-delete"
onTriggered: { deleteDialog.close(); backend.deleteLayout(deleteDialog.name) }
},
Kirigami.Action {
text: "Cancel"
icon.name: "dialog-cancel"
onTriggered: deleteDialog.close()
}
]
}
// ---------------------------------------------------------------- Screens
Component {
id: screensPage
Kirigami.ScrollablePage {
id: spage
title: "Screens"
property bool md: backend.backend === "screens"
actions: [
Kirigami.Action {
visible: spage.md
text: "Add screen"
icon.name: "list-add"
onTriggered: backend.addScreen()
},
Kirigami.Action {
visible: spage.md && backend.desktopRunning
text: "Hide/show screens"
icon.name: "view-visible"
onTriggered: backend.toggleScreens()
},
Kirigami.Action {
visible: backend.desktopRunning
text: "Restart desktop"
icon.name: "view-refresh"
tooltip: "Closes the desktop's windows (and this app) and starts it again"
onTriggered: restartDialog.open()
}
]
header: Kirigami.InlineMessage {
position: Kirigami.InlineMessage.Position.Header
visible: backend.restartNeeded || !backend.desktopRunning
type: Kirigami.MessageType.Information
text: backend.restartNeeded
? (spage.md ? "Screens were added or removed. That applies when the desktop starts again; restarting closes its windows (and this app)."
: "The number of screens, resolution, or panel width changed. They apply when the desktop starts again; restarting closes its windows (and this app).")
: "The desktop isn't running. These settings apply the next time it starts."
actions: [
Kirigami.Action {
visible: backend.restartNeeded
text: "Restart desktop"
icon.name: "system-reboot"
onTriggered: backend.restartDesktop()
}
]
}
ColumnLayout {
spacing: Kirigami.Units.largeSpacing
// ft-screens: every screen its own resolution and size.
Repeater {
model: spage.md ? backend.screenList : []
delegate: Kirigami.AbstractCard {
id: card
required property var modelData
Layout.fillWidth: true
property int customIndex: backend.screenResolutions.length
contentItem: ColumnLayout {
RowLayout {
Kirigami.Heading { level: 3; text: "Screen " + (card.modelData.index + 1) }
Controls.Label {
text: card.modelData.width + " × " + card.modelData.height
+ (card.modelData.scale !== 1 ? ", works like " + card.modelData.effective : "")
opacity: 0.7
}
Item { Layout.fillWidth: true }
Controls.RadioButton {
text: "Taskbar here"
checked: card.modelData.primary
onToggled: if (checked) backend.setPrimary(card.modelData.index)
}
Controls.ToolButton {
icon.name: "edit-delete-remove"
enabled: backend.screenList.length > 1
display: Controls.AbstractButton.IconOnly
text: "Remove this screen"
Controls.ToolTip.text: text
Controls.ToolTip.visible: hovered
onClicked: backend.removeScreen(card.modelData.index)
}
}
Kirigami.FormLayout {
Layout.fillWidth: true
RowLayout {
Kirigami.FormData.label: "Resolution:"
Controls.ComboBox {
id: res
model: backend.screenResolutions.concat([{ text: "Custom…", width: 0, height: 0 }])
textRole: "text"
Component.onCompleted: {
const i = backend.screenResolutions.findIndex(r => r.width === card.modelData.width && r.height === card.modelData.height)
currentIndex = i >= 0 ? i : card.customIndex
}
onActivated: {
const r = model[currentIndex]
if (r.width > 0) backend.setScreenSize(card.modelData.index, r.width, r.height)
}
}
Controls.SpinBox {
id: cw
visible: res.currentIndex === card.customIndex
from: 320; to: 16384; stepSize: 8; editable: true
value: card.modelData.width
}
Controls.Label { visible: cw.visible; text: "×" }
Controls.SpinBox {
id: ch
visible: cw.visible
from: 200; to: 16384; stepSize: 8; editable: true
value: card.modelData.height
}
Controls.Button {
visible: cw.visible
text: "Set"
onClicked: backend.setScreenSize(card.modelData.index, cw.value, ch.value)
}
}
RowLayout {
Kirigami.FormData.label: "Width in VR:"
Controls.Slider {
id: metres
from: 0.5; to: 6.0; stepSize: 0.05
value: card.modelData.metres
Layout.preferredWidth: Kirigami.Units.gridUnit * 12
onMoved: backend.setScreenMetres(card.modelData.index, value)
}
Controls.Label {
text: metres.value.toFixed(2) + " m wide, "
+ (metres.value * card.modelData.height / card.modelData.width).toFixed(2) + " m tall"
}
}
Controls.ComboBox {
Kirigami.FormData.label: "Scale:"
model: backend.scales
textRole: "text"
valueRole: "value"
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(card.modelData.scale))
onActivated: backend.setScale(card.modelData.index, currentValue)
}
Controls.Switch {
Kirigami.FormData.label: "Curved:"
text: "Bend around you"
checked: card.modelData.curved
onToggled: backend.setCurved(card.modelData.index, checked)
}
}
}
}
}
// gamescope: one shared resolution.
Kirigami.FormLayout {
visible: !spage.md
Layout.fillWidth: true
Controls.SpinBox {
Kirigami.FormData.label: "Screens:"
from: 1
to: 6
value: backend.screens
onValueModified: backend.setScreens(value)
}
RowLayout {
Kirigami.FormData.label: "Resolution:"
Controls.ComboBox {
id: resBox
property bool custom: currentIndex === count - 1
model: backend.resolutions.concat([{ text: "Custom…", width: 0, height: 0 }])
textRole: "text"
Component.onCompleted: {
const i = backend.resolutions.findIndex(r => r.width === backend.width && r.height === backend.height)
currentIndex = i >= 0 ? i : count - 1
}
onActivated: {
const r = model[currentIndex]
if (r.width > 0) backend.setResolution(r.width, r.height)
}
}
Controls.SpinBox {
id: customW
visible: resBox.custom
from: 640; to: 7680; stepSize: 8
editable: true
value: backend.width
}
Controls.Label { visible: resBox.custom; text: "×" }
Controls.SpinBox {
id: customH
visible: resBox.custom
from: 360; to: 4320; stepSize: 8
editable: true
value: backend.height
}
Controls.Button {
visible: resBox.custom
text: "Set"
onClicked: backend.setResolution(customW.value, customH.value)
}
}
RowLayout {
Kirigami.FormData.label: "Panel width:"
Controls.Slider {
id: physSlider
from: 0.8; to: 3.0; stepSize: 0.05
value: backend.physWidth
Layout.preferredWidth: Kirigami.Units.gridUnit * 12
onMoved: backend.setPhysWidth(value)
}
Controls.Label { text: physSlider.value.toFixed(2) + " m" }
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Each screen" }
Repeater {
model: spage.md ? [] : backend.screenList
delegate: RowLayout {
required property var modelData
Kirigami.FormData.label: "Screen " + (modelData.index + 1) + ":"
Controls.ComboBox {
model: backend.rotations
textRole: "text"
valueRole: "value"
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(modelData.rotation))
onActivated: backend.setRotation(modelData.index, currentValue)
}
Controls.ComboBox {
model: backend.scales
textRole: "text"
valueRole: "value"
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(modelData.scale))
onActivated: backend.setScale(modelData.index, currentValue)
}
Controls.Label {
text: "looks like " + modelData.effective
opacity: 0.7
}
}
}
}
}
footer: Controls.Label {
padding: Kirigami.Units.largeSpacing
wrapMode: Text.Wrap
opacity: 0.7
text: spage.md
? "Each screen is a real monitor of its own: any resolution, portrait by choosing a tall one. Resolution, "
+ "width, and curve apply at once. In VR: move a screen by the bar underneath, curve it with the round "
+ "button next to the bar, resize it by the tab on its bottom right corner; Save as profile… on the "
+ "Layout & profiles page keeps all of it."
: "gamescope draws every screen at the same resolution, at most 1920 × 1080 worth of pixels. Portrait turns "
+ "a screen on its side. Rotation and scale apply at once; the rest when the desktop starts."
}
}
}
// ---------------------------------------------------------------- Layout
Component {
id: layoutPage
Kirigami.ScrollablePage {
id: lpage
title: "Layout & profiles"
property var layout: backend.layout
property var preset: layout.preset || {}
property bool hasCustom: (layout.screens || []).some(s => s.pos !== undefined)
// Named layouts: the arrangement is one of them (named) when it came from it, and
// hasn't been placed by hand and saved without a name since.
property var names: backend.layoutNames
property bool fromNamed: names.indexOf(layout.active) >= 0
property bool named: layout.mode === "custom" && fromNamed
property bool unnamed: names.length === 0 || ((hasCustom || layout.mode === "custom") && !fromNamed)
property var choices: [{ text: "Curved around you", value: "arc" }, { text: "Flat wall", value: "flat" }]
.concat(names.map(n => ({ text: n, value: "layout:" + n })))
.concat(unnamed ? [{ text: names.length ? "Unnamed arrangement" : "Saved arrangement", value: "custom" }] : [])
actions: [
Kirigami.Action {
text: lpage.named ? "Open profile" : "Arrange now"
icon.name: "view-restore"
tooltip: lpage.named ? "Put the screens in this profile's places, around where you're facing, and open its apps (windows already open move; nothing closes)"
: "Float the screens out of the dashboard and put them in this layout, around where you're facing"
enabled: backend.desktopRunning && backend.busy === ""
onTriggered: backend.arrange()
},
Kirigami.Action {
text: "Save as profile…"
icon.name: "document-save"
tooltip: "Save where the screens are now, which ones are hidden, and the open apps and where their windows are, under a name"
enabled: backend.desktopRunning && backend.busy === ""
onTriggered: nameDialog.openFor("save", lpage.named ? lpage.layout.active
: "Layout " + (lpage.names.length + 1))
}
]
header: Kirigami.InlineMessage {
position: Kirigami.InlineMessage.Position.Header
visible: backend.busy !== ""
type: Kirigami.MessageType.Information
text: backend.busy + "… (the pointer is borrowed for a few seconds)"
}
ColumnLayout {
spacing: Kirigami.Units.largeSpacing
Kirigami.FormLayout {
Layout.fillWidth: true
RowLayout {
Kirigami.FormData.label: "Arrangement:"
Controls.ComboBox {
model: lpage.choices
textRole: "text"
valueRole: "value"
currentIndex: lpage.layout.mode !== "custom" ? (lpage.preset.kind === "flat" ? 1 : 0)
: lpage.named ? 2 + lpage.names.indexOf(lpage.layout.active)
: lpage.choices.length - 1
onActivated: {
if (currentValue === "custom") backend.setMode("custom")
else if (currentValue.startsWith("layout:")) backend.useLayout(currentValue.slice(7))
else backend.setPreset("kind", currentValue)
}
}
Controls.ToolButton {
visible: lpage.named
icon.name: "edit-rename"
text: "Rename…"
display: Controls.AbstractButton.IconOnly
Controls.ToolTip.text: text
Controls.ToolTip.visible: hovered
onClicked: nameDialog.openFor("rename", lpage.layout.active)
}
Controls.ToolButton {
visible: lpage.named
icon.name: "edit-delete"
text: "Delete…"
display: Controls.AbstractButton.IconOnly
Controls.ToolTip.text: text
Controls.ToolTip.visible: hovered
onClicked: { deleteDialog.name = lpage.layout.active; deleteDialog.open() }
}
}
Controls.Label {
visible: lpage.layout.mode === "custom"
Kirigami.FormData.label: ""
text: lpage.named ? "Where the screens were when you saved it, and the apps that were open. Open "
+ "profile puts the screens there and opens the apps. Save as profile updates "
+ "it or saves a new one."
: lpage.hasCustom ? "Where the screens were when you saved. Save as profile "
+ "names it. Pick a preset to edit."
: "Nothing saved yet: place the screens by hand, open your apps, then Save as profile."
opacity: 0.7
wrapMode: Text.Wrap
Layout.maximumWidth: Kirigami.Units.gridUnit * 20
}
// The profile's apps (docs/profiles.md): each window and where it goes.
ColumnLayout {
id: profileApps
visible: lpage.named
Kirigami.FormData.label: "Apps:"
property var windows: lpage.named ? backend.profileWindows(lpage.layout.active) : []
property var hidden: lpage.named ? backend.profileHidden(lpage.layout.active) : []
Connections {
target: backend
function onChanged() {
profileApps.windows = lpage.named ? backend.profileWindows(lpage.layout.active) : []
profileApps.hidden = lpage.named ? backend.profileHidden(lpage.layout.active) : []
}
}
Controls.Label {
visible: profileApps.windows.length === 0
text: "None saved. Open the apps you want, place their windows, then Save as profile."
opacity: 0.7
wrapMode: Text.Wrap
Layout.maximumWidth: Kirigami.Units.gridUnit * 20
}
Repeater {
model: profileApps.windows
delegate: RowLayout {
required property var modelData
required property int index
Controls.Label { text: modelData.app + " (" + modelData.where + ")" }
Controls.ToolButton {
icon.name: "list-remove"
text: "Leave out"
display: Controls.AbstractButton.IconOnly
Controls.ToolTip.text: "Leave this window out of the profile"
Controls.ToolTip.visible: hovered
onClicked: backend.removeProfileWindow(lpage.layout.active, index)
}
}
}
Controls.Label {
visible: profileApps.hidden.length > 0
text: "Hides screen" + (profileApps.hidden.length > 1 ? "s " : " ") + profileApps.hidden.join(", ")
opacity: 0.7
}
}
Controls.SpinBox {
Kirigami.FormData.label: "Rows:"
visible: lpage.layout.mode !== "custom"
from: 1
to: Math.max(1, backend.screens)
value: lpage.preset.rows || 1
onValueModified: backend.setPreset("rows", value)
}
Repeater {
model: [
{ key: "distance", label: "Distance", from: 0.6, to: 3.0, step: 0.05, unit: "m", def: 1.2 },
{ key: "gap", label: "Gap", from: 0.0, to: 0.3, step: 0.01, unit: "m", def: 0.04 },
{ key: "height", label: "Height", from: -0.8, to: 0.8, step: 0.05, unit: "m", def: 0.0 }
]
delegate: RowLayout {
required property var modelData
visible: lpage.layout.mode !== "custom"
Kirigami.FormData.label: modelData.label + ":"
Controls.Slider {
id: s
from: modelData.from; to: modelData.to; stepSize: modelData.step
value: lpage.preset[modelData.key] !== undefined ? lpage.preset[modelData.key] : modelData.def
Layout.preferredWidth: Kirigami.Units.gridUnit * 12
onMoved: backend.setPreset(modelData.key, value)
}
Controls.Label {
text: (modelData.key === "height" && s.value > 0 ? "+" : "") + s.value.toFixed(2) + " " + modelData.unit
+ (modelData.key === "height" ? " (from eye level)" : "")
}
}
}
Controls.Switch {
Kirigami.FormData.label: "When the desktop starts:"
text: "Float the screens and arrange them"
checked: lpage.layout.auto !== false
enabled: backend.defaultProfile === ""
onToggled: backend.setAuto(checked)
}
Controls.ComboBox {
Kirigami.FormData.label: "Start in profile:"
model: [{ text: "None", value: "" }].concat(lpage.names.map(n => ({ text: n, value: n })))
textRole: "text"
valueRole: "value"
currentIndex: Math.max(0, indexOfValue(backend.defaultProfile))
onActivated: backend.setDefaultProfile(currentValue)
Controls.ToolTip.text: "The desktop starts in this profile: its screens, and its apps open. Each profile also has its own entry in SteamVR's Launch a program list"
Controls.ToolTip.visible: hovered
}
}
// Preview: from above (you at the bottom) and from the front.
Kirigami.Heading { level: 3; text: "Preview" }
Canvas {
id: preview
Layout.fillWidth: true
Layout.preferredHeight: Kirigami.Units.gridUnit * 13
property var plan: backend.plan
onPlanChanged: requestPaint()
onWidthChanged: requestPaint()
onPaint: {
const ctx = getContext("2d")
ctx.reset()
const text = Kirigami.Theme.textColor
const accent = Kirigami.Theme.highlightColor
const half = width / 2
ctx.font = Kirigami.Theme.smallFont.pixelSize + "px sans-serif"
ctx.fillStyle = text
ctx.fillText("From above", 4, 12)
ctx.fillText("From the front", half + 8, 12)
if (!plan || plan.length === 0) return
// From above: x right, forward (-z) up; you are the dot at the bottom.
let ends = [[0, 0]]
for (const p of plan) {
const f = p.faceYaw * Math.PI / 180
const rx = Math.cos(f) * p.width / 2, rz = -Math.sin(f) * p.width / 2
ends.push([p.x - rx, p.z - rz], [p.x + rx, p.z + rz])
}
const xs = ends.map(e => e[0]), zs = ends.map(e => e[1])
const span = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...zs) - Math.min(...zs), 0.5)
const k = Math.min(half - 20, height - 44) / span
const cx = half / 2 - (Math.max(...xs) + Math.min(...xs)) / 2 * k
const cz = height - 12 - Math.max(...zs) * k // your dot 12 px above the bottom
const P = (x, z) => [cx + x * k, cz + z * k]
ctx.fillStyle = text
ctx.beginPath(); const me = P(0, 0); ctx.arc(me[0], me[1], 4, 0, 2 * Math.PI); ctx.fill()
ctx.lineWidth = 4
ctx.lineCap = "round"
// Screens stacked in rows share a spot from above, so they share a label ("1·3"):
// the same direction on a curve, the same x on a flat wall.
const flat = plan.every(p => Math.abs(p.faceYaw - plan[0].faceYaw) < 0.1
&& Math.abs(p.facePitch - plan[0].facePitch) < 0.1)
const labels = {}
for (const p of plan) {
const f = p.faceYaw * Math.PI / 180
const rx = Math.cos(f) * p.width / 2, rz = -Math.sin(f) * p.width / 2
const a = P(p.x - rx, p.z - rz), b = P(p.x + rx, p.z + rz)
ctx.strokeStyle = accent
ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.stroke()
const c = P(p.x, p.z), spot = flat ? Math.round(p.x * 50) : Math.round(p.faceYaw * 2)
labels[spot] = labels[spot] || { at: c, names: [] }
labels[spot].names.push(p.index + 1)
}
ctx.fillStyle = text
for (const spot in labels) {
const l = labels[spot], t = l.names.join("·")
ctx.fillText(t, l.at[0] - ctx.measureText(t).width / 2, l.at[1] - 7)
}
// From the front: x right, y up. A flat wall as it is; a curved layout
// unrolled (arc length by yaw and pitch), so the gaps show true.
const front = plan.map(p => {
if (flat) return { x: p.x, y: p.y, w: p.width, h: p.height }
const r = Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z)
const arc = m => 2 * r * Math.atan(m / 2 / r) // what the screen spans on the curve
return { x: -p.faceYaw * Math.PI / 180 * r, y: p.facePitch * Math.PI / 180 * r,
w: arc(p.width), h: arc(p.height) }
})
const fx = [], fy = []
front.forEach(f => { fx.push(f.x - f.w / 2, f.x + f.w / 2); fy.push(f.y - f.h / 2, f.y + f.h / 2) })
fy.push(0)
const fspan = Math.max(Math.max(...fx) - Math.min(...fx), Math.max(...fy) - Math.min(...fy), 0.5)
const fk = Math.min(half - 20, height - 44) / fspan
const ox = half + half / 2 - (Math.max(...fx) + Math.min(...fx)) / 2 * fk
const oy = 30 + (height - 42) / 2 + (Math.max(...fy) + Math.min(...fy)) / 2 * fk
ctx.strokeStyle = Kirigami.Theme.disabledTextColor
ctx.lineWidth = 1
ctx.setLineDash([4, 4])
ctx.beginPath(); ctx.moveTo(half + 8, oy); ctx.lineTo(width - 4, oy); ctx.stroke() // eye level
ctx.setLineDash([])
plan.forEach((p, i) => {
const f = front[i], x = ox + (f.x - f.w / 2) * fk, y = oy - (f.y + f.h / 2) * fk
ctx.fillStyle = Qt.rgba(accent.r, accent.g, accent.b, 0.35)
ctx.fillRect(x, y, f.w * fk, f.h * fk)
ctx.strokeStyle = accent
ctx.lineWidth = 2
ctx.strokeRect(x, y, f.w * fk, f.h * fk)
ctx.fillStyle = text
ctx.fillText(String(p.index + 1), x + f.w * fk / 2 - 3, y + f.h * fk / 2 + 4)
})
}
}
Controls.Label {
Layout.fillWidth: true
wrapMode: Text.Wrap
opacity: 0.7
text: "The layout goes around where you're facing when it's applied. Move screens by hand any time "
+ "(grab bar under each screen); to put them back: Meta+Shift+R in the desktop, the Reset "
+ "Screen Layout menu entry, Arrange now here, or a mouse button mapped to \"Reset desktop "
+ "screen layout\" in Frametop Input Settings → Buttons."
}
}
}
}
// ---------------------------------------------------------------- Visibility
Component {
id: visibilityPage
Kirigami.ScrollablePage {
id: vpage
title: "Visibility"
property var v: backend.visibility
actions: [
Kirigami.Action {
text: "Hide/show now"
icon.name: "view-visible"
enabled: backend.desktopRunning
onTriggered: backend.toggleScreens()
}
]
ColumnLayout {
spacing: Kirigami.Units.largeSpacing
Kirigami.FormLayout {
Layout.fillWidth: true
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "When the screens show" }
Repeater {
model: [
{ value: "always", text: "Always", help: "Meta+Shift+H (or a mapped button) hides them. During VR games, see below." },
{ value: "dashboard", text: "Only with the SteamVR dashboard open", help: "They come and go with the dashboard. Meta+Shift+H shows them anyway." },
{ value: "gesture", text: "While I look at my wrist", help: "They show while you look toward the controller below. Meta+Shift+H shows them anyway." },
{ value: "toggle", text: "Only when I show them", help: "Hidden until Meta+Shift+H (or a mapped button) shows them." }
]
delegate: ColumnLayout {
required property var modelData
spacing: 0
Controls.RadioButton {
text: modelData.text
checked: vpage.v.mode === modelData.value
onToggled: if (checked) backend.setVisibility("mode", modelData.value)
}
Controls.Label {
text: modelData.help
opacity: 0.7
font: Kirigami.Theme.smallFont
leftPadding: Kirigami.Units.gridUnit * 1.6
wrapMode: Text.Wrap
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
}
}
}
RowLayout {
Kirigami.FormData.label: "Wrist:"
visible: vpage.v.mode === "gesture"
Controls.ComboBox {
model: [{ text: "Left controller", value: "left" }, { text: "Right controller", value: "right" }]
textRole: "text"
valueRole: "value"
Component.onCompleted: currentIndex = indexOfValue(vpage.v.gesture_hand)
onActivated: backend.setVisibility("gesture_hand", currentValue)
}
}
RowLayout {
Kirigami.FormData.label: "Look within:"
visible: vpage.v.mode === "gesture"
Controls.Slider {
id: gesture
from: 5; to: 60; stepSize: 1
value: vpage.v.gesture_angle
Layout.preferredWidth: Kirigami.Units.gridUnit * 12
onMoved: backend.setVisibility("gesture_angle", value)
}
Controls.Label { text: Math.round(gesture.value) + "° of it" }
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "During VR games" }
Repeater {
model: [
{ value: "hide", text: "Hide them unless the SteamVR dashboard is open", help: "The game has the view to itself; open the dashboard (or press Meta+Shift+H) to see the screens." },
{ value: "visible", text: "Keep them visible over the game", help: "They float over the game as they are outside it." }
]
delegate: ColumnLayout {
required property var modelData
spacing: 0
Controls.RadioButton {
text: modelData.text
enabled: vpage.v.mode === "always"
checked: (vpage.v.in_games || "hide") === modelData.value
onToggled: if (checked) backend.setVisibility("in_games", modelData.value)
}
Controls.Label {
text: modelData.help
opacity: 0.7
font: Kirigami.Theme.smallFont
leftPadding: Kirigami.Units.gridUnit * 1.6
wrapMode: Text.Wrap
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
}
}
}
Controls.Label {
visible: vpage.v.mode !== "always"
text: "Applies when the screens show \"Always\"; the other choices above already keep them out of the way."
opacity: 0.7
font: Kirigami.Theme.smallFont
wrapMode: Text.Wrap
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Controllers on the screens" }
Repeater {
model: [
{ value: "outside_games", text: "Except during VR games", help: "Over a VR game the screens stay up, but the controllers stay in the game. Use the 3D mouse, or open the SteamVR dashboard, to work the screens." },
{ value: "always", text: "Always", help: "Controllers' lasers work the screens whenever they're visible, even over a VR game (which then can't use the controllers)." },
{ value: "dashboard", text: "Only with the SteamVR dashboard open", help: "Otherwise only the 3D mouse works the screens. Also for flatscreen games, which don't count as VR games." }
]
delegate: ColumnLayout {
required property var modelData
spacing: 0
Controls.RadioButton {
text: modelData.text
checked: (vpage.v.controllers || "outside_games") === modelData.value
onToggled: if (checked) backend.setVisibility("controllers", modelData.value)
}
Controls.Label {
text: modelData.help
opacity: 0.7
font: Kirigami.Theme.smallFont
leftPadding: Kirigami.Units.gridUnit * 1.6
wrapMode: Text.Wrap
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
}
}
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Screens shown" }
Repeater {
model: backend.screensShown
delegate: Controls.Switch {
required property var modelData
required property int index
Kirigami.FormData.label: "Screen " + (index + 1) + ":"
text: modelData ? "Shown" : "Hidden"
checked: modelData
onToggled: backend.setScreenShown(index, checked)
}
}
Controls.Label {
text: "A hidden screen stays hidden whatever the choices above say, and Meta+Shift+H doesn't bring it back. Windows on it stay there; new ones that would open on it float instead."
opacity: 0.7
font: Kirigami.Theme.smallFont
wrapMode: Text.Wrap
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Pinned screens" }
Repeater {
model: backend.pins
delegate: Controls.ComboBox {
required property var modelData
required property int index
Kirigami.FormData.label: "Screen " + (index + 1) + ":"
model: [
{ text: "In the room", value: "none" },
{ text: "On the left wrist", value: "left" },
{ text: "On the right wrist", value: "right" },
{ text: "On your head", value: "head" }
]
textRole: "text"
valueRole: "value"
currentIndex: Math.max(0, ["none", "left", "right", "head"].indexOf(modelData))
onActivated: backend.pin(String(index + 1), currentValue)
}
}
RowLayout {
Kirigami.FormData.label: "Wrist screens show within:"
Controls.Slider {
id: wrist
from: 20; to: 120; stepSize: 1
value: vpage.v.wrist_angle
Layout.preferredWidth: Kirigami.Units.gridUnit * 12
onMoved: backend.setVisibility("wrist_angle", value)
}
Controls.Label { text: Math.round(wrist.value) + "°" }
}
RowLayout {
Kirigami.FormData.label: "All screens:"
Controls.Button {
text: "Pin to left wrist"
enabled: backend.desktopRunning
onClicked: backend.pin("all", "left")
}
Controls.Button {
text: "Pin to right wrist"
enabled: backend.desktopRunning
onClicked: backend.pin("all", "right")
}
Controls.Button {
text: "Pin to head"
enabled: backend.desktopRunning
onClicked: backend.pin("all", "head")
}
Controls.Button {
text: "Unpin"
enabled: backend.desktopRunning
onClicked: backend.pin("all", "none")
}
}
}
Controls.Label {
Layout.fillWidth: true
wrapMode: Text.Wrap
opacity: 0.7
text: "Pin one screen: carry it by its bar and sweep its laser across your other controller. A "
+ "ring shows the target and a dot shows where the laser is; crossing the ring arms the pin (ring "
+ "and bar turn blue), crossing it again disarms it. Turn and place the screen the way you want, "
+ "then let go: it rides on that wrist at that size and distance, however far away. To adjust a "
+ "pinned screen, grab its bar, move it, and let go (it stays pinned); sweep across the ring to "
+ "take it off. It shows while you see its front within the angle above, and fades out beyond "
+ "it.\n\nPin a screen to your head: choose On your head above. It rides on the headset where it "
+ "is now, like a HUD, and shows whenever the screens do. Grab its bar to move it; it stays on "
+ "your head where you let go. Choosing a pin above keeps the screen where it is now, so place "
+ "it first. Save as profile… (Layout) keeps pins."
}
}
}
}
// ---------------------------------------------------------------- Power
Component {
id: powerPage
Kirigami.ScrollablePage {
id: ppage
title: "Power"
property var p: backend.power
// The timeout choices, plus a value set by hand in frametop.conf.
property var offChoices: {
const list = [{ text: "Never", value: 0 }].concat([1, 2, 5, 10, 15, 30, 60].map(
m => ({ text: root.duration(m * 60), value: m })))
if (!list.some(c => c.value === p.offMinutes))
list.push({ text: root.duration(Math.round(p.offMinutes * 60)), value: p.offMinutes })
return list
}
Component.onCompleted: backend.refreshPower()
actions: [
Kirigami.Action {
text: "Turn displays off now"
icon.name: "system-suspend"
tooltip: "To try it: they come back on when the headset moves or any input is used"
enabled: ppage.p.service && ppage.p.state === "on"
onTriggered: backend.displaysOffNow()
}
]
header: Kirigami.InlineMessage {
position: Kirigami.InlineMessage.Position.Header
visible: !ppage.p.service
type: Kirigami.MessageType.Warning
text: "The power service (frametop-power) isn't running, so the displays won't turn off on their own. "
+ "It starts with SteamVR once it's installed: power/run.sh install, or run ./install.sh again."
}
ColumnLayout {
spacing: Kirigami.Units.largeSpacing
Kirigami.FormLayout {
Layout.fillWidth: true
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Displays" }
Controls.ComboBox {
Kirigami.FormData.label: "Turn off when unused for:"
model: ppage.offChoices
textRole: "text"
valueRole: "value"
Component.onCompleted: currentIndex = Math.max(0, indexOfValue(ppage.p.offMinutes))
onActivated: backend.setDisplayOffMinutes(currentValue)
}
Controls.Label {
text: "Unused means the headset and controllers haven't moved and no mouse, keyboard, or button "
+ "was used. This works even when the headset seems to be worn, like on a display mount "
+ "that covers its proximity sensor. Moving the headset or using any input turns the "
+ "displays back on. Taking the headset off still turns them off within seconds."
opacity: 0.7
font: Kirigami.Theme.smallFont
wrapMode: Text.Wrap
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
}
Controls.Label {
Kirigami.FormData.label: "Now:"
visible: ppage.p.service
text: ppage.p.state === "off" ? "Off. Move the headset or use any input to turn them on."
: ppage.p.state === "away" ? "Off. SteamVR turned them off because the headset isn't being worn."
: ppage.p.offMinutes > 0
? "On, unused for " + (ppage.p.unused < 60 ? Math.floor(ppage.p.unused) + " s"
: Math.floor(ppage.p.unused / 60) + " min " + Math.floor(ppage.p.unused % 60) + " s")
: "On"
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Sleep" }
Controls.Switch {
id: awake
Kirigami.FormData.label: "While plugged in:"
text: "Stay awake"
checked: ppage.p.acSleep === 0
enabled: ppage.p.steam && !ppage.p.steamBusy
onToggled: {
backend.setStayAwake(checked)
checked = Qt.binding(() => ppage.p.acSleep === 0) // follow what Steam has
}
}
Controls.Label {
text: !ppage.p.steam
? (ppage.p.steamBusy ? "Checking Steam's setting…" : "Couldn't reach Steam: " + ppage.p.steamError)
: "Keeps the Frame awake and connected while it charges, for remote access, downloads, and "
+ "anything else running. This is Steam's own setting (Settings → Power → When Plugged In "
+ "and Idle), so the power button still puts the Frame to sleep. "
+ (ppage.p.acSleep > 0 ? "Now Steam puts it to sleep after " + root.duration(ppage.p.acSleep)
+ " without input, even while it charges. " : "")
+ "On battery, Steam's battery setting still applies ("
+ (ppage.p.batterySleep > 0 ? "sleep after " + root.duration(ppage.p.batterySleep) : "never sleep")
+ ")."
opacity: 0.7
font: Kirigami.Theme.smallFont
wrapMode: Text.Wrap
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
}
}
Controls.Label {
Layout.fillWidth: true
wrapMode: Text.Wrap
opacity: 0.7
text: "With the displays off, the headset keeps tracking and drawing, so it can wake the moment "
+ "it moves. It still uses most of its power, so leave it on a charger that keeps up with it "
+ "in use."
}
}
}
}
}