Promote shell to its own top-level directory
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property QtObject bar: null
|
||||
property string moduleName: "activeWindow"
|
||||
property var settings: ({})
|
||||
|
||||
function setting(name, fallback) {
|
||||
var value = settings ? settings[name] : undefined
|
||||
return value === undefined || value === null ? fallback : value
|
||||
}
|
||||
|
||||
readonly property var toplevel: ToplevelManager.activeToplevel
|
||||
readonly property string title: toplevel ? (toplevel.title || toplevel.appId || "") : ""
|
||||
readonly property int maxLabelWidth: Number(setting("maxWidth", 280))
|
||||
|
||||
readonly property bool vertical: bar ? bar.vertical : false
|
||||
|
||||
visible: title !== "" && !vertical
|
||||
implicitWidth: visible ? Math.min(maxLabelWidth, labelText.implicitWidth) + 16 : 0
|
||||
implicitHeight: bar ? bar.barSize : 26
|
||||
|
||||
Behavior on implicitWidth {
|
||||
NumberAnimation { duration: 180; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
clip: true
|
||||
|
||||
Text {
|
||||
id: labelText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
width: parent.width
|
||||
text: root.title
|
||||
color: root.bar ? root.bar.foreground : "#cacccc"
|
||||
font.family: root.bar ? root.bar.fontFamily : "JetBrainsMono Nerd Font"
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
opacity: 0.85
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
|
||||
onClicked: function(mouse) {
|
||||
if (!root.toplevel) return
|
||||
if (mouse.button === Qt.MiddleButton) {
|
||||
root.toplevel.close()
|
||||
} else if (mouse.button === Qt.RightButton) {
|
||||
root.toplevel.close()
|
||||
} else {
|
||||
root.toplevel.activate()
|
||||
}
|
||||
}
|
||||
onEntered: if (root.bar) root.bar.showTooltip(root, root.title)
|
||||
onExited: if (root.bar) root.bar.hideTooltip(root)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,916 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.Ui
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property QtObject bar: null
|
||||
property string moduleName: "audioPanel"
|
||||
property var settings: ({})
|
||||
|
||||
property bool popupOpen: false
|
||||
|
||||
function closePopout() { popupOpen = false }
|
||||
|
||||
readonly property var sink: Pipewire.defaultAudioSink
|
||||
readonly property var source: Pipewire.defaultAudioSource
|
||||
readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : []
|
||||
|
||||
readonly property var candidateSinks: {
|
||||
var list = []
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var n = nodes[i]
|
||||
if (n && n.isSink && !n.isStream) list.push(n)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
readonly property var candidateSources: {
|
||||
var list = []
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var n = nodes[i]
|
||||
if (n && !n.isSink && !n.isStream && n.audio) {
|
||||
var name = n.name || ""
|
||||
if (name === "quickshell") continue
|
||||
list.push(n)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
readonly property var candidateStreams: {
|
||||
var list = []
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var n = nodes[i]
|
||||
if (n && n.isStream && isPlaybackStream(n)) list.push(n)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// Identify true playback streams without reading node.properties here:
|
||||
// PwNode.properties is invalid until the node is bound, and reading it while
|
||||
// capture streams are appearing (for example, when Voxtype starts recording)
|
||||
// can destabilize Quickshell's Pipewire service. `type` mirrors media.class
|
||||
// and is safe enough for pre-bind filtering.
|
||||
function isPlaybackStream(node) {
|
||||
if (!node) return false
|
||||
var mediaClass = String(node.type || "")
|
||||
return mediaClass.indexOf("Output") !== -1
|
||||
}
|
||||
|
||||
readonly property var audioSinks: {
|
||||
var list = []
|
||||
for (var i = 0; i < candidateSinks.length; i++)
|
||||
if (candidateSinks[i].audio) list.push(candidateSinks[i])
|
||||
return list
|
||||
}
|
||||
|
||||
readonly property var audioSources: candidateSources
|
||||
|
||||
readonly property var audioStreams: {
|
||||
var list = []
|
||||
for (var i = 0; i < candidateStreams.length; i++)
|
||||
if (candidateStreams[i].audio) list.push(candidateStreams[i])
|
||||
return list
|
||||
}
|
||||
|
||||
readonly property real outputVolume: sink && sink.audio ? sink.audio.volume : 0
|
||||
readonly property bool outputMuted: sink && sink.audio ? sink.audio.muted : false
|
||||
readonly property real inputVolume: source && source.audio ? source.audio.volume : 0
|
||||
readonly property bool inputMuted: source && source.audio ? source.audio.muted : false
|
||||
|
||||
// Single cursor model shared by keyboard and mouse. Sections:
|
||||
// "output" — output slider + sink device list
|
||||
// "input" — input slider + source device list
|
||||
// "streams" — per-app playback streams
|
||||
// selectedIndex semantics within a section:
|
||||
// -1 → on the slider row (h/l adjusts volume, m/Enter mute)
|
||||
// 0..N-1 → on the Nth device/stream row
|
||||
// Visuals derive from hasCursor/current via CursorSurface, never
|
||||
// from containsMouse — that's what keeps the highlight unique across
|
||||
// keyboard + mouse like wifi does.
|
||||
property string focusSection: "output"
|
||||
property int selectedIndex: -1
|
||||
|
||||
readonly property color activeFill: bar
|
||||
? Qt.rgba(bar.foreground.r, bar.foreground.g, bar.foreground.b, 0.18)
|
||||
: "transparent"
|
||||
|
||||
function sectionCount(section) {
|
||||
if (section === "output") return audioSinks.length
|
||||
if (section === "input") return audioSources.length
|
||||
if (section === "streams") return audioStreams.length
|
||||
return 0
|
||||
}
|
||||
|
||||
function sectionVisible(section) {
|
||||
if (section === "output") return true
|
||||
if (section === "input") return audioSources.length > 0 || !!source
|
||||
if (section === "streams") return audioStreams.length > 0
|
||||
return false
|
||||
}
|
||||
|
||||
function sectionHasSlider(section) {
|
||||
if (section === "output") return true
|
||||
if (section === "input") return !!source
|
||||
return false // stream rows carry their own sliders inline; not a section-level slider
|
||||
}
|
||||
|
||||
// Order of visible sections, recomputed reactively so dropping a section
|
||||
// (e.g. no input devices) doesn't leave the cursor pointing at it.
|
||||
readonly property var visibleSections: {
|
||||
var list = []
|
||||
if (sectionVisible("output")) list.push("output")
|
||||
if (sectionVisible("input")) list.push("input")
|
||||
if (sectionVisible("streams")) list.push("streams")
|
||||
return list
|
||||
}
|
||||
|
||||
function moveCursor(delta) {
|
||||
var sections = visibleSections
|
||||
if (sections.length === 0) return
|
||||
var sIdx = sections.indexOf(focusSection)
|
||||
if (sIdx < 0) { focusSection = sections[0]; selectedIndex = sectionHasSlider(focusSection) ? -1 : 0; return }
|
||||
|
||||
var idx = selectedIndex
|
||||
var max = sectionCount(focusSection) - 1 // last device index
|
||||
var hasSlider = sectionHasSlider(focusSection)
|
||||
var floor = hasSlider ? -1 : 0 // -1 = slider row
|
||||
|
||||
if (delta > 0) {
|
||||
if (idx < max) { selectedIndex = idx + 1; return }
|
||||
// Fall through to next section.
|
||||
if (sIdx < sections.length - 1) {
|
||||
focusSection = sections[sIdx + 1]
|
||||
selectedIndex = sectionHasSlider(focusSection) ? -1 : 0
|
||||
}
|
||||
} else {
|
||||
if (idx > floor) { selectedIndex = idx - 1; return }
|
||||
// Escape upward.
|
||||
if (sIdx > 0) {
|
||||
focusSection = sections[sIdx - 1]
|
||||
var prevMax = sectionCount(focusSection) - 1
|
||||
selectedIndex = prevMax >= 0 ? prevMax : (sectionHasSlider(focusSection) ? -1 : 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust the slider associated with the focused section. Output and
|
||||
// input sliders are real volume controls; on stream rows h/l adjusts
|
||||
// that stream's volume (so keyboard parity with the inline slider).
|
||||
// For device rows (selectedIndex >= 0 in output/input) h/l is a no-op
|
||||
// — the cursor is on a discrete row, not on the slider, and silently
|
||||
// moving the global slider would surprise the user.
|
||||
function adjustVolume(delta) {
|
||||
if (focusSection === "output" && selectedIndex === -1) {
|
||||
setOutputVolume(outputVolume + delta)
|
||||
return
|
||||
}
|
||||
if (focusSection === "input" && selectedIndex === -1) {
|
||||
setInputVolume(inputVolume + delta)
|
||||
return
|
||||
}
|
||||
if (focusSection === "streams" && selectedIndex >= 0 && selectedIndex < audioStreams.length) {
|
||||
var s = audioStreams[selectedIndex]
|
||||
if (s && s.audio) s.audio.volume = Math.max(0, Math.min(1.5, s.audio.volume + delta))
|
||||
}
|
||||
}
|
||||
|
||||
// Enter/Space: activate whatever the cursor is on.
|
||||
function activateCursor() {
|
||||
if (focusSection === "output") {
|
||||
if (selectedIndex === -1) { toggleOutputMute(); return }
|
||||
var sink = audioSinks[selectedIndex]
|
||||
if (sink) setDefaultSink(sink)
|
||||
return
|
||||
}
|
||||
if (focusSection === "input") {
|
||||
if (selectedIndex === -1) { toggleInputMute(); return }
|
||||
var src = audioSources[selectedIndex]
|
||||
if (src) setDefaultSource(src)
|
||||
return
|
||||
}
|
||||
if (focusSection === "streams" && selectedIndex >= 0) {
|
||||
var st = audioStreams[selectedIndex]
|
||||
if (st && st.audio) st.audio.muted = !st.audio.muted
|
||||
}
|
||||
}
|
||||
|
||||
onPopupOpenChanged: {
|
||||
if (popupOpen) {
|
||||
focusSection = "output"
|
||||
selectedIndex = -1 // start on the output slider
|
||||
Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() })
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp / repair the cursor whenever any list refreshes underneath us.
|
||||
onAudioSinksChanged: clampCursor()
|
||||
onAudioSourcesChanged: clampCursor()
|
||||
onAudioStreamsChanged: clampCursor()
|
||||
|
||||
// Keep the keyboard-focused row inside the visible viewport of the
|
||||
// ScrollView. Each cursor target (slider rows, SinkRow, SourceRow,
|
||||
// StreamRow) calls this when it gains hasCursor. Without it, j/k can
|
||||
// walk the selection off-screen — wifi uses ListView.positionViewAtIndex
|
||||
// for this; we don't have that affordance with a multi-section Column.
|
||||
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 clampCursor() {
|
||||
var sections = visibleSections
|
||||
if (!sections || !sections.length) return
|
||||
if (sections.indexOf(focusSection) < 0) {
|
||||
focusSection = visibleSections[0]
|
||||
selectedIndex = sectionHasSlider(focusSection) ? -1 : 0
|
||||
return
|
||||
}
|
||||
var count = sectionCount(focusSection)
|
||||
var hasSlider = sectionHasSlider(focusSection)
|
||||
var floor = hasSlider ? -1 : 0
|
||||
if (selectedIndex > count - 1) selectedIndex = Math.max(floor, count - 1)
|
||||
if (selectedIndex < floor) selectedIndex = floor
|
||||
}
|
||||
|
||||
function outputIcon() {
|
||||
// Match the old Waybar pulseaudio glyph set. The Material Design speaker
|
||||
// icons render visually smaller in JetBrainsMono Nerd Font.
|
||||
if (!sink || !sink.audio) return ""
|
||||
if (outputMuted) return ""
|
||||
var v = outputVolume
|
||||
if (v >= 0.67) return ""
|
||||
if (v >= 0.34) return ""
|
||||
if (v > 0) return ""
|
||||
return ""
|
||||
}
|
||||
|
||||
function inputIcon() {
|
||||
if (!source || !source.audio) return ""
|
||||
return inputMuted ? "" : ""
|
||||
}
|
||||
|
||||
function setOutputVolume(v) {
|
||||
if (!sink || !sink.audio) return
|
||||
sink.audio.volume = Math.max(0, Math.min(1, v))
|
||||
}
|
||||
|
||||
function setInputVolume(v) {
|
||||
if (!source || !source.audio) return
|
||||
source.audio.volume = Math.max(0, Math.min(1, v))
|
||||
}
|
||||
|
||||
function toggleOutputMute() {
|
||||
if (sink && sink.audio) sink.audio.muted = !sink.audio.muted
|
||||
}
|
||||
|
||||
function toggleInputMute() {
|
||||
if (source && source.audio) source.audio.muted = !source.audio.muted
|
||||
}
|
||||
|
||||
function setDefaultSink(node) { Pipewire.preferredDefaultAudioSink = node }
|
||||
function setDefaultSource(node) { Pipewire.preferredDefaultAudioSource = node }
|
||||
|
||||
function nodeLabel(node) {
|
||||
if (!node) return "Unknown"
|
||||
return node.description || node.nickname || node.name || "Unknown"
|
||||
}
|
||||
|
||||
function nodeProps(node) {
|
||||
return node && node.ready && node.properties ? node.properties : {}
|
||||
}
|
||||
|
||||
function sinkGlyph(node) {
|
||||
if (!node) return ""
|
||||
var p = nodeProps(node)
|
||||
var blob = String([
|
||||
node.name, node.description, node.nickname,
|
||||
p["device.icon-name"] || "",
|
||||
p["device.product.name"] || ""
|
||||
].join(" ")).toLowerCase()
|
||||
if (blob.indexOf("headphone") !== -1 || blob.indexOf("headset") !== -1) return ""
|
||||
if (blob.indexOf("bluetooth") !== -1) return ""
|
||||
if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return ""
|
||||
return ""
|
||||
}
|
||||
|
||||
function sourceGlyph(node) {
|
||||
if (!node) return ""
|
||||
var p = nodeProps(node)
|
||||
var blob = String([
|
||||
node.name, node.description, node.nickname,
|
||||
p["device.icon-name"] || ""
|
||||
].join(" ")).toLowerCase()
|
||||
if (blob.indexOf("headset") !== -1) return ""
|
||||
if (blob.indexOf("bluetooth") !== -1) return ""
|
||||
if (blob.indexOf("webcam") !== -1 || blob.indexOf("camera") !== -1) return ""
|
||||
return ""
|
||||
}
|
||||
|
||||
function streamLabel(node) {
|
||||
if (!node) return "Stream"
|
||||
var p = nodeProps(node)
|
||||
return p["application.name"] || node.description || p["media.name"] || p["node.name"] || node.name || "Stream"
|
||||
}
|
||||
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
PwObjectTracker { objects: root.candidateSinks }
|
||||
PwObjectTracker { objects: root.candidateSources }
|
||||
PwObjectTracker { objects: root.audioStreams }
|
||||
|
||||
// Lets a Hyprland keybind summon the panel without a click. Mirrors the
|
||||
// networkPanel IpcHandler pattern; KeyboardPanel grants Exclusive focus
|
||||
// at map-time so j/k/h/l work the moment the panel appears.
|
||||
IpcHandler {
|
||||
target: "audioPanel"
|
||||
function toggle(): void {
|
||||
if (root.popupOpen) root.closePopout()
|
||||
else root.popupOpen = true
|
||||
}
|
||||
function show(): void { if (!root.popupOpen) root.popupOpen = true }
|
||||
function hide(): void { root.closePopout() }
|
||||
}
|
||||
|
||||
WidgetButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.outputIcon()
|
||||
fontSize: 12
|
||||
onPressed: function(b) {
|
||||
if (b === Qt.RightButton) root.toggleOutputMute()
|
||||
else if (b === Qt.MiddleButton) root.bar.run("omarchy-launch-audio")
|
||||
else root.popupOpen = !root.popupOpen
|
||||
}
|
||||
|
||||
onWheelMoved: function(delta) {
|
||||
var step = 0.05
|
||||
root.setOutputVolume(root.outputVolume + (delta > 0 ? step : -step))
|
||||
}
|
||||
}
|
||||
|
||||
KeyboardPanel {
|
||||
id: panel
|
||||
anchorItem: button
|
||||
owner: root
|
||||
bar: root.bar
|
||||
open: root.popupOpen
|
||||
contentWidth: 370
|
||||
contentHeight: Math.min(560, panelColumn.implicitHeight + 28)
|
||||
|
||||
PanelKeyCatcher {
|
||||
id: keyCatcher
|
||||
anchors.fill: parent
|
||||
onMoveRequested: function(dx, dy) {
|
||||
if (dy !== 0) root.moveCursor(dy)
|
||||
else if (dx !== 0) root.adjustVolume(dx * 0.05)
|
||||
}
|
||||
onActivateRequested: root.activateCursor()
|
||||
onCloseRequested: root.closePopout()
|
||||
onTextKey: function(t) {
|
||||
// 'm' mutes whatever the cursor is on: focused section's slider
|
||||
// for output/input, the focused stream for streams.
|
||||
if (t === "m" || t === "M") {
|
||||
if (root.focusSection === "streams" && root.selectedIndex >= 0
|
||||
&& root.selectedIndex < root.audioStreams.length) {
|
||||
var s = root.audioStreams[root.selectedIndex]
|
||||
if (s && s.audio) s.audio.muted = !s.audio.muted
|
||||
} else if (root.focusSection === "input") {
|
||||
root.toggleInputMute()
|
||||
} else {
|
||||
root.toggleOutputMute()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: 14
|
||||
|
||||
// ---- Output ----
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 6
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
PanelSectionHeader {
|
||||
text: "Output"
|
||||
foreground: root.bar.foreground
|
||||
fontFamily: root.bar.fontFamily
|
||||
fontSize: 11
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.sink ? "· " + root.nodeLabel(root.sink) : ""
|
||||
color: Qt.darker(root.bar.foreground, 1.8)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 11
|
||||
elide: Text.ElideRight
|
||||
width: parent.width - 70
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
// Output slider row — itself a cursor target (selectedIndex === -1
|
||||
// when focusSection === "output"). h/l adjust the value via
|
||||
// root.adjustVolume; m / Enter toggle mute.
|
||||
CursorSurface {
|
||||
id: outputSliderRow
|
||||
width: parent.width
|
||||
height: outputSliderInner.implicitHeight + 8
|
||||
hasCursor: root.focusSection === "output" && root.selectedIndex === -1
|
||||
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(outputSliderRow)
|
||||
foreground: root.bar.foreground
|
||||
fill: root.activeFill
|
||||
|
||||
Row {
|
||||
id: outputSliderInner
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 6
|
||||
anchors.rightMargin: 6
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
id: outputIconText
|
||||
text: root.outputIcon()
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 16
|
||||
width: 22
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
opacity: root.outputMuted ? 0.5 : 1.0
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggleOutputMute()
|
||||
}
|
||||
}
|
||||
|
||||
PanelSlider {
|
||||
id: outputSlider
|
||||
bar: root.bar
|
||||
width: parent.width - outputIconText.width - outputPercent.width - 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
minimum: 0
|
||||
maximum: 1
|
||||
step: 0.05
|
||||
value: root.outputVolume
|
||||
opacity: root.outputMuted ? 0.5 : 1.0
|
||||
enabled: !!root.sink
|
||||
|
||||
onMoved: function(v) { root.setOutputVolume(v) }
|
||||
}
|
||||
|
||||
Text {
|
||||
id: outputPercent
|
||||
text: Math.round((outputSlider.dragging ? outputSlider.liveValue : root.outputVolume) * 100) + "%"
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 11
|
||||
width: 36
|
||||
horizontalAlignment: Text.AlignRight
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
opacity: root.outputMuted ? 0.5 : 1.0
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
propagateComposedEvents: true
|
||||
onContainsMouseChanged: if (containsMouse) {
|
||||
root.focusSection = "output"
|
||||
root.selectedIndex = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.audioSinks
|
||||
|
||||
SinkRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: panelColumn.width
|
||||
node: modelData
|
||||
rowIndex: index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Input ----
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 6
|
||||
visible: root.audioSources.length > 0 || !!root.source
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
PanelSectionHeader {
|
||||
text: "Input"
|
||||
foreground: root.bar.foreground
|
||||
fontFamily: root.bar.fontFamily
|
||||
fontSize: 11
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.source ? "· " + root.nodeLabel(root.source) : ""
|
||||
color: Qt.darker(root.bar.foreground, 1.8)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 11
|
||||
elide: Text.ElideRight
|
||||
width: parent.width - 56
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
CursorSurface {
|
||||
id: inputSliderRow
|
||||
visible: !!root.source
|
||||
width: parent.width
|
||||
height: inputSliderInner.implicitHeight + 8
|
||||
hasCursor: root.focusSection === "input" && root.selectedIndex === -1
|
||||
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(inputSliderRow)
|
||||
foreground: root.bar.foreground
|
||||
fill: root.activeFill
|
||||
|
||||
Row {
|
||||
id: inputSliderInner
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 6
|
||||
anchors.rightMargin: 6
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
id: inputIconText
|
||||
text: root.inputIcon()
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 16
|
||||
width: 22
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
opacity: root.inputMuted ? 0.5 : 1.0
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggleInputMute()
|
||||
}
|
||||
}
|
||||
|
||||
PanelSlider {
|
||||
id: inputSlider
|
||||
bar: root.bar
|
||||
width: parent.width - inputIconText.width - inputPercent.width - 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
minimum: 0
|
||||
maximum: 1
|
||||
step: 0.05
|
||||
value: root.inputVolume
|
||||
opacity: root.inputMuted ? 0.5 : 1.0
|
||||
enabled: !!root.source
|
||||
|
||||
onMoved: function(v) { root.setInputVolume(v) }
|
||||
}
|
||||
|
||||
Text {
|
||||
id: inputPercent
|
||||
text: Math.round((inputSlider.dragging ? inputSlider.liveValue : root.inputVolume) * 100) + "%"
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 11
|
||||
width: 36
|
||||
horizontalAlignment: Text.AlignRight
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
opacity: root.inputMuted ? 0.5 : 1.0
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
propagateComposedEvents: true
|
||||
onContainsMouseChanged: if (containsMouse) {
|
||||
root.focusSection = "input"
|
||||
root.selectedIndex = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.audioSources
|
||||
|
||||
SourceRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: panelColumn.width
|
||||
node: modelData
|
||||
rowIndex: index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Per-app streams ----
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 6
|
||||
visible: root.audioStreams.length > 0
|
||||
|
||||
PanelSectionHeader {
|
||||
text: "Playing"
|
||||
foreground: root.bar.foreground
|
||||
fontFamily: root.bar.fontFamily
|
||||
fontSize: 11
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.audioStreams
|
||||
|
||||
StreamRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: panelColumn.width
|
||||
node: modelData
|
||||
rowIndex: index
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Reusable inline components ----
|
||||
|
||||
// Output device row — cursor target inside the "output" section. Mouse
|
||||
// hover updates the panel cursor at the root; visuals come entirely
|
||||
// from hasCursor/current via CursorSurface, never from containsMouse.
|
||||
component SinkRow: CursorSurface {
|
||||
id: sinkRow
|
||||
required property var node
|
||||
required property int rowIndex
|
||||
|
||||
readonly property bool isActive: root.sink && node && root.sink.id === node.id
|
||||
hasCursor: root.focusSection === "output" && root.selectedIndex === rowIndex
|
||||
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(sinkRow)
|
||||
current: isActive
|
||||
foreground: root.bar.foreground
|
||||
fill: root.activeFill
|
||||
implicitHeight: sinkInner.implicitHeight + 10
|
||||
|
||||
Row {
|
||||
id: sinkInner
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
text: root.sinkGlyph(sinkRow.node)
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 14
|
||||
width: 18
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.nodeLabel(sinkRow.node)
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
width: parent.width - 18 - 14 - 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
text: sinkRow.isActive ? "" : ""
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 13
|
||||
width: 14
|
||||
horizontalAlignment: Text.AlignRight
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onContainsMouseChanged: if (containsMouse) {
|
||||
root.focusSection = "output"
|
||||
root.selectedIndex = sinkRow.rowIndex
|
||||
}
|
||||
onClicked: root.setDefaultSink(sinkRow.node)
|
||||
}
|
||||
}
|
||||
|
||||
// Input device row — sibling of SinkRow for the "input" section.
|
||||
component SourceRow: CursorSurface {
|
||||
id: sourceRow
|
||||
required property var node
|
||||
required property int rowIndex
|
||||
|
||||
readonly property bool isActive: root.source && node && root.source.id === node.id
|
||||
hasCursor: root.focusSection === "input" && root.selectedIndex === rowIndex
|
||||
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(sourceRow)
|
||||
current: isActive
|
||||
foreground: root.bar.foreground
|
||||
fill: root.activeFill
|
||||
implicitHeight: sourceInner.implicitHeight + 10
|
||||
|
||||
Row {
|
||||
id: sourceInner
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
text: root.sourceGlyph(sourceRow.node)
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 14
|
||||
width: 18
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.nodeLabel(sourceRow.node)
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
width: parent.width - 18 - 14 - 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
text: sourceRow.isActive ? "" : ""
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 13
|
||||
width: 14
|
||||
horizontalAlignment: Text.AlignRight
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onContainsMouseChanged: if (containsMouse) {
|
||||
root.focusSection = "input"
|
||||
root.selectedIndex = sourceRow.rowIndex
|
||||
}
|
||||
onClicked: root.setDefaultSource(sourceRow.node)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-app stream row — cursor target inside the "streams" section.
|
||||
// The stream has its own slider inline, so h/l from the keyboard
|
||||
// adjusts THIS stream's volume (not the global output) when the cursor
|
||||
// sits on this row. Enter/Space mutes the stream.
|
||||
component StreamRow: CursorSurface {
|
||||
id: streamRow
|
||||
required property var node
|
||||
required property int rowIndex
|
||||
|
||||
readonly property real streamVolume: node && node.audio ? node.audio.volume : 0
|
||||
readonly property bool streamMuted: node && node.audio ? node.audio.muted : false
|
||||
|
||||
hasCursor: root.focusSection === "streams" && root.selectedIndex === rowIndex
|
||||
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(streamRow)
|
||||
foreground: root.bar.foreground
|
||||
fill: root.activeFill
|
||||
implicitHeight: streamColumn.implicitHeight + 8
|
||||
|
||||
Column {
|
||||
id: streamColumn
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: 6
|
||||
anchors.rightMargin: 6
|
||||
spacing: 2
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
id: streamMuteIcon
|
||||
text: streamRow.streamMuted ? "" : ""
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 12
|
||||
width: 14
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
opacity: streamRow.streamMuted ? 0.5 : 1.0
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (streamRow.node && streamRow.node.audio)
|
||||
streamRow.node.audio.muted = !streamRow.node.audio.muted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.streamLabel(streamRow.node)
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 11
|
||||
elide: Text.ElideRight
|
||||
width: parent.width - streamMuteIcon.width - streamPct.width - 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
id: streamPct
|
||||
text: Math.round(streamRow.streamVolume * 100) + "%"
|
||||
color: Qt.darker(root.bar.foreground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 11
|
||||
width: 36
|
||||
horizontalAlignment: Text.AlignRight
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
PanelSlider {
|
||||
bar: root.bar
|
||||
width: parent.width
|
||||
minimum: 0
|
||||
maximum: 1.5
|
||||
step: 0.05
|
||||
value: streamRow.streamVolume
|
||||
opacity: streamRow.streamMuted ? 0.5 : 1.0
|
||||
|
||||
onMoved: function(v) {
|
||||
if (streamRow.node && streamRow.node.audio) streamRow.node.audio.volume = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
propagateComposedEvents: true
|
||||
onContainsMouseChanged: if (containsMouse) {
|
||||
root.focusSection = "streams"
|
||||
root.selectedIndex = streamRow.rowIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Bluetooth
|
||||
import qs.Ui
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property QtObject bar: null
|
||||
property string moduleName: "bluetoothPanel"
|
||||
property var settings: ({})
|
||||
|
||||
property bool popupOpen: false
|
||||
|
||||
// Address -> true while we are waiting for a click-initiated pair to land
|
||||
// so we can chain trust + connect at root scope. Doing this in the row's
|
||||
// Connections is racy: the discovered Repeater destroys the delegate the
|
||||
// moment `paired` flips, before the row's handler reliably fires.
|
||||
property var pendingPairAddresses: ({})
|
||||
|
||||
function closePopout() { popupOpen = false }
|
||||
|
||||
readonly property var adapter: Bluetooth.defaultAdapter
|
||||
readonly property var devices: Bluetooth.devices ? Bluetooth.devices.values : []
|
||||
|
||||
readonly property var connectedDevices: {
|
||||
var list = []
|
||||
for (var i = 0; i < devices.length; i++)
|
||||
if (devices[i] && devices[i].connected) list.push(devices[i])
|
||||
return list
|
||||
}
|
||||
|
||||
readonly property var knownDevices: {
|
||||
var list = []
|
||||
for (var i = 0; i < devices.length; i++) {
|
||||
var d = devices[i]
|
||||
if (d && (d.paired || d.connected || d.bonded || d.trusted)) list.push(d)
|
||||
}
|
||||
list.sort(function(a, b) {
|
||||
if (a.connected !== b.connected) return a.connected ? -1 : 1
|
||||
return (a.name || a.deviceName || "").localeCompare(b.name || b.deviceName || "")
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
readonly property var discoveredDevices: {
|
||||
var list = []
|
||||
for (var i = 0; i < devices.length; i++) {
|
||||
var d = devices[i]
|
||||
if (!d) continue
|
||||
if (d.paired || d.connected || d.bonded || d.trusted) continue
|
||||
list.push(d)
|
||||
}
|
||||
list.sort(function(a, b) {
|
||||
return (a.name || a.deviceName || a.address || "").localeCompare(b.name || b.deviceName || b.address || "")
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
readonly property string icon: {
|
||||
if (!adapter) return ""
|
||||
if (!adapter.enabled) return ""
|
||||
if (connectedDevices.length > 0) return ""
|
||||
return ""
|
||||
}
|
||||
|
||||
// Single cursor model shared by keyboard and mouse. Sections:
|
||||
// "header" — 3 action pills (scan, tui, toggle); h/l moves between
|
||||
// them, Enter activates.
|
||||
// "known" — paired/known device rows; Enter toggles connect.
|
||||
// "discovered" — unpaired devices visible while scanning; Enter pairs.
|
||||
// Visuals always come from CursorSurface (hasCursor / current),
|
||||
// never from containsMouse. Mouse hover updates root cursor state too,
|
||||
// guaranteeing one highlight on screen.
|
||||
property string focusSection: "header"
|
||||
property int selectedIndex: 2 // default = toggle pill
|
||||
readonly property int headerPillCount: 3
|
||||
|
||||
// Stable identity for the focused known device. The known list is sorted
|
||||
// (connected-first, then alphabetical) so activating a device can shift
|
||||
// its index. We track the BlueZ address here so the cursor follows the
|
||||
// same device across reorders rather than the slot it used to occupy.
|
||||
property string focusedKnownAddress: ""
|
||||
|
||||
readonly property color activeFill: bar
|
||||
? Qt.rgba(bar.foreground.r, bar.foreground.g, bar.foreground.b, 0.18)
|
||||
: "transparent"
|
||||
|
||||
function sectionCount(section) {
|
||||
if (section === "header") return headerPillCount
|
||||
if (section === "known") return knownDevices.length
|
||||
if (section === "discovered") return discoveredDevices.length
|
||||
return 0
|
||||
}
|
||||
|
||||
function sectionVisible(section) {
|
||||
if (section === "header") return true
|
||||
if (section === "known") return knownDevices.length > 0
|
||||
if (section === "discovered") return adapter && adapter.discovering && discoveredDevices.length > 0
|
||||
return false
|
||||
}
|
||||
|
||||
readonly property var visibleSections: {
|
||||
var list = ["header"]
|
||||
if (sectionVisible("known")) list.push("known")
|
||||
if (sectionVisible("discovered")) list.push("discovered")
|
||||
return list
|
||||
}
|
||||
|
||||
// j/k navigates between sections row-by-row. The header is treated as a
|
||||
// SINGLE row (its pills sit on one horizontal line), so j/k from devices
|
||||
// jumps to/from the header as a unit, and h/l moves between the three
|
||||
// pills inside it. This matches wifi's DNS-pill behaviour.
|
||||
function moveCursor(delta) {
|
||||
var sections = visibleSections
|
||||
if (!sections || sections.length === 0) return
|
||||
var sIdx = sections.indexOf(focusSection)
|
||||
if (sIdx < 0) { focusSection = sections[0]; selectedIndex = 0; return }
|
||||
|
||||
var idx = selectedIndex
|
||||
var inHeader = focusSection === "header"
|
||||
var max = inHeader ? 0 : sectionCount(focusSection) - 1
|
||||
|
||||
if (delta > 0) {
|
||||
if (!inHeader && idx < max) { selectedIndex = idx + 1; return }
|
||||
if (sIdx < sections.length - 1) {
|
||||
focusSection = sections[sIdx + 1]
|
||||
// Entering the header from below shouldn't happen (header is first),
|
||||
// but other entries start at 0.
|
||||
selectedIndex = 0
|
||||
}
|
||||
} else {
|
||||
if (!inHeader && idx > 0) { selectedIndex = idx - 1; return }
|
||||
if (sIdx > 0) {
|
||||
focusSection = sections[sIdx - 1]
|
||||
// Entering the header always lands on the toggle pill — the most
|
||||
// common action and consistent with the on-open default. h/l from
|
||||
// there moves to scan/TUI.
|
||||
selectedIndex = focusSection === "header" ? 2 : sectionCount(focusSection) - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// h/l: only meaningful in the header. In device sections it's a no-op
|
||||
// — j/k is the canonical row navigator there.
|
||||
function moveCursorH(delta) {
|
||||
if (focusSection !== "header") return
|
||||
var next = selectedIndex + delta
|
||||
if (next < 0) next = 0
|
||||
if (next > headerPillCount - 1) next = headerPillCount - 1
|
||||
selectedIndex = next
|
||||
}
|
||||
|
||||
function activateCursor() {
|
||||
if (focusSection === "header") {
|
||||
if (selectedIndex === 0) {
|
||||
if (adapter && adapter.enabled) adapter.discovering = !adapter.discovering
|
||||
} else if (selectedIndex === 1) {
|
||||
if (bar) bar.run("omarchy-launch-bluetooth")
|
||||
closePopout()
|
||||
} else if (selectedIndex === 2) {
|
||||
if (adapter) adapter.enabled = !adapter.enabled
|
||||
}
|
||||
return
|
||||
}
|
||||
if (focusSection === "known") {
|
||||
var dev = knownDevices[selectedIndex]
|
||||
if (!dev) return
|
||||
if (!dev.trusted) dev.trusted = true
|
||||
if (dev.connected) dev.disconnect()
|
||||
else dev.connect()
|
||||
return
|
||||
}
|
||||
if (focusSection === "discovered") {
|
||||
var d = discoveredDevices[selectedIndex]
|
||||
if (!d) return
|
||||
pendingPairAddresses[d.address] = true
|
||||
d.pair()
|
||||
}
|
||||
}
|
||||
|
||||
// 'x' on a known row mirrors the row's X button: connected device
|
||||
// disconnects, everything else forgets the pairing. Mismatching this
|
||||
// (e.g. forgetting a connected device) is destructive — the X button
|
||||
// tooltip says "Disconnect" for connected rows, and the keybind has
|
||||
// to agree.
|
||||
function deleteSelected() {
|
||||
if (focusSection !== "known") return
|
||||
var dev = knownDevices[selectedIndex]
|
||||
if (!dev) return
|
||||
if (dev.connected) dev.disconnect()
|
||||
else if (dev.forget) dev.forget()
|
||||
}
|
||||
|
||||
onPopupOpenChanged: {
|
||||
if (popupOpen) {
|
||||
if (knownDevices.length > 0) { focusSection = "known"; selectedIndex = 0 }
|
||||
else { focusSection = "header"; selectedIndex = 2 }
|
||||
Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() })
|
||||
}
|
||||
}
|
||||
|
||||
// When `selectedIndex` changes inside the known section, remember which
|
||||
// address it points at. Updates from re-resolution (below) are idempotent
|
||||
// because we end up setting the same address.
|
||||
onSelectedIndexChanged: {
|
||||
if (focusSection !== "known") return
|
||||
if (selectedIndex < 0 || selectedIndex >= knownDevices.length) return
|
||||
var d = knownDevices[selectedIndex]
|
||||
focusedKnownAddress = d ? (d.address || "") : ""
|
||||
}
|
||||
|
||||
onFocusSectionChanged: {
|
||||
if (focusSection !== "known") focusedKnownAddress = ""
|
||||
}
|
||||
|
||||
onKnownDevicesChanged: {
|
||||
// Try to follow the device by address before clamping. If we can't find
|
||||
// the address (e.g. it was forgotten), fall through to clampCursor()
|
||||
// which will pull selectedIndex back into range.
|
||||
if (focusSection === "known" && focusedKnownAddress !== "") {
|
||||
for (var i = 0; i < knownDevices.length; i++) {
|
||||
if (knownDevices[i] && knownDevices[i].address === focusedKnownAddress) {
|
||||
if (selectedIndex !== i) selectedIndex = i
|
||||
clampCursor()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
clampCursor()
|
||||
}
|
||||
onDiscoveredDevicesChanged: clampCursor()
|
||||
onVisibleSectionsChanged: clampCursor()
|
||||
|
||||
// Keep the keyboard-focused row inside the visible viewport of the device
|
||||
// Flickable. Each DeviceRow calls this when it gains hasCursor. Without
|
||||
// it, j/k can walk the selection off-screen in a long device list.
|
||||
function ensureCursorVisible(item) {
|
||||
if (!item || !deviceFlick) return
|
||||
var pt = item.mapToItem(deviceFlick.contentItem, 0, 0)
|
||||
var top = pt.y
|
||||
var bottom = top + (item.height || 0)
|
||||
var viewTop = deviceFlick.contentY
|
||||
var viewBottom = viewTop + deviceFlick.height
|
||||
var margin = 6
|
||||
if (top < viewTop + margin) deviceFlick.contentY = Math.max(0, top - margin)
|
||||
else if (bottom > viewBottom - margin)
|
||||
deviceFlick.contentY = bottom + margin - deviceFlick.height
|
||||
}
|
||||
|
||||
function clampCursor() {
|
||||
var sections = visibleSections
|
||||
if (!sections || !sections.length) return
|
||||
if (sections.indexOf(focusSection) < 0) {
|
||||
focusSection = sections[0]
|
||||
selectedIndex = 0
|
||||
return
|
||||
}
|
||||
var count = sectionCount(focusSection)
|
||||
if (count === 0) {
|
||||
// Section emptied out — bounce to the previous visible one.
|
||||
var sIdx = sections.indexOf(focusSection)
|
||||
focusSection = sIdx > 0 ? sections[sIdx - 1] : sections[0]
|
||||
selectedIndex = Math.max(0, sectionCount(focusSection) - 1)
|
||||
return
|
||||
}
|
||||
if (selectedIndex > count - 1) selectedIndex = count - 1
|
||||
if (selectedIndex < 0) selectedIndex = 0
|
||||
}
|
||||
|
||||
visible: adapter !== null
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
// Non-visual lifecycle watchers, one per device. Survives popup open/close
|
||||
// and the discovered-known transition that destroys row delegates.
|
||||
Repeater {
|
||||
model: root.devices
|
||||
Item {
|
||||
required property var modelData
|
||||
visible: false
|
||||
Connections {
|
||||
target: modelData || null
|
||||
function onPairedChanged() {
|
||||
var d = modelData
|
||||
if (!d || !d.paired) return
|
||||
if (!root.pendingPairAddresses[d.address]) return
|
||||
delete root.pendingPairAddresses[d.address]
|
||||
// BlueZ pair() does not auto-trust or auto-connect. Without
|
||||
// trusted, the daemon may drop the entry shortly after pairing,
|
||||
// which makes a freshly-paired device flash "Connected" and then
|
||||
// vanish from the model.
|
||||
d.trusted = true
|
||||
if (!d.connected) d.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lets a Hyprland keybind summon the panel without a click.
|
||||
IpcHandler {
|
||||
target: "bluetoothPanel"
|
||||
function toggle(): void {
|
||||
if (root.popupOpen) root.closePopout()
|
||||
else root.popupOpen = true
|
||||
}
|
||||
function show(): void { if (!root.popupOpen) root.popupOpen = true }
|
||||
function hide(): void { root.closePopout() }
|
||||
}
|
||||
|
||||
WidgetButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.icon
|
||||
onPressed: function(b) {
|
||||
if (b === Qt.RightButton && root.adapter) root.adapter.enabled = !root.adapter.enabled
|
||||
else if (b === Qt.MiddleButton) root.bar.run("omarchy-launch-bluetooth")
|
||||
else root.popupOpen = !root.popupOpen
|
||||
}
|
||||
}
|
||||
|
||||
KeyboardPanel {
|
||||
id: panel
|
||||
anchorItem: button
|
||||
owner: root
|
||||
bar: root.bar
|
||||
open: root.popupOpen
|
||||
contentWidth: 320
|
||||
contentHeight: column.implicitHeight + 28
|
||||
|
||||
PanelKeyCatcher {
|
||||
id: keyCatcher
|
||||
anchors.fill: parent
|
||||
onMoveRequested: function(dx, dy) {
|
||||
if (dy !== 0) root.moveCursor(dy)
|
||||
else if (dx !== 0) root.moveCursorH(dx)
|
||||
}
|
||||
onActivateRequested: root.activateCursor()
|
||||
onCloseRequested: root.closePopout()
|
||||
onDeleteRequested: root.deleteSelected()
|
||||
|
||||
Column {
|
||||
id: column
|
||||
anchors.fill: parent
|
||||
spacing: 10
|
||||
|
||||
// Header: title left, on/off toggle + actions right.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: titleText.implicitHeight
|
||||
|
||||
Text {
|
||||
id: titleText
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Bluetooth"
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 13
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 4
|
||||
|
||||
HeaderPill {
|
||||
pillIndex: 0
|
||||
iconText: ""
|
||||
tooltipText: !root.adapter ? "" : !root.adapter.enabled ? "Bluetooth is off"
|
||||
: root.adapter.discovering ? "Stop scanning" : "Scan for devices"
|
||||
pillEnabled: root.adapter !== null && root.adapter.enabled
|
||||
active: root.adapter && root.adapter.discovering
|
||||
onActivated: if (root.adapter) root.adapter.discovering = !root.adapter.discovering
|
||||
}
|
||||
|
||||
HeaderPill {
|
||||
pillIndex: 1
|
||||
iconText: ""
|
||||
tooltipText: "Open Impala (TUI)"
|
||||
onActivated: { root.bar.run("omarchy-launch-bluetooth"); root.popupOpen = false }
|
||||
}
|
||||
|
||||
HeaderPill {
|
||||
pillIndex: 2
|
||||
iconText: root.adapter && root.adapter.enabled ? "" : ""
|
||||
tooltipText: root.adapter && root.adapter.enabled ? "Turn Bluetooth off" : "Turn Bluetooth on"
|
||||
active: root.adapter && root.adapter.enabled
|
||||
onActivated: if (root.adapter) root.adapter.enabled = !root.adapter.enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scrollable device list — capped so a noisy neighborhood doesn't
|
||||
// grow the popup past the screen.
|
||||
Flickable {
|
||||
id: deviceFlick
|
||||
width: parent.width
|
||||
height: Math.min(deviceList.implicitHeight, 400)
|
||||
contentWidth: width
|
||||
contentHeight: deviceList.implicitHeight
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||
|
||||
Column {
|
||||
id: deviceList
|
||||
width: parent.width
|
||||
spacing: 10
|
||||
|
||||
// Paired / known devices.
|
||||
Repeater {
|
||||
model: root.knownDevices
|
||||
DeviceRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: deviceList.width
|
||||
dev: modelData
|
||||
rowIndex: index
|
||||
isDiscovered: false
|
||||
}
|
||||
}
|
||||
|
||||
// Discovered (unpaired) devices, only shown while scanning.
|
||||
PanelSectionHeader {
|
||||
visible: root.adapter && root.adapter.discovering && root.discoveredDevices.length > 0
|
||||
text: "Discovered"
|
||||
foreground: root.bar.foreground
|
||||
fontFamily: root.bar.fontFamily
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.adapter && root.adapter.discovering ? root.discoveredDevices : []
|
||||
DeviceRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: deviceList.width
|
||||
dev: modelData
|
||||
rowIndex: index
|
||||
isDiscovered: true
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: root.knownDevices.length === 0
|
||||
&& (!root.adapter || !root.adapter.discovering || root.discoveredDevices.length === 0)
|
||||
text: !root.adapter ? "No Bluetooth adapter"
|
||||
: !root.adapter.enabled ? "Turn Bluetooth on to scan"
|
||||
: root.adapter.discovering ? "Scanning for devices…"
|
||||
: "No paired devices. Tap the scan icon to find new ones."
|
||||
color: Qt.darker(root.bar.foreground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 11
|
||||
wrapMode: Text.WordWrap
|
||||
width: deviceList.width
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Header pill: a CursorPill bound into the panel's "header" cursor
|
||||
// section. CursorPill collapses what used to be a PillButton subclass +
|
||||
// overlay MouseArea into one component; we keep the pillIndex / activated
|
||||
// shim here so the three header pill instantiations stay readable.
|
||||
component HeaderPill: CursorPill {
|
||||
id: pill
|
||||
required property int pillIndex
|
||||
property bool pillEnabled: true
|
||||
signal activated()
|
||||
|
||||
tooltipBackground: root.bar.background
|
||||
tooltipForeground: root.bar.foreground
|
||||
foreground: root.bar.foreground
|
||||
fontFamily: root.bar.fontFamily
|
||||
horizontalPadding: 6
|
||||
verticalPadding: 4
|
||||
iconSize: 14
|
||||
enabled: pillEnabled
|
||||
opacity: pillEnabled ? 1 : 0.4
|
||||
|
||||
hasCursor: root.focusSection === "header" && root.selectedIndex === pillIndex
|
||||
|
||||
onClicked: pill.activated()
|
||||
onHovered: function(isHovered) {
|
||||
if (!isHovered) return
|
||||
root.focusSection = "header"
|
||||
root.selectedIndex = pill.pillIndex
|
||||
}
|
||||
}
|
||||
|
||||
// Two-line device row showing name + live status (Connected, Connecting,
|
||||
// Pairing, Failed). Tracks pending click attempts with a Timer so a
|
||||
// connect that drops back to Disconnected within 10s surfaces as "Failed".
|
||||
// Now a cursor target: hasCursor binds to root state, mouse hover updates
|
||||
// root state. The X button on the right is a PanelActionButton.
|
||||
component DeviceRow: CursorSurface {
|
||||
id: row
|
||||
required property var dev
|
||||
required property int rowIndex
|
||||
required property bool isDiscovered
|
||||
|
||||
readonly property bool isConnected: dev && dev.connected
|
||||
readonly property int devState: dev && dev.state !== undefined ? dev.state : -1
|
||||
readonly property string sectionName: isDiscovered ? "discovered" : "known"
|
||||
|
||||
hasCursor: root.focusSection === sectionName && root.selectedIndex === rowIndex
|
||||
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(row)
|
||||
current: isConnected
|
||||
foreground: root.bar.foreground
|
||||
fill: root.activeFill
|
||||
|
||||
// 0 idle, 1 connecting, 2 disconnecting, 3 pairing, 4 failed.
|
||||
property int pendingAction: 0
|
||||
property string failureReason: ""
|
||||
|
||||
// Heuristic: while pendingAction is set, the connect/pair attempt is
|
||||
// expected to land within ~10s. If state stays Disconnected past that, we
|
||||
// declare failure. Cleared as soon as state reaches Connected.
|
||||
Timer {
|
||||
id: failureTimer
|
||||
interval: 10000
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (row.pendingAction === 1 && !row.isConnected) {
|
||||
row.pendingAction = 4
|
||||
row.failureReason = "Could not connect"
|
||||
} else if (row.pendingAction === 3 && row.dev && !row.dev.paired) {
|
||||
row.pendingAction = 4
|
||||
row.failureReason = "Pairing failed"
|
||||
} else {
|
||||
row.pendingAction = 0
|
||||
row.failureReason = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: row.dev || null
|
||||
function onConnectedChanged() {
|
||||
if (row.isConnected) { row.pendingAction = 0; row.failureReason = "" }
|
||||
}
|
||||
function onPairedChanged() {
|
||||
if (row.dev && row.dev.paired && row.pendingAction === 3) {
|
||||
row.pendingAction = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly property string statusText: {
|
||||
if (!dev) return ""
|
||||
if (pendingAction === 4) return failureReason || "Failed"
|
||||
if (pendingAction === 1 || devState === 3) return "Connecting…"
|
||||
if (pendingAction === 2 || devState === 2) return "Disconnecting…"
|
||||
if (pendingAction === 3 || (dev.pairing === true)) return "Pairing…"
|
||||
if (isConnected) {
|
||||
if (dev.batteryAvailable) return "Connected · " + Math.round(dev.battery * 100) + "%"
|
||||
return "Connected"
|
||||
}
|
||||
if (isDiscovered) return "Available · click to pair"
|
||||
return "Paired"
|
||||
}
|
||||
|
||||
readonly property color statusColor: {
|
||||
if (pendingAction === 4) return root.bar.urgent
|
||||
if (isConnected) return root.bar.foreground
|
||||
if (pendingAction === 1 || devState === 3 || pendingAction === 3) return root.bar.foreground
|
||||
return Qt.darker(root.bar.foreground, 1.5)
|
||||
}
|
||||
|
||||
implicitHeight: rowContent.implicitHeight + 12
|
||||
|
||||
MouseArea {
|
||||
id: rowMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
cursorShape: row.dev ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
|
||||
onContainsMouseChanged: if (containsMouse) {
|
||||
root.focusSection = row.sectionName
|
||||
root.selectedIndex = row.rowIndex
|
||||
}
|
||||
|
||||
onClicked: function(mouse) {
|
||||
if (!row.dev) return
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
if (row.dev.forget) row.dev.forget()
|
||||
return
|
||||
}
|
||||
if (row.isDiscovered) {
|
||||
row.pendingAction = 3
|
||||
row.failureReason = ""
|
||||
failureTimer.restart()
|
||||
root.pendingPairAddresses[row.dev.address] = true
|
||||
row.dev.pair()
|
||||
return
|
||||
}
|
||||
if (!row.dev.trusted) row.dev.trusted = true
|
||||
if (row.isConnected) return // use the X button to disconnect
|
||||
row.pendingAction = 1
|
||||
row.failureReason = ""
|
||||
failureTimer.restart()
|
||||
row.dev.connect()
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: rowContent
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
implicitHeight: Math.max(deviceIcon.implicitHeight, info.implicitHeight, disconnectBtn.implicitHeight)
|
||||
|
||||
Text {
|
||||
id: deviceIcon
|
||||
text: row.isConnected ? "" : ""
|
||||
color: row.statusColor
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 16
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
// Explicit close button on any known device. Action depends on state:
|
||||
// connected -> disconnect, otherwise -> forget the pairing entirely.
|
||||
PanelActionButton {
|
||||
id: disconnectBtn
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: !row.isDiscovered
|
||||
iconText: ""
|
||||
tooltipText: row.isConnected ? "Disconnect" : "Forget"
|
||||
foreground: root.bar.foreground
|
||||
hoverColor: root.bar.urgent
|
||||
panelBackground: root.bar.background
|
||||
fontFamily: root.bar.fontFamily
|
||||
onClicked: {
|
||||
if (!row.dev) return
|
||||
if (row.isConnected) {
|
||||
row.pendingAction = 2
|
||||
failureTimer.stop()
|
||||
row.dev.disconnect()
|
||||
} else if (row.dev.forget) {
|
||||
row.dev.forget()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: info
|
||||
spacing: 1
|
||||
anchors.left: deviceIcon.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: disconnectBtn.visible ? disconnectBtn.left : parent.right
|
||||
anchors.rightMargin: disconnectBtn.visible ? 8 : 0
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Text {
|
||||
text: row.dev ? (row.dev.deviceName || row.dev.name || row.dev.address || "Device") : ""
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 12
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
}
|
||||
Text {
|
||||
visible: row.statusText !== ""
|
||||
text: row.statusText
|
||||
color: row.statusColor
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 10
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Ui
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property QtObject bar: null
|
||||
property string moduleName: "calendar"
|
||||
property var settings: ({})
|
||||
|
||||
property date now: new Date()
|
||||
property date viewMonth: new Date()
|
||||
property bool popupOpen: false
|
||||
|
||||
function closePopout() { popupOpen = false }
|
||||
|
||||
function setting(name, fallback) {
|
||||
var value = settings ? settings[name] : undefined
|
||||
return value === undefined || value === null ? fallback : value
|
||||
}
|
||||
|
||||
function formatLabel() {
|
||||
if (!bar) return ""
|
||||
var fmt = bar.vertical
|
||||
? String(setting("verticalFormat", "HH\n—\nmm"))
|
||||
: String(setting("format", "dddd HH:mm"))
|
||||
return Qt.formatDateTime(now, fmt)
|
||||
}
|
||||
|
||||
function shiftMonth(delta) {
|
||||
var date = new Date(viewMonth)
|
||||
date.setDate(1)
|
||||
date.setMonth(date.getMonth() + delta)
|
||||
viewMonth = date
|
||||
}
|
||||
|
||||
function isoWeek(date) {
|
||||
var d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
|
||||
var day = d.getUTCDay() || 7
|
||||
d.setUTCDate(d.getUTCDate() + 4 - day)
|
||||
var yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
|
||||
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7)
|
||||
}
|
||||
|
||||
function tooltipLabel() {
|
||||
return Qt.formatDateTime(root.now, "dd MMMM yyyy") + " Week " + root.isoWeek(root.now)
|
||||
}
|
||||
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
SystemClock {
|
||||
id: clockTimer
|
||||
precision: SystemClock.Minutes
|
||||
onDateChanged: root.now = clockTimer.date
|
||||
}
|
||||
|
||||
WidgetButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.formatLabel()
|
||||
horizontalMargin: 8.75
|
||||
verticalPadding: 8.75
|
||||
tooltipText: root.tooltipLabel()
|
||||
|
||||
onPressed: function(b) {
|
||||
if (b === Qt.RightButton) {
|
||||
root.bar.run("omarchy-launch-floating-terminal-with-presentation omarchy-tz-select")
|
||||
} else {
|
||||
root.viewMonth = new Date(root.now)
|
||||
root.popupOpen = !root.popupOpen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PopupCard {
|
||||
id: popup
|
||||
anchorItem: button
|
||||
bar: root.bar
|
||||
owner: root
|
||||
open: root.popupOpen
|
||||
contentWidth: 300
|
||||
contentHeight: header.implicitHeight + grid.implicitHeight + 36
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
spacing: 8
|
||||
|
||||
Item {
|
||||
id: header
|
||||
width: parent.width
|
||||
implicitHeight: 28
|
||||
|
||||
PillButton {
|
||||
id: prevButton
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconText: ""
|
||||
foreground: root.bar.foreground
|
||||
horizontalPadding: 8
|
||||
verticalPadding: 4
|
||||
onClicked: root.shiftMonth(-1)
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: Qt.formatDate(root.viewMonth, "MMMM yyyy")
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 14
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
PillButton {
|
||||
id: nextButton
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
iconText: ""
|
||||
foreground: root.bar.foreground
|
||||
horizontalPadding: 8
|
||||
verticalPadding: 4
|
||||
onClicked: root.shiftMonth(1)
|
||||
}
|
||||
}
|
||||
|
||||
Grid {
|
||||
id: grid
|
||||
columns: 7
|
||||
rowSpacing: 4
|
||||
columnSpacing: 4
|
||||
width: parent.width
|
||||
|
||||
Repeater {
|
||||
model: ["S", "M", "T", "W", "T", "F", "S"]
|
||||
|
||||
Item {
|
||||
required property string modelData
|
||||
width: (grid.width - grid.columnSpacing * 6) / 7
|
||||
height: 18
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: modelData
|
||||
color: Qt.darker(root.bar.foreground, 1.6)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 11
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: 42
|
||||
|
||||
Rectangle {
|
||||
required property int index
|
||||
|
||||
readonly property var startOfMonth: {
|
||||
var d = new Date(root.viewMonth)
|
||||
d.setDate(1)
|
||||
return d
|
||||
}
|
||||
readonly property int firstDayOffset: startOfMonth.getDay()
|
||||
readonly property var dayDate: {
|
||||
var d = new Date(startOfMonth)
|
||||
d.setDate(d.getDate() + index - firstDayOffset)
|
||||
return d
|
||||
}
|
||||
readonly property bool inMonth: dayDate.getMonth() === root.viewMonth.getMonth()
|
||||
readonly property bool isToday: {
|
||||
var n = root.now
|
||||
return dayDate.getDate() === n.getDate() && dayDate.getMonth() === n.getMonth() && dayDate.getFullYear() === n.getFullYear()
|
||||
}
|
||||
|
||||
width: (grid.width - grid.columnSpacing * 6) / 7
|
||||
height: 28
|
||||
radius: 4
|
||||
color: isToday ? root.bar.foreground : "transparent"
|
||||
border.color: isToday ? root.bar.foreground : "transparent"
|
||||
border.width: isToday ? 1 : 0
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: dayDate.getDate()
|
||||
color: isToday ? root.bar.background : (inMonth ? root.bar.foreground : Qt.darker(root.bar.foreground, 2.2))
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 12
|
||||
font.bold: isToday
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Ui
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property QtObject bar: null
|
||||
property string moduleName: "idleInhibitor"
|
||||
property var settings: ({})
|
||||
|
||||
property bool active: false
|
||||
|
||||
readonly property string icon: active ? "" : ""
|
||||
|
||||
function refresh() {
|
||||
if (!statusProc.running) statusProc.running = true
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (root.bar) root.bar.run("omarchy-toggle-idle")
|
||||
refreshTimer.restart()
|
||||
}
|
||||
|
||||
Component.onCompleted: refresh()
|
||||
|
||||
Process {
|
||||
id: statusProc
|
||||
command: ["bash", "-lc", "pgrep -x hypridle >/dev/null 2>&1 && echo running || echo stopped"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
root.active = String(text || "").trim() === "stopped"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: refreshTimer
|
||||
interval: 1500
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 5000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
WidgetButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.icon
|
||||
active: root.active
|
||||
tooltipText: root.active ? "Staying awake — click to allow idle" : "Can idle — click to stay awake"
|
||||
onPressed: function() { root.toggle() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Io
|
||||
import qs.Ui
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property QtObject bar: null
|
||||
property string moduleName: "keyboardLayout"
|
||||
property var settings: ({})
|
||||
|
||||
property string layoutLabel: ""
|
||||
property string layoutFull: ""
|
||||
|
||||
function setting(name, fallback) {
|
||||
var value = settings ? settings[name] : undefined
|
||||
return value === undefined || value === null ? fallback : value
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (!queryProc.running) queryProc.running = true
|
||||
}
|
||||
|
||||
function cycleLayout() {
|
||||
Hyprland.dispatch("switchxkblayout current next")
|
||||
refreshTimer.restart()
|
||||
}
|
||||
|
||||
Component.onCompleted: refresh()
|
||||
|
||||
Connections {
|
||||
target: Hyprland
|
||||
function onRawEvent(event) {
|
||||
if (!event || !event.name) return
|
||||
if (String(event.name).indexOf("activelayout") !== -1) root.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: queryProc
|
||||
command: ["bash", "-lc", "hyprctl -j devices 2>/dev/null | sed -n '/keyboards/,$p' | head -200"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
var match = String(text || "").match(/"active_keymap":\s*"([^"]+)"/)
|
||||
if (!match) return
|
||||
var full = match[1]
|
||||
root.layoutFull = full
|
||||
var token = full.split(/\s+/)[0]
|
||||
root.layoutLabel = token.substring(0, 3).toUpperCase()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: refreshTimer
|
||||
interval: 600
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 10000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
visible: layoutLabel !== ""
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
WidgetButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.layoutLabel
|
||||
fontSize: 10
|
||||
horizontalMargin: 6
|
||||
tooltipText: root.layoutFull
|
||||
onPressed: function() { root.cycleLayout() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property QtObject bar: null
|
||||
property string moduleName: "lockKeys"
|
||||
property var settings: ({})
|
||||
|
||||
property bool capsOn: false
|
||||
property bool numOn: false
|
||||
property bool scrollOn: false
|
||||
property bool hideWhenOff: true
|
||||
|
||||
function setting(name, fallback) {
|
||||
var value = settings ? settings[name] : undefined
|
||||
return value === undefined || value === null ? fallback : value
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
hideWhenOff = setting("hideWhenOff", true) === true
|
||||
refresh()
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (!stateProc.running) stateProc.running = true
|
||||
}
|
||||
|
||||
property bool ledsAvailable: true
|
||||
|
||||
Process {
|
||||
id: stateProc
|
||||
command: ["bash", "-lc", "read_led() { for path in /sys/class/leds/input*::$1; do if [[ -r $path/brightness ]]; then cat $path/brightness; return; fi; done; echo missing; }; read_led capslock; read_led numlock; read_led scrolllock"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
var lines = String(text || "").split("\n")
|
||||
var caps = String(lines[0] || "").trim()
|
||||
var num = String(lines[1] || "").trim()
|
||||
var scroll = String(lines[2] || "").trim()
|
||||
root.capsOn = caps !== "missing" && parseInt(caps, 10) > 0
|
||||
root.numOn = num !== "missing" && parseInt(num, 10) > 0
|
||||
root.scrollOn = scroll !== "missing" && parseInt(scroll, 10) > 0
|
||||
root.ledsAvailable = caps !== "missing" || num !== "missing" || scroll !== "missing"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 2000
|
||||
running: root.ledsAvailable
|
||||
repeat: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
readonly property bool anyOn: capsOn || numOn || scrollOn
|
||||
visible: ledsAvailable && (hideWhenOff ? anyOn : true)
|
||||
|
||||
readonly property bool vertical: bar ? bar.vertical : false
|
||||
|
||||
implicitWidth: vertical ? (bar ? bar.barSize : 28) : (lay.item ? lay.item.implicitWidth + 8 : 0)
|
||||
implicitHeight: vertical ? (lay.item ? lay.item.implicitHeight + 8 : 0) : (bar ? bar.barSize : 26)
|
||||
|
||||
Loader {
|
||||
id: lay
|
||||
anchors.centerIn: parent
|
||||
sourceComponent: root.vertical ? colLayout : rowLayout
|
||||
}
|
||||
|
||||
Component {
|
||||
id: rowLayout
|
||||
Row {
|
||||
spacing: 4
|
||||
LockGlyph { glyph: "A"; active: root.capsOn; visible: !root.hideWhenOff || root.capsOn }
|
||||
LockGlyph { glyph: "1"; active: root.numOn; visible: !root.hideWhenOff || root.numOn }
|
||||
LockGlyph { glyph: "S"; active: root.scrollOn; visible: !root.hideWhenOff || root.scrollOn }
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: colLayout
|
||||
Column {
|
||||
spacing: 2
|
||||
LockGlyph { glyph: "A"; active: root.capsOn; visible: !root.hideWhenOff || root.capsOn }
|
||||
LockGlyph { glyph: "1"; active: root.numOn; visible: !root.hideWhenOff || root.numOn }
|
||||
LockGlyph { glyph: "S"; active: root.scrollOn; visible: !root.hideWhenOff || root.scrollOn }
|
||||
}
|
||||
}
|
||||
|
||||
component LockGlyph: Text {
|
||||
property string glyph: ""
|
||||
property bool active: false
|
||||
|
||||
text: glyph
|
||||
color: active ? (root.bar ? root.bar.foreground : "#cacccc") : Qt.rgba(0.7, 0.7, 0.7, 0.3)
|
||||
font.family: root.bar ? root.bar.fontFamily : "JetBrainsMono Nerd Font"
|
||||
font.pixelSize: 11
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Mpris
|
||||
import qs.Ui
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property QtObject bar: null
|
||||
property string moduleName: "media"
|
||||
property var settings: ({})
|
||||
|
||||
function setting(name, fallback) {
|
||||
var value = settings ? settings[name] : undefined
|
||||
return value === undefined || value === null ? fallback : value
|
||||
}
|
||||
|
||||
readonly property var players: Mpris.players ? Mpris.players.values : []
|
||||
readonly property var activePlayer: {
|
||||
var playing = null
|
||||
for (var i = 0; i < players.length; i++) {
|
||||
var p = players[i]
|
||||
if (!p) continue
|
||||
if (p.isPlaying) return p
|
||||
if (!playing && p.trackTitle) playing = p
|
||||
}
|
||||
return playing
|
||||
}
|
||||
|
||||
readonly property bool hasMedia: activePlayer !== null && (activePlayer.trackTitle || activePlayer.trackArtist)
|
||||
readonly property string playIcon: activePlayer && activePlayer.isPlaying ? "" : ""
|
||||
readonly property string title: activePlayer ? (activePlayer.trackTitle || "") : ""
|
||||
readonly property string artist: activePlayer ? (activePlayer.trackArtist || "") : ""
|
||||
|
||||
property bool popupOpen: false
|
||||
|
||||
function closePopout() { popupOpen = false }
|
||||
property real maxLabelWidth: 180
|
||||
|
||||
visible: hasMedia
|
||||
implicitWidth: hasMedia ? row.implicitWidth + 14 : 0
|
||||
implicitHeight: bar ? bar.barSize : 26
|
||||
|
||||
Row {
|
||||
id: row
|
||||
anchors.centerIn: parent
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
id: glyph
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.playIcon
|
||||
color: activePlayer && activePlayer.isPlaying ? root.bar.foreground : Qt.darker(root.bar.foreground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 12
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 160 } }
|
||||
}
|
||||
|
||||
Item {
|
||||
id: scrollClip
|
||||
width: Math.min(root.maxLabelWidth, labelText.implicitWidth)
|
||||
height: glyph.height
|
||||
clip: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: !root.bar.vertical && root.title !== ""
|
||||
|
||||
Text {
|
||||
id: labelText
|
||||
text: root.title + (root.artist ? " · " + root.artist : "")
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
property bool needsScroll: implicitWidth > scrollClip.width
|
||||
|
||||
NumberAnimation on x {
|
||||
id: scrollAnim
|
||||
running: labelText.needsScroll && !root.popupOpen && !root.bar.vertical
|
||||
loops: Animation.Infinite
|
||||
duration: Math.max(6000, labelText.implicitWidth * 25)
|
||||
from: scrollClip.width
|
||||
to: -labelText.implicitWidth
|
||||
easing.type: Easing.Linear
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: root.activePlayer ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
|
||||
onClicked: function(mouse) {
|
||||
if (!root.activePlayer) return
|
||||
if (mouse.button === Qt.MiddleButton) {
|
||||
if (root.activePlayer.canGoNext) root.activePlayer.next()
|
||||
} else if (mouse.button === Qt.RightButton) {
|
||||
root.popupOpen = !root.popupOpen
|
||||
} else {
|
||||
if (root.activePlayer.canTogglePlaying) root.activePlayer.togglePlaying()
|
||||
}
|
||||
}
|
||||
onWheel: function(wheel) {
|
||||
if (!root.activePlayer) return
|
||||
if (wheel.angleDelta.y > 0 && root.activePlayer.canGoPrevious) root.activePlayer.previous()
|
||||
else if (wheel.angleDelta.y < 0 && root.activePlayer.canGoNext) root.activePlayer.next()
|
||||
}
|
||||
onEntered: if (root.bar) root.bar.showTooltip(root, root.hasMedia ? (root.title + (root.artist ? " — " + root.artist : "")) : "")
|
||||
onExited: if (root.bar) root.bar.hideTooltip(root)
|
||||
}
|
||||
|
||||
PopupCard {
|
||||
id: popup
|
||||
anchorItem: root
|
||||
bar: root.bar
|
||||
owner: root
|
||||
open: root.popupOpen
|
||||
contentWidth: 320
|
||||
contentHeight: column.implicitHeight + 28
|
||||
|
||||
Column {
|
||||
id: column
|
||||
anchors.fill: parent
|
||||
spacing: 10
|
||||
|
||||
Row {
|
||||
spacing: 10
|
||||
width: parent.width
|
||||
|
||||
Rectangle {
|
||||
width: 64
|
||||
height: 64
|
||||
radius: 4
|
||||
color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.08)
|
||||
border.color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.2)
|
||||
border.width: 1
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 2
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
source: root.activePlayer && root.activePlayer.trackArtUrl ? root.activePlayer.trackArtUrl : ""
|
||||
visible: source !== ""
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: !root.activePlayer || !root.activePlayer.trackArtUrl
|
||||
text: ""
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 28
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
spacing: 4
|
||||
width: parent.width - 74
|
||||
|
||||
Text {
|
||||
text: root.title || "Nothing playing"
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 13
|
||||
font.bold: true
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.artist
|
||||
color: Qt.darker(root.bar.foreground, 1.3)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 11
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
visible: text !== ""
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.activePlayer && root.activePlayer.trackAlbum ? root.activePlayer.trackAlbum : ""
|
||||
color: Qt.darker(root.bar.foreground, 1.6)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 10
|
||||
elide: Text.ElideRight
|
||||
width: parent.width
|
||||
visible: text !== ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: 6
|
||||
|
||||
PillButton {
|
||||
iconText: ""
|
||||
foreground: root.bar.foreground
|
||||
horizontalPadding: 10
|
||||
verticalPadding: 6
|
||||
enabled: root.activePlayer && root.activePlayer.canGoPrevious
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
onClicked: if (root.activePlayer) root.activePlayer.previous()
|
||||
}
|
||||
|
||||
PillButton {
|
||||
iconText: root.activePlayer && root.activePlayer.isPlaying ? "" : ""
|
||||
foreground: root.bar.foreground
|
||||
horizontalPadding: 14
|
||||
verticalPadding: 6
|
||||
iconSize: 18
|
||||
enabled: root.activePlayer && root.activePlayer.canTogglePlaying
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
onClicked: if (root.activePlayer) root.activePlayer.togglePlaying()
|
||||
}
|
||||
|
||||
PillButton {
|
||||
iconText: ""
|
||||
foreground: root.bar.foreground
|
||||
horizontalPadding: 10
|
||||
verticalPadding: 6
|
||||
enabled: root.activePlayer && root.activePlayer.canGoNext
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
onClicked: if (root.activePlayer) root.activePlayer.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.Ui
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property QtObject bar: null
|
||||
property string moduleName: "microphone"
|
||||
property var settings: ({})
|
||||
|
||||
readonly property var source: Pipewire.defaultAudioSource
|
||||
readonly property bool muted: source && source.audio ? source.audio.muted : true
|
||||
readonly property real volume: source && source.audio ? source.audio.volume : 0
|
||||
readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : []
|
||||
|
||||
readonly property var activeStreams: {
|
||||
var list = []
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var node = nodes[i]
|
||||
if (node && node.isStream && node.isSink === false && !node.audio?.muted) list.push(node)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
readonly property bool inUse: activeStreams.length > 0 && !muted
|
||||
|
||||
visible: source !== null
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
function toggleMute() {
|
||||
if (source && source.audio) source.audio.muted = !source.audio.muted
|
||||
}
|
||||
|
||||
PwObjectTracker { objects: root.source ? [root.source] : [] }
|
||||
|
||||
WidgetButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.muted ? "" : ""
|
||||
active: root.inUse
|
||||
tooltipText: root.muted ? "Microphone muted" : (root.inUse ? "Microphone in use" : "Microphone live")
|
||||
onPressed: function(b) {
|
||||
if (b === Qt.MiddleButton) root.bar.run("omarchy-launch-audio")
|
||||
else root.toggleMute()
|
||||
}
|
||||
onWheelMoved: function(delta) {
|
||||
if (!root.source || !root.source.audio) return
|
||||
var step = 0.05
|
||||
root.source.audio.volume = Math.max(0, Math.min(1, root.volume + (delta > 0 ? step : -step)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Ui
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property QtObject bar: null
|
||||
property string moduleName: "monitorPanel"
|
||||
property var settings: ({})
|
||||
|
||||
property bool popupOpen: false
|
||||
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 audioPanel's slider rows). Only present if a
|
||||
// controllable backlight was detected.
|
||||
// "scale" - 6 ChoiceButton 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 Toggle 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
|
||||
|
||||
readonly property var visibleSections: {
|
||||
var list = []
|
||||
if (brightnessAvailable) list.push("brightness")
|
||||
list.push("scale")
|
||||
if (displays.length > 0) 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 semantic activation; 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
|
||||
}
|
||||
|
||||
function closePopout() { popupOpen = false }
|
||||
|
||||
IpcHandler {
|
||||
target: "monitorPanel"
|
||||
|
||||
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 toggle(): void {
|
||||
if (root.popupOpen) root.closePopout()
|
||||
else root.popupOpen = true
|
||||
}
|
||||
function show(): void { if (!root.popupOpen) root.popupOpen = true }
|
||||
function hide(): void { root.closePopout() }
|
||||
}
|
||||
|
||||
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 toggleMirror() {
|
||||
if (!internalMonitor || !externalMonitor) return
|
||||
actionProc.command = ["bash", "-lc", "if hyprctl monitors -j | jq -e --arg i '" + internalMonitor + "' --arg e '" + externalMonitor + "' '.[] | select(.name == $i and .mirrorOf == $e)' >/dev/null; then hyprctl keyword monitor '" + internalMonitor + ",preferred,auto,auto'; else hyprctl keyword monitor '" + internalMonitor + ",preferred,auto,auto,mirror," + externalMonitor + "'; fi"]
|
||||
if (!actionProc.running) actionProc.running = true
|
||||
}
|
||||
|
||||
function toggleInternal() {
|
||||
if (!internalMonitor || !externalMonitor) return
|
||||
actionProc.command = ["bash", "-lc", "if hyprctl monitors -j | jq -e --arg i '" + internalMonitor + "' '.[] | select(.name == $i)' >/dev/null; then hyprctl keyword monitor '" + internalMonitor + ",disable'; else hyprctl keyword monitor '" + internalMonitor + ",preferred,auto,auto'; fi"]
|
||||
if (!actionProc.running) actionProc.running = true
|
||||
}
|
||||
|
||||
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. Seed the cursor on each open.
|
||||
onPopupOpenChanged: {
|
||||
if (popupOpen) {
|
||||
refresh()
|
||||
if (brightnessAvailable) {
|
||||
focusSection = "brightness"
|
||||
selectedIndex = -1
|
||||
} else {
|
||||
focusSection = "scale"
|
||||
selectedIndex = 0
|
||||
}
|
||||
Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() })
|
||||
}
|
||||
}
|
||||
|
||||
onBrightnessAvailableChanged: clampCursor()
|
||||
onDisplaysChanged: clampCursor()
|
||||
onVisibleSectionsChanged: clampCursor()
|
||||
|
||||
Timer {
|
||||
interval: 5000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
Process {
|
||||
id: stateProc
|
||||
command: ["bash", "-lc", "omarchy-brightness-display 2>/dev/null || true; monitors_json=$(hyprctl monitors all -j); printf '%s\\n' \"$monitors_json\" | jq -r 'def internal: test(\"^(eDP|LVDS|DSI)-\"); ([.[] | select(.name | internal)][0].name // \"\"), ([.[] | select((.name | internal) | not)][0].name // \"\"), ([.[] | select((.name | internal) and .disabled != true)][0].name // \"\"), ([.[] | select((.name | internal) and .mirrorOf != \"none\")][0].mirrorOf // \"\")'; omarchy-hyprland-monitor-focused 2>/dev/null || echo; omarchy-hyprland-monitor-scaling 2>/dev/null || echo; printf '%s\\n' \"$monitors_json\" | jq -c '[.[] | {name, enabled:(.disabled != true), focused:(.focused == true)}]'"]
|
||||
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: ""
|
||||
fontSize: 13
|
||||
onPressed: function(b) { root.popupOpen = !root.popupOpen }
|
||||
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.popupOpen
|
||||
contentWidth: 320
|
||||
contentHeight: Math.min(560, panelColumn.implicitHeight + 28)
|
||||
|
||||
PanelKeyCatcher {
|
||||
id: keyCatcher
|
||||
anchors.fill: parent
|
||||
onMoveRequested: function(dx, dy) {
|
||||
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: root.activateCursor()
|
||||
onCloseRequested: root.closePopout()
|
||||
|
||||
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: 14
|
||||
|
||||
// ---- Brightness ----
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 6
|
||||
|
||||
PanelSectionHeader {
|
||||
text: "Brightness"
|
||||
foreground: root.bar.foreground
|
||||
fontFamily: root.bar.fontFamily
|
||||
fontSize: 11
|
||||
}
|
||||
|
||||
CursorSurface {
|
||||
id: brightnessRow
|
||||
visible: root.brightnessAvailable
|
||||
width: parent.width
|
||||
height: brightnessInner.implicitHeight + 8
|
||||
hasCursor: root.focusSection === "brightness" && root.selectedIndex === -1
|
||||
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(brightnessRow)
|
||||
foreground: root.bar.foreground
|
||||
fill: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.18)
|
||||
|
||||
Row {
|
||||
id: brightnessInner
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 6
|
||||
anchors.rightMargin: 6
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
text: ""
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 16
|
||||
width: 22
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
PanelSlider {
|
||||
id: brightnessSlider
|
||||
bar: root.bar
|
||||
width: parent.width - 22 - brightnessLabel.width - 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: 11
|
||||
width: 36
|
||||
horizontalAlignment: Text.AlignRight
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: if (hovered) {
|
||||
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: 11
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Scale ----
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 6
|
||||
|
||||
PanelSectionHeader {
|
||||
text: "Scale"
|
||||
foreground: root.bar.foreground
|
||||
fontFamily: root.bar.fontFamily
|
||||
fontSize: 11
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 6
|
||||
|
||||
Repeater {
|
||||
model: root.scaleValues
|
||||
|
||||
ChoiceButton {
|
||||
required property string modelData
|
||||
required property int index
|
||||
|
||||
width: (panelColumn.width - 30) / 6
|
||||
text: modelData + "x"
|
||||
foreground: root.bar.foreground
|
||||
background: root.bar.background
|
||||
accent: root.bar.foreground
|
||||
fontFamily: root.bar.fontFamily
|
||||
fontSize: 11
|
||||
selected: root.normalizeScale(root.monitorScale) === root.normalizeScale(modelData)
|
||||
hasCursor: root.focusSection === "scale" && root.selectedIndex === index
|
||||
onClicked: root.setScale(modelData)
|
||||
onHovered: function(h) {
|
||||
if (h) {
|
||||
root.focusSection = "scale"
|
||||
root.selectedIndex = index
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Monitors ----
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 6
|
||||
visible: root.displays.length > 0
|
||||
|
||||
PanelSectionHeader {
|
||||
text: "Monitors"
|
||||
foreground: root.bar.foreground
|
||||
fontFamily: root.bar.fontFamily
|
||||
fontSize: 11
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.displays
|
||||
|
||||
Toggle {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: panelColumn.width
|
||||
label: modelData.name + (modelData.focused ? " · focused" : "")
|
||||
checked: modelData.enabled
|
||||
enabled: !modelData.enabled || root.enabledDisplayCount > 1
|
||||
opacity: enabled ? 1.0 : 0.45
|
||||
foreground: root.bar.foreground
|
||||
accent: root.bar.foreground
|
||||
fontFamily: root.bar.fontFamily
|
||||
hasCursor: root.focusSection === "monitors" && root.selectedIndex === index
|
||||
onClicked: root.toggleDisplay(modelData.name, modelData.enabled)
|
||||
onHovered: function(h) {
|
||||
if (h) {
|
||||
root.focusSection = "monitors"
|
||||
root.selectedIndex = index
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,430 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property QtObject bar: null
|
||||
property string moduleName: "notificationCenter"
|
||||
property var settings: ({})
|
||||
|
||||
property bool popupOpen: false
|
||||
function closePopout() { popupOpen = false }
|
||||
|
||||
// Always default to the pending tab when there's anything unseen, no
|
||||
// matter how the popup was opened (click, keybind/IPC, or the closePopout
|
||||
// path). Keeps the spec from drifting based on the user's last manual
|
||||
// tab selection.
|
||||
onPopupOpenChanged: {
|
||||
if (popupOpen) {
|
||||
activeTab = pendingCount > 0 ? "pending" : "past"
|
||||
}
|
||||
}
|
||||
|
||||
// Look up the long-running notifications service through the shell host.
|
||||
readonly property var hostShell: bar && bar.shell ? bar.shell : null
|
||||
readonly property var notificationService: hostShell && typeof hostShell.firstPartyServiceFor === "function"
|
||||
? hostShell.firstPartyServiceFor("omarchy.notifications")
|
||||
: null
|
||||
|
||||
function isChromiumDerived(app, appIcon) {
|
||||
var source = (String(app || "") + "\n" + String(appIcon || "")).toLowerCase()
|
||||
return source.indexOf("chrom") >= 0 || source.indexOf("brave") >= 0 ||
|
||||
source.indexOf("vivaldi") >= 0 || source.indexOf("microsoft-edge") >= 0 ||
|
||||
source.indexOf("opera") >= 0
|
||||
}
|
||||
|
||||
function sanitizeBody(s, app, appIcon) {
|
||||
var text = String(s || "").replace(/<img[^>]*>/gi, "")
|
||||
if (!isChromiumDerived(app, appIcon)) return text
|
||||
|
||||
return text
|
||||
.replace(/^\s*<a\b[^>]*>\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^<\s]*)?\s*<\/a>\s*/i, "")
|
||||
.replace(/^\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?\s+/i, "")
|
||||
}
|
||||
|
||||
readonly property int pendingCount: notificationService ? notificationService.pendingModel.count : 0
|
||||
readonly property int pastCount: notificationService ? notificationService.pastModel.count : 0
|
||||
readonly property bool dnd: notificationService ? notificationService.doNotDisturb : false
|
||||
|
||||
// Which tab is active in the popup. Auto-selects pending when there's
|
||||
// something unseen; otherwise opens past.
|
||||
property string activeTab: "pending"
|
||||
|
||||
readonly property string icon: {
|
||||
if (dnd) return ""
|
||||
if (pendingCount > 0) return ""
|
||||
return ""
|
||||
}
|
||||
|
||||
// Theme palette (mirrors HistoryPanel's tokens so the popup matches the
|
||||
// rest of the notification stack).
|
||||
readonly property color colForeground: Color.foreground
|
||||
readonly property color colDim: Qt.darker(Color.foreground, 1.4)
|
||||
readonly property color colBorder: Qt.rgba(Color.foreground.r, Color.foreground.g, Color.foreground.b, 0.18)
|
||||
readonly property color colSurface: Qt.rgba(Color.foreground.r, Color.foreground.g, Color.foreground.b, 0.06)
|
||||
readonly property color colAccent: Color.accent
|
||||
readonly property int cardRadius: notificationService ? notificationService.cornerRadius : 0
|
||||
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
WidgetButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.icon
|
||||
active: root.pendingCount > 0 && !root.dnd
|
||||
tooltipText: root.dnd ? "Do Not Disturb"
|
||||
: (root.pendingCount > 0 ? root.pendingCount + " pending" : "No notifications")
|
||||
|
||||
onPressed: function(b) {
|
||||
if (b === Qt.RightButton) {
|
||||
if (root.notificationService) {
|
||||
root.notificationService.setDoNotDisturb(!root.notificationService.doNotDisturb)
|
||||
}
|
||||
} else {
|
||||
root.popupOpen = !root.popupOpen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Service-side IPC (omarchy-shell notifications showHistory) flips
|
||||
// historyOpenRequested; we toggle our local popup state from here so the
|
||||
// keybind path lands in the same PopupCard the click path uses.
|
||||
Connections {
|
||||
target: root.notificationService
|
||||
ignoreUnknownSignals: true
|
||||
function onHistoryOpenRequested() {
|
||||
root.popupOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
PopupCard {
|
||||
id: popup
|
||||
anchorItem: button
|
||||
bar: root.bar
|
||||
owner: root
|
||||
open: root.popupOpen
|
||||
contentWidth: 440
|
||||
contentHeight: 540
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 10
|
||||
|
||||
// ----------------------------------------- header
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
text: "Notifications"
|
||||
font.family: root.bar ? root.bar.fontFamily : ""
|
||||
color: root.colForeground
|
||||
font.pixelSize: 14
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Rectangle {
|
||||
id: dndPill
|
||||
Layout.preferredHeight: 24
|
||||
Layout.preferredWidth: dndLabel.implicitWidth + dndGlyph.implicitWidth + 18
|
||||
radius: Math.min(12, root.cardRadius + 6)
|
||||
color: dndOn ? root.colAccent : root.colSurface
|
||||
border.color: dndOn ? root.colAccent : root.colBorder
|
||||
border.width: 1
|
||||
|
||||
readonly property bool dndOn: !!root.notificationService && root.notificationService.doNotDisturb
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
id: dndGlyph
|
||||
text: dndPill.dndOn ? "" : ""
|
||||
font.family: root.bar ? root.bar.fontFamily : ""
|
||||
color: dndPill.dndOn ? Color.background : root.colDim
|
||||
font.pixelSize: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
id: dndLabel
|
||||
text: dndPill.dndOn ? "DND on" : "DND off"
|
||||
font.family: root.bar ? root.bar.fontFamily : ""
|
||||
color: dndPill.dndOn ? Color.background : root.colDim
|
||||
font.pixelSize: 10
|
||||
font.bold: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: if (root.notificationService) root.notificationService.setDoNotDisturb(!dndPill.dndOn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------- tabs
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{ key: "pending", label: "Pending", count: root.pendingCount },
|
||||
{ key: "past", label: "Recently", count: root.pastCount }
|
||||
]
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
readonly property bool isActive: root.activeTab === modelData.key
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 30
|
||||
color: "transparent"
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: modelData.label + (modelData.count > 0 ? " " + modelData.count : "")
|
||||
font.family: root.bar ? root.bar.fontFamily : ""
|
||||
color: parent.isActive ? root.colForeground : root.colDim
|
||||
font.pixelSize: 12
|
||||
font.bold: parent.isActive
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 2
|
||||
color: parent.isActive ? root.colAccent : root.colBorder
|
||||
opacity: parent.isActive ? 1 : 0.4
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.activeTab = modelData.key
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------- action row
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: (root.activeTab === "pending" && root.pendingCount > 0)
|
||||
|| (root.activeTab === "past" && root.pastCount > 0)
|
||||
spacing: 8
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Rectangle {
|
||||
Layout.preferredWidth: actionLabel.implicitWidth + 16
|
||||
Layout.preferredHeight: 22
|
||||
radius: Math.min(6, root.cardRadius)
|
||||
color: actionArea.containsMouse ? root.colBorder : "transparent"
|
||||
border.color: root.colBorder
|
||||
border.width: 1
|
||||
|
||||
Text {
|
||||
id: actionLabel
|
||||
anchors.centerIn: parent
|
||||
text: root.activeTab === "pending" ? "Mark all as seen" : "Clear recent"
|
||||
font.family: root.bar ? root.bar.fontFamily : ""
|
||||
color: root.colForeground
|
||||
font.pixelSize: 10
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: actionArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (!root.notificationService) return
|
||||
if (root.activeTab === "pending") root.notificationService.markAllSeen()
|
||||
else root.notificationService.clearPast()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------- list
|
||||
ListView {
|
||||
id: listView
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
clip: true
|
||||
spacing: 8
|
||||
|
||||
readonly property bool onPending: root.activeTab === "pending"
|
||||
model: !root.notificationService ? null
|
||||
: (onPending ? root.notificationService.pendingModel : root.notificationService.pastModel)
|
||||
visible: count > 0
|
||||
|
||||
delegate: Rectangle {
|
||||
id: rowCard
|
||||
required property int index
|
||||
required property string app
|
||||
required property string appIcon
|
||||
required property string summary
|
||||
required property string body
|
||||
required property string image
|
||||
required property int urgency
|
||||
required property double timestamp
|
||||
|
||||
readonly property bool hasMedia: image.length > 0 && (
|
||||
image.indexOf("image://icon//") === 0 || image.indexOf("file://") === 0)
|
||||
readonly property string smallIconSource: image.length > 0 ? image : appIcon
|
||||
readonly property bool hasIcon: !hasMedia && smallIconSource.length > 0
|
||||
readonly property string sanitizedBody: root.sanitizeBody(body, app, appIcon)
|
||||
|
||||
width: listView.width
|
||||
implicitHeight: rowContent.implicitHeight + 20
|
||||
radius: root.cardRadius
|
||||
color: "transparent"
|
||||
border.color: root.colBorder
|
||||
border.width: 1
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: { /* no-op */ }
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: rowContent
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
spacing: 10
|
||||
|
||||
Item {
|
||||
Layout.preferredWidth: 32
|
||||
Layout.preferredHeight: 32
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
// Hide on icon load failure so unresolved themed-icon names
|
||||
// don't render Qt's broken-image placeholder.
|
||||
visible: (rowCard.hasIcon || rowCard.hasMedia) && rowIconImage.status !== Image.Error
|
||||
|
||||
Image {
|
||||
id: rowIconImage
|
||||
anchors.fill: parent
|
||||
source: rowCard.hasMedia ? rowCard.image : rowCard.smallIconSource
|
||||
fillMode: rowCard.hasMedia ? Image.PreserveAspectCrop : Image.PreserveAspectFit
|
||||
sourceSize.width: 32 * Screen.devicePixelRatio
|
||||
sourceSize.height: 32 * Screen.devicePixelRatio
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: rowCard.summary.length > 0
|
||||
text: rowCard.summary
|
||||
font.family: root.bar ? root.bar.fontFamily : ""
|
||||
color: root.colForeground
|
||||
font.pixelSize: 13
|
||||
font.bold: true
|
||||
wrapMode: Text.WordWrap
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: rowCard.sanitizedBody.length > 0
|
||||
text: rowCard.sanitizedBody
|
||||
font.family: root.bar ? root.bar.fontFamily : ""
|
||||
textFormat: Text.PlainText
|
||||
color: root.colDim
|
||||
font.pixelSize: 11
|
||||
wrapMode: Text.WordWrap
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 2
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.preferredWidth: 18
|
||||
Layout.preferredHeight: 18
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
radius: Math.min(4, root.cardRadius)
|
||||
color: rowCloseArea.containsMouse ? root.colBorder : "transparent"
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "✕"
|
||||
font.family: root.bar ? root.bar.fontFamily : ""
|
||||
color: root.colDim
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: rowCloseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (!root.notificationService) return
|
||||
if (listView.onPending) root.notificationService.dismissPending(rowCard.index)
|
||||
else root.notificationService.dismissPast(rowCard.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------- empty state
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
visible: listView.count === 0
|
||||
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: ""
|
||||
font.family: root.bar ? root.bar.fontFamily : ""
|
||||
color: root.colBorder
|
||||
font.pixelSize: 36
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: root.activeTab === "pending"
|
||||
? "Nothing waiting for you"
|
||||
: "Nothing recent"
|
||||
font.family: root.bar ? root.bar.fontFamily : ""
|
||||
? "Nothing waiting for you"
|
||||
: "No past notifications"
|
||||
color: root.colDim
|
||||
font.pixelSize: 12
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import QtQuick
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property QtObject bar: null
|
||||
property string moduleName: "spacer"
|
||||
property var settings: ({})
|
||||
|
||||
readonly property bool vertical: bar ? bar.vertical : false
|
||||
readonly property int span: settings && settings.size !== undefined ? Number(settings.size) : 12
|
||||
|
||||
implicitWidth: vertical ? (bar ? bar.barSize : 28) : span
|
||||
implicitHeight: vertical ? span : (bar ? bar.barSize : 26)
|
||||
visible: span > 0
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Ui
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property QtObject bar: null
|
||||
property string moduleName: "systemStats"
|
||||
property var settings: ({})
|
||||
|
||||
property real cpuPercent: 0
|
||||
property real memPercent: 0
|
||||
property var cpuHistory: []
|
||||
property var memHistory: []
|
||||
property real loadAvg: 0
|
||||
|
||||
property var prevCpu: ({ idle: 0, total: 0 })
|
||||
|
||||
property bool popupOpen: false
|
||||
|
||||
function closePopout() { popupOpen = false }
|
||||
|
||||
readonly property int historyLimit: 30
|
||||
|
||||
function refresh() {
|
||||
if (!cpuProc.running) cpuProc.running = true
|
||||
if (!memProc.running) memProc.running = true
|
||||
if (!loadProc.running) loadProc.running = true
|
||||
}
|
||||
|
||||
function pushHistory(arr, value) {
|
||||
var next = arr.slice()
|
||||
next.push(value)
|
||||
if (next.length > historyLimit) next.shift()
|
||||
return next
|
||||
}
|
||||
|
||||
function updateCpu(raw) {
|
||||
var fields = String(raw || "").trim().split(/\s+/)
|
||||
if (fields.length < 8) return
|
||||
var user = parseInt(fields[1], 10) || 0
|
||||
var nice = parseInt(fields[2], 10) || 0
|
||||
var sys = parseInt(fields[3], 10) || 0
|
||||
var idle = parseInt(fields[4], 10) || 0
|
||||
var iowait = parseInt(fields[5], 10) || 0
|
||||
var irq = parseInt(fields[6], 10) || 0
|
||||
var softirq = parseInt(fields[7], 10) || 0
|
||||
|
||||
var total = user + nice + sys + idle + iowait + irq + softirq
|
||||
var totalDiff = total - prevCpu.total
|
||||
var idleDiff = idle - prevCpu.idle
|
||||
|
||||
if (prevCpu.total > 0 && totalDiff > 0) {
|
||||
var usage = (1 - idleDiff / totalDiff) * 100
|
||||
cpuPercent = Math.max(0, Math.min(100, usage))
|
||||
cpuHistory = pushHistory(cpuHistory, cpuPercent)
|
||||
}
|
||||
|
||||
prevCpu = { idle: idle, total: total }
|
||||
}
|
||||
|
||||
function updateMem(raw) {
|
||||
var lines = String(raw || "").split("\n")
|
||||
var total = 0
|
||||
var available = 0
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i]
|
||||
if (line.indexOf("MemTotal:") === 0) total = parseInt(line.replace(/[^0-9]/g, ""), 10) || 0
|
||||
else if (line.indexOf("MemAvailable:") === 0) available = parseInt(line.replace(/[^0-9]/g, ""), 10) || 0
|
||||
}
|
||||
if (total > 0) {
|
||||
memPercent = ((total - available) / total) * 100
|
||||
memHistory = pushHistory(memHistory, memPercent)
|
||||
}
|
||||
}
|
||||
|
||||
function updateLoad(raw) {
|
||||
var n = parseFloat(String(raw || "").trim().split(/\s+/)[0])
|
||||
if (!isNaN(n)) loadAvg = n
|
||||
}
|
||||
|
||||
Component.onCompleted: refresh()
|
||||
|
||||
Process {
|
||||
id: cpuProc
|
||||
command: ["bash", "-lc", "head -n1 /proc/stat"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: root.updateCpu(text)
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: memProc
|
||||
command: ["bash", "-lc", "head -n3 /proc/meminfo"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: root.updateMem(text)
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: loadProc
|
||||
command: ["bash", "-lc", "cat /proc/loadavg"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: root.updateLoad(text)
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 2000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
readonly property bool vertical: bar ? bar.vertical : false
|
||||
readonly property color statColor: bar ? bar.foreground : "#cacccc"
|
||||
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
// Hover state across the trigger button and the popup.
|
||||
property bool buttonHovered: false
|
||||
property bool popupHovered: popup.containsMouse
|
||||
|
||||
function showPopup() {
|
||||
hideTimer.stop()
|
||||
popupOpen = true
|
||||
}
|
||||
|
||||
function scheduleHide() {
|
||||
hideTimer.restart()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: hideTimer
|
||||
interval: 220
|
||||
onTriggered: {
|
||||
if (!root.buttonHovered && !root.popupHovered) root.popupOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
onButtonHoveredChanged: buttonHovered ? showPopup() : scheduleHide()
|
||||
onPopupHoveredChanged: popupHovered ? hideTimer.stop() : scheduleHide()
|
||||
|
||||
WidgetButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: ""
|
||||
horizontalMargin: 7.5
|
||||
tooltipText: ""
|
||||
|
||||
onPressed: function(b) {
|
||||
if (b === Qt.LeftButton) {
|
||||
root.popupOpen = false
|
||||
root.bar.run("omarchy-launch-or-focus-tui btop")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: hoverHandler
|
||||
target: button
|
||||
onHoveredChanged: root.buttonHovered = hovered
|
||||
}
|
||||
|
||||
PopupCard {
|
||||
id: popup
|
||||
anchorItem: button
|
||||
owner: root
|
||||
bar: root.bar
|
||||
open: root.popupOpen
|
||||
triggerMode: "hover"
|
||||
contentWidth: 320
|
||||
contentHeight: detailColumn.implicitHeight + 28
|
||||
|
||||
Column {
|
||||
id: detailColumn
|
||||
anchors.fill: parent
|
||||
spacing: 10
|
||||
|
||||
Text {
|
||||
text: "System"
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 12
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
DetailStat {
|
||||
title: "CPU"
|
||||
value: Math.round(root.cpuPercent) + "%"
|
||||
history: root.cpuHistory
|
||||
barFg: root.statColor
|
||||
fontFamily: root.bar.fontFamily
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
DetailStat {
|
||||
title: "Memory"
|
||||
value: Math.round(root.memPercent) + "%"
|
||||
history: root.memHistory
|
||||
barFg: root.statColor
|
||||
fontFamily: root.bar.fontFamily
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 6
|
||||
Text {
|
||||
text: "Load"
|
||||
color: Qt.darker(root.bar.foreground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 11
|
||||
}
|
||||
Text {
|
||||
text: root.loadAvg.toFixed(2)
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 11
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component DetailStat: Column {
|
||||
id: detail
|
||||
|
||||
property string title: ""
|
||||
property string value: ""
|
||||
property var history: []
|
||||
property color barFg: "#cacccc"
|
||||
property string fontFamily: "JetBrainsMono Nerd Font"
|
||||
|
||||
spacing: 4
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
Text {
|
||||
text: detail.title
|
||||
color: Qt.darker(detail.barFg, 1.4)
|
||||
font.family: detail.fontFamily
|
||||
font.pixelSize: 11
|
||||
}
|
||||
Item { width: detail.width - parent.children[0].implicitWidth - parent.children[2].implicitWidth; height: 1 }
|
||||
Text {
|
||||
text: detail.value
|
||||
color: detail.barFg
|
||||
font.family: detail.fontFamily
|
||||
font.pixelSize: 11
|
||||
}
|
||||
}
|
||||
|
||||
Canvas {
|
||||
id: detailCanvas
|
||||
width: parent.width
|
||||
height: 40
|
||||
property var history: detail.history
|
||||
onHistoryChanged: requestPaint()
|
||||
|
||||
onPaint: {
|
||||
var ctx = getContext("2d")
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
if (!detail.history || detail.history.length === 0) return
|
||||
|
||||
ctx.strokeStyle = detail.barFg
|
||||
ctx.fillStyle = Qt.rgba(detail.barFg.r, detail.barFg.g, detail.barFg.b, 0.25)
|
||||
ctx.lineWidth = 1.5
|
||||
|
||||
ctx.beginPath()
|
||||
var step = width / Math.max(1, detail.history.length - 1)
|
||||
for (var i = 0; i < detail.history.length; i++) {
|
||||
var x = i * step
|
||||
var y = height - (detail.history[i] / 100) * (height - 2) - 1
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
}
|
||||
ctx.stroke()
|
||||
ctx.lineTo(width, height)
|
||||
ctx.lineTo(0, height)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property QtObject bar: null
|
||||
property string moduleName: "weatherFlyout"
|
||||
property var settings: ({})
|
||||
|
||||
property bool popupOpen: false
|
||||
function closePopout() { popupOpen = false }
|
||||
|
||||
IpcHandler {
|
||||
target: "weatherFlyout"
|
||||
function show(): void {
|
||||
root.popupOpen = !root.popupOpen
|
||||
if (root.popupOpen) root.refresh()
|
||||
}
|
||||
|
||||
function toggle(): void {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
// Parsed wttr.in j1 response. Kept on failure so stale data stays visible.
|
||||
property var report: null
|
||||
property var dailyForecastReport: null
|
||||
property string wttrLocation: ""
|
||||
|
||||
// Bar pill state. Polled locally; populated by weatherProc below.
|
||||
property string label: ""
|
||||
property string klass: ""
|
||||
|
||||
function updateWeather(raw) {
|
||||
var data
|
||||
try { data = JSON.parse(raw || "{}") } catch (e) { data = {} }
|
||||
label = data.text || ""
|
||||
klass = data.class || ""
|
||||
}
|
||||
|
||||
readonly property var current: report && report.current_condition && report.current_condition[0] ? report.current_condition[0] : null
|
||||
readonly property var areaInfo: report && report.nearest_area && report.nearest_area[0] ? report.nearest_area[0] : null
|
||||
readonly property var forecastDays: buildForecastDays()
|
||||
|
||||
readonly property bool useImperial: {
|
||||
var override = setting("unit", "")
|
||||
if (override === "imperial") return true
|
||||
if (override === "metric") return false
|
||||
var name = String(Qt.locale().name || "")
|
||||
return /^en_US/.test(name) || /^en_LR/.test(name) || /^my/.test(name)
|
||||
}
|
||||
|
||||
// Auto-refresh interval in minutes; clamped to a sane minimum.
|
||||
readonly property int refreshMinutes: Math.max(1, parseInt(setting("refreshMinutes", 15), 10) || 15)
|
||||
|
||||
readonly property string reportLocation: wttrLocation || (areaInfo && areaInfo.areaName && areaInfo.areaName[0] ? areaInfo.areaName[0].value : "")
|
||||
readonly property string reportCondition: current && current.weatherDesc && current.weatherDesc[0] ? current.weatherDesc[0].value : ""
|
||||
readonly property string reportTemp: current ? formatTemp(useImperial ? current.temp_F : current.temp_C) : ""
|
||||
readonly property string reportTempNum: current ? String(useImperial ? current.temp_F : current.temp_C) : ""
|
||||
readonly property string tempUnit: "°" + (useImperial ? "F" : "C")
|
||||
readonly property string reportFeels: current ? formatTemp(useImperial ? current.FeelsLikeF : current.FeelsLikeC) : ""
|
||||
readonly property string reportWind: current ? (useImperial ? (current.windspeedMiles + " mph") : (current.windspeedKmph + " km/h")) : ""
|
||||
readonly property string reportHumidity: current ? (current.humidity + "%") : ""
|
||||
|
||||
visible: label !== ""
|
||||
implicitWidth: button.implicitWidth + 8
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
function setting(name, fallback) {
|
||||
var v = settings ? settings[name] : undefined
|
||||
return v === undefined || v === null ? fallback : v
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (!forecastProc.running) forecastProc.running = true
|
||||
if (!locationProc.running) locationProc.running = true
|
||||
}
|
||||
|
||||
function refreshDailyForecast(sourceReport) {
|
||||
var area = sourceReport && sourceReport.nearest_area && sourceReport.nearest_area[0] ? sourceReport.nearest_area[0] : root.areaInfo
|
||||
if (!area || dailyForecastProc.running) return
|
||||
|
||||
var lat = parseFloat(String(area.latitude || ""))
|
||||
var lon = parseFloat(String(area.longitude || ""))
|
||||
if (isNaN(lat) || isNaN(lon)) return
|
||||
|
||||
var url = "https://api.open-meteo.com/v1/forecast"
|
||||
+ "?latitude=" + encodeURIComponent(String(lat))
|
||||
+ "&longitude=" + encodeURIComponent(String(lon))
|
||||
+ "&daily=weather_code,temperature_2m_max,temperature_2m_min"
|
||||
+ "&forecast_days=4"
|
||||
+ "&timezone=auto"
|
||||
dailyForecastProc.command = ["curl", "-fsS", "--max-time", "5", url]
|
||||
dailyForecastProc.running = true
|
||||
}
|
||||
|
||||
function buildForecastDays() {
|
||||
var days = openMeteoForecastDays()
|
||||
return days.length > 0 ? days : wttrNextForecastDays()
|
||||
}
|
||||
|
||||
function openMeteoForecastDays() {
|
||||
var daily = dailyForecastReport && dailyForecastReport.daily ? dailyForecastReport.daily : null
|
||||
if (!daily || !daily.time) return []
|
||||
|
||||
var result = []
|
||||
for (var i = 0; i < daily.time.length && result.length < 3; ++i) {
|
||||
var date = daily.time[i]
|
||||
if (!isFutureForecastDate(date)) continue
|
||||
|
||||
var maxC = daily.temperature_2m_max ? daily.temperature_2m_max[i] : ""
|
||||
var minC = daily.temperature_2m_min ? daily.temperature_2m_min[i] : ""
|
||||
result.push({
|
||||
date: date,
|
||||
maxtempC: roundedTemp(maxC),
|
||||
mintempC: roundedTemp(minC),
|
||||
maxtempF: roundedTemp(celsiusToFahrenheit(maxC)),
|
||||
mintempF: roundedTemp(celsiusToFahrenheit(minC)),
|
||||
openMeteoWeatherCode: daily.weather_code ? daily.weather_code[i] : null
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function wttrNextForecastDays() {
|
||||
var days = report && report.weather ? report.weather : []
|
||||
var result = []
|
||||
for (var i = 0; i < days.length && result.length < 3; ++i) {
|
||||
if (isFutureForecastDate(days[i].date)) result.push(days[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function isFutureForecastDate(dateString) {
|
||||
if (!dateString) return false
|
||||
return String(dateString).slice(0, 10) > Qt.formatDate(new Date(), "yyyy-MM-dd")
|
||||
}
|
||||
|
||||
function roundedTemp(value) {
|
||||
if (value === undefined || value === null || value === "") return ""
|
||||
var n = parseFloat(String(value))
|
||||
return isNaN(n) ? "" : String(Math.round(n))
|
||||
}
|
||||
|
||||
function celsiusToFahrenheit(value) {
|
||||
if (value === undefined || value === null || value === "") return ""
|
||||
var n = parseFloat(String(value))
|
||||
return isNaN(n) ? "" : (n * 9 / 5) + 32
|
||||
}
|
||||
|
||||
function formatTemp(value) {
|
||||
if (value === undefined || value === null || value === "") return ""
|
||||
return value + "°" + (useImperial ? "F" : "C")
|
||||
}
|
||||
|
||||
function dayName(dateString) {
|
||||
if (!dateString) return ""
|
||||
var d = new Date(dateString + "T12:00:00")
|
||||
if (isNaN(d.getTime())) return ""
|
||||
return Qt.formatDate(d, "dddd")
|
||||
}
|
||||
|
||||
function maxTempForDay(day) {
|
||||
if (!day) return ""
|
||||
return formatTemp(useImperial ? day.maxtempF : day.maxtempC)
|
||||
}
|
||||
|
||||
function minTempForDay(day) {
|
||||
if (!day) return ""
|
||||
return formatTemp(useImperial ? day.mintempF : day.mintempC)
|
||||
}
|
||||
|
||||
// Bare degree value (no unit letter), used in the forecast row.
|
||||
function bareTempForDay(day, kind) {
|
||||
if (!day) return ""
|
||||
var v = useImperial
|
||||
? (kind === "max" ? day.maxtempF : day.mintempF)
|
||||
: (kind === "max" ? day.maxtempC : day.mintempC)
|
||||
if (v === undefined || v === null || v === "") return ""
|
||||
return v + "°"
|
||||
}
|
||||
|
||||
// Representative icon for a forecast day: the hourly entry nearest noon.
|
||||
function dayIcon(day) {
|
||||
if (!day) return ""
|
||||
if (day.openMeteoWeatherCode !== undefined && day.openMeteoWeatherCode !== null) return iconForOpenMeteoCode(day.openMeteoWeatherCode)
|
||||
if (!day.hourly || day.hourly.length === 0) return ""
|
||||
var best = day.hourly[0]
|
||||
var bestDist = 9999
|
||||
for (var i = 0; i < day.hourly.length; ++i) {
|
||||
var t = parseInt(String(day.hourly[i].time || "0"), 10)
|
||||
var dist = Math.abs(t - 1200)
|
||||
if (dist < bestDist) { bestDist = dist; best = day.hourly[i] }
|
||||
}
|
||||
return iconForCode(best.weatherCode, false)
|
||||
}
|
||||
|
||||
function iconForOpenMeteoCode(code) {
|
||||
var c = parseInt(String(code || "0"), 10)
|
||||
if (c === 0) return iconForCode(113, false)
|
||||
if (c === 1 || c === 2) return iconForCode(116, false)
|
||||
if (c === 3) return iconForCode(119, false)
|
||||
if (c === 45 || c === 48) return iconForCode(143, false)
|
||||
if (c === 51 || c === 53 || c === 55 || c === 56 || c === 57 || c === 61) return iconForCode(266, false)
|
||||
if (c === 63 || c === 65 || c === 66 || c === 67 || c === 80 || c === 81 || c === 82) return iconForCode(308, false)
|
||||
if (c === 71 || c === 73 || c === 75 || c === 77 || c === 85 || c === 86) return iconForCode(338, false)
|
||||
if (c === 95 || c === 96 || c === 99) return iconForCode(389, false)
|
||||
return iconForCode(119, false)
|
||||
}
|
||||
|
||||
// Mirrors omarchy-weather-icon's wttr.in code → nerd-font glyph mapping.
|
||||
function iconForCode(code, night) {
|
||||
var c = parseInt(String(code || "0"), 10)
|
||||
switch (c) {
|
||||
case 113: return night ? "" : ""
|
||||
case 116: return night ? "" : ""
|
||||
case 119: case 122: return ""
|
||||
case 143: case 248: case 260: return ""
|
||||
case 176: case 263: case 353: return night ? "" : ""
|
||||
case 179: case 227: case 230: case 323: case 326: case 368: return night ? "" : ""
|
||||
case 182: case 185: case 281: case 284: case 311: case 314:
|
||||
case 317: case 320: case 350: case 362: case 365: case 374: case 377: return ""
|
||||
case 200: case 386: case 389: case 392: case 395: return ""
|
||||
case 266: case 293: case 296: case 299: case 302: case 305: case 308: case 356: case 359: return ""
|
||||
case 329: case 332: case 335: case 338: case 371: return ""
|
||||
default: return ""
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: forecastProc
|
||||
command: ["bash", "-lc", "curl -fsS --max-time 5 'https://wttr.in/?format=j1' 2>/dev/null"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
var raw = String(text || "").trim()
|
||||
if (!raw) return
|
||||
try {
|
||||
var parsed = JSON.parse(raw)
|
||||
root.report = parsed
|
||||
root.refreshDailyForecast(parsed)
|
||||
} catch (e) {
|
||||
// Keep last-good report on parse failure so the popup isn't blanked.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: dailyForecastProc
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
var raw = String(text || "").trim()
|
||||
if (!raw) return
|
||||
try {
|
||||
root.dailyForecastReport = JSON.parse(raw)
|
||||
} catch (e) {
|
||||
// Keep last-good daily forecast on parse failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: locationProc
|
||||
command: ["bash", "-lc", "curl -fsS --max-time 4 'https://wttr.in?format=%l' 2>/dev/null"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
var raw = String(text || "").trim()
|
||||
if (!raw) return
|
||||
root.wttrLocation = raw.split(",")[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: refreshTimer
|
||||
interval: root.refreshMinutes * 60 * 1000
|
||||
running: true
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
WidgetButton {
|
||||
id: button
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
bar: root.bar
|
||||
text: root.label
|
||||
active: root.klass === "active"
|
||||
horizontalMargin: 1
|
||||
// Tooltip suppressed — the popup itself is the detail view.
|
||||
tooltipText: ""
|
||||
|
||||
onPressed: function(b) {
|
||||
if (b === Qt.RightButton) {
|
||||
root.bar.run("omarchy-notification-send \"$(omarchy-weather-status)\"")
|
||||
} else if (b === Qt.MiddleButton) {
|
||||
root.refresh()
|
||||
} else {
|
||||
var willOpen = !root.popupOpen
|
||||
root.popupOpen = willOpen
|
||||
if (willOpen) root.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PopupCard {
|
||||
id: popup
|
||||
anchorItem: button
|
||||
owner: root
|
||||
bar: root.bar
|
||||
open: root.popupOpen
|
||||
centerOnBar: true
|
||||
triggerMode: "click"
|
||||
contentWidth: 480
|
||||
contentHeight: card.implicitHeight + 28
|
||||
margin: 24
|
||||
borderColor: Color.notifications.border
|
||||
|
||||
Column {
|
||||
id: card
|
||||
anchors.fill: parent
|
||||
spacing: 14
|
||||
|
||||
// ---- Hero row: big icon + temp on the left; location and stats stacked on the right.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: Math.max(heroLeft.height, heroRight.height)
|
||||
|
||||
Row {
|
||||
id: heroLeft
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 16
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 16
|
||||
|
||||
Text {
|
||||
id: heroIcon
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.verticalCenterOffset: 5
|
||||
text: root.label || "—"
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 64
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
id: tempBig
|
||||
text: root.reportTempNum || "—"
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 56
|
||||
font.bold: true
|
||||
}
|
||||
Text {
|
||||
text: root.current ? root.tempUnit : ""
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 22
|
||||
anchors.top: tempBig.top
|
||||
anchors.topMargin: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: heroRight
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 20
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 12
|
||||
|
||||
Row {
|
||||
visible: root.reportLocation !== ""
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
text: "" // nf-fa-map_marker
|
||||
color: Qt.darker(root.bar.foreground, 1.4)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Text {
|
||||
text: (root.reportLocation || "").toUpperCase()
|
||||
color: Qt.darker(root.bar.foreground, 1.4)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 12
|
||||
font.letterSpacing: 1
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
visible: !!root.current
|
||||
spacing: 36
|
||||
|
||||
Column {
|
||||
spacing: 5
|
||||
Text {
|
||||
text: "FEELS"
|
||||
color: Qt.darker(root.bar.foreground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 11
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Text {
|
||||
text: root.reportFeels
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 15
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
spacing: 5
|
||||
Text {
|
||||
text: "WIND"
|
||||
color: Qt.darker(root.bar.foreground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 11
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Text {
|
||||
text: root.reportWind
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 15
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
spacing: 5
|
||||
Text {
|
||||
text: "HUMID"
|
||||
color: Qt.darker(root.bar.foreground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 11
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
Text {
|
||||
text: root.reportHumidity
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 15
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: !root.current
|
||||
text: "Fetching forecast…"
|
||||
color: Qt.darker(root.bar.foreground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 11
|
||||
font.italic: true
|
||||
}
|
||||
|
||||
// ---- Divider between current conditions and forecast.
|
||||
Rectangle {
|
||||
visible: root.forecastDays.length > 0
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: root.bar.foreground
|
||||
opacity: 0.12
|
||||
}
|
||||
|
||||
// ---- Forecast row: each cell has the day icon left of a day-name + hi/lo column.
|
||||
// Wrapped in an Item so the block of cells can be centered within the popup.
|
||||
Item {
|
||||
visible: root.forecastDays.length > 0
|
||||
width: parent.width
|
||||
height: forecastRow.height
|
||||
|
||||
Row {
|
||||
id: forecastRow
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: 44
|
||||
|
||||
Repeater {
|
||||
model: root.forecastDays
|
||||
|
||||
Row {
|
||||
required property var modelData
|
||||
required property int index
|
||||
spacing: 10
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.dayIcon(modelData)
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 24
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
text: root.dayName(modelData.date).toUpperCase()
|
||||
color: Qt.darker(root.bar.foreground, 1.4)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 10
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
text: root.bareTempForDay(modelData, "max")
|
||||
color: root.bar.foreground
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 12
|
||||
}
|
||||
Text {
|
||||
text: root.bareTempForDay(modelData, "min")
|
||||
color: Qt.darker(root.bar.foreground, 1.5)
|
||||
font.family: root.bar.fontFamily
|
||||
font.pixelSize: 12
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Poll the weather pill text/class every minute. Local to this widget.
|
||||
Process {
|
||||
id: weatherProc
|
||||
command: ["bash", "-lc", root.bar ? root.bar.commandWithOmarchyPath(root.bar.shellQuote(root.bar.omarchyPath + "/shell/scripts/weather.sh")) : ""]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: root.updateWeather(text)
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 60000
|
||||
running: true
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: if (!weatherProc.running) weatherProc.running = true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user