Files
arthur-os/shell/plugins/panels/Monitor.qml
T

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)
}
}
}