mirror of
https://github.com/arthur-pbty/arthur-os.git
synced 2026-08-02 12:47:49 +02:00
627 lines
20 KiB
QML
627 lines
20 KiB
QML
import QtQuick
|
|
import QtQuick.Controls
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import qs.Ui
|
|
import qs.Commons
|
|
|
|
Panel {
|
|
id: root
|
|
moduleName: "MonitorPanel"
|
|
ipcTarget: "panels.monitor"
|
|
manageIpc: false
|
|
|
|
// manageIpc: false so this panel can own the single IpcHandler the target
|
|
// permits — needed for the brightness + state methods below.
|
|
property int brightnessPercent: 0
|
|
property int pendingBrightnessPercent: 0
|
|
property bool brightnessSetQueued: false
|
|
property bool brightnessAvailable: false
|
|
property string internalMonitor: ""
|
|
property string externalMonitor: ""
|
|
property string focusedMonitor: ""
|
|
property bool internalEnabled: false
|
|
property bool mirrorEnabled: false
|
|
property string monitorScale: ""
|
|
property var displays: []
|
|
property int enabledDisplayCount: 0
|
|
|
|
// Cursor model shared by keyboard and mouse. Sections:
|
|
// "brightness" - single slider row, selectedIndex = -1 sentinel
|
|
// (mirrors Audio's slider rows). Only present if a
|
|
// controllable backlight was detected.
|
|
// "scale" - 6 Button scale presets; treated as a single
|
|
// horizontal row from j/k's perspective. h/l moves
|
|
// between presets, identical to bluetooth's header.
|
|
// "monitors" - vertical display row list for enabling/disabling displays;
|
|
// j/k walks each row.
|
|
// Mouse hover on a target updates root state via the components' `hovered`
|
|
// signal so keyboard cursor and pointer share one highlight.
|
|
readonly property var scaleValues: ["1", "1.25", "1.6", "2", "3", "4"]
|
|
property string focusSection: "scale"
|
|
property int selectedIndex: 0
|
|
property bool cursorActive: false
|
|
|
|
readonly property var visibleSections: {
|
|
var list = []
|
|
if (brightnessAvailable) list.push("brightness")
|
|
list.push("scale")
|
|
if (displays.length > 1) list.push("monitors")
|
|
return list
|
|
}
|
|
|
|
function sectionCount(section) {
|
|
if (section === "brightness") return 0 // only the slider sentinel at -1
|
|
if (section === "scale") return scaleValues.length
|
|
if (section === "monitors") return displays.length
|
|
return 0
|
|
}
|
|
|
|
function sectionIsSingleRow(section) {
|
|
// brightness has only the slider; scale presets sit horizontally.
|
|
return section === "brightness" || section === "scale"
|
|
}
|
|
|
|
function sectionFirstIndex(section) {
|
|
if (section === "brightness") return -1
|
|
return 0
|
|
}
|
|
|
|
function moveCursor(delta) {
|
|
var sections = visibleSections
|
|
if (!sections || sections.length === 0) return
|
|
var sIdx = sections.indexOf(focusSection)
|
|
if (sIdx < 0) {
|
|
focusSection = sections[0]
|
|
selectedIndex = sectionFirstIndex(focusSection)
|
|
return
|
|
}
|
|
var inSingleRow = sectionIsSingleRow(focusSection)
|
|
var max = inSingleRow ? 0 : sectionCount(focusSection) - 1
|
|
|
|
if (delta > 0) {
|
|
if (!inSingleRow && selectedIndex < max) { selectedIndex = selectedIndex + 1; return }
|
|
if (sIdx < sections.length - 1) {
|
|
focusSection = sections[sIdx + 1]
|
|
selectedIndex = sectionFirstIndex(focusSection)
|
|
}
|
|
} else {
|
|
if (!inSingleRow && selectedIndex > 0) { selectedIndex = selectedIndex - 1; return }
|
|
if (sIdx > 0) {
|
|
var prev = sections[sIdx - 1]
|
|
focusSection = prev
|
|
// Coming up from below — land on the last navigable row of the prev
|
|
// section, or its sentinel for single-row sections.
|
|
selectedIndex = sectionIsSingleRow(prev) ? sectionFirstIndex(prev) : sectionCount(prev) - 1
|
|
}
|
|
}
|
|
}
|
|
|
|
// h/l: in scale section, walks the preset row; everywhere else, no-op
|
|
// because adjustBrightness handles horizontal motion on the brightness
|
|
// slider.
|
|
function moveCursorH(delta) {
|
|
if (focusSection !== "scale") return
|
|
var next = selectedIndex + delta
|
|
if (next < 0) next = 0
|
|
if (next > scaleValues.length - 1) next = scaleValues.length - 1
|
|
selectedIndex = next
|
|
}
|
|
|
|
function adjustBrightness(delta) {
|
|
if (focusSection !== "brightness") return
|
|
if (!brightnessAvailable) return
|
|
setBrightness(root.brightnessPercent + delta)
|
|
}
|
|
|
|
function activateCursor() {
|
|
if (focusSection === "scale" && selectedIndex >= 0 && selectedIndex < scaleValues.length) {
|
|
setScale(scaleValues[selectedIndex])
|
|
return
|
|
}
|
|
if (focusSection === "monitors" && selectedIndex >= 0 && selectedIndex < displays.length) {
|
|
var d = displays[selectedIndex]
|
|
if (d) toggleDisplay(d.name, d.enabled)
|
|
}
|
|
// brightness: no separate action; the slider value is the action.
|
|
}
|
|
|
|
function clampCursor() {
|
|
var sections = visibleSections
|
|
if (!sections || !sections.length) return
|
|
if (sections.indexOf(focusSection) < 0) {
|
|
focusSection = sections[0]
|
|
selectedIndex = sectionFirstIndex(focusSection)
|
|
return
|
|
}
|
|
var count = sectionCount(focusSection)
|
|
if (sectionIsSingleRow(focusSection)) {
|
|
// brightness uses -1 sentinel; scale clamps into the preset range.
|
|
if (focusSection === "brightness") selectedIndex = -1
|
|
else if (selectedIndex < 0 || selectedIndex >= count) selectedIndex = 0
|
|
return
|
|
}
|
|
if (count === 0) {
|
|
var sIdx = sections.indexOf(focusSection)
|
|
focusSection = sIdx > 0 ? sections[sIdx - 1] : sections[0]
|
|
selectedIndex = sectionFirstIndex(focusSection)
|
|
return
|
|
}
|
|
if (selectedIndex > count - 1) selectedIndex = count - 1
|
|
if (selectedIndex < 0) selectedIndex = 0
|
|
}
|
|
|
|
// Keep the keyboard-focused row inside the viewport when the panel grows
|
|
// taller than its allotted height (lots of displays). Mirrors audio's
|
|
// ensureCursorVisible helper.
|
|
function ensureCursorVisible(item) {
|
|
if (!item || !scrollArea) return
|
|
var flick = scrollArea.contentItem
|
|
if (!flick || flick.contentY === undefined) return
|
|
var pt = item.mapToItem(flick.contentItem || flick, 0, 0)
|
|
var top = pt.y
|
|
var bottom = top + (item.height || 0)
|
|
var viewTop = flick.contentY
|
|
var viewBottom = viewTop + flick.height
|
|
var margin = 6
|
|
if (top < viewTop + margin) flick.contentY = Math.max(0, top - margin)
|
|
else if (bottom > viewBottom - margin)
|
|
flick.contentY = bottom + margin - flick.height
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "panels.monitor"
|
|
|
|
function brightness(percent: string): string {
|
|
var value = Number(percent)
|
|
root.setBrightness(value)
|
|
return "got " + root.pendingBrightnessPercent
|
|
}
|
|
|
|
function state(): string {
|
|
return JSON.stringify({
|
|
brightness: root.brightnessPercent,
|
|
brightnessAvailable: root.brightnessAvailable,
|
|
focusedMonitor: root.focusedMonitor,
|
|
scale: root.monitorScale,
|
|
displays: root.displays
|
|
})
|
|
}
|
|
|
|
function open(): void { root.open() }
|
|
function close(): void { root.close() }
|
|
function toggle(): void { root.toggle() }
|
|
function show(): void { root.open() }
|
|
function hide(): void { root.close() }
|
|
}
|
|
|
|
function refresh() {
|
|
if (!stateProc.running) stateProc.running = true
|
|
}
|
|
|
|
function setBrightness(value) {
|
|
var percent = Math.max(1, Math.min(100, Math.round(value)))
|
|
root.brightnessPercent = percent
|
|
root.pendingBrightnessPercent = percent
|
|
|
|
if (setBrightnessProc.running) {
|
|
root.brightnessSetQueued = true
|
|
return
|
|
}
|
|
|
|
root.brightnessSetQueued = false
|
|
setBrightnessProc.command = ["bash", "-lc", "omarchy-brightness-display " + percent + "%"]
|
|
setBrightnessProc.running = true
|
|
}
|
|
|
|
function previewBrightness(value) {
|
|
root.brightnessPercent = Math.max(1, Math.min(100, Math.round(value)))
|
|
brightnessDebounce.restart()
|
|
}
|
|
|
|
function normalizeScale(scale) {
|
|
var n = parseFloat(String(scale || ""))
|
|
if (!isFinite(n)) return ""
|
|
return String(Math.round(n * 100) / 100)
|
|
}
|
|
|
|
function updateDisplays(displaysJson) {
|
|
try {
|
|
root.displays = displaysJson ? JSON.parse(displaysJson) : []
|
|
} catch(e) {
|
|
root.displays = []
|
|
}
|
|
|
|
var count = 0
|
|
for (var i = 0; i < root.displays.length; i++)
|
|
if (root.displays[i] && root.displays[i].enabled) count++
|
|
root.enabledDisplayCount = count
|
|
}
|
|
|
|
function toggleDisplay(name, enabled) {
|
|
if (!name) return
|
|
if (enabled && root.enabledDisplayCount <= 1) return
|
|
|
|
actionProc.command = ["hyprctl", "keyword", "monitor", name + (enabled ? ",disable" : ",preferred,auto,auto")]
|
|
if (!actionProc.running) actionProc.running = true
|
|
}
|
|
|
|
function setScale(scale) {
|
|
actionProc.command = ["bash", "-lc", "omarchy-hyprland-monitor-scaling " + scale]
|
|
if (!actionProc.running) actionProc.running = true
|
|
}
|
|
|
|
implicitWidth: button.implicitWidth
|
|
implicitHeight: button.implicitHeight
|
|
|
|
Component.onCompleted: refresh()
|
|
|
|
// KeyboardPanel takes Exclusive focus at map-time, so SUPER-bound IPC
|
|
// summons land with j/k ready to navigate. Keep a default landing point,
|
|
// but don't paint the cursor until hover or the first navigation key.
|
|
onOpenedChanged: {
|
|
if (opened) {
|
|
refresh()
|
|
if (brightnessAvailable) {
|
|
focusSection = "brightness"
|
|
selectedIndex = -1
|
|
} else {
|
|
focusSection = "scale"
|
|
selectedIndex = 0
|
|
}
|
|
cursorActive = false
|
|
}
|
|
}
|
|
|
|
onBrightnessAvailableChanged: clampCursor()
|
|
onDisplaysChanged: clampCursor()
|
|
onVisibleSectionsChanged: clampCursor()
|
|
|
|
Timer {
|
|
interval: 5000
|
|
running: true
|
|
repeat: true
|
|
onTriggered: root.refresh()
|
|
}
|
|
|
|
Process {
|
|
id: stateProc
|
|
command: [root.bar ? root.bar.omarchyPath + "/bin/omarchy-monitor-state" : "omarchy-monitor-state"]
|
|
stdout: StdioCollector {
|
|
waitForEnd: true
|
|
onStreamFinished: {
|
|
var lines = String(text || "").split("\n")
|
|
var brightness = String(lines[0] || "").trim()
|
|
root.brightnessAvailable = brightness !== "unavailable" && brightness !== ""
|
|
root.brightnessPercent = root.brightnessAvailable ? Math.max(0, Math.min(100, parseInt(brightness, 10))) : 0
|
|
root.internalMonitor = String(lines[1] || "").trim()
|
|
root.externalMonitor = String(lines[2] || "").trim()
|
|
root.internalEnabled = String(lines[3] || "").trim() !== ""
|
|
root.mirrorEnabled = String(lines[4] || "").trim() === root.externalMonitor && root.externalMonitor !== ""
|
|
root.focusedMonitor = String(lines[5] || "").trim()
|
|
root.monitorScale = root.normalizeScale(String(lines[6] || "").trim())
|
|
root.updateDisplays(String(lines[7] || "[]").trim())
|
|
}
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: brightnessDebounce
|
|
interval: 180
|
|
repeat: false
|
|
onTriggered: root.setBrightness(root.brightnessPercent)
|
|
}
|
|
|
|
Process {
|
|
id: setBrightnessProc
|
|
stdout: StdioCollector { waitForEnd: true }
|
|
// Do NOT call refresh() after a brightness set completes. The local
|
|
// brightnessPercent we just wrote is authoritative; re-reading via
|
|
// `omarchy-brightness-display` races the hardware/driver and can
|
|
// return an empty string, which the parser then coerces to 0 —
|
|
// visible as a "bounce to zero" after h/l keypresses. External
|
|
// brightness changes are still picked up by the 5s periodic refresh,
|
|
// the open-time refresh, and Component.onCompleted.
|
|
onRunningChanged: {
|
|
if (running) return
|
|
if (root.brightnessSetQueued) {
|
|
root.setBrightness(root.pendingBrightnessPercent)
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: actionProc
|
|
stdout: StdioCollector { waitForEnd: true }
|
|
onRunningChanged: if (!running) root.refresh()
|
|
}
|
|
|
|
WidgetButton {
|
|
id: button
|
|
anchors.fill: parent
|
|
bar: root.bar
|
|
text: root.displays.length > 1 ? "" : ""
|
|
fontSize: Style.font.subtitle
|
|
onPressed: function(b) { root.toggle() }
|
|
onWheelMoved: function(delta) {
|
|
if (root.brightnessAvailable) root.setBrightness(root.brightnessPercent + (delta > 0 ? 5 : -5))
|
|
}
|
|
}
|
|
|
|
KeyboardPanel {
|
|
id: panel
|
|
anchorItem: button
|
|
owner: root
|
|
bar: root.bar
|
|
open: root.opened
|
|
focusTarget: keyCatcher
|
|
contentWidth: panel.fittedContentWidth(Style.space(320))
|
|
contentHeight: panel.fittedContentHeight(panelColumn.implicitHeight, Style.space(560))
|
|
|
|
PanelKeyCatcher {
|
|
id: keyCatcher
|
|
anchors.fill: parent
|
|
onMoveRequested: function(dx, dy) {
|
|
if (!root.cursorActive) { root.cursorActive = true; return }
|
|
if (dy !== 0) root.moveCursor(dy)
|
|
else if (dx !== 0) {
|
|
if (root.focusSection === "brightness") root.adjustBrightness(dx * 5)
|
|
else if (root.focusSection === "scale") root.moveCursorH(dx)
|
|
}
|
|
}
|
|
onActivateRequested: if (root.cursorActive) root.activateCursor()
|
|
onCloseRequested: root.close()
|
|
|
|
ScrollView {
|
|
id: scrollArea
|
|
anchors.fill: parent
|
|
clip: true
|
|
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
|
|
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
|
|
|
Column {
|
|
id: panelColumn
|
|
width: scrollArea.availableWidth
|
|
spacing: Style.space(14)
|
|
|
|
// ---- Brightness ----
|
|
Column {
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
|
|
PanelSectionHeader {
|
|
text: "Brightness"
|
|
foreground: root.bar.foreground
|
|
fontFamily: root.bar.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
}
|
|
|
|
CursorSurface {
|
|
id: brightnessRow
|
|
visible: root.brightnessAvailable
|
|
width: parent.width
|
|
height: brightnessInner.implicitHeight + Style.spacing.controlGap
|
|
hasCursor: root.cursorActive && root.focusSection === "brightness" && root.selectedIndex === -1
|
|
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(brightnessRow)
|
|
foreground: root.bar.foreground
|
|
outline: true
|
|
|
|
Row {
|
|
id: brightnessInner
|
|
anchors.fill: parent
|
|
anchors.leftMargin: Style.space(6)
|
|
anchors.rightMargin: Style.space(6)
|
|
spacing: Style.space(8)
|
|
|
|
Text {
|
|
text: ""
|
|
color: root.bar.foreground
|
|
font.family: root.bar.fontFamily
|
|
font.pixelSize: Style.font.heading
|
|
width: Style.space(22)
|
|
horizontalAlignment: Text.AlignHCenter
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
|
|
PanelSlider {
|
|
id: brightnessSlider
|
|
bar: root.bar
|
|
width: parent.width - Style.space(22) - brightnessLabel.width - Style.space(16)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
minimum: 1
|
|
maximum: 100
|
|
step: 1
|
|
value: root.brightnessPercent
|
|
integer: true
|
|
onMoved: function(v) { root.previewBrightness(v) }
|
|
onReleased: function(v) {
|
|
brightnessDebounce.stop()
|
|
root.setBrightness(v)
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: brightnessLabel
|
|
text: Math.round(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent) + "%"
|
|
color: root.bar.foreground
|
|
font.family: root.bar.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
width: Style.space(36)
|
|
horizontalAlignment: Text.AlignRight
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
}
|
|
|
|
HoverHandler {
|
|
onHoveredChanged: if (hovered) {
|
|
root.cursorActive = true
|
|
root.focusSection = "brightness"
|
|
root.selectedIndex = -1
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
visible: !root.brightnessAvailable
|
|
text: "No controllable backlight found"
|
|
color: Qt.darker(root.bar.foreground, 1.5)
|
|
font.family: root.bar.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
}
|
|
}
|
|
|
|
// ---- Scale ----
|
|
Column {
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
|
|
PanelSectionHeader {
|
|
text: "Scale"
|
|
foreground: root.bar.foreground
|
|
fontFamily: root.bar.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
}
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
|
|
Repeater {
|
|
model: root.scaleValues
|
|
|
|
ScalePill {
|
|
required property string modelData
|
|
required property int index
|
|
|
|
scaleValue: modelData
|
|
scaleIndex: index
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Monitors ----
|
|
Column {
|
|
width: parent.width
|
|
spacing: Style.space(6)
|
|
visible: root.displays.length > 1
|
|
|
|
PanelSectionHeader {
|
|
text: "Monitors"
|
|
foreground: root.bar.foreground
|
|
fontFamily: root.bar.fontFamily
|
|
fontSize: Style.font.bodySmall
|
|
}
|
|
|
|
Repeater {
|
|
model: root.displays
|
|
|
|
MonitorRow {
|
|
required property var modelData
|
|
required property int index
|
|
|
|
width: panelColumn.width
|
|
display: modelData
|
|
rowIndex: index
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
component ScalePill: Button {
|
|
id: pill
|
|
required property string scaleValue
|
|
required property int scaleIndex
|
|
|
|
text: scaleValue + "x"
|
|
foreground: root.bar.foreground
|
|
fontFamily: root.bar.fontFamily
|
|
horizontalPadding: Style.spacing.controlPaddingX
|
|
verticalPadding: Style.spacing.controlPaddingY
|
|
|
|
active: root.normalizeScale(root.monitorScale) === root.normalizeScale(scaleValue)
|
|
hasCursor: root.cursorActive && root.focusSection === "scale" && root.selectedIndex === scaleIndex
|
|
|
|
onClicked: root.setScale(scaleValue)
|
|
onHovered: function(isHovered) {
|
|
if (!isHovered) return
|
|
root.cursorActive = true
|
|
root.focusSection = "scale"
|
|
root.selectedIndex = pill.scaleIndex
|
|
}
|
|
}
|
|
|
|
component MonitorRow: CursorSurface {
|
|
id: monitorRow
|
|
required property var display
|
|
required property int rowIndex
|
|
|
|
readonly property bool isFocused: display && display.focused
|
|
readonly property bool canToggle: display && (!display.enabled || root.enabledDisplayCount > 1)
|
|
|
|
hasCursor: root.cursorActive && root.focusSection === "monitors" && root.selectedIndex === rowIndex
|
|
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(monitorRow)
|
|
current: isFocused
|
|
foreground: root.bar.foreground
|
|
fill: Style.hoverFillFor(root.bar.foreground, Color.accent)
|
|
currentFill: Style.selectedFillFor(root.bar.foreground, Color.accent)
|
|
implicitHeight: monitorInner.implicitHeight + Style.spacing.xl
|
|
opacity: canToggle ? 1.0 : 0.45
|
|
|
|
Row {
|
|
id: monitorInner
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
anchors.leftMargin: Style.space(6)
|
|
anchors.rightMargin: Style.space(6)
|
|
spacing: Style.space(8)
|
|
|
|
Text {
|
|
text: ""
|
|
color: root.bar.foreground
|
|
font.family: root.bar.fontFamily
|
|
font.pixelSize: Style.font.title
|
|
width: Style.space(22)
|
|
horizontalAlignment: Text.AlignHCenter
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
|
|
Text {
|
|
text: monitorRow.display.name + (monitorRow.display.focused ? " · focused" : "")
|
|
color: root.bar.foreground
|
|
font.family: root.bar.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
elide: Text.ElideRight
|
|
width: parent.width - Style.space(22) - Style.space(14) - Style.space(16)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
|
|
Text {
|
|
text: monitorRow.display.enabled ? "" : ""
|
|
color: root.bar.foreground
|
|
font.family: root.bar.fontFamily
|
|
font.pixelSize: Style.font.subtitle
|
|
width: Style.space(14)
|
|
horizontalAlignment: Text.AlignRight
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: monitorRow.canToggle ? Qt.PointingHandCursor : Qt.ArrowCursor
|
|
onContainsMouseChanged: if (containsMouse) {
|
|
root.cursorActive = true
|
|
root.focusSection = "monitors"
|
|
root.selectedIndex = monitorRow.rowIndex
|
|
}
|
|
onClicked: if (monitorRow.canToggle) root.toggleDisplay(monitorRow.display.name, monitorRow.display.enabled)
|
|
}
|
|
}
|
|
}
|