* Answer omarchy-shell calls over the shell's own socket Every omarchy-shell call started a qs ipc client, ~45ms of startup for one IPC call: a theme switch makes two, and every script-driven OSD, toggle refresh and lock query paid it too. The shell now serves a socket in XDG_RUNTIME_DIR, named from its config path and Wayland display as qs ipc selects its instance, and omarchy-shell tries it first through socat, which starts in ~5ms. First-party handlers register as ShellIpc, an IpcHandler that qs ipc still reaches, and the socket calls only the functions a handler declares with their exact argument count, allowed by name so QObject methods such as destroy() stay out of reach. When the shell ran nothing it answers SKIP, and omarchy-shell asks qs ipc for its exact answer, so errors, third-party plugins and an unreachable socket behave as before. A call that may have run is never retried: a timeout or a connection closed without an answer reports the shell as not responding. omarchy-shell shell ping takes ~13-18ms instead of ~61ms, and omarchy-osd reaches the screen in ~36ms instead of ~77ms. Output and exit status match the qs ipc path across 26 calls, errors and quiet mode included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Only accept whole socket replies and retry only unmade connections A reply cut off after its OK prefix passed for the whole answer, and an empty reply with socat failing was retried through qs ipc although the request might already have been delivered. An answer now counts only once its record separator arrived. socat's own errors join the reply, so only its connect error, a socket nothing listens on, falls back to qs ipc beside an explicit SKIP; anything else is reported as not responding rather than retried. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
930 lines
32 KiB
QML
930 lines
32 KiB
QML
import QtQuick
|
||
import QtQuick.Controls
|
||
import Quickshell
|
||
import Quickshell.Io
|
||
import qs.Ui
|
||
import qs.Commons
|
||
import "Model.js" as Model
|
||
|
||
Panel {
|
||
id: root
|
||
moduleName: "omarchy.monitor"
|
||
ipcTarget: "omarchy.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
|
||
|
||
// Carry sub-notch touchpad deltas between wheel events.
|
||
property real wheelAccumulator: 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 scalePresets: ["1", "1.25", "1.6", "2", "3", "4"]
|
||
readonly property var scaleValues: {
|
||
for (var i = 0; i < displays.length; i++) {
|
||
var display = displays[i]
|
||
if (display && display.focused)
|
||
return Model.availableScales(scalePresets, display.width, display.height)
|
||
}
|
||
return scalePresets
|
||
}
|
||
property string focusSection: "scale"
|
||
property int selectedIndex: 0
|
||
property bool cursorActive: false
|
||
|
||
// Text size slider — curated macOS-style notches (px). The panel snaps to
|
||
// these stops; the CLI (omarchy-display-text-size) accepts any integer in range.
|
||
readonly property var textSizeStops: [9, 10, 11, 12, 14, 16, 20]
|
||
// While a change is in flight, the chosen stop index overrides the live
|
||
// base-size so the knob doesn't snap back during the file round-trip. -1 =
|
||
// no pending change; follow Style.font.baseSize.
|
||
property int textSizePreviewIndex: -1
|
||
|
||
// A text-size change reflows the whole panel (both font and spacing scale),
|
||
// which slides rows under a stationary pointer and fires synthetic hover.
|
||
// While true, hover is not allowed to hijack the keyboard focus section —
|
||
// otherwise h/l on the text-size slider can jump focus to another row.
|
||
property bool reflowingText: false
|
||
function markReflowing() {
|
||
root.reflowingText = true
|
||
reflowSettle.restart()
|
||
}
|
||
|
||
readonly property var visibleSections: {
|
||
var list = []
|
||
if (brightnessAvailable) list.push("brightness")
|
||
list.push("textsize")
|
||
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 === "textsize") return 0 // slider sentinel at -1, like brightness
|
||
if (section === "scale") return scaleValues.length
|
||
if (section === "monitors") return displays.length
|
||
return 0
|
||
}
|
||
|
||
function sectionIsSingleRow(section) {
|
||
// brightness and text size are lone sliders; scale presets sit horizontally.
|
||
return section === "brightness" || section === "textsize" || section === "scale"
|
||
}
|
||
|
||
function sectionFirstIndex(section) {
|
||
if (section === "brightness" || section === "textsize") 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/text size use the -1 sentinel; scale clamps into the presets.
|
||
if (focusSection === "brightness" || focusSection === "textsize") 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
|
||
}
|
||
|
||
function brightnessIpc(percent) {
|
||
var value = Number(percent)
|
||
root.setBrightness(value)
|
||
return "got " + root.pendingBrightnessPercent
|
||
}
|
||
|
||
function stateIpc() {
|
||
return JSON.stringify({
|
||
brightness: root.brightnessPercent,
|
||
brightnessAvailable: root.brightnessAvailable,
|
||
focusedMonitor: root.focusedMonitor,
|
||
scale: root.monitorScale,
|
||
displays: root.displays
|
||
})
|
||
}
|
||
|
||
ShellIpc {
|
||
target: "omarchy.monitor"
|
||
|
||
function brightness(percent: string): string { return root.brightnessIpc(percent) }
|
||
function state(): string { return root.stateIpc() }
|
||
function open() { root.open() }
|
||
function close() { root.close() }
|
||
function toggle() { root.toggle() }
|
||
function show() { root.open() }
|
||
function hide() { root.close() }
|
||
}
|
||
|
||
function refresh() {
|
||
if (!stateProc.running) stateProc.running = true
|
||
}
|
||
|
||
function setBrightness(value) {
|
||
var percent = Model.clampBrightness(value)
|
||
root.brightnessPercent = percent
|
||
root.pendingBrightnessPercent = percent
|
||
|
||
if (setBrightnessProc.running) {
|
||
root.brightnessSetQueued = true
|
||
return
|
||
}
|
||
|
||
root.brightnessSetQueued = false
|
||
setBrightnessProc.command = ["omarchy-brightness-display", "--no-osd", "--monitor", root.focusedMonitor, percent + "%"]
|
||
setBrightnessProc.running = true
|
||
}
|
||
|
||
function previewBrightness(value) {
|
||
root.brightnessPercent = Model.clampBrightness(value)
|
||
brightnessDebounce.restart()
|
||
}
|
||
|
||
function showBrightnessOsd(percent) {
|
||
if (!bar || !bar.shell) return
|
||
bar.shell.summon("omarchy.osd", JSON.stringify({
|
||
icon: "brightness",
|
||
value: percent
|
||
}))
|
||
}
|
||
|
||
function normalizeScale(scale) {
|
||
return Model.normalizeScale(scale)
|
||
}
|
||
|
||
function activeScaleIndex() {
|
||
for (var i = 0; i < displays.length; i++) {
|
||
var display = displays[i]
|
||
if (display && display.focused)
|
||
return Model.matchingScaleIndex(scaleValues, monitorScale, display.width, display.height)
|
||
}
|
||
return -1
|
||
}
|
||
|
||
function effectiveScale(scale) {
|
||
for (var i = 0; i < displays.length; i++) {
|
||
var display = displays[i]
|
||
if (display && display.focused)
|
||
return Model.cleanScale(scale, display.width, display.height)
|
||
}
|
||
return normalizeScale(scale)
|
||
}
|
||
|
||
// Playful mood-name for a given brightness percent. Bands intentionally
|
||
// span ~10–20 points so casual tweaks change the label, while small
|
||
// nudges within one band don't.
|
||
function brightnessName(percent) {
|
||
return Model.brightnessName(percent)
|
||
}
|
||
|
||
function updateDisplays(displaysJson) {
|
||
var parsed = Model.parseDisplays(displaysJson)
|
||
root.displays = parsed.displays
|
||
root.enabledDisplayCount = parsed.enabledDisplayCount
|
||
}
|
||
|
||
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", "-c", "omarchy-hyprland-monitor-scaling " + scale]
|
||
if (!actionProc.running) actionProc.running = true
|
||
}
|
||
|
||
// ---- Text size (shell base font + GTK text-scaling, via one CLI) ----
|
||
function nearestTextStop(px) {
|
||
var best = 0
|
||
var bestDist = 1e9
|
||
for (var i = 0; i < textSizeStops.length; i++) {
|
||
var d = Math.abs(textSizeStops[i] - px)
|
||
if (d < bestDist) { bestDist = d; best = i }
|
||
}
|
||
return best
|
||
}
|
||
|
||
// Effective stop index: the pending choice while a change is in flight,
|
||
// otherwise whatever Style's live base-size rounds to.
|
||
function currentTextIndex() {
|
||
return textSizePreviewIndex >= 0 ? textSizePreviewIndex : nearestTextStop(Style.font.baseSize)
|
||
}
|
||
|
||
// px shown in the header: the pending stop if any, else the true base-size
|
||
// (which may be an off-notch value set from the CLI).
|
||
function displayedTextPx() {
|
||
return textSizePreviewIndex >= 0 ? textSizeStops[textSizePreviewIndex] : Style.font.baseSize
|
||
}
|
||
|
||
function setTextSize(px) {
|
||
textScaleProc.command = ["omarchy-display-text-size", String(px)]
|
||
if (!textScaleProc.running) textScaleProc.running = true
|
||
}
|
||
|
||
function adjustTextSize(deltaSteps) {
|
||
var idx = currentTextIndex() + deltaSteps
|
||
if (idx < 0) idx = 0
|
||
if (idx > textSizeStops.length - 1) idx = textSizeStops.length - 1
|
||
markReflowing()
|
||
textSizePreviewIndex = idx
|
||
setTextSize(textSizeStops[idx])
|
||
}
|
||
|
||
implicitWidth: button.implicitWidth
|
||
implicitHeight: button.implicitHeight
|
||
|
||
Component.onCompleted: refresh()
|
||
|
||
// KeyboardPanel primes focus at open-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()
|
||
onScaleValuesChanged: clampCursor()
|
||
onVisibleSectionsChanged: clampCursor()
|
||
|
||
// Only poll while the panel is open; the bar glyph tracks monitor count via
|
||
// Quickshell.screens, and open-time refresh + Component.onCompleted cover the
|
||
// rest. External brightness changes are reflected whenever the panel is open.
|
||
Timer {
|
||
interval: 5000
|
||
running: root.opened
|
||
repeat: true
|
||
onTriggered: root.refresh()
|
||
}
|
||
|
||
Process {
|
||
id: stateProc
|
||
command: ["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()
|
||
}
|
||
|
||
// Applies text size via the CLI, which rewrites the shell override file;
|
||
// Style picks the new base-size up through its own file watch, so there's
|
||
// nothing to refresh here.
|
||
Process {
|
||
id: textScaleProc
|
||
stdout: StdioCollector { waitForEnd: true }
|
||
}
|
||
|
||
// Clears the hover-suppression flag once the reflow triggered by a text-size
|
||
// change has settled.
|
||
Timer {
|
||
id: reflowSettle
|
||
interval: 300
|
||
repeat: false
|
||
onTriggered: root.reflowingText = false
|
||
}
|
||
|
||
// Once Style's base-size catches up to the pending choice, drop the preview
|
||
// so the slider tracks the live value again. The change itself reflows the
|
||
// panel, so suppress hover for a beat while it lands.
|
||
Connections {
|
||
target: Style
|
||
function onFontBaseSizeChanged() {
|
||
root.markReflowing()
|
||
if (root.textSizePreviewIndex >= 0
|
||
&& root.nearestTextStop(Style.font.baseSize) === root.textSizePreviewIndex)
|
||
root.textSizePreviewIndex = -1
|
||
}
|
||
}
|
||
|
||
BarIconButton {
|
||
id: button
|
||
anchors.fill: parent
|
||
bar: root.bar
|
||
text: Quickshell.screens.length > 1 ? "" : ""
|
||
onPressed: function(b) { root.toggle() }
|
||
onWheelMoved: function(delta) {
|
||
if (!root.brightnessAvailable) return
|
||
var wheel = Util.wheelSteps(root.wheelAccumulator, delta)
|
||
root.wheelAccumulator = wheel.remainder
|
||
if (wheel.steps === 0) return
|
||
root.setBrightness(root.brightnessPercent + wheel.steps * 5)
|
||
root.showBrightnessOsd(root.brightnessPercent)
|
||
}
|
||
}
|
||
|
||
KeyboardPanel {
|
||
id: panel
|
||
anchorItem: button
|
||
owner: root
|
||
bar: root.bar
|
||
open: root.opened
|
||
focusTarget: keyCatcher
|
||
contentWidth: panel.fittedContentWidth(Style.space(380))
|
||
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 === "textsize") root.adjustTextSize(dx)
|
||
else if (root.focusSection === "scale") root.moveCursorH(dx)
|
||
}
|
||
}
|
||
onActivateRequested: if (root.cursorActive) root.activateCursor()
|
||
onCloseRequested: root.close()
|
||
onTabRequested: function(direction) { root.switchPanel(direction) }
|
||
|
||
ScrollView {
|
||
id: scrollArea
|
||
anchors.fill: parent
|
||
clip: true
|
||
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
|
||
ScrollBar.vertical.policy: panelColumn.implicitHeight > height ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff
|
||
Binding {
|
||
target: scrollArea.contentItem
|
||
property: "interactive"
|
||
value: panelColumn.implicitHeight > scrollArea.height
|
||
}
|
||
|
||
Column {
|
||
id: panelColumn
|
||
width: scrollArea.availableWidth
|
||
spacing: Style.space(14)
|
||
|
||
// ---------- Hero: display icon · title/status ----------
|
||
Item {
|
||
width: parent.width
|
||
implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight)
|
||
|
||
Text {
|
||
id: heroIcon
|
||
textFormat: Text.PlainText
|
||
text: root.displays.length > 1 ? "" : ""
|
||
color: root.bar.foreground
|
||
font.family: root.bar.fontFamily
|
||
font.pixelSize: Style.font.display
|
||
anchors.left: parent.left
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
|
||
Column {
|
||
id: heroLabels
|
||
anchors.left: heroIcon.right
|
||
anchors.leftMargin: Style.space(14)
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: Style.space(2)
|
||
|
||
Text {
|
||
text: "Display"
|
||
color: root.bar.foreground
|
||
font.family: root.bar.fontFamily
|
||
font.pixelSize: Style.font.title
|
||
font.bold: true
|
||
elide: Text.ElideRight
|
||
width: parent.width
|
||
}
|
||
|
||
Text {
|
||
id: heroLabel
|
||
textFormat: Text.PlainText
|
||
text: {
|
||
if (root.brightnessAvailable) {
|
||
return root.brightnessName(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent).toUpperCase()
|
||
}
|
||
return "FIXED BRIGHTNESS"
|
||
}
|
||
color: Qt.darker(root.bar.foreground, 1.4)
|
||
font.family: root.bar.fontFamily
|
||
font.pixelSize: Style.font.caption
|
||
font.bold: true
|
||
font.letterSpacing: 1.2
|
||
elide: Text.ElideRight
|
||
width: parent.width
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- Brightness ----------
|
||
PanelSeparator {
|
||
visible: root.brightnessAvailable
|
||
foreground: root.bar.foreground
|
||
}
|
||
|
||
Column {
|
||
visible: root.brightnessAvailable
|
||
width: parent.width
|
||
spacing: Style.space(6)
|
||
|
||
Item {
|
||
width: parent.width
|
||
implicitHeight: Math.max(brightnessHeader.implicitHeight, brightnessPercent.implicitHeight)
|
||
|
||
PanelSectionHeader {
|
||
id: brightnessHeader
|
||
text: "BRIGHTNESS"
|
||
foreground: root.bar.foreground
|
||
fontFamily: root.bar.fontFamily
|
||
anchors.left: parent.left
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
|
||
Text {
|
||
id: brightnessPercent
|
||
textFormat: Text.PlainText
|
||
text: Math.round(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent) + "%"
|
||
color: Qt.darker(root.bar.foreground, 1.4)
|
||
font.family: root.bar.fontFamily
|
||
font.pixelSize: Style.font.caption
|
||
font.bold: true
|
||
anchors.right: parent.right
|
||
anchors.rightMargin: Style.space(6)
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
}
|
||
|
||
CursorSurface {
|
||
id: brightnessRow
|
||
width: parent.width
|
||
height: brightnessSlider.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
|
||
|
||
PanelSlider {
|
||
id: brightnessSlider
|
||
bar: root.bar
|
||
anchors.fill: parent
|
||
anchors.leftMargin: Style.space(6)
|
||
anchors.rightMargin: Style.space(6)
|
||
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)
|
||
}
|
||
}
|
||
|
||
HoverHandler {
|
||
onHoveredChanged: if (hovered && !root.reflowingText) {
|
||
root.cursorActive = true
|
||
root.focusSection = "brightness"
|
||
root.selectedIndex = -1
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- Text size ----------
|
||
PanelSeparator {
|
||
foreground: root.bar.foreground
|
||
}
|
||
|
||
Column {
|
||
width: parent.width
|
||
spacing: Style.space(6)
|
||
|
||
Item {
|
||
width: parent.width
|
||
implicitHeight: Math.max(textSizeHeader.implicitHeight, textSizePx.implicitHeight)
|
||
|
||
PanelSectionHeader {
|
||
id: textSizeHeader
|
||
text: "TEXT SIZE"
|
||
foreground: root.bar.foreground
|
||
fontFamily: root.bar.fontFamily
|
||
anchors.left: parent.left
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
|
||
Text {
|
||
id: textSizePx
|
||
textFormat: Text.PlainText
|
||
text: (textSizeSlider.dragging
|
||
? root.textSizeStops[Math.round(textSizeSlider.liveValue)]
|
||
: root.displayedTextPx()) + "px"
|
||
color: Qt.darker(root.bar.foreground, 1.4)
|
||
font.family: root.bar.fontFamily
|
||
font.pixelSize: Style.font.caption
|
||
font.bold: true
|
||
anchors.right: parent.right
|
||
anchors.rightMargin: Style.space(6)
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
}
|
||
|
||
CursorSurface {
|
||
id: textSizeRow
|
||
width: parent.width
|
||
height: textSizeSlider.implicitHeight + Style.spacing.controlGap
|
||
hasCursor: root.cursorActive && root.focusSection === "textsize" && root.selectedIndex === -1
|
||
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(textSizeRow)
|
||
foreground: root.bar.foreground
|
||
outline: true
|
||
|
||
PanelSlider {
|
||
id: textSizeSlider
|
||
bar: root.bar
|
||
anchors.fill: parent
|
||
anchors.leftMargin: Style.space(6)
|
||
anchors.rightMargin: Style.space(6)
|
||
minimum: 0
|
||
maximum: root.textSizeStops.length - 1
|
||
step: 1
|
||
integer: true
|
||
tickCount: root.textSizeStops.length
|
||
value: root.currentTextIndex()
|
||
onReleased: function(v) { root.setTextSize(root.textSizeStops[Math.round(v)]) }
|
||
}
|
||
|
||
HoverHandler {
|
||
onHoveredChanged: if (hovered && !root.reflowingText) {
|
||
root.cursorActive = true
|
||
root.focusSection = "textsize"
|
||
root.selectedIndex = -1
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- Scale ----------
|
||
PanelSeparator {
|
||
foreground: root.bar.foreground
|
||
}
|
||
|
||
Column {
|
||
width: parent.width
|
||
spacing: Style.space(10)
|
||
|
||
Item {
|
||
width: parent.width
|
||
implicitHeight: Math.max(scaleHeader.implicitHeight, scaleMonitor.implicitHeight)
|
||
|
||
PanelSectionHeader {
|
||
id: scaleHeader
|
||
text: "SCALE"
|
||
foreground: root.bar.foreground
|
||
fontFamily: root.bar.fontFamily
|
||
anchors.left: parent.left
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
|
||
// Name the monitor SCALE targets, since it only applies to the
|
||
// focused one.
|
||
Text {
|
||
id: scaleMonitor
|
||
textFormat: Text.PlainText
|
||
text: root.focusedMonitor
|
||
// Only worth naming when more than one display is in play.
|
||
visible: root.focusedMonitor !== "" && root.enabledDisplayCount > 1
|
||
color: Qt.darker(root.bar.foreground, 1.4)
|
||
font.family: root.bar.fontFamily
|
||
font.pixelSize: Style.font.caption
|
||
font.bold: true
|
||
anchors.right: parent.right
|
||
anchors.rightMargin: Style.space(6)
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
}
|
||
}
|
||
|
||
Grid {
|
||
id: scaleRow
|
||
width: parent.width
|
||
columns: root.scaleValues.length
|
||
spacing: Style.spacing.xs
|
||
|
||
readonly property real cellWidth: root.scaleValues.length > 0
|
||
? (width - spacing * (columns - 1)) / columns
|
||
: 0
|
||
|
||
Repeater {
|
||
model: root.scaleValues
|
||
|
||
ScalePill {
|
||
required property string modelData
|
||
required property int index
|
||
|
||
scaleValue: modelData
|
||
scaleIndex: index
|
||
width: scaleRow.cellWidth
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- Monitors ----------
|
||
PanelSeparator {
|
||
visible: root.displays.length > 1
|
||
foreground: root.bar.foreground
|
||
}
|
||
|
||
Column {
|
||
width: parent.width
|
||
spacing: Style.space(10)
|
||
visible: root.displays.length > 1
|
||
|
||
PanelSectionHeader {
|
||
text: "DISPLAYS"
|
||
foreground: root.bar.foreground
|
||
fontFamily: root.bar.fontFamily
|
||
}
|
||
|
||
Repeater {
|
||
model: root.displays
|
||
|
||
MonitorRow {
|
||
required property var modelData
|
||
required property int index
|
||
|
||
width: panelColumn.width
|
||
display: modelData
|
||
rowIndex: index
|
||
}
|
||
}
|
||
}
|
||
|
||
Item {
|
||
width: parent.width
|
||
height: Style.space(4)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
component ScalePill: Button {
|
||
id: pill
|
||
required property string scaleValue
|
||
required property int scaleIndex
|
||
|
||
text: root.effectiveScale(scaleValue) + "x"
|
||
fontSize: Style.font.caption
|
||
foreground: root.bar.foreground
|
||
fontFamily: root.bar.fontFamily
|
||
horizontalPadding: Style.spacing.sm
|
||
verticalPadding: Style.spacing.controlPaddingY
|
||
bordered: true
|
||
|
||
active: root.activeScaleIndex() === scaleIndex
|
||
hasCursor: root.cursorActive && root.focusSection === "scale" && root.selectedIndex === scaleIndex
|
||
|
||
onClicked: root.setScale(scaleValue)
|
||
onHovered: function(isHovered) {
|
||
if (!isHovered || root.reflowingText) 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 {
|
||
textFormat: Text.PlainText
|
||
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 {
|
||
textFormat: Text.PlainText
|
||
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.reflowingText) {
|
||
root.cursorActive = true
|
||
root.focusSection = "monitors"
|
||
root.selectedIndex = monitorRow.rowIndex
|
||
}
|
||
onClicked: if (monitorRow.canToggle) root.toggleDisplay(monitorRow.display.name, monitorRow.display.enabled)
|
||
}
|
||
}
|
||
}
|