diff --git a/AGENTS.md b/AGENTS.md
index b6c2ea26..15bc29d2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -101,10 +101,11 @@ Exceptions are allowed for bootstrap, preflight, migration, and package-helper s
Run focused automated tests for the area you changed. Current test entry points:
-- `bash test/cli.sh` - CLI routing, command metadata, theme helpers, and safe dispatch coverage
-- `bash test/shell.sh` - all Omarchy shell tests under `test/shell/`
+- `./test/all` - aggregate runner for CLI and shell tests
+- `./test/cli` - CLI routing, command metadata, theme helpers, and safe dispatch coverage
+- `./test/shell` - all Omarchy shell tests under `test/shell.d/`
-New Omarchy shell tests should live in `test/shell/*-test.sh` so `test/shell.sh` picks them up automatically. Source `test/shell/base-test.sh` for shared root-path discovery, assertions, and Node test helpers.
+New Omarchy shell tests should live in `test/shell.d/*-test.sh` so `./test/shell` picks them up automatically. Source `test/shell.d/base-test.sh` for shared root-path discovery, assertions, and Node test helpers.
For visual changes, such as omarchy-shell styling, desktop appearance, screenshots, or screen recording flows, verify with the running UI in addition to automated tests. Take and analyze screenshots with `omarchy capture screenshot fullscreen save`. For animation, transitions, capture, or screen recording behavior, make a short recording with `omarchy screenrecord --fullscreen`, stop it with `omarchy screenrecord --stop-recording`, and review the output before finishing.
diff --git a/shell/plugins/audio/AudioModel.js b/shell/plugins/audio/AudioModel.js
new file mode 100644
index 00000000..94b7cda3
--- /dev/null
+++ b/shell/plugins/audio/AudioModel.js
@@ -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
+ }
+}
diff --git a/shell/plugins/audio/BarWidget.qml b/shell/plugins/audio/BarWidget.qml
index 0f98ae40..1c46334f 100644
--- a/shell/plugins/audio/BarWidget.qml
+++ b/shell/plugins/audio/BarWidget.qml
@@ -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
diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml
index 09c9fbea..f011e242 100644
--- a/shell/plugins/bar/Bar.qml
+++ b/shell/plugins/bar/Bar.qml
@@ -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) : ""
}
diff --git a/shell/plugins/bar/BarModel.js b/shell/plugins/bar/BarModel.js
new file mode 100644
index 00000000..e94d8a4f
--- /dev/null
+++ b/shell/plugins/bar/BarModel.js
@@ -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
+ }
+}
diff --git a/shell/plugins/bluetooth/BarWidget.qml b/shell/plugins/bluetooth/BarWidget.qml
index 88a58894..02596aba 100644
--- a/shell/plugins/bluetooth/BarWidget.qml
+++ b/shell/plugins/bluetooth/BarWidget.qml
@@ -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()
}
diff --git a/shell/plugins/bluetooth/BluetoothModel.js b/shell/plugins/bluetooth/BluetoothModel.js
new file mode 100644
index 00000000..bb6f7184
--- /dev/null
+++ b/shell/plugins/bluetooth/BluetoothModel.js
@@ -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
+ }
+}
diff --git a/shell/plugins/clipboard/Clipboard.qml b/shell/plugins/clipboard/Clipboard.qml
index 848e4847..6a7bc7b9 100644
--- a/shell/plugins/clipboard/Clipboard.qml
+++ b/shell/plugins/clipboard/Clipboard.qml
@@ -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
diff --git a/shell/plugins/clipboard/ClipboardHistory.js b/shell/plugins/clipboard/ClipboardHistory.js
new file mode 100644
index 00000000..6fb0d5b2
--- /dev/null
+++ b/shell/plugins/clipboard/ClipboardHistory.js
@@ -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
+ }
+}
diff --git a/shell/plugins/image-picker/ImagePicker.qml b/shell/plugins/image-picker/ImagePicker.qml
index 5e66056e..bb3c3fe6 100644
--- a/shell/plugins/image-picker/ImagePicker.qml
+++ b/shell/plugins/image-picker/ImagePicker.qml
@@ -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() {
diff --git a/shell/plugins/image-picker/ImagePickerModel.js b/shell/plugins/image-picker/ImagePickerModel.js
new file mode 100644
index 00000000..f34fdcab
--- /dev/null
+++ b/shell/plugins/image-picker/ImagePickerModel.js
@@ -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
+ }
+}
diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml
index 1e06c4ab..b8958699 100644
--- a/shell/plugins/menu/Menu.qml
+++ b/shell/plugins/menu/Menu.qml
@@ -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() {
diff --git a/shell/plugins/menu/MenuModel.js b/shell/plugins/menu/MenuModel.js
new file mode 100644
index 00000000..2d8cb0bc
--- /dev/null
+++ b/shell/plugins/menu/MenuModel.js
@@ -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
+ }
+}
diff --git a/shell/plugins/monitor/BarWidget.qml b/shell/plugins/monitor/BarWidget.qml
index 619749d9..dbe6a1b1 100644
--- a/shell/plugins/monitor/BarWidget.qml
+++ b/shell/plugins/monitor/BarWidget.qml
@@ -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 ~10–20 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) {
diff --git a/shell/plugins/monitor/MonitorModel.js b/shell/plugins/monitor/MonitorModel.js
new file mode 100644
index 00000000..afe3efca
--- /dev/null
+++ b/shell/plugins/monitor/MonitorModel.js
@@ -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
+ }
+}
diff --git a/shell/plugins/network/BarWidget.qml b/shell/plugins/network/BarWidget.qml
index 30cbe509..329e7032 100644
--- a/shell/plugins/network/BarWidget.qml
+++ b/shell/plugins/network/BarWidget.qml
@@ -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) {
diff --git a/shell/plugins/network/NetworkModel.js b/shell/plugins/network/NetworkModel.js
new file mode 100644
index 00000000..7f81b60c
--- /dev/null
+++ b/shell/plugins/network/NetworkModel.js
@@ -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
+ }
+}
diff --git a/shell/plugins/notifications/BarWidget.qml b/shell/plugins/notifications/BarWidget.qml
index 19730ec2..a1217044 100644
--- a/shell/plugins/notifications/BarWidget.qml
+++ b/shell/plugins/notifications/BarWidget.qml
@@ -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(/
]*>/gi, "")
- if (!isChromiumDerived(app, appIcon)) return text
-
- return text
- .replace(/^\s*]*>\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) {
diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js
new file mode 100644
index 00000000..adf8099a
--- /dev/null
+++ b/shell/plugins/notifications/NotificationLogic.js
@@ -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(/
]*>/gi, "")
+ if (!isChromiumDerived(app, appIcon)) return text
+
+ return text
+ .replace(/^\s*]*>\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
+ }
+}
diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml
index ebca0b32..a4ce58ad 100644
--- a/shell/plugins/notifications/Service.qml
+++ b/shell/plugins/notifications/Service.qml
@@ -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() {
diff --git a/shell/plugins/notifications/components/NotificationCard.qml b/shell/plugins/notifications/components/NotificationCard.qml
index adfabd21..db40c6b0 100644
--- a/shell/plugins/notifications/components/NotificationCard.qml
+++ b/shell/plugins/notifications/components/NotificationCard.qml
@@ -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, "
")
@@ -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(/
]*>/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*]*>\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) {
diff --git a/shell/plugins/osd/Osd.qml b/shell/plugins/osd/Osd.qml
index 345499de..a571b932 100644
--- a/shell/plugins/osd/Osd.qml
+++ b/shell/plugins/osd/Osd.qml
@@ -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()
diff --git a/shell/plugins/osd/OsdModel.js b/shell/plugins/osd/OsdModel.js
new file mode 100644
index 00000000..daf12464
--- /dev/null
+++ b/shell/plugins/osd/OsdModel.js
@@ -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
+ }
+}
diff --git a/shell/plugins/polkit/PolkitAgent.qml b/shell/plugins/polkit/PolkitAgent.qml
index a1a62edf..dc42d235 100644
--- a/shell/plugins/polkit/PolkitAgent.qml
+++ b/shell/plugins/polkit/PolkitAgent.qml
@@ -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() {
diff --git a/shell/plugins/polkit/PolkitModel.js b/shell/plugins/polkit/PolkitModel.js
new file mode 100644
index 00000000..02444d55
--- /dev/null
+++ b/shell/plugins/polkit/PolkitModel.js
@@ -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
+ }
+}
diff --git a/shell/plugins/power/BarWidget.qml b/shell/plugins/power/BarWidget.qml
index e4fdb811..46f2975c 100644
--- a/shell/plugins/power/BarWidget.qml
+++ b/shell/plugins/power/BarWidget.qml
@@ -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
diff --git a/shell/plugins/power/PowerModel.js b/shell/plugins/power/PowerModel.js
new file mode 100644
index 00000000..11901fe8
--- /dev/null
+++ b/shell/plugins/power/PowerModel.js
@@ -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
+ }
+}
diff --git a/shell/plugins/reminders/ReminderFlow.qml b/shell/plugins/reminders/ReminderFlow.qml
index d6dbd0ca..779d535f 100644
--- a/shell/plugins/reminders/ReminderFlow.qml
+++ b/shell/plugins/reminders/ReminderFlow.qml
@@ -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)
}
}
diff --git a/shell/plugins/reminders/ReminderFlowModel.js b/shell/plugins/reminders/ReminderFlowModel.js
new file mode 100644
index 00000000..05b94c8c
--- /dev/null
+++ b/shell/plugins/reminders/ReminderFlowModel.js
@@ -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
+ }
+}
diff --git a/shell/plugins/services/battery/BatteryModel.js b/shell/plugins/services/battery/BatteryModel.js
new file mode 100644
index 00000000..2aca5dbd
--- /dev/null
+++ b/shell/plugins/services/battery/BatteryModel.js
@@ -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
+ }
+}
diff --git a/shell/plugins/services/battery/Service.qml b/shell/plugins/services/battery/Service.qml
index 26796882..c2ab7625 100644
--- a/shell/plugins/services/battery/Service.qml
+++ b/shell/plugins/services/battery/Service.qml
@@ -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) {
diff --git a/shell/plugins/services/idle/IdleModel.js b/shell/plugins/services/idle/IdleModel.js
new file mode 100644
index 00000000..8e2315d0
--- /dev/null
+++ b/shell/plugins/services/idle/IdleModel.js
@@ -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
+ }
+}
diff --git a/shell/plugins/services/idle/Service.qml b/shell/plugins/services/idle/Service.qml
index 623e4f80..9542c10a 100644
--- a/shell/plugins/services/idle/Service.qml
+++ b/shell/plugins/services/idle/Service.qml
@@ -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) {
diff --git a/shell/plugins/services/media/MediaModel.js b/shell/plugins/services/media/MediaModel.js
new file mode 100644
index 00000000..00cc9130
--- /dev/null
+++ b/shell/plugins/services/media/MediaModel.js
@@ -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
+ }
+}
diff --git a/shell/plugins/services/media/Service.qml b/shell/plugins/services/media/Service.qml
index e1598c27..66f9d820 100644
--- a/shell/plugins/services/media/Service.qml
+++ b/shell/plugins/services/media/Service.qml
@@ -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) {
diff --git a/shell/plugins/weather/Panel.qml b/shell/plugins/weather/Panel.qml
index dc57d8d0..33890c2b 100644
--- a/shell/plugins/weather/Panel.qml
+++ b/shell/plugins/weather/Panel.qml
@@ -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 {
diff --git a/shell/plugins/weather/WeatherModel.js b/shell/plugins/weather/WeatherModel.js
new file mode 100644
index 00000000..2ccb3321
--- /dev/null
+++ b/shell/plugins/weather/WeatherModel.js
@@ -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
+ }
+}
diff --git a/test/all b/test/all
new file mode 100755
index 00000000..693a8c4e
--- /dev/null
+++ b/test/all
@@ -0,0 +1,14 @@
+#!/bin/bash
+
+set -euo pipefail
+
+ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
+tests=(
+ "$ROOT/test/cli"
+ "$ROOT/test/shell"
+)
+
+for test in "${tests[@]}"; do
+ printf '==> %s\n' "${test#$ROOT/}"
+ "$test"
+done
diff --git a/test/cli.sh b/test/cli
old mode 100644
new mode 100755
similarity index 100%
rename from test/cli.sh
rename to test/cli
diff --git a/test/shell.sh b/test/shell
old mode 100644
new mode 100755
similarity index 93%
rename from test/shell.sh
rename to test/shell
index 6ff004a8..215b98aa
--- a/test/shell.sh
+++ b/test/shell
@@ -3,7 +3,7 @@
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
-TEST_DIR="$ROOT/test/shell"
+TEST_DIR="$ROOT/test/shell.d"
shopt -s nullglob
tests=()
diff --git a/test/shell.d/audio-test.sh b/test/shell.d/audio-test.sh
new file mode 100644
index 00000000..91ccb861
--- /dev/null
+++ b/test/shell.d/audio-test.sh
@@ -0,0 +1,49 @@
+#!/bin/bash
+
+set -euo pipefail
+
+source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
+
+run_node_test <<'JS'
+const audio = requireFromRoot('shell/plugins/audio/AudioModel.js')
+
+assert(audio.isPlaybackStream({ isStream: true, isSink: true }), 'audio detects sink-backed playback streams')
+assert(audio.isPlaybackStream({ isStream: true, type: 'Stream/Output/Audio' }), 'audio detects typed playback streams')
+assert(!audio.isPlaybackStream({ isStream: false, isSink: true }), 'audio rejects non-stream playback nodes')
+assert(audio.isAudioSource({ audio: {} }), 'audio detects nodes with audio as sources')
+assert(audio.isAudioSource({ type: 'Audio/Source' }), 'audio detects typed source nodes')
+
+assertEqual(audio.outputVolumeName(0, false), 'Silenced', 'audio labels silent output')
+assertEqual(audio.outputVolumeName(0.9, false), 'Party mode', 'audio labels loud output')
+assertEqual(audio.outputVolumeName(0.5, true), 'Muted', 'audio labels muted output')
+
+assertDeepEqual(audio.parseSinkAvailability('alsa_output\t1\nhdmi_output\t0\n'), { alsa_output: true, hdmi_output: false }, 'audio parses sink availability')
+assertEqual(audio.friendlyDeviceLabel('Built-in Audio Speakers Output'), 'Speakers', 'audio cleans device labels')
+assertEqual(
+ audio.nodeLabel({ ready: true, properties: { 'node.nick': 'Built-in Audio Microphones Input' }, name: 'alsa_input' }),
+ 'Microphone',
+ 'audio chooses friendly node labels'
+)
+
+const headphones = { ready: true, name: 'bluez_output.airpods', properties: { 'device.product.name': 'AirPods Headphones' } }
+assert(audio.isHeadphones(headphones), 'audio detects headphone devices')
+assertEqual(audio.sinkGlyph(headphones), '', 'audio uses headphone sink glyph')
+assert(audio.sourceGlyph({ ready: true, properties: { 'device.icon-name': 'camera-webcam' } }).length > 0, 'audio maps webcam source glyph')
+
+assertEqual(audio.friendlyStreamLabel('spotify'), 'Spotify', 'audio normalizes known stream labels')
+assert(audio.streamRepresentsMprisPlayer('Chromium', 'Chromium Browser'), 'audio matches related stream and MPRIS labels')
+
+const players = [
+ { identity: 'Spotify', canPlay: true, isPlaying: true, dbusName: 'org.mpris.MediaPlayer2.spotify' },
+ { identity: 'Chromium', canPlay: true, isPlaying: false, dbusName: 'org.mpris.MediaPlayer2.chromium' }
+]
+const streams = [
+ { ready: true, properties: { 'application.name': 'Chromium' } },
+ { ready: true, properties: { 'application.name': 'audio-src' } }
+]
+
+assertEqual(audio.matchingMprisStreamLabel('Chromium', players), 'Chromium', 'audio finds matching MPRIS labels')
+assertEqual(audio.unmatchedMprisStreamLabel('audio-src', players, streams), 'Spotify', 'audio uses unmatched MPRIS player for generic streams')
+assertEqual(audio.streamLabel(streams[1], players, streams), 'Spotify', 'audio labels generic streams from MPRIS')
+assert(audio.streamRepresentsPlayer(streams[1], players[0], players, streams), 'audio links generic streams to active player')
+JS
diff --git a/test/shell.d/bar-test.sh b/test/shell.d/bar-test.sh
new file mode 100644
index 00000000..f96a7988
--- /dev/null
+++ b/test/shell.d/bar-test.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+
+set -euo pipefail
+
+source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
+
+run_node_test <<'JS'
+const bar = requireFromRoot('shell/plugins/bar/BarModel.js')
+
+assertEqual(bar.normalizePosition('left'), 'left', 'bar accepts valid positions')
+assertEqual(bar.normalizePosition('sideways'), 'top', 'bar defaults invalid positions')
+assertDeepEqual(bar.entrySettings({ id: 'omarchy.clock', format: 'HH:mm' }), { format: 'HH:mm' }, 'bar extracts entry settings')
+assertEqual(bar.entryId({ id: 'omarchy.clock' }), 'omarchy.clock', 'bar extracts object entry ids')
+assertEqual(bar.entryId('omarchy.clock'), 'omarchy.clock', 'bar extracts string entry ids')
+
+const entries = [{ id: 'a' }, { id: 'omarchy.tray' }, { id: 'b' }]
+assertDeepEqual(bar.pinTrayToInner(entries, 'left').map(bar.entryId), ['a', 'b', 'omarchy.tray'], 'bar pins tray to left inner edge')
+assertDeepEqual(bar.pinTrayToInner(entries, 'right').map(bar.entryId), ['omarchy.tray', 'a', 'b'], 'bar pins tray to right inner edge')
+
+assertEqual(bar.moduleString({ id: 'custom', label: 42 }, 'label', 'fallback'), '42', 'bar stringifies module settings')
+assertEqual(bar.entryIndex(entries, 'b'), 2, 'bar finds entry indexes')
+assertDeepEqual(bar.entriesBefore(entries, 'b').map(bar.entryId), ['a', 'omarchy.tray'], 'bar returns entries before target')
+assertDeepEqual(bar.entriesAfter(entries, 'a').map(bar.entryId), ['omarchy.tray', 'b'], 'bar returns entries after target')
+
+assertEqual(bar.expandPath('~/module.qml', '/home/dhh'), '/home/dhh/module.qml', 'bar expands tilde paths')
+assertEqual(bar.expandPath('$HOME/module.qml', '/home/dhh'), '/home/dhh/module.qml', 'bar expands HOME paths')
+assert(bar.customModuleSafeName('local.weather'), 'bar accepts safe custom module names')
+assert(!bar.customModuleSafeName('../escape'), 'bar rejects path traversal custom module names')
+assertEqual(bar.customModuleType({ id: 'custom', exec: 'date' }), 'command', 'bar infers command custom modules')
+assertEqual(bar.customModuleType({ id: 'custom', source: '~/Custom.qml' }), 'qml', 'bar infers qml custom modules')
+assertEqual(
+ bar.customModulePath({ id: 'local.weather' }, '/home/dhh', '/home/dhh/.config/omarchy'),
+ '/home/dhh/.config/omarchy/bar/modules/local.weather.qml',
+ 'bar builds default custom module paths'
+)
+JS
diff --git a/test/shell/base-test.sh b/test/shell.d/base-test.sh
similarity index 92%
rename from test/shell/base-test.sh
rename to test/shell.d/base-test.sh
index ecc8f8f4..7bb019a7 100644
--- a/test/shell/base-test.sh
+++ b/test/shell.d/base-test.sh
@@ -1,12 +1,12 @@
#!/bin/bash
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
- echo "source test/shell/base-test.sh from a shell test; do not run it directly" >&2
+ echo "source test/shell.d/base-test.sh from a shell test; do not run it directly" >&2
exit 1
fi
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
-SHELL_TEST_DIR="$ROOT/test/shell"
+SHELL_TEST_DIR="$ROOT/test/shell.d"
export ROOT
diff --git a/test/shell.d/battery-test.sh b/test/shell.d/battery-test.sh
new file mode 100644
index 00000000..0447779a
--- /dev/null
+++ b/test/shell.d/battery-test.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+
+set -euo pipefail
+
+source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
+
+run_node_test <<'JS'
+const battery = requireFromRoot('shell/plugins/services/battery/BatteryModel.js')
+const discharging = 1
+
+assertEqual(battery.batteryPercentage({ isPresent: true, percentage: 0.126 }), 13, 'battery rounds display percentage')
+assertEqual(battery.batteryPercentage({ isPresent: false, percentage: 0.5 }), -1, 'battery reports missing battery')
+assert(battery.isDischarging({ isPresent: true, state: discharging }, true, discharging), 'battery detects discharging state')
+assert(!battery.isDischarging({ isPresent: true, state: discharging }, false, discharging), 'battery requires on-battery state')
+
+assertDeepEqual(
+ battery.shouldWarnLowBattery({ isPresent: true, percentage: 0.08, state: discharging }, true, discharging, 10, false),
+ { level: 8, notify: true, notifiedLowBattery: true },
+ 'battery warns once under threshold'
+)
+assertDeepEqual(
+ battery.shouldWarnLowBattery({ isPresent: true, percentage: 0.08, state: discharging }, true, discharging, 10, true),
+ { level: 8, notify: false, notifiedLowBattery: true },
+ 'battery keeps low-battery notified state'
+)
+assertDeepEqual(
+ battery.shouldWarnLowBattery({ isPresent: true, percentage: 0.4, state: discharging }, true, discharging, 10, true),
+ { level: 40, notify: false, notifiedLowBattery: false },
+ 'battery clears notified state after recovery'
+)
+JS
diff --git a/test/shell.d/bluetooth-test.sh b/test/shell.d/bluetooth-test.sh
new file mode 100644
index 00000000..9c1ac939
--- /dev/null
+++ b/test/shell.d/bluetooth-test.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+
+set -euo pipefail
+
+source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
+
+run_node_test <<'JS'
+const bluetooth = requireFromRoot('shell/plugins/bluetooth/BluetoothModel.js')
+
+assert(bluetooth.isUuidLike('0000110b-0000-1000-8000-00805f9b34fb'), 'bluetooth detects UUID-like names')
+assert(bluetooth.isAddressLike('AA:BB:CC:DD:EE:FF'), 'bluetooth detects address-like names')
+assert(!bluetooth.hasHumanName({ name: 'AA:BB:CC:DD:EE:FF' }), 'bluetooth rejects address-only device labels')
+assert(bluetooth.hasHumanName({ deviceName: 'MX Master 3S' }), 'bluetooth accepts human device labels')
+
+const devices = [
+ { name: 'Speaker', connected: false, paired: true, address: '2' },
+ { name: 'Headphones', connected: true, address: '1' },
+ { name: 'Keyboard', connected: false, address: '3' },
+ { name: 'AA:BB:CC:DD:EE:FF', connected: true, address: '4' },
+ { name: 'Mouse', connected: false, trusted: true, address: '5' }
+]
+
+const lists = bluetooth.deviceLists(devices)
+assertDeepEqual(lists.connected.map(bluetooth.deviceLabel), ['Headphones'], 'bluetooth groups connected devices')
+assertDeepEqual(lists.known.map(bluetooth.deviceLabel), ['Mouse', 'Speaker'], 'bluetooth groups known devices by label')
+assertDeepEqual(lists.discovered.map(bluetooth.deviceLabel), ['Keyboard'], 'bluetooth groups discovered devices')
+assertDeepEqual(bluetooth.visibleSections(lists, true), ['connected', 'known', 'discovered'], 'bluetooth shows discovered section while scanning')
+assertDeepEqual(bluetooth.visibleSections(lists, false), ['connected', 'known'], 'bluetooth hides discovered section when not scanning')
+
+assertDeepEqual(
+ bluetooth.withPendingAction({ a: 'connecting' }, 'b', 'forgetting'),
+ { a: 'connecting', b: 'forgetting' },
+ 'bluetooth adds pending actions immutably'
+)
+assertDeepEqual(bluetooth.withPendingAction({ a: 'connecting' }, 'a', ''), {}, 'bluetooth clears pending actions immutably')
+JS
diff --git a/test/shell.d/clipboard-test.sh b/test/shell.d/clipboard-test.sh
new file mode 100644
index 00000000..5d05bd54
--- /dev/null
+++ b/test/shell.d/clipboard-test.sh
@@ -0,0 +1,62 @@
+#!/bin/bash
+
+set -euo pipefail
+
+source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
+
+run_node_test <<'JS'
+const clipboard = requireFromRoot('shell/plugins/clipboard/ClipboardHistory.js')
+
+assertDeepEqual(
+ clipboard.normalizeEntry('hello'),
+ { type: 'text', text: 'hello' },
+ 'clipboard normalizes string entries'
+)
+
+assertDeepEqual(
+ clipboard.normalizeEntry({ kind: 'image', path: '/tmp/a.png' }),
+ { type: 'image', path: '/tmp/a.png', mime: 'image/png' },
+ 'clipboard normalizes image entries with default mime'
+)
+
+assertDeepEqual(
+ clipboard.parseHistory(JSON.stringify(['one', '', { type: 'text', text: 'two' }, { type: 'image', path: '/tmp/a.jpg', mime: 'image/jpeg' }])),
+ [
+ { type: 'text', text: 'one' },
+ { type: 'text', text: 'two' },
+ { type: 'image', path: '/tmp/a.jpg', mime: 'image/jpeg' }
+ ],
+ 'clipboard history parser drops invalid entries'
+)
+
+const history = [
+ { type: 'text', text: 'old' },
+ { type: 'text', text: 'new' },
+ { type: 'image', path: '/tmp/a.png', mime: 'image/png' }
+]
+
+assertDeepEqual(
+ clipboard.addEntry(history, { type: 'text', text: 'new' }, 100),
+ [
+ { type: 'text', text: 'new' },
+ { type: 'text', text: 'old' },
+ { type: 'image', path: '/tmp/a.png', mime: 'image/png' }
+ ],
+ 'clipboard addEntry moves duplicate text to front'
+)
+
+assertDeepEqual(
+ clipboard.displayRows(history, 'image', 50).map(row => ({ type: row.entryType, preview: row.previewText, mime: row.mime })),
+ [{ type: 'image', preview: 'Image', mime: 'image/png' }],
+ 'clipboard display rows search image metadata'
+)
+
+assertDeepEqual(
+ clipboard.displayRows([{ type: 'text', text: 'line one\nline two' }], '', 50)[0].previewText,
+ 'line one line two',
+ 'clipboard display rows collapse text whitespace'
+)
+
+assertDeepEqual(clipboard.displayRows(history, '', 0), [], 'clipboard display rows supports zero result limit')
+assertDeepEqual(clipboard.addEntry(history, 'next', 0), [], 'clipboard addEntry supports zero history limit')
+JS
diff --git a/test/shell/emojis-test.sh b/test/shell.d/emojis-test.sh
similarity index 100%
rename from test/shell/emojis-test.sh
rename to test/shell.d/emojis-test.sh
diff --git a/test/shell.d/idle-test.sh b/test/shell.d/idle-test.sh
new file mode 100644
index 00000000..3d6dc64c
--- /dev/null
+++ b/test/shell.d/idle-test.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+
+set -euo pipefail
+
+source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
+
+run_node_test <<'JS'
+const idle = requireFromRoot('shell/plugins/services/idle/IdleModel.js')
+
+assertEqual(idle.secondsFromConfig('42.9', 10), 42, 'idle floors configured seconds')
+assertEqual(idle.secondsFromConfig('-1', 10), 10, 'idle rejects negative seconds')
+assertEqual(idle.secondsFromConfig('nope', 10), 10, 'idle rejects invalid seconds')
+
+assertDeepEqual(idle.eventParts({ data: 'a,b,c' }, 2), ['a', 'b', 'c'], 'idle parses raw event data')
+assertDeepEqual(
+ idle.eventParts({ parse: function(count) { return ['parsed', count] } }, 4),
+ ['parsed', 4],
+ 'idle prefers event parser when available'
+)
+
+assertDeepEqual(
+ idle.screensaverWindowsAfter({ a: true }, 'b', true),
+ { windows: { a: true, b: true }, count: 2 },
+ 'idle adds visible screensaver windows'
+)
+assertDeepEqual(
+ idle.screensaverWindowsAfter({ a: true, b: true }, 'a', false),
+ { windows: { b: true }, count: 1 },
+ 'idle removes closed screensaver windows'
+)
+assertDeepEqual(
+ idle.screensaverWindowsAfter({ a: true }, '', false),
+ { windows: { a: true }, count: 1 },
+ 'idle leaves screensaver windows unchanged without an address'
+)
+JS
diff --git a/test/shell.d/image-picker-test.sh b/test/shell.d/image-picker-test.sh
new file mode 100644
index 00000000..f52b90a2
--- /dev/null
+++ b/test/shell.d/image-picker-test.sh
@@ -0,0 +1,43 @@
+#!/bin/bash
+
+set -euo pipefail
+
+source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
+
+run_node_test <<'JS'
+const picker = requireFromRoot('shell/plugins/image-picker/ImagePickerModel.js')
+
+assertEqual(picker.nameForPath('/themes/nord-river.png'), 'nord-river', 'image picker strips directory and extension')
+assertEqual(picker.labelForPath('/themes/nord_river.png'), 'Nord River', 'image picker builds display labels')
+
+const rows = [
+ '/themes/a/nord-river.png\t/cache/nord-river.jpg',
+ '/themes/b/nord-river.png\t/cache/duplicate.jpg',
+ '/themes/a/gruvbox-dark.jpeg',
+ '',
+ '\t/cache/no-path.jpg',
+ '/themes/a/plain'
+].join('\n')
+
+const images = picker.loadRows(rows)
+assertDeepEqual(
+ images,
+ [
+ { filePath: '/themes/a/nord-river.png', fileName: 'nord-river.png', thumbnailPath: '/cache/nord-river.jpg' },
+ { filePath: '/themes/a/gruvbox-dark.jpeg', fileName: 'gruvbox-dark.jpeg', thumbnailPath: '/themes/a/gruvbox-dark.jpeg' },
+ { filePath: '/themes/a/plain', fileName: 'plain', thumbnailPath: '/themes/a/plain' }
+ ],
+ 'image picker parses rows and dedupes by file name'
+)
+
+assert(picker.itemMatches(images, 0, 'river'), 'image picker matches file names')
+assert(picker.itemMatches(images, 1, 'Gruvbox Dark'), 'image picker matches labels case-insensitively')
+assert(!picker.itemMatches(images, 2, 'river'), 'image picker rejects non-matching filters')
+assertEqual(picker.firstMatchingIndex(images, 'plain'), 2, 'image picker finds first matching index')
+assertEqual(picker.indexForSelectedImage(images, '/themes/a/gruvbox-dark.jpeg'), 1, 'image picker finds selected image')
+assertEqual(picker.indexForSelectedImage(images, '/missing.png'), 0, 'image picker defaults selected image to first row')
+
+assertEqual(picker.filteredPosition(images, 2, 'dark'), 1, 'image picker computes filtered position')
+assertEqual(picker.selectedFilteredPosition(images, 2, 'dark'), 0, 'image picker selected filtered position falls back when selected is hidden')
+assertEqual(picker.nextSelectedIndexForFilter(images, 0, 'dark'), 1, 'image picker moves selection to first match when filter hides current item')
+JS
diff --git a/test/shell/launcher-search-test.sh b/test/shell.d/launcher-search-test.sh
similarity index 100%
rename from test/shell/launcher-search-test.sh
rename to test/shell.d/launcher-search-test.sh
diff --git a/test/shell.d/media-test.sh b/test/shell.d/media-test.sh
new file mode 100644
index 00000000..d7574e69
--- /dev/null
+++ b/test/shell.d/media-test.sh
@@ -0,0 +1,39 @@
+#!/bin/bash
+
+set -euo pipefail
+
+source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
+
+run_node_test <<'JS'
+const media = requireFromRoot('shell/plugins/services/media/MediaModel.js')
+
+assert(media.isProxyPlayer({ dbusName: 'org.mpris.MediaPlayer2.playerctld' }), 'media detects playerctld proxy by DBus name')
+assert(media.isProxyPlayer({ desktopEntry: 'playerctld' }), 'media detects playerctld proxy by desktop entry')
+assert(media.hasMetadata({ identity: 'Spotify' }), 'media detects identity metadata')
+assert(media.hasTrackMetadata({ trackTitle: 'Track' }), 'media detects track metadata')
+assert(media.playerCanControl({ canGoNext: true }), 'media detects controllable players')
+assert(media.canHandleAction({ canTogglePlaying: true }, 'playPause'), 'media maps playPause capability')
+assert(media.canCycleSource({ identity: 'Spotify', canPlay: true }), 'media detects cycleable sources')
+
+assert(media.isPlaybackStream({ isStream: true, type: 'Stream/Output/Audio' }), 'media detects playback streams')
+assertEqual(media.streamLabelKey('PipeWire ALSA [Chromium]'), 'chromium', 'media normalizes stream labels')
+assertEqual(
+ media.rawStreamLabel({ ready: true, properties: { 'application.name': 'Chromium' }, name: 'fallback' }),
+ 'Chromium',
+ 'media extracts raw stream labels'
+)
+assertEqual(
+ media.playerAppLabel({ dbusName: 'org.mpris.MediaPlayer2.spotify.instance42' }),
+ 'spotify',
+ 'media derives player app labels from DBus names'
+)
+assert(media.playerHasPlaybackStream(
+ { desktopEntry: 'chromium' },
+ [{ ready: true, properties: { 'application.name': 'Chromium' } }]
+), 'media matches players to playback streams')
+
+assertEqual(media.playerKey({ dbusName: 'org.mpris.MediaPlayer2.spotify' }), 'org.mpris.MediaPlayer2.spotify', 'media derives stable player keys')
+assertEqual(media.labelFor({ trackTitle: 'Song', identity: 'Spotify' }), 'Song', 'media labels players by track first')
+assertEqual(media.osdMessage({ trackTitle: 'Song', trackArtist: 'Artist' }, 'Fallback'), 'Song - Artist', 'media builds OSD messages')
+assertEqual(media.osdMessage(null, 'Fallback'), 'Fallback', 'media falls back OSD messages')
+JS
diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh
new file mode 100644
index 00000000..6a03b91b
--- /dev/null
+++ b/test/shell.d/menu-test.sh
@@ -0,0 +1,88 @@
+#!/bin/bash
+
+set -euo pipefail
+
+source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
+
+run_node_test <<'JS'
+const menu = requireFromRoot('shell/plugins/menu/MenuModel.js')
+
+const parsed = menu.parseMenuJsonc(`
+{
+ // comment
+ "items": {
+ "root": { "label": "Go" },
+ "style": { "label": "Style" },
+ "style.theme": {
+ "label": "Themes",
+ "aliases": "theme",
+ "keywords": "appearance appearance colors",
+ "action": "omarchy-theme-set"
+ },
+ },
+}
+`)
+
+assertEqual(parsed.length, 3, 'menu parses JSONC with comments and trailing commas')
+assertDeepEqual(
+ parsed.find(item => item.id === 'style.theme'),
+ {
+ id: 'style.theme',
+ parent: 'style',
+ kind: 'action',
+ icon: '',
+ label: 'Themes',
+ target: '',
+ keywords: 'appearance colors',
+ description: '',
+ action: 'omarchy-theme-set',
+ provider: '',
+ aliases: ['theme'],
+ when: '',
+ checked: ''
+ },
+ 'menu normalizes parsed items'
+)
+
+const user = [
+ menu.normalizeItem('style.theme', { label: 'Theme picker', aliases: ['theme', 'colors'], action: 'custom-theme' }),
+ menu.normalizeItem('tools', { label: 'Tools' })
+]
+const merged = menu.mergeMenuSources(parsed, user)
+assertEqual(merged.items['style.theme'].label, 'Theme picker', 'menu user entries override default entries')
+assertEqual(merged.items['style.theme'].order, 2, 'menu preserves original order on override')
+assert(merged.items.root, 'menu injects root when merging sources')
+
+assertEqual(menu.slugify('Power Saver!'), 'power-saver', 'menu slugifies provider rows')
+assertEqual(menu.pathFor(merged.items, 'style.theme'), 'Style › Theme picker', 'menu builds item paths')
+assertEqual(menu.parentPathFor(merged.items, 'style.theme'), 'Style', 'menu builds parent paths')
+assert(menu.isDescendantOf(merged.items, 'style.theme', 'style'), 'menu detects descendants')
+assertEqual(menu.childCount(merged.items, merged.itemOrder, 'style'), 1, 'menu counts children')
+assertEqual(menu.labelFor({ id: 'style.theme', label: 'Theme', checked: 'cmd' }, { 'style.theme': true }), 'Theme ✓', 'menu appends checked marker')
+
+const entry = merged.items['style.theme']
+assert(menu.matchesQuery(entry, 'theme', true), 'menu matches labels and aliases')
+assert(menu.matchesQuery(entry, 'colors', true), 'menu matches aliases')
+assert(!menu.matchesQuery(entry, 'missing', true), 'menu rejects missing terms')
+assert(!menu.matchesQuery(entry, 'theme', false), 'menu hides invisible matches')
+assert(menu.searchScore(merged.items, entry, 'theme') < menu.searchScore(merged.items, entry, 'appearance'), 'menu scores name matches above keyword matches')
+
+assertDeepEqual(
+ menu.displayRow(merged.items, merged.itemOrder, {}, entry, 'Style', 12, 'search'),
+ {
+ itemId: 'style.theme',
+ kind: 'action',
+ icon: '',
+ label: 'Theme picker',
+ target: 'style.theme',
+ detail: 'Style',
+ path: 'Style › Theme picker',
+ childCount: 0,
+ action: 'custom-theme',
+ provider: '',
+ score: 12,
+ section: 'search'
+ },
+ 'menu builds display rows'
+)
+JS
diff --git a/test/shell.d/monitor-test.sh b/test/shell.d/monitor-test.sh
new file mode 100644
index 00000000..3586e8e4
--- /dev/null
+++ b/test/shell.d/monitor-test.sh
@@ -0,0 +1,39 @@
+#!/bin/bash
+
+set -euo pipefail
+
+source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
+
+run_node_test <<'JS'
+const monitor = requireFromRoot('shell/plugins/monitor/MonitorModel.js')
+
+assertEqual(monitor.clampBrightness(0), 1, 'monitor clamps minimum brightness')
+assertEqual(monitor.clampBrightness(101), 100, 'monitor clamps maximum brightness')
+assertEqual(monitor.clampBrightness(42.4), 42, 'monitor rounds brightness')
+assertEqual(monitor.clampBrightness('nope'), 1, 'monitor rejects invalid brightness')
+
+assertEqual(monitor.normalizeScale('1.250'), '1.25', 'monitor normalizes fractional scale')
+assertEqual(monitor.normalizeScale('nope'), '', 'monitor rejects invalid scale')
+
+assertEqual(monitor.brightnessName(96), 'Sun blast', 'monitor names very bright displays')
+assertEqual(monitor.brightnessName(12), 'Candlelit', 'monitor names dim displays')
+
+assertDeepEqual(
+ monitor.parseDisplays(JSON.stringify([
+ { name: 'eDP-1', enabled: true },
+ { name: 'HDMI-A-1', enabled: false },
+ { name: 'DP-1', enabled: true }
+ ])),
+ {
+ displays: [
+ { name: 'eDP-1', enabled: true },
+ { name: 'HDMI-A-1', enabled: false },
+ { name: 'DP-1', enabled: true }
+ ],
+ enabledDisplayCount: 2
+ },
+ 'monitor parses display state'
+)
+
+assertDeepEqual(monitor.parseDisplays('{'), { displays: [], enabledDisplayCount: 0 }, 'monitor handles invalid display JSON')
+JS
diff --git a/test/shell.d/network-test.sh b/test/shell.d/network-test.sh
new file mode 100644
index 00000000..c671f620
--- /dev/null
+++ b/test/shell.d/network-test.sh
@@ -0,0 +1,53 @@
+#!/bin/bash
+
+set -euo pipefail
+
+source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
+
+run_node_test <<'JS'
+const network = requireFromRoot('shell/plugins/network/NetworkModel.js')
+
+assertDeepEqual(
+ network.parseNetworkStatus('wifi\tCafe WiFi\t78\t5200\n'),
+ { kind: 'wifi', label: 'Cafe WiFi', signalStrength: 78, frequency: '5200' },
+ 'network parses bar status'
+)
+assertEqual(network.connectionIcon('wifi', 80), network.wifiIconFor(80), 'network maps wifi icon from signal')
+assertEqual(network.formatHeaderSpeed('1000'), '1gbit', 'network formats gigabit speed')
+assertEqual(network.formatHeaderSpeed('2500'), '2.5gbit', 'network formats fractional gigabit speed')
+assertEqual(network.formatHeaderFreq('5200'), '5.2ghz', 'network formats wifi frequency')
+assertEqual(network.headerDetail({ type: 'ethernet', speed: '100' }), '100mbit', 'network header uses ethernet speed')
+
+assertDeepEqual(
+ network.parseKeyValue('iface\twlan0\nrx_bytes\t100\ntx_bytes\t50\n'),
+ { iface: 'wlan0', rx_bytes: '100', tx_bytes: '50' },
+ 'network parses detail key values'
+)
+assertDeepEqual(
+ network.throughputState({ prevIface: '', prevSampleTime: 0 }, { iface: 'wlan0', rx_bytes: '100', tx_bytes: '50' }, 10),
+ { prevIface: 'wlan0', prevRxBytes: 100, prevTxBytes: 50, prevSampleTime: 10, downloadRate: 0, uploadRate: 0 },
+ 'network seeds throughput state on first sample'
+)
+assertDeepEqual(
+ network.throughputState({ prevIface: 'wlan0', prevRxBytes: 100, prevTxBytes: 50, prevSampleTime: 10 }, { iface: 'wlan0', rx_bytes: '300', tx_bytes: '90' }, 12),
+ { prevIface: 'wlan0', prevRxBytes: 300, prevTxBytes: 90, prevSampleTime: 12, downloadRate: 100, uploadRate: 20 },
+ 'network computes throughput deltas'
+)
+
+assertEqual(network.formatBytes(1536), '1.5 KB', 'network formats bytes')
+assertEqual(network.formatRate(1536), '1.5 KB/s', 'network formats rates')
+
+const rows = network.sortWifiRows([
+ { ssid: 'Open', connected: false, known: false, signal: 95 },
+ { ssid: 'Known', connected: false, known: true, signal: 10 },
+ { ssid: 'Connected', connected: true, known: true, signal: 20 }
+])
+assertDeepEqual(rows.map(row => row.ssid), ['Connected', 'Known', 'Open'], 'network sorts wifi rows by connection and known state')
+assertEqual(network.wifiSectionTitle(rows, 0), 'KNOWN NETWORKS', 'network labels known wifi section')
+assertEqual(network.wifiSectionTitle(rows, 2), 'OTHER NETWORKS', 'network labels other wifi section')
+
+const reasons = { NoSecrets: 1, WifiAuthTimeout: 2, WifiNetworkLost: 3, WifiClientDisconnected: 4, WifiClientFailed: 5 }
+assertEqual(network.networkFailureReason(1, reasons), 'Passphrase required', 'network maps missing passphrase failures')
+assertEqual(network.networkFailureReason(2, reasons), 'Wrong password', 'network maps auth timeout failures')
+assertEqual(network.networkFailureReason(99, reasons), 'Failed to connect', 'network maps unknown failures')
+JS
diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh
new file mode 100644
index 00000000..817f806f
--- /dev/null
+++ b/test/shell.d/notifications-test.sh
@@ -0,0 +1,125 @@
+#!/bin/bash
+
+set -euo pipefail
+
+source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
+
+run_node_test <<'JS'
+const notifications = requireFromRoot('shell/plugins/notifications/NotificationLogic.js')
+
+assert(notifications.isChromiumDerived('Brave Browser', ''), 'notifications detect chromium-derived apps by name')
+assert(notifications.isChromiumDerived('', 'microsoft-edge'), 'notifications detect chromium-derived apps by icon')
+assert(!notifications.isChromiumDerived('Slack', ''), 'notifications do not treat unrelated apps as chromium-derived')
+
+assertEqual(
+ notifications.sanitizeBody('
Hello', 'Slack', ''),
+ 'Hello',
+ 'notifications strip inline image tags'
+)
+
+assertEqual(
+ notifications.sanitizeBody('example.com Message body', 'Chromium', ''),
+ 'Message body',
+ 'notifications strip chromium leading origin links'
+)
+
+assertEqual(
+ notifications.sanitizeBody('https://example.com/path Message body', 'Chromium', ''),
+ 'Message body',
+ 'notifications strip chromium leading origin text'
+)
+
+assertEqual(
+ notifications.sanitizeBody('https://example.com/path Message body', 'Slack', ''),
+ 'https://example.com/path Message body',
+ 'notifications keep non-browser leading origin text'
+)
+
+assert(notifications.summaryStartsWithGlyph(' Silenced'), 'notifications detect glyph-prefixed summaries')
+assert(!notifications.summaryStartsWithGlyph('Normal summary'), 'notifications ignore normal summaries as glyph-prefixed')
+
+assert(notifications.shouldBypassDnd({ appName: 'omarchy-action', urgency: 1 }, 2), 'omarchy action toasts bypass DND')
+assert(notifications.shouldBypassDnd({ appName: 'notify-send', urgency: 2 }, 2), 'critical notify-send bypasses DND')
+assert(!notifications.shouldBypassDnd({ appName: 'notify-send', urgency: 1 }, 2), 'normal notify-send does not bypass DND')
+assert(!notifications.shouldBypassDnd({ appName: 'Slack', urgency: 2 }, 2), 'critical app notifications do not bypass DND')
+
+const notification = {
+ id: 12,
+ appName: 'Mail',
+ appIcon: 'mail',
+ summary: 42,
+ body: 'Body',
+ image: 'file:///tmp/mail.png',
+ hints: { 'omarchy-glyph': '!' },
+ urgency: 1
+}
+const snapshot = notifications.snapshotOf(notification, 12345)
+assertDeepEqual(
+ {
+ id: snapshot.id,
+ originalId: snapshot.originalId,
+ app: snapshot.app,
+ appIcon: snapshot.appIcon,
+ summary: snapshot.summary,
+ body: snapshot.body,
+ image: snapshot.image,
+ glyph: snapshot.glyph,
+ urgency: snapshot.urgency,
+ timestamp: snapshot.timestamp
+ },
+ {
+ id: 12,
+ originalId: 12,
+ app: 'Mail',
+ appIcon: 'mail',
+ summary: '42',
+ body: 'Body',
+ image: 'file:///tmp/mail.png',
+ glyph: '!',
+ urgency: 1,
+ timestamp: 12345
+ },
+ 'notifications create stable snapshots'
+)
+
+const history = notifications.parseHistory(JSON.stringify({
+ dnd: true,
+ pending: [
+ { id: 1, originalId: 10, summary: 'old', timestamp: 100 },
+ { id: 2, originalId: 10, summary: 'new', timestamp: 200 },
+ { id: 3, originalId: 11, summary: 'other', timestamp: 150 }
+ ],
+ past: [
+ { id: 4, summary: 'past', timestamp: 50 }
+ ],
+ entries: [
+ { id: 5, summary: 'legacy', timestamp: 75 }
+ ]
+}), 1, 100)
+
+assertEqual(history.dnd, true, 'notifications parse persisted DND state')
+assertEqual(history.hadDuplicates, true, 'notifications report duplicate history rows')
+assertDeepEqual(
+ history.pending.map(row => ({ id: row.id, originalId: row.originalId, summary: row.summary, urgency: row.urgency, timestamp: row.timestamp })),
+ [
+ { id: 2, originalId: 10, summary: 'new', urgency: 1, timestamp: 200 },
+ { id: 3, originalId: 11, summary: 'other', urgency: 1, timestamp: 150 }
+ ],
+ 'notifications dedupe pending history by original id'
+)
+assertDeepEqual(
+ history.past.map(row => row.summary),
+ ['legacy', 'past'],
+ 'notifications merge legacy entries into past history'
+)
+assertDeepEqual(
+ notifications.parseHistory(JSON.stringify({ pending: [{ id: 1, timestamp: 1 }] }), 1, 0).pending,
+ [],
+ 'notifications history parser supports zero result cap'
+)
+assert(notifications.parseHistory('{', 1, 100).error, 'notifications flag invalid history JSON')
+
+assertEqual(notifications.imageExtension('/tmp/screenshot.PNG'), 'png', 'notifications normalize image extensions')
+assertEqual(notifications.imageExtension('/tmp/no-extension'), 'png', 'notifications default missing image extension')
+assertEqual(notifications.imageExtension('/tmp/archive.reallylong'), 'png', 'notifications reject suspicious image extensions')
+JS
diff --git a/test/shell.d/osd-test.sh b/test/shell.d/osd-test.sh
new file mode 100644
index 00000000..e791d626
--- /dev/null
+++ b/test/shell.d/osd-test.sh
@@ -0,0 +1,41 @@
+#!/bin/bash
+
+set -euo pipefail
+
+source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
+
+run_node_test <<'JS'
+const osd = requireFromRoot('shell/plugins/osd/OsdModel.js')
+
+assertEqual(osd.iconFor('', 0), osd.iconFor('muted', 50), 'osd falls back to muted icon at zero percent')
+assertEqual(osd.iconFor('volume-high', 1), osd.iconFor('', 100), 'osd maps high volume aliases')
+assertEqual(osd.iconFor('custom-symbol', 50), 'custom-symbol', 'osd preserves unknown explicit icons')
+
+assertDeepEqual(
+ osd.stateForShow('volume', '', '75', '100', '', '800'),
+ {
+ iconKey: 'volume',
+ maxValue: 100,
+ hasProgress: true,
+ value: 75,
+ message: '75%',
+ icon: osd.iconFor('volume', 75),
+ duration: 800
+ },
+ 'osd builds progress state'
+)
+
+assertDeepEqual(
+ osd.stateForShow('media-pause', 'Paused', '', '100', '', 'nope'),
+ {
+ iconKey: 'media-pause',
+ maxValue: 100,
+ hasProgress: false,
+ value: 0,
+ message: 'Paused',
+ icon: osd.iconFor('media-pause', -1),
+ duration: 1200
+ },
+ 'osd builds message state'
+)
+JS
diff --git a/test/shell.d/plugins-test.sh b/test/shell.d/plugins-test.sh
new file mode 100644
index 00000000..0372917d
--- /dev/null
+++ b/test/shell.d/plugins-test.sh
@@ -0,0 +1,151 @@
+#!/bin/bash
+
+set -euo pipefail
+
+source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
+
+run_node_test <<'JS'
+const fs = require('fs')
+
+const pluginsDir = path.join(root, 'shell/plugins')
+const kindEntryPoints = {
+ 'bar': 'bar',
+ 'bar-widget': 'barWidget',
+ 'menu': 'menu',
+ 'overlay': 'overlay',
+ 'panel': 'panel',
+ 'service': 'service'
+}
+
+function isPlainObject(value) {
+ return !!value && typeof value === 'object' && !Array.isArray(value)
+}
+
+function walk(dir) {
+ const rows = []
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const fullPath = path.join(dir, entry.name)
+ if (entry.isDirectory()) {
+ rows.push(...walk(fullPath))
+ } else if (entry.isFile() && (entry.name === 'manifest.json' || entry.name.endsWith('.manifest.json'))) {
+ rows.push(fullPath)
+ }
+ }
+ return rows.sort()
+}
+
+function relativeFromPlugins(filePath) {
+ return path.relative(pluginsDir, filePath).split(path.sep).join('/')
+}
+
+function sourceDirForManifest(manifestPath) {
+ return path.dirname(manifestPath)
+}
+
+const errors = []
+
+function check(condition, detail) {
+ if (!condition) errors.push(detail)
+}
+
+function assertSafeEntryPoint(manifest, manifestPath, key, value) {
+ const label = `${manifest.id} ${key} entry point`
+ check(typeof value === 'string' && value.length > 0, `${label} must be a non-empty string`)
+ check(!path.isAbsolute(value), `${label} must be relative`)
+ check(!String(value).split(/[\\/]+/).includes('..'), `${label} must stay inside plugin source`)
+ check(fs.existsSync(path.join(sourceDirForManifest(manifestPath), String(value))), `${label} file must exist`)
+}
+
+const manifests = walk(pluginsDir)
+const manifestPaths = manifests.map(relativeFromPlugins)
+const manifestSet = new Set(manifestPaths)
+
+assert(manifests.length > 0, 'plugin manifests are present')
+
+for (const entry of fs.readdirSync(pluginsDir, { withFileTypes: true })) {
+ if (!entry.isDirectory() || entry.name === 'services') continue
+ check(
+ manifestSet.has(`${entry.name}/manifest.json`),
+ `top-level plugin ${entry.name} must have a manifest`
+ )
+}
+
+const serviceRoot = path.join(pluginsDir, 'services')
+for (const entry of fs.readdirSync(serviceRoot, { withFileTypes: true })) {
+ if (!entry.isDirectory()) continue
+ check(
+ manifestSet.has(`services/${entry.name}/manifest.json`),
+ `service plugin ${entry.name} must have a manifest`
+ )
+}
+
+for (const manifestPath of manifestPaths) {
+ const depth = manifestPath.split('/').length
+ check(depth >= 2 && depth <= 3, `${manifestPath} must be discoverable by PluginRegistry`)
+}
+
+const ids = new Set()
+for (const manifestPath of manifests) {
+ const relativePath = relativeFromPlugins(manifestPath)
+ let manifest = null
+ try {
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
+ } catch (error) {
+ errors.push(`${relativePath} must parse as JSON: ${error.message}`)
+ continue
+ }
+
+ check(isPlainObject(manifest), `${relativePath} must parse to an object`)
+ if (!isPlainObject(manifest)) continue
+
+ check(manifest.schemaVersion === 1, `${relativePath} must use schema version 1`)
+
+ for (const field of ['id', 'name', 'version', 'description']) {
+ check(typeof manifest[field] === 'string' && manifest[field].length > 0, `${manifest.id || relativePath} must have ${field}`)
+ }
+
+ check(String(manifest.id).startsWith('omarchy.'), `${manifest.id} must use the first-party namespace`)
+ check(!String(manifest.id).includes('/') && !String(manifest.id).includes('..'), `${manifest.id} must be safe as a plugin id`)
+ check(!ids.has(manifest.id), `${manifest.id} must be unique`)
+ ids.add(manifest.id)
+
+ check(Array.isArray(manifest.kinds) && manifest.kinds.length > 0, `${manifest.id} must declare plugin kinds`)
+ check(
+ JSON.stringify([...new Set(manifest.kinds || [])]) === JSON.stringify(manifest.kinds || []),
+ `${manifest.id} must not duplicate plugin kinds`
+ )
+ check(isPlainObject(manifest.entryPoints), `${manifest.id} must have an entryPoints object`)
+
+ for (const kind of manifest.kinds || []) {
+ check(kindEntryPoints[kind], `${manifest.id} must use supported plugin kind ${kind}`)
+ const entryPointKey = kindEntryPoints[kind]
+ check(manifest.entryPoints && manifest.entryPoints[entryPointKey], `${manifest.id} must declare ${entryPointKey} entry point`)
+ }
+
+ for (const key of Object.keys(manifest.entryPoints || {})) {
+ check(Object.values(kindEntryPoints).includes(key), `${manifest.id} entry point ${key} must be a supported key`)
+ assertSafeEntryPoint(manifest, manifestPath, key, manifest.entryPoints[key])
+ }
+
+ if (manifest.keepLoaded !== undefined) {
+ check(typeof manifest.keepLoaded === 'boolean', `${manifest.id} keepLoaded must be boolean when present`)
+ }
+
+ if ((manifest.kinds || []).includes('bar-widget')) {
+ check(isPlainObject(manifest.barWidget), `${manifest.id} must have barWidget metadata`)
+ for (const field of ['displayName', 'description', 'category']) {
+ check(
+ manifest.barWidget && typeof manifest.barWidget[field] === 'string' && manifest.barWidget[field].length > 0,
+ `${manifest.id} barWidget metadata must have ${field}`
+ )
+ }
+ check(manifest.barWidget && typeof manifest.barWidget.allowMultiple === 'boolean', `${manifest.id} barWidget allowMultiple must be boolean`)
+ }
+
+ if (relativePath.endsWith('.manifest.json')) {
+ check(JSON.stringify(manifest.kinds) === JSON.stringify(['bar-widget']), `${manifest.id} sibling manifest must be a bar widget`)
+ }
+}
+
+assert(errors.length === 0, 'plugin manifests match shell registry contract', errors.join('\n'))
+JS
diff --git a/test/shell.d/polkit-test.sh b/test/shell.d/polkit-test.sh
new file mode 100644
index 00000000..1ca2a935
--- /dev/null
+++ b/test/shell.d/polkit-test.sh
@@ -0,0 +1,30 @@
+#!/bin/bash
+
+set -euo pipefail
+
+source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
+
+run_node_test <<'JS'
+const polkit = requireFromRoot('shell/plugins/polkit/PolkitModel.js')
+
+assert(polkit.promptLooksFingerprint('Swipe your finger'), 'polkit detects fingerprint prompts')
+assert(polkit.promptLooksFingerprint('fprintd verification'), 'polkit detects fprint prompts')
+assert(!polkit.promptLooksFingerprint('Password:'), 'polkit ignores password prompts')
+
+assert(
+ polkit.fingerprintFirstFromPamConfig(`
+# comment
+auth sufficient pam_fprintd.so
+auth include system-auth
+`),
+ 'polkit detects fingerprint-first PAM config'
+)
+assert(
+ !polkit.fingerprintFirstFromPamConfig(`
+account include system-auth
+auth include system-auth
+auth sufficient pam_fprintd.so
+`),
+ 'polkit detects password-first PAM config'
+)
+JS
diff --git a/test/shell.d/power-test.sh b/test/shell.d/power-test.sh
new file mode 100644
index 00000000..ca7b02e7
--- /dev/null
+++ b/test/shell.d/power-test.sh
@@ -0,0 +1,30 @@
+#!/bin/bash
+
+set -euo pipefail
+
+source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
+
+run_node_test <<'JS'
+const power = requireFromRoot('shell/plugins/power/PowerModel.js')
+const states = { Charging: 1, Discharging: 2, FullyCharged: 3, PendingCharge: 4 }
+
+assertEqual(power.selectProfileIndex(0, 1, ['balanced', 'performance']), 1, 'power advances profile selection')
+assertEqual(power.selectProfileIndex(1, 1, ['balanced', 'performance']), 1, 'power clamps profile selection')
+
+assertDeepEqual(power.parseKeyValue('time\t2:00\nenergy\t42\n'), { time: '2:00', energy: '42' }, 'power parses key-value output')
+assertDeepEqual(
+ power.parseProfiles('power-saver\t0\nbalanced\t1\nperformance\t0\n', 5),
+ { profiles: ['power-saver', 'balanced', 'performance'], activeProfile: 'balanced', profileIndex: 2 },
+ 'power parses profile output and clamps selection'
+)
+
+assert(power.profileIcon('performance').length > 0, 'power maps profile icons')
+assertEqual(power.batteryFraction({ isPresent: true, percentage: 1.5 }), 1, 'power clamps battery fraction')
+
+assert(power.chargeThresholdActive({ isPresent: true, percentage: 0.8, state: states.PendingCharge }, false, states), 'power detects threshold by pending charge state')
+assert(power.chargeThresholdActive({ isPresent: true, percentage: 0.8, state: states.Charging, changeRate: 0.1, timeToFull: 120 }, false, states), 'power detects threshold by stalled charging')
+assert(!power.chargeThresholdActive({ isPresent: true, percentage: 0.8, state: states.Charging, changeRate: 1.0, timeToFull: 120 }, false, states), 'power does not flag active charging as threshold')
+assertEqual(power.modeLabel({ isPresent: true, percentage: 1, state: states.FullyCharged }, false, states), 'Fully charged', 'power labels full battery')
+assertEqual(power.modeLabel({ isPresent: true, percentage: 0.5, state: states.Discharging }, true, states), 'On battery', 'power labels battery mode')
+assert(power.batteryIcon({ isPresent: true, percentage: 0.4, state: states.Charging }, false, states).length > 0, 'power maps battery icons')
+JS
diff --git a/test/shell.d/reminders-test.sh b/test/shell.d/reminders-test.sh
new file mode 100644
index 00000000..4ad3c4e2
--- /dev/null
+++ b/test/shell.d/reminders-test.sh
@@ -0,0 +1,34 @@
+#!/bin/bash
+
+set -euo pipefail
+
+source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
+
+run_node_test <<'JS'
+const reminders = requireFromRoot('shell/plugins/reminders/ReminderFlowModel.js')
+
+assertEqual(reminders.validMinutes('15'), '15', 'reminders accepts positive integer minutes')
+assertEqual(reminders.validMinutes(' 5 '), '5', 'reminders trims minute input')
+assertEqual(reminders.validMinutes('0'), '', 'reminders rejects zero minutes')
+assertEqual(reminders.validMinutes('-5'), '', 'reminders rejects negative minutes')
+assertEqual(reminders.validMinutes('1.5'), '', 'reminders rejects fractional minutes')
+assertEqual(reminders.validMinutes('soon'), '', 'reminders rejects non-numeric minutes')
+
+assertDeepEqual(
+ reminders.reminderArgs('10', 'Check the oven'),
+ ['10', 'Check the oven'],
+ 'reminders builds command args with message'
+)
+
+assertDeepEqual(
+ reminders.reminderArgs('10', ''),
+ ['10'],
+ 'reminders omits empty message arg'
+)
+
+assertDeepEqual(
+ reminders.reminderArgs('0', 'ignored'),
+ [],
+ 'reminders command args are empty for invalid minutes'
+)
+JS
diff --git a/test/shell.d/weather-test.sh b/test/shell.d/weather-test.sh
new file mode 100644
index 00000000..bb106afd
--- /dev/null
+++ b/test/shell.d/weather-test.sh
@@ -0,0 +1,63 @@
+#!/bin/bash
+
+set -euo pipefail
+
+source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
+
+run_node_test <<'JS'
+const weather = requireFromRoot('shell/plugins/weather/WeatherModel.js')
+
+assertDeepEqual(
+ weather.parseWeatherStatus('{"text":"☀","class":"sunny"}'),
+ { label: '☀', klass: 'sunny' },
+ 'weather parses pill status JSON'
+)
+assertDeepEqual(weather.parseWeatherStatus('{'), { label: '', klass: '' }, 'weather handles invalid pill status JSON')
+
+assertEqual(weather.roundedTemp('21.6'), '22', 'weather rounds temperatures')
+assertEqual(weather.roundedTemp('nope'), '', 'weather ignores invalid temperatures')
+assertEqual(weather.formatTemp(72, true), '72°F', 'weather formats imperial temperatures')
+assertEqual(weather.formatTemp(22, false), '22°C', 'weather formats metric temperatures')
+assertEqual(weather.dayName('2026-05-25'), 'Monday', 'weather derives day names')
+
+const openMeteo = {
+ daily: {
+ time: ['2026-05-25', '2026-05-26', '2026-05-27', '2026-05-28', '2026-05-29'],
+ temperature_2m_max: [20.1, 21.6, 18.2, 17.9, 22.4],
+ temperature_2m_min: [12.2, 13.1, 10.8, 9.2, 11.5],
+ weather_code: [0, 63, 95, 3, 1]
+ }
+}
+
+assertDeepEqual(
+ weather.openMeteoForecastDays(openMeteo, '2026-05-25').map(day => ({
+ date: day.date,
+ maxtempC: day.maxtempC,
+ mintempF: day.mintempF,
+ code: day.openMeteoWeatherCode
+ })),
+ [
+ { date: '2026-05-26', maxtempC: '22', mintempF: '56', code: 63 },
+ { date: '2026-05-27', maxtempC: '18', mintempF: '51', code: 95 },
+ { date: '2026-05-28', maxtempC: '18', mintempF: '49', code: 3 }
+ ],
+ 'weather builds future Open-Meteo forecast days'
+)
+
+const wttr = {
+ weather: [
+ { date: '2026-05-25', maxtempC: '20', mintempC: '12' },
+ { date: '2026-05-26', maxtempC: '22', mintempC: '13' }
+ ]
+}
+assertEqual(weather.buildForecastDays(wttr, {}, '2026-05-25')[0].date, '2026-05-26', 'weather falls back to wttr forecast')
+assertEqual(weather.bareTempForDay({ maxtempC: '22', mintempC: '13', maxtempF: '72', mintempF: '55' }, 'max', false), '22°', 'weather formats forecast metric highs')
+assertEqual(weather.bareTempForDay({ maxtempC: '22', mintempC: '13', maxtempF: '72', mintempF: '55' }, 'min', true), '55°', 'weather formats forecast imperial lows')
+
+assert(weather.dayIcon({ openMeteoWeatherCode: 95 }).length > 0, 'weather maps Open-Meteo weather icons')
+assertEqual(
+ weather.dayIcon({ hourly: [{ time: '900', weatherCode: 113 }, { time: '1200', weatherCode: 389 }, { time: '1800', weatherCode: 116 }] }),
+ weather.iconForCode(389, false),
+ 'weather picks hourly forecast icon nearest noon'
+)
+JS