Add shell plugin model tests

This commit is contained in:
David Heinemeier Hansson
2026-05-25 14:18:39 +02:00
parent 4277f5346b
commit 829c1fa4f7
61 changed files with 3236 additions and 1168 deletions
+262
View File
@@ -0,0 +1,262 @@
function isPlaybackStream(node) {
if (!node || !node.isStream) return false
if (node.isSink === true) return true
var mediaClass = String(node.type || "")
return mediaClass.indexOf("Stream/Output/Audio") !== -1
|| mediaClass.indexOf("AudioOutStream") !== -1
|| mediaClass.indexOf("Output") !== -1
}
function isAudioSource(node) {
if (!node) return false
if (node.audio) return true
var mediaClass = String(node.type || "")
return mediaClass.indexOf("Audio/Source") !== -1
|| mediaClass.indexOf("AudioSource") !== -1
|| mediaClass.indexOf("Source") !== -1
}
function listSnapshot(list) {
return list && list.slice ? list.slice() : []
}
function outputVolumeName(volume, muted) {
if (muted) return "Muted"
var p = Math.round(volume * 100)
if (p === 0) return "Silenced"
if (p >= 100) return "Concert hall"
if (p >= 85) return "Party mode"
if (p >= 70) return "Cranked up"
if (p >= 50) return "Steady groove"
if (p >= 30) return "Easy listening"
if (p >= 15) return "Murmur"
return "Whisper"
}
function parseSinkAvailability(raw) {
var next = {}
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
var parts = line.split("\t")
if (parts.length >= 2) next[parts[0]] = parts[1] !== "0"
}
return next
}
function friendlyDeviceLabel(text) {
var label = String(text || "").trim()
label = label.replace(/^sof-soundwire\s+/i, "")
label = label.replace(/^built-?in audio\s+/i, "")
label = label.replace(/\s+Output$/i, "")
label = label.replace(/\s+Input$/i, "")
label = label.replace(/\bMicrophones\b/g, "Microphone")
return label
}
function nodeProps(node) {
return node && node.ready && node.properties ? node.properties : {}
}
function nodeLabel(node) {
if (!node) return "Unknown"
var p = nodeProps(node)
var nickname = friendlyDeviceLabel(node.nickname || node.nick || p["node.nick"] || p["device.profile.description"] || "")
if (nickname) return nickname
return friendlyDeviceLabel(node.description || p["node.description"] || node.name || "Unknown")
}
function isHeadphones(node) {
if (!node) return false
var p = nodeProps(node)
var blob = String([
node.name, node.description, node.nickname,
p["device.icon-name"] || "",
p["device.product.name"] || "",
p["node.description"] || "",
p["node.nick"] || ""
].join(" ")).toLowerCase()
return blob.indexOf("headphone") !== -1
|| blob.indexOf("headset") !== -1
|| blob.indexOf("earbud") !== -1
|| blob.indexOf("earphone") !== -1
|| blob.indexOf("airpod") !== -1
}
function sinkGlyph(node) {
if (!node) return "󰓃"
if (isHeadphones(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("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 friendlyStreamLabel(label) {
label = String(label || "").trim()
if (!label) return ""
var known = {
"spotify": "Spotify"
}
var normalized = label.toLowerCase()
return known[normalized] || label
}
function streamLabelKey(label) {
return String(label || "").trim().toLowerCase()
}
function streamLabelIsGeneric(label) {
return streamLabelKey(label) === "audio-src"
}
function rawStreamLabel(node) {
if (!node) return ""
var p = nodeProps(node)
return p["application.name"]
|| node.description
|| p["media.name"]
|| p["node.name"]
|| node.name
}
function mprisPlayerLabel(player) {
if (!player) return ""
return friendlyStreamLabel(player.identity || player.desktopEntry || "")
}
function mprisPlayerIsProxy(player) {
var dbusName = String(player && player.dbusName || "").toLowerCase()
var desktopEntry = String(player && player.desktopEntry || "").toLowerCase()
return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld"
}
function streamRepresentsMprisPlayer(streamLabel, playerLabel) {
var streamKey = streamLabelKey(friendlyStreamLabel(streamLabel))
var playerKey = streamLabelKey(playerLabel)
if (!streamKey || !playerKey) return false
return streamKey === playerKey
|| streamKey.indexOf(playerKey) !== -1
|| playerKey.indexOf(streamKey) !== -1
}
function mprisLabelsFor(players, predicate) {
var values = Array.isArray(players) ? players : []
var playingCandidates = []
var candidates = []
var playingProxyCandidates = []
var proxyCandidates = []
for (var i = 0; i < values.length; i++) {
var player = values[i]
if (!player) continue
if (!player.isPlaying && !player.canPlay) continue
var playerLabel = mprisPlayerLabel(player)
if (!playerLabel || !predicate(playerLabel)) continue
if (mprisPlayerIsProxy(player)) {
if (player.isPlaying) playingProxyCandidates.push(playerLabel)
proxyCandidates.push(playerLabel)
} else {
if (player.isPlaying) playingCandidates.push(playerLabel)
candidates.push(playerLabel)
}
}
if (playingCandidates.length === 1) return playingCandidates[0]
if (playingCandidates.length === 0 && playingProxyCandidates.length === 1) return playingProxyCandidates[0]
if (candidates.length === 1) return candidates[0]
if (candidates.length === 0 && proxyCandidates.length === 1) return proxyCandidates[0]
return ""
}
function matchingMprisStreamLabel(label, players) {
if (streamLabelIsGeneric(label)) return ""
return mprisLabelsFor(players, function(playerLabel) {
return streamRepresentsMprisPlayer(label, playerLabel)
})
}
function unmatchedMprisStreamLabel(label, players, streams) {
if (!streamLabelIsGeneric(label)) return ""
return mprisLabelsFor(players, function(playerLabel) {
var values = Array.isArray(streams) ? streams : []
for (var i = 0; i < values.length; i++) {
var stream = values[i]
var streamLabel = rawStreamLabel(stream)
if (!streamLabelIsGeneric(streamLabel) && streamRepresentsMprisPlayer(streamLabel, playerLabel))
return false
}
return true
})
}
function streamLabel(node, players, streams) {
if (!node) return "Stream"
var label = rawStreamLabel(node)
return friendlyStreamLabel(matchingMprisStreamLabel(label, players)
|| unmatchedMprisStreamLabel(label, players, streams)
|| label) || "Stream"
}
function streamRepresentsPlayer(node, player, players, streams) {
if (!node || !player) return false
var playerLabel = mprisPlayerLabel(player)
if (!playerLabel) return false
var label = rawStreamLabel(node)
if (!streamLabelIsGeneric(label)) return streamRepresentsMprisPlayer(label, playerLabel)
return streamRepresentsMprisPlayer(streamLabel(node, players, streams), playerLabel)
}
if (typeof module !== "undefined") {
module.exports = {
isPlaybackStream: isPlaybackStream,
isAudioSource: isAudioSource,
listSnapshot: listSnapshot,
outputVolumeName: outputVolumeName,
parseSinkAvailability: parseSinkAvailability,
friendlyDeviceLabel: friendlyDeviceLabel,
nodeProps: nodeProps,
nodeLabel: nodeLabel,
isHeadphones: isHeadphones,
sinkGlyph: sinkGlyph,
sourceGlyph: sourceGlyph,
friendlyStreamLabel: friendlyStreamLabel,
streamLabelKey: streamLabelKey,
streamLabelIsGeneric: streamLabelIsGeneric,
rawStreamLabel: rawStreamLabel,
mprisPlayerLabel: mprisPlayerLabel,
mprisPlayerIsProxy: mprisPlayerIsProxy,
streamRepresentsMprisPlayer: streamRepresentsMprisPlayer,
mprisLabelsFor: mprisLabelsFor,
matchingMprisStreamLabel: matchingMprisStreamLabel,
unmatchedMprisStreamLabel: unmatchedMprisStreamLabel,
streamLabel: streamLabel,
streamRepresentsPlayer: streamRepresentsPlayer
}
}
+24 -164
View File
@@ -6,6 +6,7 @@ import Quickshell.Services.Mpris
import Quickshell.Services.Pipewire
import qs.Ui
import qs.Commons
import "AudioModel.js" as AudioModel
Panel {
id: root
@@ -61,23 +62,11 @@ Panel {
// playback streams consistently accept audio input from clients and publish
// `isSink: true`; capture streams publish as stream sources.
function isPlaybackStream(node) {
if (!node || !node.isStream) return false
if (node.isSink === true) return true
var mediaClass = String(node.type || "")
return mediaClass.indexOf("Stream/Output/Audio") !== -1
|| mediaClass.indexOf("AudioOutStream") !== -1
|| mediaClass.indexOf("Output") !== -1
return AudioModel.isPlaybackStream(node)
}
function isAudioSource(node) {
if (!node) return false
if (node.audio) return true
var mediaClass = String(node.type || "")
return mediaClass.indexOf("Audio/Source") !== -1
|| mediaClass.indexOf("AudioSource") !== -1
|| mediaClass.indexOf("Source") !== -1
return AudioModel.isAudioSource(node)
}
property var cachedAudioSinks: []
@@ -274,7 +263,7 @@ Panel {
onAudioStreamsChanged: scheduleDisplayAudioModelRefresh()
function listSnapshot(list) {
return list && list.slice ? list.slice() : []
return AudioModel.listSnapshot(list)
}
function refreshDisplayAudioModels() {
@@ -365,16 +354,7 @@ Panel {
// panel's brightnessName ladder; bands are wide enough that small
// tweaks don't rename the room you're in.
function outputVolumeName(volume, muted) {
if (muted) return "Muted"
var p = Math.round(volume * 100)
if (p === 0) return "Silenced"
if (p >= 100) return "Concert hall"
if (p >= 85) return "Party mode"
if (p >= 70) return "Cranked up"
if (p >= 50) return "Steady groove"
if (p >= 30) return "Easy listening"
if (p >= 15) return "Murmur"
return "Whisper"
return AudioModel.outputVolumeName(volume, muted)
}
function setOutputVolume(v) {
@@ -426,203 +406,83 @@ Panel {
}
function updateSinkAvailability(raw) {
var next = {}
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
var parts = line.split("\t")
if (parts.length >= 2) next[parts[0]] = parts[1] !== "0"
}
sinkAvailability = next
sinkAvailability = AudioModel.parseSinkAvailability(raw)
sinkAvailabilityLoaded = true
}
function friendlyDeviceLabel(text) {
var label = String(text || "").trim()
label = label.replace(/^sof-soundwire\s+/i, "")
label = label.replace(/^built-?in audio\s+/i, "")
label = label.replace(/\s+Output$/i, "")
label = label.replace(/\s+Input$/i, "")
label = label.replace(/\bMicrophones\b/g, "Microphone")
return label
return AudioModel.friendlyDeviceLabel(text)
}
function nodeLabel(node) {
if (!node) return "Unknown"
var p = nodeProps(node)
var nickname = friendlyDeviceLabel(node.nickname || node.nick || p["node.nick"] || p["device.profile.description"] || "")
if (nickname) return nickname
return friendlyDeviceLabel(node.description || p["node.description"] || node.name || "Unknown")
return AudioModel.nodeLabel(node)
}
function nodeProps(node) {
return node && node.ready && node.properties ? node.properties : {}
return AudioModel.nodeProps(node)
}
function isHeadphones(node) {
if (!node) return false
var p = nodeProps(node)
var blob = String([
node.name, node.description, node.nickname,
p["device.icon-name"] || "",
p["device.product.name"] || "",
p["node.description"] || "",
p["node.nick"] || ""
].join(" ")).toLowerCase()
return blob.indexOf("headphone") !== -1
|| blob.indexOf("headset") !== -1
|| blob.indexOf("earbud") !== -1
|| blob.indexOf("earphone") !== -1
|| blob.indexOf("airpod") !== -1
return AudioModel.isHeadphones(node)
}
function sinkGlyph(node) {
if (!node) return "󰓃"
if (isHeadphones(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("bluetooth") !== -1) return "󰂯"
if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return "󰍹"
return "󰓃"
return AudioModel.sinkGlyph(node)
}
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 "󰍬"
return AudioModel.sourceGlyph(node)
}
function friendlyStreamLabel(label) {
label = String(label || "").trim()
if (!label) return ""
var known = {
"spotify": "Spotify"
}
var normalized = label.toLowerCase()
return known[normalized] || label
return AudioModel.friendlyStreamLabel(label)
}
function streamLabelKey(label) {
return String(label || "").trim().toLowerCase()
return AudioModel.streamLabelKey(label)
}
function streamLabelIsGeneric(label) {
return streamLabelKey(label) === "audio-src"
return AudioModel.streamLabelIsGeneric(label)
}
function rawStreamLabel(node) {
if (!node) return ""
var p = nodeProps(node)
return p["application.name"]
|| node.description
|| p["media.name"]
|| p["node.name"]
|| node.name
return AudioModel.rawStreamLabel(node)
}
function mprisPlayerLabel(player) {
if (!player) return ""
return friendlyStreamLabel(player.identity || player.desktopEntry || "")
return AudioModel.mprisPlayerLabel(player)
}
function mprisPlayerIsProxy(player) {
var dbusName = String(player && player.dbusName || "").toLowerCase()
var desktopEntry = String(player && player.desktopEntry || "").toLowerCase()
return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld"
return AudioModel.mprisPlayerIsProxy(player)
}
function streamRepresentsMprisPlayer(streamLabel, playerLabel) {
var streamKey = streamLabelKey(friendlyStreamLabel(streamLabel))
var playerKey = streamLabelKey(playerLabel)
if (!streamKey || !playerKey) return false
return streamKey === playerKey
|| streamKey.indexOf(playerKey) !== -1
|| playerKey.indexOf(streamKey) !== -1
return AudioModel.streamRepresentsMprisPlayer(streamLabel, playerLabel)
}
function mprisLabelsFor(predicate) {
var playingCandidates = []
var candidates = []
var playingProxyCandidates = []
var proxyCandidates = []
for (var i = 0; i < mprisPlayers.length; i++) {
var player = mprisPlayers[i]
if (!player) continue
if (!player.isPlaying && !player.canPlay) continue
var playerLabel = mprisPlayerLabel(player)
if (!playerLabel || !predicate(playerLabel)) continue
if (mprisPlayerIsProxy(player)) {
if (player.isPlaying) playingProxyCandidates.push(playerLabel)
proxyCandidates.push(playerLabel)
} else {
if (player.isPlaying) playingCandidates.push(playerLabel)
candidates.push(playerLabel)
}
}
if (playingCandidates.length === 1) return playingCandidates[0]
if (playingCandidates.length === 0 && playingProxyCandidates.length === 1) return playingProxyCandidates[0]
if (candidates.length === 1) return candidates[0]
if (candidates.length === 0 && proxyCandidates.length === 1) return proxyCandidates[0]
return ""
return AudioModel.mprisLabelsFor(mprisPlayers, predicate)
}
function matchingMprisStreamLabel(label) {
if (streamLabelIsGeneric(label)) return ""
return mprisLabelsFor(function(playerLabel) {
return streamRepresentsMprisPlayer(label, playerLabel)
})
return AudioModel.matchingMprisStreamLabel(label, mprisPlayers)
}
function unmatchedMprisStreamLabel(label) {
// Spotify exposes its PipeWire stream as "audio-src". For generic stream
// names, use the one MPRIS player not already represented by another audio
// stream (e.g. Chromium, or ALSA apps like cliamp).
if (!streamLabelIsGeneric(label)) return ""
return mprisLabelsFor(function(playerLabel) {
for (var i = 0; i < displayAudioStreams.length; i++) {
var stream = displayAudioStreams[i]
var streamLabel = rawStreamLabel(stream)
if (!streamLabelIsGeneric(streamLabel)
&& streamRepresentsMprisPlayer(streamLabel, playerLabel))
return false
}
return true
})
return AudioModel.unmatchedMprisStreamLabel(label, mprisPlayers, displayAudioStreams)
}
function streamLabel(node) {
if (!node) return "Stream"
var label = rawStreamLabel(node)
return friendlyStreamLabel(matchingMprisStreamLabel(label)
|| unmatchedMprisStreamLabel(label)
|| label) || "Stream"
return AudioModel.streamLabel(node, mprisPlayers, displayAudioStreams)
}
function streamRepresentsPlayer(node, player) {
if (!node || !player) return false
var playerLabel = mprisPlayerLabel(player)
if (!playerLabel) return false
var label = rawStreamLabel(node)
if (!streamLabelIsGeneric(label)) return streamRepresentsMprisPlayer(label, playerLabel)
return streamRepresentsMprisPlayer(streamLabel(node), playerLabel)
return AudioModel.streamRepresentsPlayer(node, player, mprisPlayers, displayAudioStreams)
}
implicitWidth: button.implicitWidth
+13 -60
View File
@@ -5,6 +5,7 @@ import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Ui
import "BarModel.js" as BarModel
Item {
id: root
@@ -110,8 +111,7 @@ Item {
readonly property int barSize: vertical ? Style.bar.sizeVertical : Style.bar.sizeHorizontal
function normalizePosition(value) {
var next = String(value || "").trim()
return /^(top|bottom|left|right)$/.test(next) ? next : "top"
return BarModel.normalizePosition(value)
}
// Apply tray-pinning on top of the shared layout normalization so the
@@ -130,17 +130,7 @@ Item {
// sections. The drawer's reserved space then sits next to the bar center,
// not stranded mid-section.
function pinTrayToInner(entries, section) {
var trayEntry = null
var result = []
for (var i = 0; i < entries.length; i++) {
if (entryId(entries[i]) === "omarchy.tray") trayEntry = entries[i]
else result.push(entries[i])
}
if (trayEntry) {
if (section === "right") result.unshift(trayEntry)
else result.push(trayEntry)
}
return result
return BarModel.pinTrayToInner(entries, section)
}
function applyBarConfig() {
@@ -162,49 +152,27 @@ Item {
}
function entrySettings(entry) {
if (!Util.isPlainObject(entry)) return {}
var copy = {}
for (var key in entry) {
if (key === "id") continue
copy[key] = entry[key]
}
return copy
return BarModel.entrySettings(entry)
}
function entryId(entry) {
if (typeof entry === "string") return entry
if (Util.isPlainObject(entry)) {
var id = entry["id"]
if (id !== undefined && id !== null && String(id) !== "") return String(id)
}
return ""
return BarModel.entryId(entry)
}
function moduleString(entry, key, fallback) {
var settings = entrySettings(entry)
var value = settings[key]
return value === undefined || value === null ? fallback : String(value)
return BarModel.moduleString(entry, key, fallback)
}
function entryIndex(entries, name) {
if (!Array.isArray(entries)) return -1
for (var i = 0; i < entries.length; i++) {
if (entryId(entries[i]) === name)
return i
}
return -1
return BarModel.entryIndex(entries, name)
}
function entriesBefore(entries, name) {
var index = entryIndex(entries, name)
return index <= 0 ? [] : entries.slice(0, index)
return BarModel.entriesBefore(entries, name)
}
function entriesAfter(entries, name) {
var index = entryIndex(entries, name)
return index === -1 ? [] : entries.slice(index + 1)
return BarModel.entriesAfter(entries, name)
}
function canonicalWidgetId(name) {
@@ -212,34 +180,19 @@ Item {
}
function expandPath(path) {
var value = String(path || "")
if (value === "") return ""
if (value.indexOf("~/") === 0) return home + value.substring(1)
if (value.indexOf("$HOME/") === 0) return home + value.substring(5)
return value
return BarModel.expandPath(path, home)
}
function customModuleSafeName(name) {
var value = String(name || "")
return value !== "" && value.indexOf("..") === -1 && value[0] !== "/"
return BarModel.customModuleSafeName(name)
}
function customModuleType(entry) {
var settings = entrySettings(entry)
var type = String(settings.type || "")
if (type) return type
if (settings.exec) return "command"
if (settings.source) return "qml"
return ""
return BarModel.customModuleType(entry)
}
function customModuleSource(entry) {
var settings = entrySettings(entry)
var name = entryId(entry)
var source = settings.source ? expandPath(settings.source) : ""
if (!source && customModuleSafeName(name))
source = omarchyConfigDir + "/bar/modules/" + String(name) + ".qml"
var source = BarModel.customModulePath(entry, home, omarchyConfigDir)
return source ? Util.fileUrl(source) : ""
}
+114
View File
@@ -0,0 +1,114 @@
function isPlainObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function normalizePosition(value) {
var next = String(value || "").trim()
return /^(top|bottom|left|right)$/.test(next) ? next : "top"
}
function entrySettings(entry) {
if (!isPlainObject(entry)) return {}
var copy = {}
for (var key in entry) {
if (key === "id") continue
copy[key] = entry[key]
}
return copy
}
function entryId(entry) {
if (typeof entry === "string") return entry
if (isPlainObject(entry)) {
var id = entry["id"]
if (id !== undefined && id !== null && String(id) !== "") return String(id)
}
return ""
}
function pinTrayToInner(entries, section) {
var trayEntry = null
var result = []
var values = Array.isArray(entries) ? entries : []
for (var i = 0; i < values.length; i++) {
if (entryId(values[i]) === "omarchy.tray") trayEntry = values[i]
else result.push(values[i])
}
if (trayEntry) {
if (section === "right") result.unshift(trayEntry)
else result.push(trayEntry)
}
return result
}
function moduleString(entry, key, fallback) {
var settings = entrySettings(entry)
var value = settings[key]
return value === undefined || value === null ? fallback : String(value)
}
function entryIndex(entries, name) {
if (!Array.isArray(entries)) return -1
for (var i = 0; i < entries.length; i++) {
if (entryId(entries[i]) === name) return i
}
return -1
}
function entriesBefore(entries, name) {
var index = entryIndex(entries, name)
return index <= 0 ? [] : entries.slice(0, index)
}
function entriesAfter(entries, name) {
var index = entryIndex(entries, name)
return index === -1 ? [] : entries.slice(index + 1)
}
function expandPath(value, home) {
var path = String(value || "")
if (path === "") return ""
if (path.indexOf("~/") === 0) return home + path.substring(1)
if (path.indexOf("$HOME/") === 0) return home + path.substring(5)
return path
}
function customModuleSafeName(name) {
var value = String(name || "")
return value !== "" && value.indexOf("..") === -1 && value[0] !== "/"
}
function customModuleType(entry) {
var settings = entrySettings(entry)
var type = String(settings.type || "")
if (type) return type
if (settings.exec) return "command"
if (settings.source) return "qml"
return ""
}
function customModulePath(entry, home, configDir) {
var settings = entrySettings(entry)
var name = entryId(entry)
var source = settings.source ? expandPath(settings.source, home) : ""
if (!source && customModuleSafeName(name))
source = String(configDir || "") + "/bar/modules/" + String(name) + ".qml"
return source
}
if (typeof module !== "undefined") {
module.exports = {
normalizePosition: normalizePosition,
entrySettings: entrySettings,
entryId: entryId,
pinTrayToInner: pinTrayToInner,
moduleString: moduleString,
entryIndex: entryIndex,
entriesBefore: entriesBefore,
entriesAfter: entriesAfter,
expandPath: expandPath,
customModuleSafeName: customModuleSafeName,
customModuleType: customModuleType,
customModulePath: customModulePath
}
}
+14 -66
View File
@@ -5,6 +5,7 @@ import Quickshell.Io
import Quickshell.Bluetooth
import qs.Ui
import qs.Commons
import "BluetoothModel.js" as BluetoothModel
Panel {
id: root
@@ -20,66 +21,25 @@ Panel {
readonly property var devices: Bluetooth.devices ? Bluetooth.devices.values : []
function deviceLabel(device) {
if (!device) return ""
return String(device.deviceName || device.name || "").trim()
return BluetoothModel.deviceLabel(device)
}
function isUuidLike(value) {
var text = (value || "").trim()
if (text === "") return false
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)
|| /^[0-9a-f]{32}$/i.test(text)
|| /^0x[0-9a-f]{4,32}$/i.test(text)
|| /^0000[0-9a-f]{4}-0000-1000-8000-00805f9b34fb$/i.test(text)
return BluetoothModel.isUuidLike(value)
}
function isAddressLike(value) {
var text = (value || "").trim()
return /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(text)
return BluetoothModel.isAddressLike(value)
}
function hasHumanName(device) {
var label = deviceLabel(device)
return label !== "" && !isUuidLike(label) && !isAddressLike(label)
return BluetoothModel.hasHumanName(device)
}
readonly property var connectedDevices: {
var list = []
for (var i = 0; i < devices.length; i++) {
var d = devices[i]
if (d && d.connected && hasHumanName(d)) list.push(d)
}
list.sort(function(a, b) {
return deviceLabel(a).localeCompare(deviceLabel(b))
})
return list
}
readonly property var knownDevices: {
var list = []
for (var i = 0; i < devices.length; i++) {
var d = devices[i]
if (d && hasHumanName(d) && !d.connected && (d.paired || d.bonded || d.trusted)) list.push(d)
}
list.sort(function(a, b) {
return deviceLabel(a).localeCompare(deviceLabel(b))
})
return list
}
readonly property var discoveredDevices: {
var list = []
for (var i = 0; i < devices.length; i++) {
var d = devices[i]
if (!d || !hasHumanName(d)) continue
if (d.connected || d.paired || d.bonded || d.trusted) continue
list.push(d)
}
list.sort(function(a, b) {
return deviceLabel(a).localeCompare(deviceLabel(b))
})
return list
}
readonly property var deviceGroups: BluetoothModel.deviceLists(devices)
readonly property var connectedDevices: deviceGroups.connected || []
readonly property var knownDevices: deviceGroups.known || []
readonly property var discoveredDevices: deviceGroups.discovered || []
readonly property string icon: {
if (!adapter) return ""
@@ -145,18 +105,11 @@ Panel {
}
readonly property var visibleSections: {
var list = []
if (sectionVisible("connected")) list.push("connected")
if (sectionVisible("known")) list.push("known")
if (sectionVisible("discovered")) list.push("discovered")
return list
return BluetoothModel.visibleSections(deviceGroups, adapter && adapter.discovering)
}
function devicesForSection(section) {
if (section === "connected") return connectedDevices
if (section === "known") return knownDevices
if (section === "discovered") return discoveredDevices
return []
return BluetoothModel.sectionDevices(deviceGroups, section)
}
function deviceAt(section, index) {
@@ -165,21 +118,16 @@ Panel {
}
function cloneMap(map) {
var next = ({})
for (var key in map) next[key] = map[key]
return next
return BluetoothModel.cloneMap(map)
}
function pendingAction(address) {
return address && pendingActions[address] ? pendingActions[address] : ""
return BluetoothModel.pendingAction(pendingActions, address)
}
function setPendingAction(address, action) {
if (!address) return
var next = cloneMap(pendingActions)
if (action) next[address] = action
else delete next[address]
pendingActions = next
pendingActions = BluetoothModel.withPendingAction(pendingActions, address, action)
if (action) pendingTimeout.restart()
}
+100
View File
@@ -0,0 +1,100 @@
function deviceLabel(device) {
if (!device) return ""
return String(device.deviceName || device.name || "").trim()
}
function isUuidLike(value) {
var text = String(value || "").trim()
if (text === "") return false
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)
|| /^[0-9a-f]{32}$/i.test(text)
|| /^0x[0-9a-f]{4,32}$/i.test(text)
|| /^0000[0-9a-f]{4}-0000-1000-8000-00805f9b34fb$/i.test(text)
}
function isAddressLike(value) {
var text = String(value || "").trim()
return /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(text)
}
function hasHumanName(device) {
var label = deviceLabel(device)
return label !== "" && !isUuidLike(label) && !isAddressLike(label)
}
function sortedByLabel(devices) {
var list = Array.isArray(devices) ? devices.slice() : []
list.sort(function(a, b) { return deviceLabel(a).localeCompare(deviceLabel(b)) })
return list
}
function deviceLists(devices) {
var values = Array.isArray(devices) ? devices : []
var connected = []
var known = []
var discovered = []
for (var i = 0; i < values.length; i++) {
var d = values[i]
if (!d || !hasHumanName(d)) continue
if (d.connected) connected.push(d)
else if (d.paired || d.bonded || d.trusted) known.push(d)
else discovered.push(d)
}
return {
connected: sortedByLabel(connected),
known: sortedByLabel(known),
discovered: sortedByLabel(discovered)
}
}
function cloneMap(map) {
var next = ({})
for (var key in map || {}) next[key] = map[key]
return next
}
function pendingAction(actions, address) {
return address && actions && actions[address] ? actions[address] : ""
}
function withPendingAction(actions, address, action) {
var next = cloneMap(actions)
if (!address) return next
if (action) next[address] = action
else delete next[address]
return next
}
function visibleSections(lists, discovering) {
var sections = []
if (lists && lists.connected && lists.connected.length > 0) sections.push("connected")
if (lists && lists.known && lists.known.length > 0) sections.push("known")
if (discovering && lists && lists.discovered && lists.discovered.length > 0) sections.push("discovered")
return sections
}
function sectionDevices(lists, section) {
if (!lists) return []
if (section === "connected") return lists.connected || []
if (section === "known") return lists.known || []
if (section === "discovered") return lists.discovered || []
return []
}
if (typeof module !== "undefined") {
module.exports = {
deviceLabel: deviceLabel,
isUuidLike: isUuidLike,
isAddressLike: isAddressLike,
hasHumanName: hasHumanName,
sortedByLabel: sortedByLabel,
deviceLists: deviceLists,
cloneMap: cloneMap,
pendingAction: pendingAction,
withPendingAction: withPendingAction,
visibleSections: visibleSections,
sectionDevices: sectionDevices
}
}
+17 -68
View File
@@ -3,6 +3,7 @@ import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import qs.Commons
import "ClipboardHistory.js" as ClipboardHistory
Item {
id: root
@@ -55,48 +56,15 @@ Item {
}
function normalizeEntry(value) {
if (typeof value === "string") {
return value.length > 0 ? { type: "text", text: value } : null
}
if (!value || typeof value !== "object") return null
var type = String(value.type || value.kind || "")
if (type === "text") {
var text = String(value.text || "")
return text.length > 0 ? { type: "text", text: text } : null
}
if (type === "image") {
var path = String(value.path || "")
if (!path) return null
return {
type: "image",
path: path,
mime: String(value.mime || "image/png")
}
}
return null
return ClipboardHistory.normalizeEntry(value)
}
function entryKey(entry) {
if (!entry) return ""
if (entry.type === "image") return "image:" + String(entry.path || "")
return "text:" + String(entry.text || "")
return ClipboardHistory.entryKey(entry)
}
function loadHistory(raw) {
try {
var parsed = JSON.parse(String(raw || "[]"))
var next = []
if (Array.isArray(parsed)) {
for (var i = 0; i < parsed.length; i++) {
var entry = root.normalizeEntry(parsed[i])
if (entry) next.push(entry)
}
}
root.history = next
} catch (e) {
root.history = []
}
root.history = ClipboardHistory.parseHistory(raw)
if (root.opened) root.rebuildDisplay()
}
@@ -105,52 +73,33 @@ Item {
}
function addClipboardEntry(entry) {
var normalized = root.normalizeEntry(entry)
var normalized = ClipboardHistory.normalizeEntry(entry)
if (!normalized) return
var key = root.entryKey(normalized)
var next = [normalized]
for (var i = 0; i < root.history.length && next.length < 100; i++) {
var existing = root.normalizeEntry(root.history[i])
if (!existing || root.entryKey(existing) === key) continue
next.push(existing)
}
root.history = next
root.history = ClipboardHistory.addEntry(root.history, normalized, 100)
root.saveHistory()
if (root.opened) root.rebuildDisplay()
}
function addClipboardJson(line) {
var raw = String(line || "").trim()
if (!raw) return
try { root.addClipboardEntry(JSON.parse(raw)) } catch (e) {}
root.addClipboardEntry(ClipboardHistory.parseEntryJson(line))
}
function rebuildDisplay() {
var query = root.filterText.trim().toLowerCase()
var rows = ClipboardHistory.displayRows(root.history, root.filterText, 50)
displayModel.clear()
var outCount = 0
for (var i = 0; i < root.history.length; i++) {
var entry = root.normalizeEntry(root.history[i])
if (!entry) continue
var isImage = entry.type === "image"
var searchable = isImage ? ("image " + String(entry.mime || "")) : String(entry.text || "")
if (query && searchable.toLowerCase().indexOf(query) < 0) continue
for (var i = 0; i < rows.length; i++) {
var row = rows[i]
displayModel.append({
entryType: entry.type,
fullText: isImage ? "" : String(entry.text || ""),
previewText: isImage ? "Image" : String(entry.text || "").replace(/\s+/g, " "),
previewImage: isImage ? Util.fileUrl(entry.path) : "",
path: isImage ? String(entry.path || "") : "",
mime: isImage ? String(entry.mime || "image/png") : "text/plain",
index: outCount
entryType: row.entryType,
fullText: row.fullText,
previewText: row.previewText,
previewImage: row.previewImage ? Util.fileUrl(row.previewImage) : "",
path: row.path,
mime: row.mime,
index: row.index
})
outCount++
if (outCount >= 50) break
}
if (displayModel.count === 0) selectedIndex = 0
+129
View File
@@ -0,0 +1,129 @@
function normalizeEntry(value) {
if (typeof value === "string")
return value.length > 0 ? { type: "text", text: value } : null
if (!value || typeof value !== "object") return null
var type = String(value.type || value.kind || "")
if (type === "text") {
var text = String(value.text || "")
return text.length > 0 ? { type: "text", text: text } : null
}
if (type === "image") {
var path = String(value.path || "")
if (!path) return null
return {
type: "image",
path: path,
mime: String(value.mime || "image/png")
}
}
return null
}
function entryKey(entry) {
if (!entry) return ""
if (entry.type === "image") return "image:" + String(entry.path || "")
return "text:" + String(entry.text || "")
}
function parseHistory(raw) {
try {
var parsed = JSON.parse(String(raw || "[]"))
var next = []
if (!Array.isArray(parsed)) return next
for (var i = 0; i < parsed.length; i++) {
var entry = normalizeEntry(parsed[i])
if (entry) next.push(entry)
}
return next
} catch (e) {
return []
}
}
function addEntry(history, entry, limit) {
var normalized = normalizeEntry(entry)
var max = limit === undefined || limit === null ? 100 : Number(limit)
if (isNaN(max)) max = 100
max = Math.max(0, max)
if (!normalized) return Array.isArray(history) ? history.slice(0, max) : []
if (max === 0) return []
var key = entryKey(normalized)
var next = [normalized]
var values = Array.isArray(history) ? history : []
for (var i = 0; i < values.length && next.length < max; i++) {
var existing = normalizeEntry(values[i])
if (!existing || entryKey(existing) === key) continue
next.push(existing)
}
return next
}
function parseEntryJson(line) {
var raw = String(line || "").trim()
if (!raw) return null
try { return normalizeEntry(JSON.parse(raw)) } catch (e) { return null }
}
function searchableText(entry) {
if (!entry) return ""
if (entry.type === "image") return "image " + String(entry.mime || "")
return String(entry.text || "")
}
function previewText(entry) {
if (!entry) return ""
if (entry.type === "image") return "Image"
return String(entry.text || "").replace(/\s+/g, " ")
}
function displayRows(history, query, limit) {
var values = Array.isArray(history) ? history : []
var needle = String(query || "").trim().toLowerCase()
var max = limit === undefined || limit === null ? 50 : Number(limit)
if (isNaN(max)) max = 50
max = Math.max(0, max)
if (max === 0) return []
var rows = []
for (var i = 0; i < values.length; i++) {
var entry = normalizeEntry(values[i])
if (!entry) continue
if (needle && searchableText(entry).toLowerCase().indexOf(needle) < 0) continue
var isImage = entry.type === "image"
rows.push({
entryType: entry.type,
fullText: isImage ? "" : String(entry.text || ""),
previewText: previewText(entry),
previewImage: isImage ? String(entry.path || "") : "",
path: isImage ? String(entry.path || "") : "",
mime: isImage ? String(entry.mime || "image/png") : "text/plain",
index: rows.length
})
if (rows.length >= max) break
}
return rows
}
if (typeof module !== "undefined") {
module.exports = {
normalizeEntry: normalizeEntry,
entryKey: entryKey,
parseHistory: parseHistory,
addEntry: addEntry,
parseEntryJson: parseEntryJson,
searchableText: searchableText,
previewText: previewText,
displayRows: displayRows
}
}
+10 -50
View File
@@ -5,6 +5,7 @@ import QtQuick
import QtQuick.Effects
import QtQuick.Shapes
import qs.Commons
import "ImagePickerModel.js" as ImagePickerModel
Item {
id: root
@@ -70,11 +71,11 @@ Item {
}
function nameForPath(path) {
return path.split("/").pop().replace(/\.[^/.]+$/, "")
return ImagePickerModel.nameForPath(path)
}
function labelForPath(path) {
return nameForPath(path).replace(/[-_]+/g, " ").replace(/\b\w/g, function(match) { return match.toUpperCase() })
return ImagePickerModel.labelForPath(path)
}
function currentLabel() {
@@ -85,37 +86,19 @@ Item {
}
function itemMatches(index) {
if (index < 0 || index >= imageArray.length) return false
if (!filterText) return true
var path = imageArray[index].filePath
var needle = filterText.toLowerCase()
return nameForPath(path).toLowerCase().indexOf(needle) !== -1 || labelForPath(path).toLowerCase().indexOf(needle) !== -1
return ImagePickerModel.itemMatches(imageArray, index, filterText)
}
function firstMatchingIndex() {
for (var i = 0; i < imageArray.length; i++) {
if (itemMatches(i)) return i
}
return -1
return ImagePickerModel.firstMatchingIndex(imageArray, filterText)
}
function filteredPosition(index) {
if (!filterText) return index
var position = 0
for (var i = 0; i < index; i++) {
if (itemMatches(i)) position++
}
return position
return ImagePickerModel.filteredPosition(imageArray, index, filterText)
}
function selectedFilteredPosition() {
if (!filterText) return selectedIndex
return itemMatches(selectedIndex) ? filteredPosition(selectedIndex) : 0
return ImagePickerModel.selectedFilteredPosition(imageArray, selectedIndex, filterText)
}
function select(index, immediate) {
@@ -146,7 +129,7 @@ Item {
filterText = nextFilterText
if (!itemMatches(selectedIndex)) {
var first = firstMatchingIndex()
var first = ImagePickerModel.nextSelectedIndexForFilter(imageArray, selectedIndex, filterText)
if (first >= 0) selectedIndex = first
}
}
@@ -210,25 +193,7 @@ Item {
}
function loadRows(rows, reveal) {
var newImages = []
var seen = {}
var paths = rows.split("\n")
for (var i = 0; i < paths.length; i++) {
var row = paths[i]
if (!row) continue
var columns = row.split("\t")
var path = columns[0]
if (!path) continue
var fileName = path.split("/").pop()
if (seen[fileName]) continue
seen[fileName] = true
newImages.push({
filePath: path,
fileName: fileName,
thumbnailPath: columns[1] || path
})
}
var newImages = ImagePickerModel.loadRows(rows)
root.loadedImageRows = rows
root.selectedIndex = root.indexForSelectedImage(newImages)
@@ -304,12 +269,7 @@ Item {
}
function indexForSelectedImage(images) {
for (var i = 0; i < images.length; i++) {
if (images[i].filePath === selectedImage)
return i
}
return 0
return ImagePickerModel.indexForSelectedImage(images, selectedImage)
}
function selectedImageIndex() {
@@ -0,0 +1,97 @@
function nameForPath(path) {
return String(path || "").split("/").pop().replace(/\.[^/.]+$/, "")
}
function labelForPath(path) {
return nameForPath(path).replace(/[-_]+/g, " ").replace(/\b\w/g, function(match) { return match.toUpperCase() })
}
function loadRows(rows) {
var images = []
var seen = {}
var paths = String(rows || "").split("\n")
for (var i = 0; i < paths.length; i++) {
var row = paths[i]
if (!row) continue
var columns = row.split("\t")
var path = columns[0]
if (!path) continue
var fileName = path.split("/").pop()
if (seen[fileName]) continue
seen[fileName] = true
images.push({
filePath: path,
fileName: fileName,
thumbnailPath: columns[1] || path
})
}
return images
}
function itemMatches(images, index, filterText) {
if (!Array.isArray(images) || index < 0 || index >= images.length) return false
var needle = String(filterText || "").toLowerCase()
if (!needle) return true
var path = String(images[index].filePath || "")
return nameForPath(path).toLowerCase().indexOf(needle) !== -1
|| labelForPath(path).toLowerCase().indexOf(needle) !== -1
}
function firstMatchingIndex(images, filterText) {
var values = Array.isArray(images) ? images : []
for (var i = 0; i < values.length; i++) {
if (itemMatches(values, i, filterText)) return i
}
return -1
}
function filteredPosition(images, index, filterText) {
if (!filterText) return index
var position = 0
for (var i = 0; i < index; i++) {
if (itemMatches(images, i, filterText)) position++
}
return position
}
function selectedFilteredPosition(images, selectedIndex, filterText) {
if (!filterText) return selectedIndex
return itemMatches(images, selectedIndex, filterText) ? filteredPosition(images, selectedIndex, filterText) : 0
}
function indexForSelectedImage(images, selectedImage) {
var values = Array.isArray(images) ? images : []
for (var i = 0; i < values.length; i++) {
if (values[i].filePath === selectedImage) return i
}
return 0
}
function nextSelectedIndexForFilter(images, selectedIndex, filterText) {
if (itemMatches(images, selectedIndex, filterText)) return selectedIndex
return firstMatchingIndex(images, filterText)
}
if (typeof module !== "undefined") {
module.exports = {
nameForPath: nameForPath,
labelForPath: labelForPath,
loadRows: loadRows,
itemMatches: itemMatches,
firstMatchingIndex: firstMatchingIndex,
filteredPosition: filteredPosition,
selectedFilteredPosition: selectedFilteredPosition,
indexForSelectedImage: indexForSelectedImage,
nextSelectedIndexForFilter: nextSelectedIndexForFilter
}
}
+24 -195
View File
@@ -3,6 +3,7 @@ import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import qs.Commons
import "MenuModel.js" as MenuModel
Item {
id: root
@@ -169,112 +170,35 @@ Item {
// ------------------------------------------------------------------
function stripJsonc(raw) {
// Match what the bash bin's jq pipeline accepts: full-line `//` comments
// plus trailing commas before } or ]. Anything fancier should land in
// valid JSON anyway.
return String(raw || "")
.replace(/^\s*\/\/[^\n]*(\n|$)/gm, "")
.replace(/,(\s*[}\]])/g, "$1")
return MenuModel.stripJsonc(raw)
}
function normalizeAliases(value) {
if (Array.isArray(value)) return value.filter(function(v) { return v })
if (typeof value === "string" && value) return [value]
return []
return MenuModel.normalizeAliases(value)
}
function normalizeKeywords(id, aliases, raw) {
var parts = String(raw || "").split(/\s+/)
var seen = {}
var out = []
for (var i = 0; i < parts.length; i++) {
var p = parts[i]
if (!p || seen[p]) continue
seen[p] = true
out.push(p)
}
return out.join(" ")
return MenuModel.normalizeKeywords(id, aliases, raw)
}
function normalizeItem(id, raw) {
var aliases = normalizeAliases(raw.aliases)
var parent = raw.parent
if (parent === undefined) {
parent = id.indexOf(".") >= 0 ? id.split(".").slice(0, -1).join(".") : "root"
}
if (id === "root") parent = ""
var kind = raw.action ? "action" : (raw.target ? "link" : "menu")
return {
id: id,
parent: parent,
kind: kind,
icon: raw.icon || "",
label: raw.label || id,
target: raw.target || "",
keywords: normalizeKeywords(id, aliases, raw.keywords),
description: raw.description || "",
action: raw.action || "",
provider: raw.provider || "",
aliases: aliases,
when: raw.when || "",
checked: raw.checked || ""
}
return MenuModel.normalizeItem(id, raw)
}
function parseMenuJsonc(raw) {
var stripped = stripJsonc(raw)
if (!stripped.trim()) return []
var parsed
try { parsed = JSON.parse(stripped) } catch (e) {
console.warn("omarchy-menu: JSONC parse failed:", e)
return []
}
if (typeof parsed !== "object" || parsed === null) return []
var source = (parsed.items && typeof parsed.items === "object" && !Array.isArray(parsed.items))
? parsed.items
: parsed
var out = []
for (var id in source) {
var entry = source[id]
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue
out.push(normalizeItem(id, entry))
}
return out
return MenuModel.parseMenuJsonc(raw)
}
// Merge defaults + user extension. Later entries override earlier ones
// on a per-key basis (so the user can tweak label/icon/action without
// re-declaring the whole row).
function rebuildItemsFromSources() {
var nextItems = ({})
var nextOrder = []
var sources = [root.defaultMenuItems || [], root.userMenuItems || []]
for (var s = 0; s < sources.length; s++) {
var src = sources[s]
for (var i = 0; i < src.length; i++) {
var entry = src[i]
if (!entry || !entry.id) continue
if (!nextItems[entry.id]) nextOrder.push(entry.id)
var prior = nextItems[entry.id] || {}
var merged = {}
for (var k in prior) merged[k] = prior[k]
for (var k2 in entry) merged[k2] = entry[k2]
merged.id = entry.id
nextItems[entry.id] = merged
}
}
if (!nextItems.root) {
nextItems.root = { id: "root", parent: "", kind: "menu", icon: "", label: "Go", target: "", keywords: "", description: "", aliases: [], when: "", checked: "", action: "", provider: "" }
nextOrder.unshift("root")
}
for (var k3 = 0; k3 < nextOrder.length; k3++) nextItems[nextOrder[k3]].order = k3
var mergedMenu = MenuModel.mergeMenuSources(root.defaultMenuItems, root.userMenuItems)
root.providerRevision += 1
root.providersLoaded = ({})
root.providerQueue = []
root.items = nextItems
root.itemOrder = nextOrder
root.items = mergedMenu.items
root.itemOrder = mergedMenu.itemOrder
root.rowsLoaded = true
root.evaluateGuards()
if (root.opened) {
@@ -305,7 +229,7 @@ Item {
})
function slugify(value) {
return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item"
return MenuModel.slugify(value)
}
function startProviderForMenu(id) {
@@ -399,62 +323,23 @@ Item {
}
function depthFor(id) {
var depth = 0
var current = root.item(id)
var guard = 0
while (current && current.parent && current.parent !== "root" && guard < 32) {
depth += 1
current = root.item(current.parent)
guard += 1
}
return depth
return MenuModel.depthFor(root.items, id)
}
function pathFor(id) {
var labels = []
var current = root.item(id)
var guard = 0
while (current && current.id !== "root" && guard < 32) {
labels.unshift(current.label)
current = root.item(current.parent)
guard += 1
}
return labels.join(" ")
return MenuModel.pathFor(root.items, id)
}
function parentPathFor(id) {
var entry = root.item(id)
if (!entry || !entry.parent || entry.parent === "root") return ""
return root.pathFor(entry.parent)
return MenuModel.parentPathFor(root.items, id)
}
function isDescendantOf(id, ancestorId) {
if (ancestorId === "root") return id !== "root"
var current = root.item(id)
var guard = 0
while (current && current.parent && guard < 32) {
if (current.parent === ancestorId) return true
current = root.item(current.parent)
guard += 1
}
return false
return MenuModel.isDescendantOf(root.items, id, ancestorId)
}
function childCount(id) {
var count = 0
for (var i = 0; i < root.itemOrder.length; i++) {
var entry = root.item(root.itemOrder[i])
if (entry && entry.parent === id) count += 1
}
return count
return MenuModel.childCount(root.items, root.itemOrder, id)
}
// Items whose `when:` evaluated to false are hidden everywhere — nav,
@@ -468,95 +353,39 @@ Item {
// Label with the ✓ marker baked in when `checked:` evaluated truthy.
function labelFor(entry) {
if (!entry) return ""
if (entry.checked && root.checkedResults[entry.id]) return entry.label + " ✓"
return entry.label
return MenuModel.labelFor(entry, root.checkedResults)
}
function searchableToken(value) {
return String(value || "").replace(/[._-]+/g, " ")
return MenuModel.searchableToken(value)
}
function leafIdFor(id) {
var parts = String(id || "").split(".")
return parts.length > 0 ? parts[parts.length - 1] : id
return MenuModel.leafIdFor(id)
}
function nameSearchText(entry) {
if (!entry) return ""
var aliases = []
for (var i = 0; i < entry.aliases.length; i++) aliases.push(root.searchableToken(entry.aliases[i]))
return [entry.label, root.searchableToken(root.leafIdFor(entry.id)), aliases.join(" ")].join(" ").toLowerCase()
return MenuModel.nameSearchText(entry)
}
function termInSearchWords(term, text) {
var words = String(text || "").toLowerCase().split(/\s+/)
for (var i = 0; i < words.length; i++) {
if (words[i] === term) return true
}
return false
return MenuModel.termInSearchWords(term, text)
}
function keywordTextMatches(query, text) {
var terms = query.toLowerCase().trim().split(/\s+/)
for (var i = 0; i < terms.length; i++) {
if (terms[i] && !root.termInSearchWords(terms[i], text)) return false
}
return true
return MenuModel.keywordTextMatches(query, text)
}
function matchesQuery(entry, query) {
if (!entry || entry.id === "root") return false
if (!root.isVisible(entry)) return false
var nameText = root.nameSearchText(entry)
var keywordText = (entry.keywords + " " + entry.description).toLowerCase()
var terms = query.toLowerCase().trim().split(/\s+/)
for (var i = 0; i < terms.length; i++) {
if (!terms[i]) continue
if (nameText.indexOf(terms[i]) >= 0) continue
if (root.termInSearchWords(terms[i], keywordText)) continue
return false
}
return true
return MenuModel.matchesQuery(entry, query, root.isVisible(entry))
}
function searchScore(entry, query) {
var needle = query.toLowerCase().trim()
var label = entry.label.toLowerCase()
var nameText = root.nameSearchText(entry)
var keywordText = (entry.keywords + " " + entry.description).toLowerCase()
var score = 80
if (label === needle) score = entry.parent === "root" ? 2 : 0
else if (label.indexOf(needle) === 0) score = 10
else if (label.indexOf(needle) >= 0) score = 30
else if (nameText.indexOf(needle) >= 0) score = 40
else if (root.keywordTextMatches(needle, keywordText)) score = 60
if (entry.kind === "menu" || entry.kind === "link") score -= 2
return score * 1000 + root.depthFor(entry.id) * 25 + entry.order
return MenuModel.searchScore(root.items, entry, query)
}
function displayRow(entry, detail, score, section) {
var target = entry.kind === "link" ? entry.target : entry.id
return {
itemId: entry.id,
kind: entry.kind,
icon: entry.icon,
label: root.labelFor(entry),
target: target,
detail: detail || "",
path: root.pathFor(entry.id),
childCount: (entry.kind === "menu" || entry.kind === "link") ? root.childCount(target) : 0,
action: entry.action || "",
provider: entry.provider || "",
score: score || 0,
section: section || ""
}
return MenuModel.displayRow(root.items, root.itemOrder, root.checkedResults, entry, detail, score, section)
}
function rebuildDmenuDisplay() {
+293
View File
@@ -0,0 +1,293 @@
function stripJsonc(raw) {
return String(raw || "")
.replace(/^\s*\/\/[^\n]*(\n|$)/gm, "")
.replace(/,(\s*[}\]])/g, "$1")
}
function normalizeAliases(value) {
if (Array.isArray(value)) return value.filter(function(v) { return v })
if (typeof value === "string" && value) return [value]
return []
}
function normalizeKeywords(id, aliases, raw) {
var parts = String(raw || "").split(/\s+/)
var seen = {}
var out = []
for (var i = 0; i < parts.length; i++) {
var p = parts[i]
if (!p || seen[p]) continue
seen[p] = true
out.push(p)
}
return out.join(" ")
}
function normalizeItem(id, raw) {
var value = raw || {}
var aliases = normalizeAliases(value.aliases)
var parent = value.parent
if (parent === undefined)
parent = id.indexOf(".") >= 0 ? id.split(".").slice(0, -1).join(".") : "root"
if (id === "root") parent = ""
var kind = value.action ? "action" : (value.target ? "link" : "menu")
return {
id: id,
parent: parent,
kind: kind,
icon: value.icon || "",
label: value.label || id,
target: value.target || "",
keywords: normalizeKeywords(id, aliases, value.keywords),
description: value.description || "",
action: value.action || "",
provider: value.provider || "",
aliases: aliases,
when: value.when || "",
checked: value.checked || ""
}
}
function parseMenuJsonc(raw) {
var stripped = stripJsonc(raw)
if (!stripped.trim()) return []
var parsed
try {
parsed = JSON.parse(stripped)
} catch (e) {
return []
}
if (typeof parsed !== "object" || parsed === null) return []
var source = (parsed.items && typeof parsed.items === "object" && !Array.isArray(parsed.items))
? parsed.items
: parsed
var out = []
for (var id in source) {
var entry = source[id]
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue
out.push(normalizeItem(id, entry))
}
return out
}
function mergeMenuSources(defaultItems, userItems) {
var nextItems = ({})
var nextOrder = []
var sources = [defaultItems || [], userItems || []]
for (var s = 0; s < sources.length; s++) {
var src = sources[s]
for (var i = 0; i < src.length; i++) {
var entry = src[i]
if (!entry || !entry.id) continue
if (!nextItems[entry.id]) nextOrder.push(entry.id)
var prior = nextItems[entry.id] || {}
var merged = {}
for (var k in prior) merged[k] = prior[k]
for (var k2 in entry) merged[k2] = entry[k2]
merged.id = entry.id
nextItems[entry.id] = merged
}
}
if (!nextItems.root) {
nextItems.root = { id: "root", parent: "", kind: "menu", icon: "", label: "Go", target: "", keywords: "", description: "", aliases: [], when: "", checked: "", action: "", provider: "" }
nextOrder.unshift("root")
}
for (var k3 = 0; k3 < nextOrder.length; k3++) nextItems[nextOrder[k3]].order = k3
return {
items: nextItems,
itemOrder: nextOrder
}
}
function item(items, id) {
return items && items[id] ? items[id] : null
}
function slugify(value) {
return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item"
}
function depthFor(items, id) {
var depth = 0
var current = item(items, id)
var guard = 0
while (current && current.parent && current.parent !== "root" && guard < 32) {
depth += 1
current = item(items, current.parent)
guard += 1
}
return depth
}
function pathFor(items, id) {
var labels = []
var current = item(items, id)
var guard = 0
while (current && current.id !== "root" && guard < 32) {
labels.unshift(current.label)
current = item(items, current.parent)
guard += 1
}
return labels.join(" ")
}
function parentPathFor(items, id) {
var entry = item(items, id)
if (!entry || !entry.parent || entry.parent === "root") return ""
return pathFor(items, entry.parent)
}
function isDescendantOf(items, id, ancestorId) {
if (ancestorId === "root") return id !== "root"
var current = item(items, id)
var guard = 0
while (current && current.parent && guard < 32) {
if (current.parent === ancestorId) return true
current = item(items, current.parent)
guard += 1
}
return false
}
function childCount(items, itemOrder, id) {
var count = 0
var order = Array.isArray(itemOrder) ? itemOrder : []
for (var i = 0; i < order.length; i++) {
var entry = item(items, order[i])
if (entry && entry.parent === id) count += 1
}
return count
}
function labelFor(entry, checkedResults) {
if (!entry) return ""
if (entry.checked && checkedResults && checkedResults[entry.id]) return entry.label + " ✓"
return entry.label
}
function searchableToken(value) {
return String(value || "").replace(/[._-]+/g, " ")
}
function leafIdFor(id) {
var parts = String(id || "").split(".")
return parts.length > 0 ? parts[parts.length - 1] : id
}
function nameSearchText(entry) {
if (!entry) return ""
var aliases = []
var values = Array.isArray(entry.aliases) ? entry.aliases : []
for (var i = 0; i < values.length; i++) aliases.push(searchableToken(values[i]))
return [entry.label, searchableToken(leafIdFor(entry.id)), aliases.join(" ")].join(" ").toLowerCase()
}
function termInSearchWords(term, text) {
var words = String(text || "").toLowerCase().split(/\s+/)
for (var i = 0; i < words.length; i++) {
if (words[i] === term) return true
}
return false
}
function keywordTextMatches(query, text) {
var terms = String(query || "").toLowerCase().trim().split(/\s+/)
for (var i = 0; i < terms.length; i++) {
if (terms[i] && !termInSearchWords(terms[i], text)) return false
}
return true
}
function matchesQuery(entry, query, visible) {
if (!entry || entry.id === "root") return false
if (!visible) return false
var nameText = nameSearchText(entry)
var keywordText = (entry.keywords + " " + entry.description).toLowerCase()
var terms = String(query || "").toLowerCase().trim().split(/\s+/)
for (var i = 0; i < terms.length; i++) {
if (!terms[i]) continue
if (nameText.indexOf(terms[i]) >= 0) continue
if (termInSearchWords(terms[i], keywordText)) continue
return false
}
return true
}
function searchScore(items, entry, query) {
var needle = String(query || "").toLowerCase().trim()
var label = entry.label.toLowerCase()
var nameText = nameSearchText(entry)
var keywordText = (entry.keywords + " " + entry.description).toLowerCase()
var score = 80
if (label === needle) score = entry.parent === "root" ? 2 : 0
else if (label.indexOf(needle) === 0) score = 10
else if (label.indexOf(needle) >= 0) score = 30
else if (nameText.indexOf(needle) >= 0) score = 40
else if (keywordTextMatches(needle, keywordText)) score = 60
if (entry.kind === "menu" || entry.kind === "link") score -= 2
return score * 1000 + depthFor(items, entry.id) * 25 + entry.order
}
function displayRow(items, itemOrder, checkedResults, entry, detail, score, section) {
var target = entry.kind === "link" ? entry.target : entry.id
return {
itemId: entry.id,
kind: entry.kind,
icon: entry.icon,
label: labelFor(entry, checkedResults),
target: target,
detail: detail || "",
path: pathFor(items, entry.id),
childCount: (entry.kind === "menu" || entry.kind === "link") ? childCount(items, itemOrder, target) : 0,
action: entry.action || "",
provider: entry.provider || "",
score: score || 0,
section: section || ""
}
}
if (typeof module !== "undefined") {
module.exports = {
stripJsonc: stripJsonc,
normalizeAliases: normalizeAliases,
normalizeKeywords: normalizeKeywords,
normalizeItem: normalizeItem,
parseMenuJsonc: parseMenuJsonc,
mergeMenuSources: mergeMenuSources,
item: item,
slugify: slugify,
depthFor: depthFor,
pathFor: pathFor,
parentPathFor: parentPathFor,
isDescendantOf: isDescendantOf,
childCount: childCount,
labelFor: labelFor,
searchableToken: searchableToken,
leafIdFor: leafIdFor,
nameSearchText: nameSearchText,
termInSearchWords: termInSearchWords,
keywordTextMatches: keywordTextMatches,
matchesQuery: matchesQuery,
searchScore: searchScore,
displayRow: displayRow
}
}
+8 -24
View File
@@ -4,6 +4,7 @@ import Quickshell
import Quickshell.Io
import qs.Ui
import qs.Commons
import "MonitorModel.js" as MonitorModel
Panel {
id: root
@@ -202,7 +203,7 @@ Panel {
}
function setBrightness(value) {
var percent = Math.max(1, Math.min(100, Math.round(value)))
var percent = MonitorModel.clampBrightness(value)
root.brightnessPercent = percent
root.pendingBrightnessPercent = percent
@@ -217,42 +218,25 @@ Panel {
}
function previewBrightness(value) {
root.brightnessPercent = Math.max(1, Math.min(100, Math.round(value)))
root.brightnessPercent = MonitorModel.clampBrightness(value)
brightnessDebounce.restart()
}
function normalizeScale(scale) {
var n = parseFloat(String(scale || ""))
if (!isFinite(n)) return ""
return String(Math.round(n * 100) / 100)
return MonitorModel.normalizeScale(scale)
}
// Playful mood-name for a given brightness percent. Bands intentionally
// span ~1020 points so casual tweaks change the label, while small
// nudges within one band don't.
function brightnessName(percent) {
var p = Math.round(percent)
if (p >= 95) return "Sun blast"
if (p >= 80) return "Solar flare"
if (p >= 65) return "Golden hour"
if (p >= 45) return "Even day"
if (p >= 30) return "Soft glow"
if (p >= 20) return "Lamp light"
if (p >= 10) return "Candlelit"
return "Night owl"
return MonitorModel.brightnessName(percent)
}
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
var parsed = MonitorModel.parseDisplays(displaysJson)
root.displays = parsed.displays
root.enabledDisplayCount = parsed.enabledDisplayCount
}
function toggleDisplay(name, enabled) {
+52
View File
@@ -0,0 +1,52 @@
function clampBrightness(value) {
var n = Number(value)
if (!isFinite(n)) return 1
return Math.max(1, Math.min(100, Math.round(n)))
}
function normalizeScale(scale) {
var n = parseFloat(String(scale || ""))
if (!isFinite(n)) return ""
return String(Math.round(n * 100) / 100)
}
function brightnessName(percent) {
var p = Math.round(percent)
if (p >= 95) return "Sun blast"
if (p >= 80) return "Solar flare"
if (p >= 65) return "Golden hour"
if (p >= 45) return "Even day"
if (p >= 30) return "Soft glow"
if (p >= 20) return "Lamp light"
if (p >= 10) return "Candlelit"
return "Night owl"
}
function parseDisplays(raw) {
var displays = []
try {
displays = raw ? JSON.parse(String(raw)) : []
} catch (e) {
displays = []
}
if (!Array.isArray(displays)) displays = []
var count = 0
for (var i = 0; i < displays.length; i++) {
if (displays[i] && displays[i].enabled) count++
}
return {
displays: displays,
enabledDisplayCount: count
}
}
if (typeof module !== "undefined") {
module.exports = {
clampBrightness: clampBrightness,
normalizeScale: normalizeScale,
brightnessName: brightnessName,
parseDisplays: parseDisplays
}
}
+40 -100
View File
@@ -5,6 +5,7 @@ import Quickshell.Io
import Quickshell.Networking
import qs.Ui
import qs.Commons
import "NetworkModel.js" as NetworkModel
Panel {
id: root
@@ -228,11 +229,11 @@ Panel {
property string frequency: ""
function updateNetwork(raw) {
var parts = String(raw || "disconnected\t\t\t").replace(/\r?\n+$/, "").split("\t")
kind = parts[0] || "disconnected"
label = parts[1] || ""
signalStrength = parts[2] ? parseInt(parts[2], 10) : -1
frequency = parts[3] || ""
var parsed = NetworkModel.parseNetworkStatus(raw)
kind = parsed.kind
label = parsed.label
signalStrength = parsed.signalStrength
frequency = parsed.frequency
}
function copyToClipboard(value) {
@@ -240,15 +241,7 @@ Panel {
Quickshell.execDetached(["bash", "-lc", "printf %s " + Util.shellQuote(value) + " | wl-copy"])
}
readonly property string icon: {
if (kind === "wifi") {
var icons = ["󰤯", "󰤟", "󰤢", "󰤥", "󰤨"]
var index = Math.max(0, Math.min(4, Math.ceil(signalStrength / 20) - 1))
return icons[index]
}
if (kind === "ethernet") return "󰈀"
return "󰤮"
}
readonly property string icon: NetworkModel.connectionIcon(kind, signalStrength)
function refresh(scanWifi) {
if (scanWifi === undefined) scanWifi = false
@@ -270,79 +263,47 @@ Panel {
}
function formatHeaderSpeed(mbps) {
var v = parseInt(mbps, 10)
if (!v || v < 0) return ""
if (v >= 1000) return (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + "gbit"
return v + "mbit"
return NetworkModel.formatHeaderSpeed(mbps)
}
function formatHeaderFreq(mhz) {
var v = parseFloat(mhz)
if (!v) return ""
var ghz = v / 1000
return ghz.toFixed(ghz % 1 === 0 ? 0 : 1) + "ghz"
return NetworkModel.formatHeaderFreq(mhz)
}
function headerDetail() {
if (info.type === "ethernet") return formatHeaderSpeed(info.speed || "")
if (info.type === "wifi") return formatHeaderFreq(info.freq || "")
return ""
return NetworkModel.headerDetail(info)
}
function updateDetails(raw) {
var next = {}
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i]
if (!line) continue
var idx = line.indexOf("\t")
if (idx === -1) continue
next[line.substring(0, idx)] = line.substring(idx + 1).trim()
}
var next = NetworkModel.parseKeyValue(raw)
info = next
updateThroughput(next)
}
function updateThroughput(next) {
var iface = next.iface || ""
var rx = parseFloat(next.rx_bytes || "0")
var tx = parseFloat(next.tx_bytes || "0")
var now = Date.now() / 1000
var state = NetworkModel.throughputState({
prevIface: prevIface,
prevRxBytes: prevRxBytes,
prevTxBytes: prevTxBytes,
prevSampleTime: prevSampleTime,
downloadRate: downloadRate,
uploadRate: uploadRate
}, next, Date.now() / 1000)
// Interface changed (or first sample) — reseed without emitting a rate;
// raw counters from two different NICs are meaningless to subtract.
if (iface !== prevIface || prevSampleTime === 0) {
prevIface = iface
prevRxBytes = rx
prevTxBytes = tx
prevSampleTime = now
downloadRate = 0
uploadRate = 0
return
}
var dt = now - prevSampleTime
if (dt > 0) {
// Math.max guards against counter wrap or reset to 0 (interface flap).
downloadRate = Math.max(0, (rx - prevRxBytes) / dt)
uploadRate = Math.max(0, (tx - prevTxBytes) / dt)
}
prevRxBytes = rx
prevTxBytes = tx
prevSampleTime = now
prevIface = state.prevIface
prevRxBytes = state.prevRxBytes
prevTxBytes = state.prevTxBytes
prevSampleTime = state.prevSampleTime
downloadRate = state.downloadRate
uploadRate = state.uploadRate
}
function formatBytes(bytes) {
var n = Number(bytes)
if (!isFinite(n) || n < 0) n = 0
if (n < 1024) return Math.round(n) + " B"
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB"
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB"
return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB"
return NetworkModel.formatBytes(bytes)
}
function formatRate(bytesPerSec) {
return formatBytes(bytesPerSec) + "/s"
return NetworkModel.formatRate(bytesPerSec)
}
function findDevice(type) {
@@ -369,42 +330,20 @@ Panel {
var network = networks[i]
if (!network) continue
checkActionCompletion(network)
var isConnected = network.connected
var ssid = network.name || ""
nets.push({
network: network,
connected: isConnected,
known: network.known,
ssid: ssid,
signal: Math.round((network.signalStrength || 0) * 100),
security: network.security
})
var row = NetworkModel.wifiRow(network)
if (row) nets.push(row)
}
nets.sort(function(a, b) {
if (a.connected !== b.connected) return a.connected ? -1 : 1
if (a.known !== b.known) return a.known ? -1 : 1
return b.signal - a.signal
})
wifiNetworks = nets
wifiNetworks = NetworkModel.sortWifiRows(nets)
wifiStationAvailable = !!wifiDevice
scanning = false
}
function wifiSectionTitle(index) {
if (index < 0 || index >= wifiNetworks.length) return ""
var net = wifiNetworks[index]
if (!net) return ""
if (net.known && index === 0) return "KNOWN NETWORKS"
if (!net.known && (index === 0 || (wifiNetworks[index - 1] && wifiNetworks[index - 1].known))) return "OTHER NETWORKS"
return ""
return NetworkModel.wifiSectionTitle(wifiNetworks, index)
}
function wifiIconFor(strength) {
var icons = ["󰤯", "󰤟", "󰤢", "󰤥", "󰤨"]
var index = Math.max(0, Math.min(4, Math.ceil(strength / 20) - 1))
return icons[index]
return NetworkModel.wifiIconFor(strength)
}
function updateDns(raw) {
@@ -435,7 +374,7 @@ Panel {
}
function isProtected(security) {
return security !== WifiSecurityType.Open
return NetworkModel.isProtected(security, WifiSecurityType.Open)
}
function openPasswordPrompt(ssid) {
@@ -493,12 +432,13 @@ Panel {
}
function networkFailureReason(reason) {
if (reason === ConnectionFailReason.NoSecrets) return "Passphrase required"
if (reason === ConnectionFailReason.WifiAuthTimeout) return "Wrong password"
if (reason === ConnectionFailReason.WifiNetworkLost) return "Network lost"
if (reason === ConnectionFailReason.WifiClientDisconnected) return "Disconnected"
if (reason === ConnectionFailReason.WifiClientFailed) return "Connection failed"
return "Failed to connect"
return NetworkModel.networkFailureReason(reason, {
NoSecrets: ConnectionFailReason.NoSecrets,
WifiAuthTimeout: ConnectionFailReason.WifiAuthTimeout,
WifiNetworkLost: ConnectionFailReason.WifiNetworkLost,
WifiClientDisconnected: ConnectionFailReason.WifiClientDisconnected,
WifiClientFailed: ConnectionFailReason.WifiClientFailed
})
}
function checkActionCompletion(network) {
+173
View File
@@ -0,0 +1,173 @@
function parseNetworkStatus(raw) {
var parts = String(raw || "disconnected\t\t\t").replace(/\r?\n+$/, "").split("\t")
return {
kind: parts[0] || "disconnected",
label: parts[1] || "",
signalStrength: parts[2] ? parseInt(parts[2], 10) : -1,
frequency: parts[3] || ""
}
}
function wifiIconFor(strength) {
var icons = ["󰤯", "󰤟", "󰤢", "󰤥", "󰤨"]
var index = Math.max(0, Math.min(4, Math.ceil(strength / 20) - 1))
return icons[index]
}
function connectionIcon(kind, signalStrength) {
if (kind === "wifi") return wifiIconFor(signalStrength)
if (kind === "ethernet") return "󰈀"
return "󰤮"
}
function formatHeaderSpeed(mbps) {
var v = parseInt(mbps, 10)
if (!v || v < 0) return ""
if (v >= 1000) return (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + "gbit"
return v + "mbit"
}
function formatHeaderFreq(mhz) {
var v = parseFloat(mhz)
if (!v) return ""
var ghz = v / 1000
return ghz.toFixed(ghz % 1 === 0 ? 0 : 1) + "ghz"
}
function headerDetail(info) {
var value = info || {}
if (value.type === "ethernet") return formatHeaderSpeed(value.speed || "")
if (value.type === "wifi") return formatHeaderFreq(value.freq || "")
return ""
}
function parseKeyValue(raw) {
var next = {}
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i]
if (!line) continue
var idx = line.indexOf("\t")
if (idx === -1) continue
next[line.substring(0, idx)] = line.substring(idx + 1).trim()
}
return next
}
function throughputState(previous, next, now) {
var prev = previous || {}
var sample = next || {}
var iface = sample.iface || ""
var rx = parseFloat(sample.rx_bytes || "0")
var tx = parseFloat(sample.tx_bytes || "0")
var previousTime = Number(prev.prevSampleTime || 0)
if (iface !== (prev.prevIface || "") || previousTime === 0) {
return {
prevIface: iface,
prevRxBytes: rx,
prevTxBytes: tx,
prevSampleTime: now,
downloadRate: 0,
uploadRate: 0
}
}
var downloadRate = Number(prev.downloadRate || 0)
var uploadRate = Number(prev.uploadRate || 0)
var dt = now - previousTime
if (dt > 0) {
downloadRate = Math.max(0, (rx - Number(prev.prevRxBytes || 0)) / dt)
uploadRate = Math.max(0, (tx - Number(prev.prevTxBytes || 0)) / dt)
}
return {
prevIface: iface,
prevRxBytes: rx,
prevTxBytes: tx,
prevSampleTime: now,
downloadRate: downloadRate,
uploadRate: uploadRate
}
}
function formatBytes(bytes) {
var n = Number(bytes)
if (!isFinite(n) || n < 0) n = 0
if (n < 1024) return Math.round(n) + " B"
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB"
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB"
return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB"
}
function formatRate(bytesPerSec) {
return formatBytes(bytesPerSec) + "/s"
}
function wifiRow(network) {
if (!network) return null
return {
network: network,
connected: !!network.connected,
known: !!network.known,
ssid: network.name || "",
signal: Math.round((network.signalStrength || 0) * 100),
security: network.security
}
}
function sortWifiRows(rows) {
var nets = Array.isArray(rows) ? rows.slice() : []
nets.sort(function(a, b) {
if (a.connected !== b.connected) return a.connected ? -1 : 1
if (a.known !== b.known) return a.known ? -1 : 1
return b.signal - a.signal
})
return nets
}
function wifiSectionTitle(wifiNetworks, index) {
var networks = Array.isArray(wifiNetworks) ? wifiNetworks : []
if (index < 0 || index >= networks.length) return ""
var net = networks[index]
if (!net) return ""
if (net.known && index === 0) return "KNOWN NETWORKS"
if (!net.known && (index === 0 || (networks[index - 1] && networks[index - 1].known))) return "OTHER NETWORKS"
return ""
}
function isProtected(security, openSecurity) {
return security !== openSecurity
}
function networkFailureReason(reason, reasons) {
var r = reasons || {}
if (reason === r.NoSecrets) return "Passphrase required"
if (reason === r.WifiAuthTimeout) return "Wrong password"
if (reason === r.WifiNetworkLost) return "Network lost"
if (reason === r.WifiClientDisconnected) return "Disconnected"
if (reason === r.WifiClientFailed) return "Connection failed"
return "Failed to connect"
}
if (typeof module !== "undefined") {
module.exports = {
parseNetworkStatus: parseNetworkStatus,
wifiIconFor: wifiIconFor,
connectionIcon: connectionIcon,
formatHeaderSpeed: formatHeaderSpeed,
formatHeaderFreq: formatHeaderFreq,
headerDetail: headerDetail,
parseKeyValue: parseKeyValue,
throughputState: throughputState,
formatBytes: formatBytes,
formatRate: formatRate,
wifiRow: wifiRow,
sortWifiRows: sortWifiRows,
wifiSectionTitle: wifiSectionTitle,
isProtected: isProtected,
networkFailureReason: networkFailureReason
}
}
+3 -10
View File
@@ -3,6 +3,7 @@ import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Ui
import "NotificationLogic.js" as NotificationLogic
BarWidget {
id: root
@@ -29,19 +30,11 @@ BarWidget {
: 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
return NotificationLogic.isChromiumDerived(app, appIcon)
}
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, "")
return NotificationLogic.sanitizeBody(s, app, appIcon)
}
function notificationIconSource(icon) {
@@ -0,0 +1,178 @@
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(body, app, appIcon) {
var text = String(body || "").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, "")
}
function summaryStartsWithGlyph(summary) {
var text = String(summary || "").replace(/^\s+/, "")
if (!text) return false
var offset = 1
var first = text.charCodeAt(0)
if (first >= 0xd800 && first <= 0xdbff && text.length > 1) offset = 2
var spaces = 0
while (offset < text.length && text.charAt(offset) === " ") {
spaces++
offset++
}
return spaces >= 2
}
function shouldBypassDnd(notification, criticalUrgency) {
var appName = String((notification && notification.appName) || "")
if (appName === "omarchy-action") return true
return appName === "notify-send" && notification && notification.urgency === criticalUrgency
}
function glyphFromHints(hints) {
try {
if (hints) {
var glyph = hints["omarchy-glyph"]
if (glyph !== undefined && glyph !== null) return String(glyph)
}
} catch (e) {
}
return ""
}
function snapshotOf(notification, timestamp) {
var n = notification || {}
var id = n.id || 0
return {
id: id,
originalId: id,
app: n.appName || "",
appIcon: n.appIcon || "",
summary: String(n.summary || ""),
body: n.body || "",
image: n.image || "",
glyph: glyphFromHints(n.hints),
urgency: n.urgency,
timestamp: timestamp === undefined ? Date.now() : timestamp,
ref: notification
}
}
function historyEntry(value, normalUrgency) {
var e = value || {}
return {
id: e.id || 0,
originalId: e.originalId || e.id || 0,
app: e.app || "",
appIcon: e.appIcon || "",
summary: e.summary || "",
body: e.body || "",
image: e.image || "",
glyph: e.glyph || "",
urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency,
timestamp: e.timestamp || 0,
ref: null
}
}
function dedupeByOriginalId(rows) {
var values = Array.isArray(rows) ? rows : []
var keep = {}
for (var i = 0; i < values.length; i++) {
var row = values[i]
if (!row) continue
var key = row.originalId
if (key === undefined || key === null) key = "_" + i
var prior = keep[key]
if (!prior || (row.timestamp || 0) >= (prior.timestamp || 0)) keep[key] = row
}
var out = []
for (var id in keep) out.push(keep[id])
out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
return out
}
function parseHistory(raw, normalUrgency, historyCap) {
var text = String(raw || "").trim()
var cap = historyCap === undefined || historyCap === null ? 100 : Number(historyCap)
if (isNaN(cap)) cap = 100
cap = Math.max(0, cap)
if (!text) return { empty: true, error: false, dnd: null, pending: [], past: [], hadDuplicates: false }
try {
var parsed = JSON.parse(text)
var pendingRaw = (parsed && Array.isArray(parsed.pending)) ? parsed.pending : []
var pastRaw = (parsed && Array.isArray(parsed.past)) ? parsed.past : []
if (parsed && Array.isArray(parsed.entries)) pastRaw = pastRaw.concat(parsed.entries)
var pendingDeduped = dedupeByOriginalId(pendingRaw)
var pastDeduped = dedupeByOriginalId(pastRaw)
return {
empty: false,
error: false,
dnd: parsed && typeof parsed.dnd === "boolean" ? parsed.dnd : null,
pending: pendingDeduped.slice(0, cap).map(function(entry) { return historyEntry(entry, normalUrgency) }),
past: pastDeduped.slice(0, cap).map(function(entry) { return historyEntry(entry, normalUrgency) }),
hadDuplicates: pendingDeduped.length !== pendingRaw.length || pastDeduped.length !== pastRaw.length
}
} catch (e) {
return { empty: false, error: true, errorMessage: String(e), dnd: null, pending: [], past: [], hadDuplicates: false }
}
}
function dumpRows(rows) {
var values = Array.isArray(rows) ? rows : []
var out = []
for (var i = 0; i < values.length; i++) {
var r = values[i]
if (!r) continue
out.push({
id: r.id,
originalId: r.originalId,
app: r.app,
appIcon: r.appIcon,
summary: r.summary,
body: r.body,
image: r.image,
glyph: r.glyph || "",
urgency: r.urgency,
timestamp: r.timestamp
})
}
return out
}
function imageExtension(srcPath) {
var lower = String(srcPath || "").toLowerCase()
var dot = lower.lastIndexOf(".")
if (dot < 0) return "png"
var ext = lower.substring(dot + 1)
if (ext.length === 0 || ext.length > 5) return "png"
return ext
}
if (typeof module !== "undefined") {
module.exports = {
isChromiumDerived: isChromiumDerived,
sanitizeBody: sanitizeBody,
summaryStartsWithGlyph: summaryStartsWithGlyph,
shouldBypassDnd: shouldBypassDnd,
glyphFromHints: glyphFromHints,
snapshotOf: snapshotOf,
historyEntry: historyEntry,
dedupeByOriginalId: dedupeByOriginalId,
parseHistory: parseHistory,
dumpRows: dumpRows,
imageExtension: imageExtension
}
}
+26 -102
View File
@@ -9,6 +9,7 @@ import Quickshell.Services.Notifications
import qs.Commons
import "components"
import "NotificationLogic.js" as NotificationLogic
Item {
id: service
@@ -112,36 +113,11 @@ Item {
// chat apps set app_name to their brand (Discord/Slack/Vesktop), which
// falls outside this rule.
function shouldBypassDnd(notification) {
var appName = String(notification.appName || "")
if (appName === "omarchy-action") return true
if (appName === "notify-send" && notification.urgency === NotificationUrgency.Critical) return true
return false
return NotificationLogic.shouldBypassDnd(notification, NotificationUrgency.Critical)
}
function snapshotOf(notification) {
var glyph = ""
try {
if (notification.hints) {
var hintGlyph = notification.hints["omarchy-glyph"]
if (hintGlyph !== undefined && hintGlyph !== null)
glyph = String(hintGlyph)
}
} catch (e) { glyph = "" }
var summary = String(notification.summary || "")
return {
id: notification.id,
originalId: notification.id,
app: notification.appName || "",
appIcon: notification.appIcon || "",
summary: summary,
body: notification.body || "",
image: notification.image || "",
glyph: glyph,
urgency: notification.urgency,
timestamp: Date.now(),
ref: notification
}
return NotificationLogic.snapshotOf(notification, Date.now())
}
function handleNotification(notification) {
@@ -384,12 +360,7 @@ Item {
// and skip them for v1.
function imageExtension(srcPath) {
var lower = srcPath.toLowerCase()
var dot = lower.lastIndexOf(".")
if (dot < 0) return "png"
var ext = lower.substring(dot + 1)
if (ext.length === 0 || ext.length > 5) return "png"
return ext
return NotificationLogic.imageExtension(srcPath)
}
function maybeCacheImage(snapshot) {
@@ -541,78 +512,31 @@ Item {
// guard, the second fire appends a second copy of every persisted row
// to the in-memory model.
if (service.historyLoaded) return
var text = String(raw || "").trim()
if (!text) { service.historyLoaded = true; return }
try {
var parsed = JSON.parse(text)
if (parsed && typeof parsed.dnd === "boolean") {
service._hydrating = true
persisted.doNotDisturb = parsed.dnd
service._hydrating = false
}
var pending = (parsed && Array.isArray(parsed.pending)) ? parsed.pending : []
var past = (parsed && Array.isArray(parsed.past)) ? parsed.past : []
// v1 backwards compat: the old schema had a single `entries` array.
// Treat all of those as past since the user already presumably saw
// them (and DND-suppressed notifications from before the split are
// a rare edge case).
if (parsed && Array.isArray(parsed.entries)) past = past.concat(parsed.entries)
function entryFor(e) {
return {
id: e.id || 0,
originalId: e.originalId || e.id || 0,
app: e.app || "",
appIcon: e.appIcon || "",
summary: e.summary || "",
body: e.body || "",
image: e.image || "",
glyph: e.glyph || "",
urgency: typeof e.urgency === "number" ? e.urgency : NotificationUrgency.Normal,
timestamp: e.timestamp || 0,
ref: null
}
}
// Older builds didn't dedupe chat-app replacements, so hydrated files
// can hold hundreds of identical rows (same originalId). Collapse on
// load — keep the newest occurrence (highest timestamp) and drop the
// rest. Save is rescheduled below so the disk file rewrites cleanly.
function dedupeByOriginalId(rows) {
var keep = {}
for (var k = 0; k < rows.length; k++) {
var r = rows[k]
if (!r) continue
var key = r.originalId
if (key === undefined || key === null) { keep["_" + k] = r; continue }
var prior = keep[key]
if (!prior || (r.timestamp || 0) >= (prior.timestamp || 0)) keep[key] = r
}
var out = []
for (var id in keep) out.push(keep[id])
out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
return out
}
var pendingDeduped = dedupeByOriginalId(pending)
var pastDeduped = dedupeByOriginalId(past)
var hadDuplicates = pendingDeduped.length !== pending.length
|| pastDeduped.length !== past.length
// Newest-first on disk; insert in order so models match.
Qt.callLater(function() {
for (var i = 0; i < pendingDeduped.length; i++) {
pendingModel.append(entryFor(pendingDeduped[i]))
if (pendingModel.count > service.historyCap) pendingModel.remove(pendingModel.count - 1)
}
for (var j = 0; j < pastDeduped.length; j++) {
pastModel.append(entryFor(pastDeduped[j]))
if (pastModel.count > service.historyCap) pastModel.remove(pastModel.count - 1)
}
service.historyLoaded = true
if (hadDuplicates) service.scheduleHistorySave()
})
} catch (e) {
console.warn("notifications: history parse failed:", e)
var parsed = NotificationLogic.parseHistory(raw, NotificationUrgency.Normal, service.historyCap)
if (parsed.empty) {
service.historyLoaded = true
return
}
if (parsed.error) {
console.warn("notifications: history parse failed:", parsed.errorMessage || "")
service.historyLoaded = true
return
}
if (parsed.dnd !== null) {
service._hydrating = true
persisted.doNotDisturb = parsed.dnd
service._hydrating = false
}
// Newest-first on disk; append in order so models match.
Qt.callLater(function() {
for (var i = 0; i < parsed.pending.length; i++) pendingModel.append(parsed.pending[i])
for (var j = 0; j < parsed.past.length; j++) pastModel.append(parsed.past[j])
service.historyLoaded = true
if (parsed.hadDuplicates) service.scheduleHistorySave()
})
}
function flushHistory() {
@@ -6,6 +6,7 @@ import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Commons
import "../NotificationLogic.js" as NotificationLogic
Rectangle {
id: root
@@ -37,15 +38,9 @@ Rectangle {
readonly property string smallIconSource: image.length > 0 ? image : iconSource(appIcon)
readonly property bool hasGlyph: glyph.length > 0
readonly property bool hasSmallIcon: smallIconSource.length > 0 || hasGlyph
readonly property bool summaryStartsWithGlyph: /^\s*\S\s{2,}/.test(summary)
readonly property bool summaryStartsWithGlyph: NotificationLogic.summaryStartsWithGlyph(summary)
readonly property bool singleLineToast: sanitizedBody.length === 0
readonly property bool collapseRedundantIcon: singleLineToast && !hasGlyph && summaryStartsWithGlyph
readonly property bool chromiumDerived: {
var source = (app + "\n" + appIcon).toLowerCase()
return source.indexOf("chrom") >= 0 || source.indexOf("brave") >= 0 ||
source.indexOf("vivaldi") >= 0 || source.indexOf("microsoft-edge") >= 0 ||
source.indexOf("opera") >= 0
}
readonly property string sanitizedBody: sanitizeBody(body)
readonly property string styledBody: sanitizedBody.replace(/\r\n|\r|\n/g, "<br/>")
@@ -54,15 +49,7 @@ Rectangle {
readonly property color accentColor: urgency === 2 ? Color.urgent : (urgency === 0 ? dimColor : Color.notifications.countdown)
function sanitizeBody(s) {
var text = String(s).replace(/<img[^>]*>/gi, "")
if (!chromiumDerived) return text
// Chromium web notifications often prefix the body with the sending
// origin, sometimes as a hyperlink. The browser icon already identifies
// the source, so drop only that leading URL/domain.
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, "")
return NotificationLogic.sanitizeBody(s, app, appIcon)
}
function iconSource(icon) {
+10 -31
View File
@@ -3,6 +3,7 @@ import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.Commons
import "OsdModel.js" as OsdModel
Item {
id: root
@@ -22,40 +23,18 @@ Item {
readonly property bool mediaOsd: iconKey.indexOf("media") === 0 || iconKey.indexOf("player") === 0
function iconFor(name, percent) {
var n = String(name || "").toLowerCase()
if (n === "volume-muted" || n === "volume-mute" || n === "muted" || n === "mute") return ""
if (n === "volume-low") return ""
if (n === "volume-medium") return ""
if (n === "volume-high" || n === "volume") return ""
if (n === "microphone-muted" || n === "microphone-off" || n === "mic-muted" || n === "mic-off") return "󰍭"
if (n === "microphone" || n === "mic") return "󰍬"
if (n === "keyboard") return "󰌌"
if (n === "brightness" || n === "display") return "󰍹"
if (n === "touchpad") return "󰟸"
if (n === "touch" || n === "touchscreen") return "󰜉"
if (n === "media" || n === "player") return "󰝚"
if (n === "media-source" || n === "player-source") return "󰝚"
if (n === "media-play" || n === "player-play") return "󰐊"
if (n === "media-pause" || n === "player-pause") return "󰏤"
if (n === "media-next" || n === "player-next") return "󰒭"
if (n === "media-previous" || n === "player-previous") return "󰒮"
if (n.length > 0) return name
if (percent <= 0) return ""
if (percent <= 33) return ""
if (percent <= 66) return ""
return ""
return OsdModel.iconFor(name, percent)
}
function show(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration) {
iconKey = String(iconName || "").toLowerCase()
maxValue = Math.max(1, parseInt(rawMax || "100", 10))
var parsed = parseInt(rawValue || "0", 10)
hasProgress = rawValue !== "" && !isNaN(parsed) && rawMessage === ""
value = hasProgress ? Util.clamp(parsed, 0, maxValue) : 0
message = String(rawMessage || (hasProgress ? (rawProgressText || Math.round(value * 100 / maxValue) + "%") : ""))
icon = iconFor(iconName, hasProgress ? Math.round(value * 100 / maxValue) : -1)
var parsedDuration = parseInt(rawDuration || "1200", 10)
duration = isNaN(parsedDuration) ? 1200 : Math.max(0, parsedDuration)
var next = OsdModel.stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration)
iconKey = next.iconKey
maxValue = next.maxValue
hasProgress = next.hasProgress
value = next.value
message = next.message
icon = next.icon
duration = next.duration
opened = true
if (duration > 0) hideTimer.restart()
else hideTimer.stop()
+54
View File
@@ -0,0 +1,54 @@
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value))
}
function iconFor(name, percent) {
var n = String(name || "").toLowerCase()
if (n === "volume-muted" || n === "volume-mute" || n === "muted" || n === "mute") return ""
if (n === "volume-low") return ""
if (n === "volume-medium") return ""
if (n === "volume-high" || n === "volume") return ""
if (n === "microphone-muted" || n === "microphone-off" || n === "mic-muted" || n === "mic-off") return "󰍭"
if (n === "microphone" || n === "mic") return "󰍬"
if (n === "keyboard") return "󰌌"
if (n === "brightness" || n === "display") return "󰍹"
if (n === "touchpad") return "󰟸"
if (n === "touch" || n === "touchscreen") return "󰜉"
if (n === "media" || n === "player") return "󰝚"
if (n === "media-source" || n === "player-source") return "󰝚"
if (n === "media-play" || n === "player-play") return "󰐊"
if (n === "media-pause" || n === "player-pause") return "󰏤"
if (n === "media-next" || n === "player-next") return "󰒭"
if (n === "media-previous" || n === "player-previous") return "󰒮"
if (n.length > 0) return name
if (percent <= 0) return ""
if (percent <= 33) return ""
if (percent <= 66) return ""
return ""
}
function stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration) {
var maxValue = Math.max(1, parseInt(rawMax || "100", 10))
var parsedValue = parseInt(rawValue || "0", 10)
var hasProgress = rawValue !== "" && !isNaN(parsedValue) && rawMessage === ""
var value = hasProgress ? clamp(parsedValue, 0, maxValue) : 0
var percent = hasProgress ? Math.round(value * 100 / maxValue) : -1
var parsedDuration = parseInt(rawDuration || "1200", 10)
return {
iconKey: String(iconName || "").toLowerCase(),
maxValue: maxValue,
hasProgress: hasProgress,
value: value,
message: String(rawMessage || (hasProgress ? (rawProgressText || percent + "%") : "")),
icon: iconFor(iconName, percent),
duration: isNaN(parsedDuration) ? 1200 : Math.max(0, parsedDuration)
}
}
if (typeof module !== "undefined") {
module.exports = {
iconFor: iconFor,
stateForShow: stateForShow
}
}
+3 -11
View File
@@ -4,6 +4,7 @@ import Quickshell.Io
import Quickshell.Services.Polkit
import Quickshell.Wayland
import qs.Commons
import "PolkitModel.js" as PolkitModel
Item {
id: root
@@ -38,20 +39,11 @@ Item {
readonly property int cardHeight: panel.height > 0 ? Math.min(fieldHeight + contentMargin * 2, panel.height - Style.gapsOut * 2) : fieldHeight + contentMargin * 2
function promptLooksFingerprint(text) {
var s = String(text || "").toLowerCase()
return s.indexOf("finger") !== -1 || s.indexOf("fprint") !== -1 || s.indexOf("swipe") !== -1
return PolkitModel.promptLooksFingerprint(text)
}
function loadPamConfig(raw) {
fingerprintFirst = false
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].replace(/^\s+|\s+$/g, "")
if (!line || line.charAt(0) === "#") continue
if (!line.match(/^auth\s+/)) continue
fingerprintFirst = line.indexOf("pam_fprintd.so") !== -1
return
}
fingerprintFirst = PolkitModel.fingerprintFirstFromPamConfig(raw)
}
function resetSnapshot() {
+22
View File
@@ -0,0 +1,22 @@
function promptLooksFingerprint(text) {
var s = String(text || "").toLowerCase()
return s.indexOf("finger") !== -1 || s.indexOf("fprint") !== -1 || s.indexOf("swipe") !== -1
}
function fingerprintFirstFromPamConfig(raw) {
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].replace(/^\s+|\s+$/g, "")
if (!line || line.charAt(0) === "#") continue
if (!line.match(/^auth\s+/)) continue
return line.indexOf("pam_fprintd.so") !== -1
}
return false
}
if (typeof module !== "undefined") {
module.exports = {
promptLooksFingerprint: promptLooksFingerprint,
fingerprintFirstFromPamConfig: fingerprintFirstFromPamConfig
}
}
+22 -60
View File
@@ -4,6 +4,7 @@ import Quickshell.Io
import Quickshell.Services.UPower
import qs.Commons
import qs.Ui
import "PowerModel.js" as PowerModel
Panel {
id: root
@@ -20,9 +21,17 @@ Panel {
return !!(device && device.isPresent)
}
function upowerStates() {
return {
Charging: UPowerDeviceState.Charging,
Discharging: UPowerDeviceState.Discharging,
FullyCharged: UPowerDeviceState.FullyCharged,
PendingCharge: UPowerDeviceState.PendingCharge
}
}
function selectProfileByDelta(delta) {
if (profiles.length === 0) { profileIndex = 0; return }
profileIndex = Math.max(0, Math.min(profiles.length - 1, profileIndex + delta))
profileIndex = PowerModel.selectProfileIndex(profileIndex, delta, profiles)
}
function activateSelectedProfile() {
@@ -32,41 +41,16 @@ Panel {
function batteryIcon() {
var device = UPower.displayDevice
if (!root.batteryPresent) return ""
var chargingIcons = ["󰢜", "󰂆", "󰂇", "󰂈", "󰢝", "󰂉", "󰢞", "󰂊", "󰂋", "󰂅"]
var defaultIcons = ["󰁺", "󰁻", "󰁼", "󰁽", "󰁾", "󰁿", "󰂀", "󰂁", "󰂂", "󰁹"]
var index = Math.max(0, Math.min(9, Math.floor(device.percentage * 10)))
if (root.chargeThresholdActive) return defaultIcons[index]
if (device.state === UPowerDeviceState.FullyCharged) return "󰂅"
if (device.state === UPowerDeviceState.Charging) return chargingIcons[index]
if (!UPower.onBattery) return ""
return defaultIcons[index]
return PowerModel.batteryIcon(device, UPower.onBattery, upowerStates())
}
function modeLabel() {
var device = UPower.displayDevice
if (!root.batteryPresent) return ""
var percentage = device && device.isPresent ? device.percentage : 0
if (chargeThresholdActive) {
return "Threshold"
} else if (!UPower.onBattery && percentage >= 1) {
return "Fully charged"
} else if (UPower.onBattery) {
return "On battery"
} else {
return "Charging"
}
return PowerModel.modeLabel(device, UPower.onBattery, upowerStates())
}
function profileIcon(name) {
if (name === "power-saver") return "󰌪"
if (name === "balanced") return "󰊚"
if (name === "performance") return "󰓅"
return "󰂄"
return PowerModel.profileIcon(name)
}
readonly property bool fullyCharged: {
@@ -75,14 +59,7 @@ Panel {
}
readonly property bool chargeThresholdActive: {
var device = UPower.displayDevice
if (!(device && device.isPresent && !UPower.onBattery)) return false
var fraction = Math.max(0, Math.min(1, device.percentage))
if (device.state === UPowerDeviceState.Discharging || device.state === UPowerDeviceState.PendingCharge) return true
if (device.state === UPowerDeviceState.FullyCharged && fraction < 0.99) return true
if (device.state !== UPowerDeviceState.Charging || fraction >= 0.99) return false
return Number(device.changeRate || 0) <= 0.2 || Number(device.timeToFull || 0) >= 8 * 60 * 60
return PowerModel.chargeThresholdActive(device, UPower.onBattery, upowerStates())
}
readonly property bool batteryFull: fullyCharged || (!UPower.onBattery && batteryFraction >= 1)
readonly property bool batteryFlowIdle: batteryFull || chargeThresholdActive
@@ -90,7 +67,7 @@ Panel {
// 0..1 charge level, used by the visual progress bar.
readonly property real batteryFraction: {
var d = UPower.displayDevice
return d && d.isPresent ? Math.max(0, Math.min(1, d.percentage)) : 0
return PowerModel.batteryFraction(d)
}
readonly property bool batteryLow: UPower.onBattery && batteryFraction > 0 && batteryFraction <= 0.2
@@ -154,13 +131,7 @@ Panel {
}
function updateKeyValue(raw, targetName) {
var next = {}
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var idx = lines[i].indexOf("\t")
if (idx <= 0) continue
next[lines[i].substring(0, idx)] = lines[i].substring(idx + 1).trim()
}
var next = PowerModel.parseKeyValue(raw)
// Keep last known good data if a refresh briefly returns nothing — happens
// around AC plug/unplug events. Avoids the section collapsing mid-transition.
if (Object.keys(next).length === 0) return
@@ -169,22 +140,13 @@ Panel {
}
function updateProfiles(raw) {
var lines = String(raw || "").split("\n")
var list = []
var active = ""
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
var parts = line.split("\t")
list.push(parts[0])
if (parts[1] === "1") active = parts[0]
}
var parsed = PowerModel.parseProfiles(raw, profileIndex)
// Same guard as battery: preserve the last known profile list across
// transient empty payloads so the buttons don't blink out.
if (list.length === 0) return
profiles = list
activeProfile = active
if (profileIndex >= profiles.length) profileIndex = Math.max(0, profiles.length - 1)
if (parsed.profiles.length === 0) return
profiles = parsed.profiles
activeProfile = parsed.activeProfile
profileIndex = parsed.profileIndex
if (opened && activeProfile !== "") {
var idx = profiles.indexOf(activeProfile)
if (idx >= 0) profileIndex = idx
+104
View File
@@ -0,0 +1,104 @@
function clampIndex(index, length) {
if (length <= 0) return 0
return Math.max(0, Math.min(length - 1, index))
}
function selectProfileIndex(index, delta, profiles) {
var values = Array.isArray(profiles) ? profiles : []
if (values.length === 0) return 0
return clampIndex(index + delta, values.length)
}
function parseKeyValue(raw) {
var next = {}
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var idx = lines[i].indexOf("\t")
if (idx <= 0) continue
next[lines[i].substring(0, idx)] = lines[i].substring(idx + 1).trim()
}
return next
}
function parseProfiles(raw, previousIndex) {
var lines = String(raw || "").split("\n")
var list = []
var active = ""
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
var parts = line.split("\t")
list.push(parts[0])
if (parts[1] === "1") active = parts[0]
}
return {
profiles: list,
activeProfile: active,
profileIndex: clampIndex(previousIndex || 0, list.length)
}
}
function profileIcon(name) {
if (name === "power-saver") return "󰌪"
if (name === "balanced") return "󰊚"
if (name === "performance") return "󰓅"
return "󰂄"
}
function batteryFraction(device) {
return device && device.isPresent ? Math.max(0, Math.min(1, device.percentage)) : 0
}
function chargeThresholdActive(device, onBattery, states) {
var d = device || {}
var s = states || {}
if (!(d && d.isPresent && !onBattery)) return false
var fraction = batteryFraction(d)
if (d.state === s.Discharging || d.state === s.PendingCharge) return true
if (d.state === s.FullyCharged && fraction < 0.99) return true
if (d.state !== s.Charging || fraction >= 0.99) return false
return Number(d.changeRate || 0) <= 0.2 || Number(d.timeToFull || 0) >= 8 * 60 * 60
}
function batteryIcon(device, onBattery, states) {
var d = device || {}
if (!d.isPresent) return ""
var chargingIcons = ["󰢜", "󰂆", "󰂇", "󰂈", "󰢝", "󰂉", "󰢞", "󰂊", "󰂋", "󰂅"]
var defaultIcons = ["󰁺", "󰁻", "󰁼", "󰁽", "󰁾", "󰁿", "󰂀", "󰂁", "󰂂", "󰁹"]
var index = Math.max(0, Math.min(9, Math.floor(d.percentage * 10)))
var threshold = chargeThresholdActive(d, onBattery, states)
if (threshold) return defaultIcons[index]
if (d.state === states.FullyCharged) return "󰂅"
if (d.state === states.Charging) return chargingIcons[index]
if (!onBattery) return ""
return defaultIcons[index]
}
function modeLabel(device, onBattery, states) {
var d = device || {}
if (!d.isPresent) return ""
var percentage = d.isPresent ? d.percentage : 0
if (chargeThresholdActive(d, onBattery, states)) return "Threshold"
if (!onBattery && percentage >= 1) return "Fully charged"
if (onBattery) return "On battery"
return "Charging"
}
if (typeof module !== "undefined") {
module.exports = {
clampIndex: clampIndex,
selectProfileIndex: selectProfileIndex,
parseKeyValue: parseKeyValue,
parseProfiles: parseProfiles,
profileIcon: profileIcon,
batteryFraction: batteryFraction,
chargeThresholdActive: chargeThresholdActive,
batteryIcon: batteryIcon,
modeLabel: modeLabel
}
}
+8 -9
View File
@@ -2,10 +2,12 @@ import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.Commons
import "ReminderFlowModel.js" as ReminderFlowModel
Item {
id: root
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
property var shell: null
property var manifest: null
@@ -62,15 +64,15 @@ Item {
var selection = root.filterText
if (root.step === "minutes") {
var nextMinutes = selection.trim()
var nextMinutes = ReminderFlowModel.validMinutes(selection)
if (!nextMinutes) {
if (!selection.trim()) {
root.dismiss()
return
}
if (!/^[0-9]+$/.test(nextMinutes) || Number(nextMinutes) <= 0) {
Quickshell.execDetached(["bash", "-lc", "omarchy-notification-send 'Invalid reminder' 'Enter the number of minutes'"])
if (!nextMinutes) {
Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-notification-send", "Invalid reminder", "Enter the number of minutes"])
return
}
@@ -82,12 +84,9 @@ Item {
}
if (root.step === "message") {
var command = "omarchy-reminder " + Util.shellQuote(root.minutes)
if (selection.length > 0)
command += " " + Util.shellQuote(selection)
var args = [root.omarchyPath + "/bin/omarchy-reminder"].concat(ReminderFlowModel.reminderArgs(root.minutes, selection))
root.dismiss()
Quickshell.execDetached(["bash", "-lc", command])
Quickshell.execDetached(args)
}
}
@@ -0,0 +1,21 @@
function validMinutes(value) {
var minutes = String(value || "").trim()
return /^[0-9]+$/.test(minutes) && Number(minutes) > 0 ? minutes : ""
}
function reminderArgs(minutes, message) {
var valid = validMinutes(minutes)
if (!valid) return []
var args = [valid]
var text = String(message || "")
if (text.length > 0) args.push(text)
return args
}
if (typeof module !== "undefined") {
module.exports = {
validMinutes: validMinutes,
reminderArgs: reminderArgs
}
}
@@ -0,0 +1,28 @@
function batteryPercentage(device) {
if (!device || !device.isPresent) return -1
return Math.round(Number(device.percentage || 0) * 100)
}
function isDischarging(device, onBattery, dischargingState) {
return !!(device && device.isPresent && onBattery && device.state === dischargingState)
}
function shouldWarnLowBattery(device, onBattery, dischargingState, threshold, alreadyNotified) {
var level = batteryPercentage(device)
if (level < 0) return { level: level, notify: false, notifiedLowBattery: false }
var low = isDischarging(device, onBattery, dischargingState) && level <= threshold
return {
level: level,
notify: low && !alreadyNotified,
notifiedLowBattery: low
}
}
if (typeof module !== "undefined") {
module.exports = {
batteryPercentage: batteryPercentage,
isDischarging: isDischarging,
shouldWarnLowBattery: shouldWarnLowBattery
}
}
+6 -21
View File
@@ -2,6 +2,7 @@ import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.UPower
import "BatteryModel.js" as BatteryModel
Item {
id: root
@@ -18,33 +19,17 @@ Item {
}
function batteryPercentage() {
var device = UPower.displayDevice
if (!device || !device.isPresent) return -1
return Math.round(Number(device.percentage || 0) * 100)
return BatteryModel.batteryPercentage(UPower.displayDevice)
}
function isDischarging() {
var device = UPower.displayDevice
return !!(device && device.isPresent
&& UPower.onBattery
&& device.state === UPowerDeviceState.Discharging)
return BatteryModel.isDischarging(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging)
}
function checkBattery() {
var level = batteryPercentage()
if (level < 0) {
persisted.notifiedLowBattery = false
return
}
if (isDischarging() && level <= batteryThreshold) {
if (!persisted.notifiedLowBattery) {
persisted.notifiedLowBattery = true
sendLowBatteryWarning(level)
}
} else {
persisted.notifiedLowBattery = false
}
var state = BatteryModel.shouldWarnLowBattery(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging, batteryThreshold, persisted.notifiedLowBattery)
persisted.notifiedLowBattery = state.notifiedLowBattery
if (state.notify) sendLowBatteryWarning(state.level)
}
function sendLowBatteryWarning(level) {
+52
View File
@@ -0,0 +1,52 @@
function secondsFromConfig(value, fallback) {
var n = Number(value)
if (!isFinite(n) || n < 0) return fallback
return Math.floor(n)
}
function eventParts(event, count) {
try {
if (event && event.parse) return event.parse(count)
} catch (error) {
}
return String(event && event.data ? event.data : "").split(",")
}
function screensaverWindowsAfter(windows, address, visible) {
var key = String(address || "")
if (!key) {
var current = windows || {}
var existingCount = 0
for (var currentKey in current) {
if (current[currentKey]) existingCount++
}
return { windows: current, count: existingCount }
}
var next = {}
var count = 0
for (var existing in windows || {}) {
if (existing !== key && windows[existing]) {
next[existing] = true
count++
}
}
if (visible) {
next[key] = true
count++
}
return {
windows: next,
count: count
}
}
if (typeof module !== "undefined") {
module.exports = {
secondsFromConfig: secondsFromConfig,
eventParts: eventParts,
screensaverWindowsAfter: screensaverWindowsAfter
}
}
+6 -26
View File
@@ -3,6 +3,7 @@ import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import Quickshell.Wayland
import "IdleModel.js" as IdleModel
Item {
id: root
@@ -36,9 +37,7 @@ Item {
property int screensaverWindowCount: 0
function secondsFromConfig(value, fallback) {
var n = Number(value)
if (!isFinite(n) || n < 0) return fallback
return Math.floor(n)
return IdleModel.secondsFromConfig(value, fallback)
}
function nowIso() {
@@ -117,25 +116,9 @@ Item {
}
function setScreensaverWindow(address, visible) {
var key = String(address || "")
if (!key) return
var next = {}
var count = 0
for (var existing in root.screensaverWindows) {
if (existing !== key && root.screensaverWindows[existing]) {
next[existing] = true
count++
}
}
if (visible) {
next[key] = true
count++
}
root.screensaverWindows = next
root.screensaverWindowCount = count
var next = IdleModel.screensaverWindowsAfter(root.screensaverWindows, address, visible)
root.screensaverWindows = next.windows
root.screensaverWindowCount = next.count
}
function handleScreensaverWindowOpened(address) {
@@ -156,10 +139,7 @@ Item {
}
function eventParts(event, count) {
try {
if (event && event.parse) return event.parse(count)
} catch (error) {}
return String(event && event.data ? event.data : "").split(",")
return IdleModel.eventParts(event, count)
}
function handleHyprlandEvent(event) {
+126
View File
@@ -0,0 +1,126 @@
function isProxyPlayer(player) {
var dbusName = String(player && player.dbusName || "").toLowerCase()
var desktopEntry = String(player && player.desktopEntry || "").toLowerCase()
return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld"
}
function hasMetadata(player) {
return !!(player && (player.trackTitle || player.trackArtist || player.identity || player.desktopEntry))
}
function hasTrackMetadata(player) {
return !!(player && (player.trackTitle || player.trackArtist || player.trackAlbum || player.trackArtUrl))
}
function playerCanControl(player) {
return !!(player && (player.canTogglePlaying || player.canPlay || player.canPause || player.canGoNext || player.canGoPrevious))
}
function canHandleAction(player, action) {
if (!player) return false
if (action === "next") return !!player.canGoNext
if (action === "previous") return !!player.canGoPrevious
if (action === "play") return !!(player.canPlay || player.canTogglePlaying)
if (action === "pause") return !!(player.canPause || player.canTogglePlaying)
if (action === "playPause") return !!(player.canTogglePlaying || player.canPlay || player.canPause)
return false
}
function canCycleSource(player) {
return !!(player && hasMetadata(player) && (player.isPlaying || player.canPlay))
}
function nodeProps(node) {
return node && node.ready && node.properties ? node.properties : {}
}
function isPlaybackStream(node) {
if (!node || !node.isStream) return false
if (node.isSink === true) return true
var mediaClass = String(node.type || "")
return mediaClass.indexOf("Stream/Output/Audio") !== -1
|| mediaClass.indexOf("AudioOutStream") !== -1
|| mediaClass.indexOf("Output") !== -1
}
function streamLabelKey(label) {
var key = String(label || "").toLowerCase()
key = key.replace(/^pipewire alsa \[/, "")
key = key.replace(/\]$/, "")
key = key.replace(/^alsa playback \[/, "")
key = key.replace(/[^a-z0-9]+/g, "")
return key
}
function rawStreamLabel(node) {
if (!node) return ""
var p = nodeProps(node)
return p["application.name"]
|| node.description
|| p["media.name"]
|| p["node.name"]
|| node.name
}
function playerAppLabel(player) {
if (!player) return ""
var dbus = String(player.dbusName || "")
dbus = dbus.replace(/^org\.mpris\.MediaPlayer2\./, "")
dbus = dbus.replace(/\.instance[0-9]+$/, "")
return player.desktopEntry || player.identity || dbus
}
function playerHasPlaybackStream(player, playbackStreams) {
var playerKey = streamLabelKey(playerAppLabel(player))
if (!playerKey) return false
var streams = Array.isArray(playbackStreams) ? playbackStreams : []
for (var i = 0; i < streams.length; i++) {
var streamKey = streamLabelKey(rawStreamLabel(streams[i]))
if (!streamKey) continue
if (streamKey === playerKey
|| streamKey.indexOf(playerKey) !== -1
|| playerKey.indexOf(streamKey) !== -1)
return true
}
return false
}
function playerKey(player) {
if (!player) return ""
return String(player.dbusName || player.desktopEntry || player.identity || "")
}
function labelFor(player) {
if (!player) return ""
return player.trackTitle || player.identity || player.desktopEntry || ""
}
function osdMessage(player, fallback) {
if (!player) return fallback
var label = labelFor(player)
if (label && player.trackArtist) return label + " - " + player.trackArtist
return label || fallback
}
if (typeof module !== "undefined") {
module.exports = {
isProxyPlayer: isProxyPlayer,
hasMetadata: hasMetadata,
hasTrackMetadata: hasTrackMetadata,
playerCanControl: playerCanControl,
canHandleAction: canHandleAction,
canCycleSource: canCycleSource,
nodeProps: nodeProps,
isPlaybackStream: isPlaybackStream,
streamLabelKey: streamLabelKey,
rawStreamLabel: rawStreamLabel,
playerAppLabel: playerAppLabel,
playerHasPlaybackStream: playerHasPlaybackStream,
playerKey: playerKey,
labelFor: labelFor,
osdMessage: osdMessage
}
}
+16 -61
View File
@@ -3,6 +3,7 @@ import Quickshell
import Quickshell.Io
import Quickshell.Services.Mpris
import Quickshell.Services.Pipewire
import "MediaModel.js" as MediaModel
Item {
id: root
@@ -33,97 +34,55 @@ Item {
readonly property string identity: activePlayer ? (activePlayer.identity || activePlayer.desktopEntry || "") : ""
function isProxyPlayer(player) {
var dbusName = String(player && player.dbusName || "").toLowerCase()
var desktopEntry = String(player && player.desktopEntry || "").toLowerCase()
return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld"
return MediaModel.isProxyPlayer(player)
}
function hasMetadata(player) {
return !!(player && (player.trackTitle || player.trackArtist || player.identity || player.desktopEntry))
return MediaModel.hasMetadata(player)
}
function hasTrackMetadata(player) {
return !!(player && (player.trackTitle || player.trackArtist || player.trackAlbum || player.trackArtUrl))
return MediaModel.hasTrackMetadata(player)
}
function playerCanControl(player) {
return !!(player && (player.canTogglePlaying || player.canPlay || player.canPause || player.canGoNext || player.canGoPrevious))
return MediaModel.playerCanControl(player)
}
function canHandleAction(player, action) {
if (!player) return false
if (action === "next") return !!player.canGoNext
if (action === "previous") return !!player.canGoPrevious
if (action === "play") return !!(player.canPlay || player.canTogglePlaying)
if (action === "pause") return !!(player.canPause || player.canTogglePlaying)
if (action === "playPause") return !!(player.canTogglePlaying || player.canPlay || player.canPause)
return false
return MediaModel.canHandleAction(player, action)
}
function canCycleSource(player) {
return !!(player && hasMetadata(player) && (player.isPlaying || player.canPlay))
return MediaModel.canCycleSource(player)
}
function nodeProps(node) {
return node && node.ready && node.properties ? node.properties : {}
return MediaModel.nodeProps(node)
}
function isPlaybackStream(node) {
if (!node || !node.isStream) return false
if (node.isSink === true) return true
var mediaClass = String(node.type || "")
return mediaClass.indexOf("Stream/Output/Audio") !== -1
|| mediaClass.indexOf("AudioOutStream") !== -1
|| mediaClass.indexOf("Output") !== -1
return MediaModel.isPlaybackStream(node)
}
function streamLabelKey(label) {
var key = String(label || "").toLowerCase()
key = key.replace(/^pipewire alsa \[/, "")
key = key.replace(/\]$/, "")
key = key.replace(/^alsa playback \[/, "")
key = key.replace(/[^a-z0-9]+/g, "")
return key
return MediaModel.streamLabelKey(label)
}
function rawStreamLabel(node) {
if (!node) return ""
var p = nodeProps(node)
return p["application.name"]
|| node.description
|| p["media.name"]
|| p["node.name"]
|| node.name
return MediaModel.rawStreamLabel(node)
}
function playerAppLabel(player) {
if (!player) return ""
var dbus = String(player.dbusName || "")
dbus = dbus.replace(/^org\.mpris\.MediaPlayer2\./, "")
dbus = dbus.replace(/\.instance[0-9]+$/, "")
return player.desktopEntry || player.identity || dbus
return MediaModel.playerAppLabel(player)
}
function playerHasPlaybackStream(player) {
var playerKey = streamLabelKey(playerAppLabel(player))
if (!playerKey) return false
for (var i = 0; i < playbackStreams.length; i++) {
var streamKey = streamLabelKey(rawStreamLabel(playbackStreams[i]))
if (!streamKey) continue
if (streamKey === playerKey
|| streamKey.indexOf(playerKey) !== -1
|| playerKey.indexOf(streamKey) !== -1)
return true
}
return false
return MediaModel.playerHasPlaybackStream(player, playbackStreams)
}
function playerKey(player) {
if (!player) return ""
return String(player.dbusName || player.desktopEntry || player.identity || "")
return MediaModel.playerKey(player)
}
function playerForKey(key) {
@@ -272,15 +231,11 @@ Item {
}
function labelFor(player) {
if (!player) return ""
return player.trackTitle || player.identity || player.desktopEntry || ""
return MediaModel.labelFor(player)
}
function osdMessage(player, fallback) {
if (!player) return fallback
var label = labelFor(player)
if (label && player.trackArtist) return label + " - " + player.trackArtist
return label || fallback
return MediaModel.osdMessage(player, fallback)
}
function showOsd(actionLabel, iconName, player) {
+16 -88
View File
@@ -3,6 +3,7 @@ import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
import "WeatherModel.js" as WeatherModel
Panel {
id: root
@@ -31,10 +32,9 @@ Panel {
property string klass: ""
function updateWeather(raw) {
var data
try { data = JSON.parse(raw || "{}") } catch (e) { data = {} }
label = data.text || ""
klass = data.class || ""
var data = WeatherModel.parseWeatherStatus(raw)
label = data.label
klass = data.klass
}
readonly property var current: report && report.current_condition && report.current_condition[0] ? report.current_condition[0] : null
@@ -83,126 +83,54 @@ Panel {
}
function buildForecastDays() {
var days = openMeteoForecastDays()
return days.length > 0 ? days : wttrNextForecastDays()
return WeatherModel.buildForecastDays(report, dailyForecastReport, Qt.formatDate(new Date(), "yyyy-MM-dd"))
}
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
return WeatherModel.openMeteoForecastDays(dailyForecastReport, Qt.formatDate(new Date(), "yyyy-MM-dd"))
}
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
return WeatherModel.wttrNextForecastDays(report, Qt.formatDate(new Date(), "yyyy-MM-dd"))
}
function isFutureForecastDate(dateString) {
if (!dateString) return false
return String(dateString).slice(0, 10) > Qt.formatDate(new Date(), "yyyy-MM-dd")
return WeatherModel.isFutureForecastDate(dateString, 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))
return WeatherModel.roundedTemp(value)
}
function celsiusToFahrenheit(value) {
if (value === undefined || value === null || value === "") return ""
var n = parseFloat(String(value))
return isNaN(n) ? "" : (n * 9 / 5) + 32
return WeatherModel.celsiusToFahrenheit(value)
}
function formatTemp(value) {
if (value === undefined || value === null || value === "") return ""
return value + "°" + (useImperial ? "F" : "C")
return WeatherModel.formatTemp(value, useImperial)
}
function dayName(dateString) {
if (!dateString) return ""
var d = new Date(dateString + "T12:00:00")
if (isNaN(d.getTime())) return ""
return Qt.formatDate(d, "dddd")
return WeatherModel.dayName(dateString, function(date) { return Qt.formatDate(date, "dddd") })
}
// 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 + "°"
return WeatherModel.bareTempForDay(day, kind, useImperial)
}
// 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)
return WeatherModel.dayIcon(day)
}
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)
return WeatherModel.iconForOpenMeteoCode(code)
}
// 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 ""
}
return WeatherModel.iconForCode(code, night)
}
Process {
+155
View File
@@ -0,0 +1,155 @@
function parseWeatherStatus(raw) {
try {
var data = JSON.parse(String(raw || "{}"))
return {
label: data.text || "",
klass: data.class || ""
}
} catch (e) {
return { label: "", klass: "" }
}
}
function isFutureForecastDate(dateString, todayString) {
if (!dateString) return false
return String(dateString).slice(0, 10) > String(todayString || "")
}
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, useImperial) {
if (value === undefined || value === null || value === "") return ""
return value + "°" + (useImperial ? "F" : "C")
}
function dayName(dateString, formatter) {
if (!dateString) return ""
var d = new Date(dateString + "T12:00:00")
if (isNaN(d.getTime())) return ""
if (formatter) return formatter(d)
return ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()]
}
function openMeteoForecastDays(dailyForecastReport, todayString) {
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, todayString)) 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(report, todayString) {
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, todayString)) result.push(days[i])
}
return result
}
function buildForecastDays(report, dailyForecastReport, todayString) {
var days = openMeteoForecastDays(dailyForecastReport, todayString)
return days.length > 0 ? days : wttrNextForecastDays(report, todayString)
}
function bareTempForDay(day, kind, useImperial) {
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 + "°"
}
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)
}
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 ""
}
}
if (typeof module !== "undefined") {
module.exports = {
parseWeatherStatus: parseWeatherStatus,
isFutureForecastDate: isFutureForecastDate,
roundedTemp: roundedTemp,
celsiusToFahrenheit: celsiusToFahrenheit,
formatTemp: formatTemp,
dayName: dayName,
openMeteoForecastDays: openMeteoForecastDays,
wttrNextForecastDays: wttrNextForecastDays,
buildForecastDays: buildForecastDays,
bareTempForDay: bareTempForDay,
dayIcon: dayIcon,
iconForOpenMeteoCode: iconForOpenMeteoCode,
iconForCode: iconForCode
}
}