Add shell plugin model tests
This commit is contained in:
@@ -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:
|
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
|
- `./test/all` - aggregate runner for CLI and shell tests
|
||||||
- `bash test/shell.sh` - all Omarchy shell tests under `test/shell/`
|
- `./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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import Quickshell.Services.Mpris
|
|||||||
import Quickshell.Services.Pipewire
|
import Quickshell.Services.Pipewire
|
||||||
import qs.Ui
|
import qs.Ui
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
|
import "AudioModel.js" as AudioModel
|
||||||
|
|
||||||
Panel {
|
Panel {
|
||||||
id: root
|
id: root
|
||||||
@@ -61,23 +62,11 @@ Panel {
|
|||||||
// playback streams consistently accept audio input from clients and publish
|
// playback streams consistently accept audio input from clients and publish
|
||||||
// `isSink: true`; capture streams publish as stream sources.
|
// `isSink: true`; capture streams publish as stream sources.
|
||||||
function isPlaybackStream(node) {
|
function isPlaybackStream(node) {
|
||||||
if (!node || !node.isStream) return false
|
return AudioModel.isPlaybackStream(node)
|
||||||
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) {
|
function isAudioSource(node) {
|
||||||
if (!node) return false
|
return AudioModel.isAudioSource(node)
|
||||||
if (node.audio) return true
|
|
||||||
|
|
||||||
var mediaClass = String(node.type || "")
|
|
||||||
return mediaClass.indexOf("Audio/Source") !== -1
|
|
||||||
|| mediaClass.indexOf("AudioSource") !== -1
|
|
||||||
|| mediaClass.indexOf("Source") !== -1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
property var cachedAudioSinks: []
|
property var cachedAudioSinks: []
|
||||||
@@ -274,7 +263,7 @@ Panel {
|
|||||||
onAudioStreamsChanged: scheduleDisplayAudioModelRefresh()
|
onAudioStreamsChanged: scheduleDisplayAudioModelRefresh()
|
||||||
|
|
||||||
function listSnapshot(list) {
|
function listSnapshot(list) {
|
||||||
return list && list.slice ? list.slice() : []
|
return AudioModel.listSnapshot(list)
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshDisplayAudioModels() {
|
function refreshDisplayAudioModels() {
|
||||||
@@ -365,16 +354,7 @@ Panel {
|
|||||||
// panel's brightnessName ladder; bands are wide enough that small
|
// panel's brightnessName ladder; bands are wide enough that small
|
||||||
// tweaks don't rename the room you're in.
|
// tweaks don't rename the room you're in.
|
||||||
function outputVolumeName(volume, muted) {
|
function outputVolumeName(volume, muted) {
|
||||||
if (muted) return "Muted"
|
return AudioModel.outputVolumeName(volume, 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 setOutputVolume(v) {
|
function setOutputVolume(v) {
|
||||||
@@ -426,203 +406,83 @@ Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateSinkAvailability(raw) {
|
function updateSinkAvailability(raw) {
|
||||||
var next = {}
|
sinkAvailability = AudioModel.parseSinkAvailability(raw)
|
||||||
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
|
|
||||||
sinkAvailabilityLoaded = true
|
sinkAvailabilityLoaded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function friendlyDeviceLabel(text) {
|
function friendlyDeviceLabel(text) {
|
||||||
var label = String(text || "").trim()
|
return AudioModel.friendlyDeviceLabel(text)
|
||||||
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 nodeLabel(node) {
|
function nodeLabel(node) {
|
||||||
if (!node) return "Unknown"
|
return AudioModel.nodeLabel(node)
|
||||||
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 nodeProps(node) {
|
function nodeProps(node) {
|
||||||
return node && node.ready && node.properties ? node.properties : {}
|
return AudioModel.nodeProps(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHeadphones(node) {
|
function isHeadphones(node) {
|
||||||
if (!node) return false
|
return AudioModel.isHeadphones(node)
|
||||||
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) {
|
function sinkGlyph(node) {
|
||||||
if (!node) return ""
|
return AudioModel.sinkGlyph(node)
|
||||||
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) {
|
function sourceGlyph(node) {
|
||||||
if (!node) return ""
|
return AudioModel.sourceGlyph(node)
|
||||||
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) {
|
function friendlyStreamLabel(label) {
|
||||||
label = String(label || "").trim()
|
return AudioModel.friendlyStreamLabel(label)
|
||||||
if (!label) return ""
|
|
||||||
|
|
||||||
var known = {
|
|
||||||
"spotify": "Spotify"
|
|
||||||
}
|
|
||||||
var normalized = label.toLowerCase()
|
|
||||||
return known[normalized] || label
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function streamLabelKey(label) {
|
function streamLabelKey(label) {
|
||||||
return String(label || "").trim().toLowerCase()
|
return AudioModel.streamLabelKey(label)
|
||||||
}
|
}
|
||||||
|
|
||||||
function streamLabelIsGeneric(label) {
|
function streamLabelIsGeneric(label) {
|
||||||
return streamLabelKey(label) === "audio-src"
|
return AudioModel.streamLabelIsGeneric(label)
|
||||||
}
|
}
|
||||||
|
|
||||||
function rawStreamLabel(node) {
|
function rawStreamLabel(node) {
|
||||||
if (!node) return ""
|
return AudioModel.rawStreamLabel(node)
|
||||||
var p = nodeProps(node)
|
|
||||||
return p["application.name"]
|
|
||||||
|| node.description
|
|
||||||
|| p["media.name"]
|
|
||||||
|| p["node.name"]
|
|
||||||
|| node.name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mprisPlayerLabel(player) {
|
function mprisPlayerLabel(player) {
|
||||||
if (!player) return ""
|
return AudioModel.mprisPlayerLabel(player)
|
||||||
return friendlyStreamLabel(player.identity || player.desktopEntry || "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mprisPlayerIsProxy(player) {
|
function mprisPlayerIsProxy(player) {
|
||||||
var dbusName = String(player && player.dbusName || "").toLowerCase()
|
return AudioModel.mprisPlayerIsProxy(player)
|
||||||
var desktopEntry = String(player && player.desktopEntry || "").toLowerCase()
|
|
||||||
return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function streamRepresentsMprisPlayer(streamLabel, playerLabel) {
|
function streamRepresentsMprisPlayer(streamLabel, playerLabel) {
|
||||||
var streamKey = streamLabelKey(friendlyStreamLabel(streamLabel))
|
return AudioModel.streamRepresentsMprisPlayer(streamLabel, playerLabel)
|
||||||
var playerKey = streamLabelKey(playerLabel)
|
|
||||||
if (!streamKey || !playerKey) return false
|
|
||||||
return streamKey === playerKey
|
|
||||||
|| streamKey.indexOf(playerKey) !== -1
|
|
||||||
|| playerKey.indexOf(streamKey) !== -1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mprisLabelsFor(predicate) {
|
function mprisLabelsFor(predicate) {
|
||||||
var playingCandidates = []
|
return AudioModel.mprisLabelsFor(mprisPlayers, predicate)
|
||||||
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 ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchingMprisStreamLabel(label) {
|
function matchingMprisStreamLabel(label) {
|
||||||
if (streamLabelIsGeneric(label)) return ""
|
return AudioModel.matchingMprisStreamLabel(label, mprisPlayers)
|
||||||
return mprisLabelsFor(function(playerLabel) {
|
|
||||||
return streamRepresentsMprisPlayer(label, playerLabel)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function unmatchedMprisStreamLabel(label) {
|
function unmatchedMprisStreamLabel(label) {
|
||||||
// Spotify exposes its PipeWire stream as "audio-src". For generic stream
|
// Spotify exposes its PipeWire stream as "audio-src". For generic stream
|
||||||
// names, use the one MPRIS player not already represented by another audio
|
// names, use the one MPRIS player not already represented by another audio
|
||||||
// stream (e.g. Chromium, or ALSA apps like cliamp).
|
// stream (e.g. Chromium, or ALSA apps like cliamp).
|
||||||
if (!streamLabelIsGeneric(label)) return ""
|
return AudioModel.unmatchedMprisStreamLabel(label, mprisPlayers, displayAudioStreams)
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function streamLabel(node) {
|
function streamLabel(node) {
|
||||||
if (!node) return "Stream"
|
return AudioModel.streamLabel(node, mprisPlayers, displayAudioStreams)
|
||||||
var label = rawStreamLabel(node)
|
|
||||||
return friendlyStreamLabel(matchingMprisStreamLabel(label)
|
|
||||||
|| unmatchedMprisStreamLabel(label)
|
|
||||||
|| label) || "Stream"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function streamRepresentsPlayer(node, player) {
|
function streamRepresentsPlayer(node, player) {
|
||||||
if (!node || !player) return false
|
return AudioModel.streamRepresentsPlayer(node, player, mprisPlayers, displayAudioStreams)
|
||||||
var playerLabel = mprisPlayerLabel(player)
|
|
||||||
if (!playerLabel) return false
|
|
||||||
|
|
||||||
var label = rawStreamLabel(node)
|
|
||||||
if (!streamLabelIsGeneric(label)) return streamRepresentsMprisPlayer(label, playerLabel)
|
|
||||||
return streamRepresentsMprisPlayer(streamLabel(node), playerLabel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
implicitWidth: button.implicitWidth
|
implicitWidth: button.implicitWidth
|
||||||
|
|||||||
+13
-60
@@ -5,6 +5,7 @@ import QtQuick
|
|||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
import qs.Ui
|
import qs.Ui
|
||||||
|
import "BarModel.js" as BarModel
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
@@ -110,8 +111,7 @@ Item {
|
|||||||
readonly property int barSize: vertical ? Style.bar.sizeVertical : Style.bar.sizeHorizontal
|
readonly property int barSize: vertical ? Style.bar.sizeVertical : Style.bar.sizeHorizontal
|
||||||
|
|
||||||
function normalizePosition(value) {
|
function normalizePosition(value) {
|
||||||
var next = String(value || "").trim()
|
return BarModel.normalizePosition(value)
|
||||||
return /^(top|bottom|left|right)$/.test(next) ? next : "top"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply tray-pinning on top of the shared layout normalization so the
|
// 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,
|
// sections. The drawer's reserved space then sits next to the bar center,
|
||||||
// not stranded mid-section.
|
// not stranded mid-section.
|
||||||
function pinTrayToInner(entries, section) {
|
function pinTrayToInner(entries, section) {
|
||||||
var trayEntry = null
|
return BarModel.pinTrayToInner(entries, section)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBarConfig() {
|
function applyBarConfig() {
|
||||||
@@ -162,49 +152,27 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function entrySettings(entry) {
|
function entrySettings(entry) {
|
||||||
if (!Util.isPlainObject(entry)) return {}
|
return BarModel.entrySettings(entry)
|
||||||
var copy = {}
|
|
||||||
for (var key in entry) {
|
|
||||||
if (key === "id") continue
|
|
||||||
copy[key] = entry[key]
|
|
||||||
}
|
|
||||||
return copy
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function entryId(entry) {
|
function entryId(entry) {
|
||||||
if (typeof entry === "string") return entry
|
return BarModel.entryId(entry)
|
||||||
if (Util.isPlainObject(entry)) {
|
|
||||||
var id = entry["id"]
|
|
||||||
if (id !== undefined && id !== null && String(id) !== "") return String(id)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function moduleString(entry, key, fallback) {
|
function moduleString(entry, key, fallback) {
|
||||||
var settings = entrySettings(entry)
|
return BarModel.moduleString(entry, key, fallback)
|
||||||
var value = settings[key]
|
|
||||||
return value === undefined || value === null ? fallback : String(value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function entryIndex(entries, name) {
|
function entryIndex(entries, name) {
|
||||||
if (!Array.isArray(entries)) return -1
|
return BarModel.entryIndex(entries, name)
|
||||||
|
|
||||||
for (var i = 0; i < entries.length; i++) {
|
|
||||||
if (entryId(entries[i]) === name)
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function entriesBefore(entries, name) {
|
function entriesBefore(entries, name) {
|
||||||
var index = entryIndex(entries, name)
|
return BarModel.entriesBefore(entries, name)
|
||||||
return index <= 0 ? [] : entries.slice(0, index)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function entriesAfter(entries, name) {
|
function entriesAfter(entries, name) {
|
||||||
var index = entryIndex(entries, name)
|
return BarModel.entriesAfter(entries, name)
|
||||||
return index === -1 ? [] : entries.slice(index + 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function canonicalWidgetId(name) {
|
function canonicalWidgetId(name) {
|
||||||
@@ -212,34 +180,19 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function expandPath(path) {
|
function expandPath(path) {
|
||||||
var value = String(path || "")
|
return BarModel.expandPath(path, home)
|
||||||
if (value === "") return ""
|
|
||||||
if (value.indexOf("~/") === 0) return home + value.substring(1)
|
|
||||||
if (value.indexOf("$HOME/") === 0) return home + value.substring(5)
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function customModuleSafeName(name) {
|
function customModuleSafeName(name) {
|
||||||
var value = String(name || "")
|
return BarModel.customModuleSafeName(name)
|
||||||
return value !== "" && value.indexOf("..") === -1 && value[0] !== "/"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function customModuleType(entry) {
|
function customModuleType(entry) {
|
||||||
var settings = entrySettings(entry)
|
return BarModel.customModuleType(entry)
|
||||||
var type = String(settings.type || "")
|
|
||||||
if (type) return type
|
|
||||||
if (settings.exec) return "command"
|
|
||||||
if (settings.source) return "qml"
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function customModuleSource(entry) {
|
function customModuleSource(entry) {
|
||||||
var settings = entrySettings(entry)
|
var source = BarModel.customModulePath(entry, home, omarchyConfigDir)
|
||||||
var name = entryId(entry)
|
|
||||||
var source = settings.source ? expandPath(settings.source) : ""
|
|
||||||
if (!source && customModuleSafeName(name))
|
|
||||||
source = omarchyConfigDir + "/bar/modules/" + String(name) + ".qml"
|
|
||||||
|
|
||||||
return source ? Util.fileUrl(source) : ""
|
return source ? Util.fileUrl(source) : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import Quickshell.Io
|
|||||||
import Quickshell.Bluetooth
|
import Quickshell.Bluetooth
|
||||||
import qs.Ui
|
import qs.Ui
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
|
import "BluetoothModel.js" as BluetoothModel
|
||||||
|
|
||||||
Panel {
|
Panel {
|
||||||
id: root
|
id: root
|
||||||
@@ -20,66 +21,25 @@ Panel {
|
|||||||
readonly property var devices: Bluetooth.devices ? Bluetooth.devices.values : []
|
readonly property var devices: Bluetooth.devices ? Bluetooth.devices.values : []
|
||||||
|
|
||||||
function deviceLabel(device) {
|
function deviceLabel(device) {
|
||||||
if (!device) return ""
|
return BluetoothModel.deviceLabel(device)
|
||||||
return String(device.deviceName || device.name || "").trim()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUuidLike(value) {
|
function isUuidLike(value) {
|
||||||
var text = (value || "").trim()
|
return BluetoothModel.isUuidLike(value)
|
||||||
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) {
|
function isAddressLike(value) {
|
||||||
var text = (value || "").trim()
|
return BluetoothModel.isAddressLike(value)
|
||||||
return /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasHumanName(device) {
|
function hasHumanName(device) {
|
||||||
var label = deviceLabel(device)
|
return BluetoothModel.hasHumanName(device)
|
||||||
return label !== "" && !isUuidLike(label) && !isAddressLike(label)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property var connectedDevices: {
|
readonly property var deviceGroups: BluetoothModel.deviceLists(devices)
|
||||||
var list = []
|
readonly property var connectedDevices: deviceGroups.connected || []
|
||||||
for (var i = 0; i < devices.length; i++) {
|
readonly property var knownDevices: deviceGroups.known || []
|
||||||
var d = devices[i]
|
readonly property var discoveredDevices: deviceGroups.discovered || []
|
||||||
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 string icon: {
|
readonly property string icon: {
|
||||||
if (!adapter) return ""
|
if (!adapter) return ""
|
||||||
@@ -145,18 +105,11 @@ Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
readonly property var visibleSections: {
|
readonly property var visibleSections: {
|
||||||
var list = []
|
return BluetoothModel.visibleSections(deviceGroups, adapter && adapter.discovering)
|
||||||
if (sectionVisible("connected")) list.push("connected")
|
|
||||||
if (sectionVisible("known")) list.push("known")
|
|
||||||
if (sectionVisible("discovered")) list.push("discovered")
|
|
||||||
return list
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function devicesForSection(section) {
|
function devicesForSection(section) {
|
||||||
if (section === "connected") return connectedDevices
|
return BluetoothModel.sectionDevices(deviceGroups, section)
|
||||||
if (section === "known") return knownDevices
|
|
||||||
if (section === "discovered") return discoveredDevices
|
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deviceAt(section, index) {
|
function deviceAt(section, index) {
|
||||||
@@ -165,21 +118,16 @@ Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cloneMap(map) {
|
function cloneMap(map) {
|
||||||
var next = ({})
|
return BluetoothModel.cloneMap(map)
|
||||||
for (var key in map) next[key] = map[key]
|
|
||||||
return next
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pendingAction(address) {
|
function pendingAction(address) {
|
||||||
return address && pendingActions[address] ? pendingActions[address] : ""
|
return BluetoothModel.pendingAction(pendingActions, address)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPendingAction(address, action) {
|
function setPendingAction(address, action) {
|
||||||
if (!address) return
|
if (!address) return
|
||||||
var next = cloneMap(pendingActions)
|
pendingActions = BluetoothModel.withPendingAction(pendingActions, address, action)
|
||||||
if (action) next[address] = action
|
|
||||||
else delete next[address]
|
|
||||||
pendingActions = next
|
|
||||||
if (action) pendingTimeout.restart()
|
if (action) pendingTimeout.restart()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import Quickshell.Io
|
|||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
|
import "ClipboardHistory.js" as ClipboardHistory
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
@@ -55,48 +56,15 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeEntry(value) {
|
function normalizeEntry(value) {
|
||||||
if (typeof value === "string") {
|
return ClipboardHistory.normalizeEntry(value)
|
||||||
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) {
|
function entryKey(entry) {
|
||||||
if (!entry) return ""
|
return ClipboardHistory.entryKey(entry)
|
||||||
if (entry.type === "image") return "image:" + String(entry.path || "")
|
|
||||||
return "text:" + String(entry.text || "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadHistory(raw) {
|
function loadHistory(raw) {
|
||||||
try {
|
root.history = ClipboardHistory.parseHistory(raw)
|
||||||
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 = []
|
|
||||||
}
|
|
||||||
if (root.opened) root.rebuildDisplay()
|
if (root.opened) root.rebuildDisplay()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,52 +73,33 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addClipboardEntry(entry) {
|
function addClipboardEntry(entry) {
|
||||||
var normalized = root.normalizeEntry(entry)
|
var normalized = ClipboardHistory.normalizeEntry(entry)
|
||||||
if (!normalized) return
|
if (!normalized) return
|
||||||
|
|
||||||
var key = root.entryKey(normalized)
|
root.history = ClipboardHistory.addEntry(root.history, normalized, 100)
|
||||||
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.saveHistory()
|
root.saveHistory()
|
||||||
if (root.opened) root.rebuildDisplay()
|
if (root.opened) root.rebuildDisplay()
|
||||||
}
|
}
|
||||||
|
|
||||||
function addClipboardJson(line) {
|
function addClipboardJson(line) {
|
||||||
var raw = String(line || "").trim()
|
root.addClipboardEntry(ClipboardHistory.parseEntryJson(line))
|
||||||
if (!raw) return
|
|
||||||
try { root.addClipboardEntry(JSON.parse(raw)) } catch (e) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rebuildDisplay() {
|
function rebuildDisplay() {
|
||||||
var query = root.filterText.trim().toLowerCase()
|
var rows = ClipboardHistory.displayRows(root.history, root.filterText, 50)
|
||||||
|
|
||||||
displayModel.clear()
|
displayModel.clear()
|
||||||
var outCount = 0
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
var row = rows[i]
|
||||||
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
|
|
||||||
|
|
||||||
displayModel.append({
|
displayModel.append({
|
||||||
entryType: entry.type,
|
entryType: row.entryType,
|
||||||
fullText: isImage ? "" : String(entry.text || ""),
|
fullText: row.fullText,
|
||||||
previewText: isImage ? "Image" : String(entry.text || "").replace(/\s+/g, " "),
|
previewText: row.previewText,
|
||||||
previewImage: isImage ? Util.fileUrl(entry.path) : "",
|
previewImage: row.previewImage ? Util.fileUrl(row.previewImage) : "",
|
||||||
path: isImage ? String(entry.path || "") : "",
|
path: row.path,
|
||||||
mime: isImage ? String(entry.mime || "image/png") : "text/plain",
|
mime: row.mime,
|
||||||
index: outCount
|
index: row.index
|
||||||
})
|
})
|
||||||
outCount++
|
|
||||||
if (outCount >= 50) break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (displayModel.count === 0) selectedIndex = 0
|
if (displayModel.count === 0) selectedIndex = 0
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import QtQuick
|
|||||||
import QtQuick.Effects
|
import QtQuick.Effects
|
||||||
import QtQuick.Shapes
|
import QtQuick.Shapes
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
|
import "ImagePickerModel.js" as ImagePickerModel
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
@@ -70,11 +71,11 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function nameForPath(path) {
|
function nameForPath(path) {
|
||||||
return path.split("/").pop().replace(/\.[^/.]+$/, "")
|
return ImagePickerModel.nameForPath(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function labelForPath(path) {
|
function labelForPath(path) {
|
||||||
return nameForPath(path).replace(/[-_]+/g, " ").replace(/\b\w/g, function(match) { return match.toUpperCase() })
|
return ImagePickerModel.labelForPath(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentLabel() {
|
function currentLabel() {
|
||||||
@@ -85,37 +86,19 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function itemMatches(index) {
|
function itemMatches(index) {
|
||||||
if (index < 0 || index >= imageArray.length) return false
|
return ImagePickerModel.itemMatches(imageArray, index, filterText)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstMatchingIndex() {
|
function firstMatchingIndex() {
|
||||||
for (var i = 0; i < imageArray.length; i++) {
|
return ImagePickerModel.firstMatchingIndex(imageArray, filterText)
|
||||||
if (itemMatches(i)) return i
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function filteredPosition(index) {
|
function filteredPosition(index) {
|
||||||
if (!filterText) return index
|
return ImagePickerModel.filteredPosition(imageArray, index, filterText)
|
||||||
|
|
||||||
var position = 0
|
|
||||||
for (var i = 0; i < index; i++) {
|
|
||||||
if (itemMatches(i)) position++
|
|
||||||
}
|
|
||||||
|
|
||||||
return position
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedFilteredPosition() {
|
function selectedFilteredPosition() {
|
||||||
if (!filterText) return selectedIndex
|
return ImagePickerModel.selectedFilteredPosition(imageArray, selectedIndex, filterText)
|
||||||
|
|
||||||
return itemMatches(selectedIndex) ? filteredPosition(selectedIndex) : 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function select(index, immediate) {
|
function select(index, immediate) {
|
||||||
@@ -146,7 +129,7 @@ Item {
|
|||||||
filterText = nextFilterText
|
filterText = nextFilterText
|
||||||
|
|
||||||
if (!itemMatches(selectedIndex)) {
|
if (!itemMatches(selectedIndex)) {
|
||||||
var first = firstMatchingIndex()
|
var first = ImagePickerModel.nextSelectedIndexForFilter(imageArray, selectedIndex, filterText)
|
||||||
if (first >= 0) selectedIndex = first
|
if (first >= 0) selectedIndex = first
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,25 +193,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadRows(rows, reveal) {
|
function loadRows(rows, reveal) {
|
||||||
var newImages = []
|
var newImages = ImagePickerModel.loadRows(rows)
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
root.loadedImageRows = rows
|
root.loadedImageRows = rows
|
||||||
root.selectedIndex = root.indexForSelectedImage(newImages)
|
root.selectedIndex = root.indexForSelectedImage(newImages)
|
||||||
@@ -304,12 +269,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function indexForSelectedImage(images) {
|
function indexForSelectedImage(images) {
|
||||||
for (var i = 0; i < images.length; i++) {
|
return ImagePickerModel.indexForSelectedImage(images, selectedImage)
|
||||||
if (images[i].filePath === selectedImage)
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedImageIndex() {
|
function selectedImageIndex() {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
function nameForPath(path) {
|
||||||
|
return String(path || "").split("/").pop().replace(/\.[^/.]+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelForPath(path) {
|
||||||
|
return nameForPath(path).replace(/[-_]+/g, " ").replace(/\b\w/g, function(match) { return match.toUpperCase() })
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRows(rows) {
|
||||||
|
var images = []
|
||||||
|
var seen = {}
|
||||||
|
var paths = String(rows || "").split("\n")
|
||||||
|
|
||||||
|
for (var i = 0; i < paths.length; i++) {
|
||||||
|
var row = paths[i]
|
||||||
|
if (!row) continue
|
||||||
|
|
||||||
|
var columns = row.split("\t")
|
||||||
|
var path = columns[0]
|
||||||
|
if (!path) continue
|
||||||
|
|
||||||
|
var fileName = path.split("/").pop()
|
||||||
|
if (seen[fileName]) continue
|
||||||
|
seen[fileName] = true
|
||||||
|
|
||||||
|
images.push({
|
||||||
|
filePath: path,
|
||||||
|
fileName: fileName,
|
||||||
|
thumbnailPath: columns[1] || path
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemMatches(images, index, filterText) {
|
||||||
|
if (!Array.isArray(images) || index < 0 || index >= images.length) return false
|
||||||
|
var needle = String(filterText || "").toLowerCase()
|
||||||
|
if (!needle) return true
|
||||||
|
|
||||||
|
var path = String(images[index].filePath || "")
|
||||||
|
return nameForPath(path).toLowerCase().indexOf(needle) !== -1
|
||||||
|
|| labelForPath(path).toLowerCase().indexOf(needle) !== -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstMatchingIndex(images, filterText) {
|
||||||
|
var values = Array.isArray(images) ? images : []
|
||||||
|
for (var i = 0; i < values.length; i++) {
|
||||||
|
if (itemMatches(values, i, filterText)) return i
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredPosition(images, index, filterText) {
|
||||||
|
if (!filterText) return index
|
||||||
|
|
||||||
|
var position = 0
|
||||||
|
for (var i = 0; i < index; i++) {
|
||||||
|
if (itemMatches(images, i, filterText)) position++
|
||||||
|
}
|
||||||
|
|
||||||
|
return position
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedFilteredPosition(images, selectedIndex, filterText) {
|
||||||
|
if (!filterText) return selectedIndex
|
||||||
|
return itemMatches(images, selectedIndex, filterText) ? filteredPosition(images, selectedIndex, filterText) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexForSelectedImage(images, selectedImage) {
|
||||||
|
var values = Array.isArray(images) ? images : []
|
||||||
|
for (var i = 0; i < values.length; i++) {
|
||||||
|
if (values[i].filePath === selectedImage) return i
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextSelectedIndexForFilter(images, selectedIndex, filterText) {
|
||||||
|
if (itemMatches(images, selectedIndex, filterText)) return selectedIndex
|
||||||
|
return firstMatchingIndex(images, filterText)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== "undefined") {
|
||||||
|
module.exports = {
|
||||||
|
nameForPath: nameForPath,
|
||||||
|
labelForPath: labelForPath,
|
||||||
|
loadRows: loadRows,
|
||||||
|
itemMatches: itemMatches,
|
||||||
|
firstMatchingIndex: firstMatchingIndex,
|
||||||
|
filteredPosition: filteredPosition,
|
||||||
|
selectedFilteredPosition: selectedFilteredPosition,
|
||||||
|
indexForSelectedImage: indexForSelectedImage,
|
||||||
|
nextSelectedIndexForFilter: nextSelectedIndexForFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-195
@@ -3,6 +3,7 @@ import Quickshell.Io
|
|||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
|
import "MenuModel.js" as MenuModel
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
@@ -169,112 +170,35 @@ Item {
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
function stripJsonc(raw) {
|
function stripJsonc(raw) {
|
||||||
// Match what the bash bin's jq pipeline accepts: full-line `//` comments
|
return MenuModel.stripJsonc(raw)
|
||||||
// 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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAliases(value) {
|
function normalizeAliases(value) {
|
||||||
if (Array.isArray(value)) return value.filter(function(v) { return v })
|
return MenuModel.normalizeAliases(value)
|
||||||
if (typeof value === "string" && value) return [value]
|
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeKeywords(id, aliases, raw) {
|
function normalizeKeywords(id, aliases, raw) {
|
||||||
var parts = String(raw || "").split(/\s+/)
|
return MenuModel.normalizeKeywords(id, aliases, raw)
|
||||||
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) {
|
function normalizeItem(id, raw) {
|
||||||
var aliases = normalizeAliases(raw.aliases)
|
return MenuModel.normalizeItem(id, raw)
|
||||||
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 || ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMenuJsonc(raw) {
|
function parseMenuJsonc(raw) {
|
||||||
var stripped = stripJsonc(raw)
|
return MenuModel.parseMenuJsonc(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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge defaults + user extension. Later entries override earlier ones
|
// Merge defaults + user extension. Later entries override earlier ones
|
||||||
// on a per-key basis (so the user can tweak label/icon/action without
|
// on a per-key basis (so the user can tweak label/icon/action without
|
||||||
// re-declaring the whole row).
|
// re-declaring the whole row).
|
||||||
function rebuildItemsFromSources() {
|
function rebuildItemsFromSources() {
|
||||||
var nextItems = ({})
|
var mergedMenu = MenuModel.mergeMenuSources(root.defaultMenuItems, root.userMenuItems)
|
||||||
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
|
|
||||||
root.providerRevision += 1
|
root.providerRevision += 1
|
||||||
root.providersLoaded = ({})
|
root.providersLoaded = ({})
|
||||||
root.providerQueue = []
|
root.providerQueue = []
|
||||||
root.items = nextItems
|
root.items = mergedMenu.items
|
||||||
root.itemOrder = nextOrder
|
root.itemOrder = mergedMenu.itemOrder
|
||||||
root.rowsLoaded = true
|
root.rowsLoaded = true
|
||||||
root.evaluateGuards()
|
root.evaluateGuards()
|
||||||
if (root.opened) {
|
if (root.opened) {
|
||||||
@@ -305,7 +229,7 @@ Item {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function slugify(value) {
|
function slugify(value) {
|
||||||
return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item"
|
return MenuModel.slugify(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function startProviderForMenu(id) {
|
function startProviderForMenu(id) {
|
||||||
@@ -399,62 +323,23 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function depthFor(id) {
|
function depthFor(id) {
|
||||||
var depth = 0
|
return MenuModel.depthFor(root.items, id)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pathFor(id) {
|
function pathFor(id) {
|
||||||
var labels = []
|
return MenuModel.pathFor(root.items, id)
|
||||||
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(" › ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parentPathFor(id) {
|
function parentPathFor(id) {
|
||||||
var entry = root.item(id)
|
return MenuModel.parentPathFor(root.items, id)
|
||||||
if (!entry || !entry.parent || entry.parent === "root") return ""
|
|
||||||
|
|
||||||
return root.pathFor(entry.parent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDescendantOf(id, ancestorId) {
|
function isDescendantOf(id, ancestorId) {
|
||||||
if (ancestorId === "root") return id !== "root"
|
return MenuModel.isDescendantOf(root.items, id, ancestorId)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function childCount(id) {
|
function childCount(id) {
|
||||||
var count = 0
|
return MenuModel.childCount(root.items, root.itemOrder, id)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Items whose `when:` evaluated to false are hidden everywhere — nav,
|
// 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.
|
// Label with the ✓ marker baked in when `checked:` evaluated truthy.
|
||||||
function labelFor(entry) {
|
function labelFor(entry) {
|
||||||
if (!entry) return ""
|
return MenuModel.labelFor(entry, root.checkedResults)
|
||||||
if (entry.checked && root.checkedResults[entry.id]) return entry.label + " ✓"
|
|
||||||
return entry.label
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function searchableToken(value) {
|
function searchableToken(value) {
|
||||||
return String(value || "").replace(/[._-]+/g, " ")
|
return MenuModel.searchableToken(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function leafIdFor(id) {
|
function leafIdFor(id) {
|
||||||
var parts = String(id || "").split(".")
|
return MenuModel.leafIdFor(id)
|
||||||
return parts.length > 0 ? parts[parts.length - 1] : id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function nameSearchText(entry) {
|
function nameSearchText(entry) {
|
||||||
if (!entry) return ""
|
return MenuModel.nameSearchText(entry)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function termInSearchWords(term, text) {
|
function termInSearchWords(term, text) {
|
||||||
var words = String(text || "").toLowerCase().split(/\s+/)
|
return MenuModel.termInSearchWords(term, text)
|
||||||
for (var i = 0; i < words.length; i++) {
|
|
||||||
if (words[i] === term) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function keywordTextMatches(query, text) {
|
function keywordTextMatches(query, text) {
|
||||||
var terms = query.toLowerCase().trim().split(/\s+/)
|
return MenuModel.keywordTextMatches(query, text)
|
||||||
for (var i = 0; i < terms.length; i++) {
|
|
||||||
if (terms[i] && !root.termInSearchWords(terms[i], text)) return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesQuery(entry, query) {
|
function matchesQuery(entry, query) {
|
||||||
if (!entry || entry.id === "root") return false
|
return MenuModel.matchesQuery(entry, query, root.isVisible(entry))
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function searchScore(entry, query) {
|
function searchScore(entry, query) {
|
||||||
var needle = query.toLowerCase().trim()
|
return MenuModel.searchScore(root.items, entry, query)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayRow(entry, detail, score, section) {
|
function displayRow(entry, detail, score, section) {
|
||||||
var target = entry.kind === "link" ? entry.target : entry.id
|
return MenuModel.displayRow(root.items, root.itemOrder, root.checkedResults, entry, detail, score, section)
|
||||||
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 || ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rebuildDmenuDisplay() {
|
function rebuildDmenuDisplay() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import Quickshell
|
|||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import qs.Ui
|
import qs.Ui
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
|
import "MonitorModel.js" as MonitorModel
|
||||||
|
|
||||||
Panel {
|
Panel {
|
||||||
id: root
|
id: root
|
||||||
@@ -202,7 +203,7 @@ Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setBrightness(value) {
|
function setBrightness(value) {
|
||||||
var percent = Math.max(1, Math.min(100, Math.round(value)))
|
var percent = MonitorModel.clampBrightness(value)
|
||||||
root.brightnessPercent = percent
|
root.brightnessPercent = percent
|
||||||
root.pendingBrightnessPercent = percent
|
root.pendingBrightnessPercent = percent
|
||||||
|
|
||||||
@@ -217,42 +218,25 @@ Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function previewBrightness(value) {
|
function previewBrightness(value) {
|
||||||
root.brightnessPercent = Math.max(1, Math.min(100, Math.round(value)))
|
root.brightnessPercent = MonitorModel.clampBrightness(value)
|
||||||
brightnessDebounce.restart()
|
brightnessDebounce.restart()
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeScale(scale) {
|
function normalizeScale(scale) {
|
||||||
var n = parseFloat(String(scale || ""))
|
return MonitorModel.normalizeScale(scale)
|
||||||
if (!isFinite(n)) return ""
|
|
||||||
return String(Math.round(n * 100) / 100)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Playful mood-name for a given brightness percent. Bands intentionally
|
// Playful mood-name for a given brightness percent. Bands intentionally
|
||||||
// span ~10–20 points so casual tweaks change the label, while small
|
// span ~10–20 points so casual tweaks change the label, while small
|
||||||
// nudges within one band don't.
|
// nudges within one band don't.
|
||||||
function brightnessName(percent) {
|
function brightnessName(percent) {
|
||||||
var p = Math.round(percent)
|
return MonitorModel.brightnessName(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 updateDisplays(displaysJson) {
|
function updateDisplays(displaysJson) {
|
||||||
try {
|
var parsed = MonitorModel.parseDisplays(displaysJson)
|
||||||
root.displays = displaysJson ? JSON.parse(displaysJson) : []
|
root.displays = parsed.displays
|
||||||
} catch(e) {
|
root.enabledDisplayCount = parsed.enabledDisplayCount
|
||||||
root.displays = []
|
|
||||||
}
|
|
||||||
|
|
||||||
var count = 0
|
|
||||||
for (var i = 0; i < root.displays.length; i++)
|
|
||||||
if (root.displays[i] && root.displays[i].enabled) count++
|
|
||||||
root.enabledDisplayCount = count
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDisplay(name, enabled) {
|
function toggleDisplay(name, enabled) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import Quickshell.Io
|
|||||||
import Quickshell.Networking
|
import Quickshell.Networking
|
||||||
import qs.Ui
|
import qs.Ui
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
|
import "NetworkModel.js" as NetworkModel
|
||||||
|
|
||||||
Panel {
|
Panel {
|
||||||
id: root
|
id: root
|
||||||
@@ -228,11 +229,11 @@ Panel {
|
|||||||
property string frequency: ""
|
property string frequency: ""
|
||||||
|
|
||||||
function updateNetwork(raw) {
|
function updateNetwork(raw) {
|
||||||
var parts = String(raw || "disconnected\t\t\t").replace(/\r?\n+$/, "").split("\t")
|
var parsed = NetworkModel.parseNetworkStatus(raw)
|
||||||
kind = parts[0] || "disconnected"
|
kind = parsed.kind
|
||||||
label = parts[1] || ""
|
label = parsed.label
|
||||||
signalStrength = parts[2] ? parseInt(parts[2], 10) : -1
|
signalStrength = parsed.signalStrength
|
||||||
frequency = parts[3] || ""
|
frequency = parsed.frequency
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToClipboard(value) {
|
function copyToClipboard(value) {
|
||||||
@@ -240,15 +241,7 @@ Panel {
|
|||||||
Quickshell.execDetached(["bash", "-lc", "printf %s " + Util.shellQuote(value) + " | wl-copy"])
|
Quickshell.execDetached(["bash", "-lc", "printf %s " + Util.shellQuote(value) + " | wl-copy"])
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property string icon: {
|
readonly property string icon: NetworkModel.connectionIcon(kind, signalStrength)
|
||||||
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 ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function refresh(scanWifi) {
|
function refresh(scanWifi) {
|
||||||
if (scanWifi === undefined) scanWifi = false
|
if (scanWifi === undefined) scanWifi = false
|
||||||
@@ -270,79 +263,47 @@ Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatHeaderSpeed(mbps) {
|
function formatHeaderSpeed(mbps) {
|
||||||
var v = parseInt(mbps, 10)
|
return NetworkModel.formatHeaderSpeed(mbps)
|
||||||
if (!v || v < 0) return ""
|
|
||||||
if (v >= 1000) return (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + "gbit"
|
|
||||||
return v + "mbit"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatHeaderFreq(mhz) {
|
function formatHeaderFreq(mhz) {
|
||||||
var v = parseFloat(mhz)
|
return NetworkModel.formatHeaderFreq(mhz)
|
||||||
if (!v) return ""
|
|
||||||
var ghz = v / 1000
|
|
||||||
return ghz.toFixed(ghz % 1 === 0 ? 0 : 1) + "ghz"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function headerDetail() {
|
function headerDetail() {
|
||||||
if (info.type === "ethernet") return formatHeaderSpeed(info.speed || "")
|
return NetworkModel.headerDetail(info)
|
||||||
if (info.type === "wifi") return formatHeaderFreq(info.freq || "")
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDetails(raw) {
|
function updateDetails(raw) {
|
||||||
var next = {}
|
var next = NetworkModel.parseKeyValue(raw)
|
||||||
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()
|
|
||||||
}
|
|
||||||
info = next
|
info = next
|
||||||
updateThroughput(next)
|
updateThroughput(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateThroughput(next) {
|
function updateThroughput(next) {
|
||||||
var iface = next.iface || ""
|
var state = NetworkModel.throughputState({
|
||||||
var rx = parseFloat(next.rx_bytes || "0")
|
prevIface: prevIface,
|
||||||
var tx = parseFloat(next.tx_bytes || "0")
|
prevRxBytes: prevRxBytes,
|
||||||
var now = Date.now() / 1000
|
prevTxBytes: prevTxBytes,
|
||||||
|
prevSampleTime: prevSampleTime,
|
||||||
|
downloadRate: downloadRate,
|
||||||
|
uploadRate: uploadRate
|
||||||
|
}, next, Date.now() / 1000)
|
||||||
|
|
||||||
// Interface changed (or first sample) — reseed without emitting a rate;
|
prevIface = state.prevIface
|
||||||
// raw counters from two different NICs are meaningless to subtract.
|
prevRxBytes = state.prevRxBytes
|
||||||
if (iface !== prevIface || prevSampleTime === 0) {
|
prevTxBytes = state.prevTxBytes
|
||||||
prevIface = iface
|
prevSampleTime = state.prevSampleTime
|
||||||
prevRxBytes = rx
|
downloadRate = state.downloadRate
|
||||||
prevTxBytes = tx
|
uploadRate = state.uploadRate
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes) {
|
||||||
var n = Number(bytes)
|
return NetworkModel.formatBytes(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) {
|
function formatRate(bytesPerSec) {
|
||||||
return formatBytes(bytesPerSec) + "/s"
|
return NetworkModel.formatRate(bytesPerSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
function findDevice(type) {
|
function findDevice(type) {
|
||||||
@@ -369,42 +330,20 @@ Panel {
|
|||||||
var network = networks[i]
|
var network = networks[i]
|
||||||
if (!network) continue
|
if (!network) continue
|
||||||
checkActionCompletion(network)
|
checkActionCompletion(network)
|
||||||
var isConnected = network.connected
|
var row = NetworkModel.wifiRow(network)
|
||||||
var ssid = network.name || ""
|
if (row) nets.push(row)
|
||||||
nets.push({
|
|
||||||
network: network,
|
|
||||||
connected: isConnected,
|
|
||||||
known: network.known,
|
|
||||||
ssid: ssid,
|
|
||||||
signal: Math.round((network.signalStrength || 0) * 100),
|
|
||||||
security: network.security
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
nets.sort(function(a, b) {
|
wifiNetworks = NetworkModel.sortWifiRows(nets)
|
||||||
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
|
|
||||||
wifiStationAvailable = !!wifiDevice
|
wifiStationAvailable = !!wifiDevice
|
||||||
scanning = false
|
scanning = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function wifiSectionTitle(index) {
|
function wifiSectionTitle(index) {
|
||||||
if (index < 0 || index >= wifiNetworks.length) return ""
|
return NetworkModel.wifiSectionTitle(wifiNetworks, index)
|
||||||
|
|
||||||
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 ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function wifiIconFor(strength) {
|
function wifiIconFor(strength) {
|
||||||
var icons = ["", "", "", "", ""]
|
return NetworkModel.wifiIconFor(strength)
|
||||||
var index = Math.max(0, Math.min(4, Math.ceil(strength / 20) - 1))
|
|
||||||
return icons[index]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDns(raw) {
|
function updateDns(raw) {
|
||||||
@@ -435,7 +374,7 @@ Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isProtected(security) {
|
function isProtected(security) {
|
||||||
return security !== WifiSecurityType.Open
|
return NetworkModel.isProtected(security, WifiSecurityType.Open)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPasswordPrompt(ssid) {
|
function openPasswordPrompt(ssid) {
|
||||||
@@ -493,12 +432,13 @@ Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function networkFailureReason(reason) {
|
function networkFailureReason(reason) {
|
||||||
if (reason === ConnectionFailReason.NoSecrets) return "Passphrase required"
|
return NetworkModel.networkFailureReason(reason, {
|
||||||
if (reason === ConnectionFailReason.WifiAuthTimeout) return "Wrong password"
|
NoSecrets: ConnectionFailReason.NoSecrets,
|
||||||
if (reason === ConnectionFailReason.WifiNetworkLost) return "Network lost"
|
WifiAuthTimeout: ConnectionFailReason.WifiAuthTimeout,
|
||||||
if (reason === ConnectionFailReason.WifiClientDisconnected) return "Disconnected"
|
WifiNetworkLost: ConnectionFailReason.WifiNetworkLost,
|
||||||
if (reason === ConnectionFailReason.WifiClientFailed) return "Connection failed"
|
WifiClientDisconnected: ConnectionFailReason.WifiClientDisconnected,
|
||||||
return "Failed to connect"
|
WifiClientFailed: ConnectionFailReason.WifiClientFailed
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkActionCompletion(network) {
|
function checkActionCompletion(network) {
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
function parseNetworkStatus(raw) {
|
||||||
|
var parts = String(raw || "disconnected\t\t\t").replace(/\r?\n+$/, "").split("\t")
|
||||||
|
return {
|
||||||
|
kind: parts[0] || "disconnected",
|
||||||
|
label: parts[1] || "",
|
||||||
|
signalStrength: parts[2] ? parseInt(parts[2], 10) : -1,
|
||||||
|
frequency: parts[3] || ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wifiIconFor(strength) {
|
||||||
|
var icons = ["", "", "", "", ""]
|
||||||
|
var index = Math.max(0, Math.min(4, Math.ceil(strength / 20) - 1))
|
||||||
|
return icons[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectionIcon(kind, signalStrength) {
|
||||||
|
if (kind === "wifi") return wifiIconFor(signalStrength)
|
||||||
|
if (kind === "ethernet") return ""
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHeaderSpeed(mbps) {
|
||||||
|
var v = parseInt(mbps, 10)
|
||||||
|
if (!v || v < 0) return ""
|
||||||
|
if (v >= 1000) return (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + "gbit"
|
||||||
|
return v + "mbit"
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHeaderFreq(mhz) {
|
||||||
|
var v = parseFloat(mhz)
|
||||||
|
if (!v) return ""
|
||||||
|
var ghz = v / 1000
|
||||||
|
return ghz.toFixed(ghz % 1 === 0 ? 0 : 1) + "ghz"
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerDetail(info) {
|
||||||
|
var value = info || {}
|
||||||
|
if (value.type === "ethernet") return formatHeaderSpeed(value.speed || "")
|
||||||
|
if (value.type === "wifi") return formatHeaderFreq(value.freq || "")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKeyValue(raw) {
|
||||||
|
var next = {}
|
||||||
|
var lines = String(raw || "").split("\n")
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
var line = lines[i]
|
||||||
|
if (!line) continue
|
||||||
|
var idx = line.indexOf("\t")
|
||||||
|
if (idx === -1) continue
|
||||||
|
next[line.substring(0, idx)] = line.substring(idx + 1).trim()
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function throughputState(previous, next, now) {
|
||||||
|
var prev = previous || {}
|
||||||
|
var sample = next || {}
|
||||||
|
var iface = sample.iface || ""
|
||||||
|
var rx = parseFloat(sample.rx_bytes || "0")
|
||||||
|
var tx = parseFloat(sample.tx_bytes || "0")
|
||||||
|
var previousTime = Number(prev.prevSampleTime || 0)
|
||||||
|
|
||||||
|
if (iface !== (prev.prevIface || "") || previousTime === 0) {
|
||||||
|
return {
|
||||||
|
prevIface: iface,
|
||||||
|
prevRxBytes: rx,
|
||||||
|
prevTxBytes: tx,
|
||||||
|
prevSampleTime: now,
|
||||||
|
downloadRate: 0,
|
||||||
|
uploadRate: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadRate = Number(prev.downloadRate || 0)
|
||||||
|
var uploadRate = Number(prev.uploadRate || 0)
|
||||||
|
var dt = now - previousTime
|
||||||
|
if (dt > 0) {
|
||||||
|
downloadRate = Math.max(0, (rx - Number(prev.prevRxBytes || 0)) / dt)
|
||||||
|
uploadRate = Math.max(0, (tx - Number(prev.prevTxBytes || 0)) / dt)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
prevIface: iface,
|
||||||
|
prevRxBytes: rx,
|
||||||
|
prevTxBytes: tx,
|
||||||
|
prevSampleTime: now,
|
||||||
|
downloadRate: downloadRate,
|
||||||
|
uploadRate: uploadRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
var n = Number(bytes)
|
||||||
|
if (!isFinite(n) || n < 0) n = 0
|
||||||
|
if (n < 1024) return Math.round(n) + " B"
|
||||||
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB"
|
||||||
|
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB"
|
||||||
|
return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB"
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRate(bytesPerSec) {
|
||||||
|
return formatBytes(bytesPerSec) + "/s"
|
||||||
|
}
|
||||||
|
|
||||||
|
function wifiRow(network) {
|
||||||
|
if (!network) return null
|
||||||
|
return {
|
||||||
|
network: network,
|
||||||
|
connected: !!network.connected,
|
||||||
|
known: !!network.known,
|
||||||
|
ssid: network.name || "",
|
||||||
|
signal: Math.round((network.signalStrength || 0) * 100),
|
||||||
|
security: network.security
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortWifiRows(rows) {
|
||||||
|
var nets = Array.isArray(rows) ? rows.slice() : []
|
||||||
|
nets.sort(function(a, b) {
|
||||||
|
if (a.connected !== b.connected) return a.connected ? -1 : 1
|
||||||
|
if (a.known !== b.known) return a.known ? -1 : 1
|
||||||
|
return b.signal - a.signal
|
||||||
|
})
|
||||||
|
return nets
|
||||||
|
}
|
||||||
|
|
||||||
|
function wifiSectionTitle(wifiNetworks, index) {
|
||||||
|
var networks = Array.isArray(wifiNetworks) ? wifiNetworks : []
|
||||||
|
if (index < 0 || index >= networks.length) return ""
|
||||||
|
|
||||||
|
var net = networks[index]
|
||||||
|
if (!net) return ""
|
||||||
|
|
||||||
|
if (net.known && index === 0) return "KNOWN NETWORKS"
|
||||||
|
if (!net.known && (index === 0 || (networks[index - 1] && networks[index - 1].known))) return "OTHER NETWORKS"
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProtected(security, openSecurity) {
|
||||||
|
return security !== openSecurity
|
||||||
|
}
|
||||||
|
|
||||||
|
function networkFailureReason(reason, reasons) {
|
||||||
|
var r = reasons || {}
|
||||||
|
if (reason === r.NoSecrets) return "Passphrase required"
|
||||||
|
if (reason === r.WifiAuthTimeout) return "Wrong password"
|
||||||
|
if (reason === r.WifiNetworkLost) return "Network lost"
|
||||||
|
if (reason === r.WifiClientDisconnected) return "Disconnected"
|
||||||
|
if (reason === r.WifiClientFailed) return "Connection failed"
|
||||||
|
return "Failed to connect"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== "undefined") {
|
||||||
|
module.exports = {
|
||||||
|
parseNetworkStatus: parseNetworkStatus,
|
||||||
|
wifiIconFor: wifiIconFor,
|
||||||
|
connectionIcon: connectionIcon,
|
||||||
|
formatHeaderSpeed: formatHeaderSpeed,
|
||||||
|
formatHeaderFreq: formatHeaderFreq,
|
||||||
|
headerDetail: headerDetail,
|
||||||
|
parseKeyValue: parseKeyValue,
|
||||||
|
throughputState: throughputState,
|
||||||
|
formatBytes: formatBytes,
|
||||||
|
formatRate: formatRate,
|
||||||
|
wifiRow: wifiRow,
|
||||||
|
sortWifiRows: sortWifiRows,
|
||||||
|
wifiSectionTitle: wifiSectionTitle,
|
||||||
|
isProtected: isProtected,
|
||||||
|
networkFailureReason: networkFailureReason
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import QtQuick.Layouts
|
|||||||
import Quickshell
|
import Quickshell
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
import qs.Ui
|
import qs.Ui
|
||||||
|
import "NotificationLogic.js" as NotificationLogic
|
||||||
|
|
||||||
BarWidget {
|
BarWidget {
|
||||||
id: root
|
id: root
|
||||||
@@ -29,19 +30,11 @@ BarWidget {
|
|||||||
: null
|
: null
|
||||||
|
|
||||||
function isChromiumDerived(app, appIcon) {
|
function isChromiumDerived(app, appIcon) {
|
||||||
var source = (String(app || "") + "\n" + String(appIcon || "")).toLowerCase()
|
return NotificationLogic.isChromiumDerived(app, appIcon)
|
||||||
return source.indexOf("chrom") >= 0 || source.indexOf("brave") >= 0 ||
|
|
||||||
source.indexOf("vivaldi") >= 0 || source.indexOf("microsoft-edge") >= 0 ||
|
|
||||||
source.indexOf("opera") >= 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeBody(s, app, appIcon) {
|
function sanitizeBody(s, app, appIcon) {
|
||||||
var text = String(s || "").replace(/<img[^>]*>/gi, "")
|
return NotificationLogic.sanitizeBody(s, app, appIcon)
|
||||||
if (!isChromiumDerived(app, appIcon)) return text
|
|
||||||
|
|
||||||
return text
|
|
||||||
.replace(/^\s*<a\b[^>]*>\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^<\s]*)?\s*<\/a>\s*/i, "")
|
|
||||||
.replace(/^\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?\s+/i, "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function notificationIconSource(icon) {
|
function notificationIconSource(icon) {
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
function isChromiumDerived(app, appIcon) {
|
||||||
|
var source = (String(app || "") + "\n" + String(appIcon || "")).toLowerCase()
|
||||||
|
return source.indexOf("chrom") >= 0 || source.indexOf("brave") >= 0 ||
|
||||||
|
source.indexOf("vivaldi") >= 0 || source.indexOf("microsoft-edge") >= 0 ||
|
||||||
|
source.indexOf("opera") >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeBody(body, app, appIcon) {
|
||||||
|
var text = String(body || "").replace(/<img[^>]*>/gi, "")
|
||||||
|
if (!isChromiumDerived(app, appIcon)) return text
|
||||||
|
|
||||||
|
return text
|
||||||
|
.replace(/^\s*<a\b[^>]*>\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^<\s]*)?\s*<\/a>\s*/i, "")
|
||||||
|
.replace(/^\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?\s+/i, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryStartsWithGlyph(summary) {
|
||||||
|
var text = String(summary || "").replace(/^\s+/, "")
|
||||||
|
if (!text) return false
|
||||||
|
|
||||||
|
var offset = 1
|
||||||
|
var first = text.charCodeAt(0)
|
||||||
|
if (first >= 0xd800 && first <= 0xdbff && text.length > 1) offset = 2
|
||||||
|
|
||||||
|
var spaces = 0
|
||||||
|
while (offset < text.length && text.charAt(offset) === " ") {
|
||||||
|
spaces++
|
||||||
|
offset++
|
||||||
|
}
|
||||||
|
|
||||||
|
return spaces >= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldBypassDnd(notification, criticalUrgency) {
|
||||||
|
var appName = String((notification && notification.appName) || "")
|
||||||
|
if (appName === "omarchy-action") return true
|
||||||
|
return appName === "notify-send" && notification && notification.urgency === criticalUrgency
|
||||||
|
}
|
||||||
|
|
||||||
|
function glyphFromHints(hints) {
|
||||||
|
try {
|
||||||
|
if (hints) {
|
||||||
|
var glyph = hints["omarchy-glyph"]
|
||||||
|
if (glyph !== undefined && glyph !== null) return String(glyph)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotOf(notification, timestamp) {
|
||||||
|
var n = notification || {}
|
||||||
|
var id = n.id || 0
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
originalId: id,
|
||||||
|
app: n.appName || "",
|
||||||
|
appIcon: n.appIcon || "",
|
||||||
|
summary: String(n.summary || ""),
|
||||||
|
body: n.body || "",
|
||||||
|
image: n.image || "",
|
||||||
|
glyph: glyphFromHints(n.hints),
|
||||||
|
urgency: n.urgency,
|
||||||
|
timestamp: timestamp === undefined ? Date.now() : timestamp,
|
||||||
|
ref: notification
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyEntry(value, normalUrgency) {
|
||||||
|
var e = value || {}
|
||||||
|
return {
|
||||||
|
id: e.id || 0,
|
||||||
|
originalId: e.originalId || e.id || 0,
|
||||||
|
app: e.app || "",
|
||||||
|
appIcon: e.appIcon || "",
|
||||||
|
summary: e.summary || "",
|
||||||
|
body: e.body || "",
|
||||||
|
image: e.image || "",
|
||||||
|
glyph: e.glyph || "",
|
||||||
|
urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency,
|
||||||
|
timestamp: e.timestamp || 0,
|
||||||
|
ref: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeByOriginalId(rows) {
|
||||||
|
var values = Array.isArray(rows) ? rows : []
|
||||||
|
var keep = {}
|
||||||
|
for (var i = 0; i < values.length; i++) {
|
||||||
|
var row = values[i]
|
||||||
|
if (!row) continue
|
||||||
|
var key = row.originalId
|
||||||
|
if (key === undefined || key === null) key = "_" + i
|
||||||
|
var prior = keep[key]
|
||||||
|
if (!prior || (row.timestamp || 0) >= (prior.timestamp || 0)) keep[key] = row
|
||||||
|
}
|
||||||
|
|
||||||
|
var out = []
|
||||||
|
for (var id in keep) out.push(keep[id])
|
||||||
|
out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHistory(raw, normalUrgency, historyCap) {
|
||||||
|
var text = String(raw || "").trim()
|
||||||
|
var cap = historyCap === undefined || historyCap === null ? 100 : Number(historyCap)
|
||||||
|
if (isNaN(cap)) cap = 100
|
||||||
|
cap = Math.max(0, cap)
|
||||||
|
if (!text) return { empty: true, error: false, dnd: null, pending: [], past: [], hadDuplicates: false }
|
||||||
|
|
||||||
|
try {
|
||||||
|
var parsed = JSON.parse(text)
|
||||||
|
var pendingRaw = (parsed && Array.isArray(parsed.pending)) ? parsed.pending : []
|
||||||
|
var pastRaw = (parsed && Array.isArray(parsed.past)) ? parsed.past : []
|
||||||
|
if (parsed && Array.isArray(parsed.entries)) pastRaw = pastRaw.concat(parsed.entries)
|
||||||
|
|
||||||
|
var pendingDeduped = dedupeByOriginalId(pendingRaw)
|
||||||
|
var pastDeduped = dedupeByOriginalId(pastRaw)
|
||||||
|
|
||||||
|
return {
|
||||||
|
empty: false,
|
||||||
|
error: false,
|
||||||
|
dnd: parsed && typeof parsed.dnd === "boolean" ? parsed.dnd : null,
|
||||||
|
pending: pendingDeduped.slice(0, cap).map(function(entry) { return historyEntry(entry, normalUrgency) }),
|
||||||
|
past: pastDeduped.slice(0, cap).map(function(entry) { return historyEntry(entry, normalUrgency) }),
|
||||||
|
hadDuplicates: pendingDeduped.length !== pendingRaw.length || pastDeduped.length !== pastRaw.length
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return { empty: false, error: true, errorMessage: String(e), dnd: null, pending: [], past: [], hadDuplicates: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dumpRows(rows) {
|
||||||
|
var values = Array.isArray(rows) ? rows : []
|
||||||
|
var out = []
|
||||||
|
for (var i = 0; i < values.length; i++) {
|
||||||
|
var r = values[i]
|
||||||
|
if (!r) continue
|
||||||
|
out.push({
|
||||||
|
id: r.id,
|
||||||
|
originalId: r.originalId,
|
||||||
|
app: r.app,
|
||||||
|
appIcon: r.appIcon,
|
||||||
|
summary: r.summary,
|
||||||
|
body: r.body,
|
||||||
|
image: r.image,
|
||||||
|
glyph: r.glyph || "",
|
||||||
|
urgency: r.urgency,
|
||||||
|
timestamp: r.timestamp
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageExtension(srcPath) {
|
||||||
|
var lower = String(srcPath || "").toLowerCase()
|
||||||
|
var dot = lower.lastIndexOf(".")
|
||||||
|
if (dot < 0) return "png"
|
||||||
|
var ext = lower.substring(dot + 1)
|
||||||
|
if (ext.length === 0 || ext.length > 5) return "png"
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== "undefined") {
|
||||||
|
module.exports = {
|
||||||
|
isChromiumDerived: isChromiumDerived,
|
||||||
|
sanitizeBody: sanitizeBody,
|
||||||
|
summaryStartsWithGlyph: summaryStartsWithGlyph,
|
||||||
|
shouldBypassDnd: shouldBypassDnd,
|
||||||
|
glyphFromHints: glyphFromHints,
|
||||||
|
snapshotOf: snapshotOf,
|
||||||
|
historyEntry: historyEntry,
|
||||||
|
dedupeByOriginalId: dedupeByOriginalId,
|
||||||
|
parseHistory: parseHistory,
|
||||||
|
dumpRows: dumpRows,
|
||||||
|
imageExtension: imageExtension
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import Quickshell.Services.Notifications
|
|||||||
import qs.Commons
|
import qs.Commons
|
||||||
|
|
||||||
import "components"
|
import "components"
|
||||||
|
import "NotificationLogic.js" as NotificationLogic
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: service
|
id: service
|
||||||
@@ -112,36 +113,11 @@ Item {
|
|||||||
// chat apps set app_name to their brand (Discord/Slack/Vesktop), which
|
// chat apps set app_name to their brand (Discord/Slack/Vesktop), which
|
||||||
// falls outside this rule.
|
// falls outside this rule.
|
||||||
function shouldBypassDnd(notification) {
|
function shouldBypassDnd(notification) {
|
||||||
var appName = String(notification.appName || "")
|
return NotificationLogic.shouldBypassDnd(notification, NotificationUrgency.Critical)
|
||||||
if (appName === "omarchy-action") return true
|
|
||||||
if (appName === "notify-send" && notification.urgency === NotificationUrgency.Critical) return true
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshotOf(notification) {
|
function snapshotOf(notification) {
|
||||||
var glyph = ""
|
return NotificationLogic.snapshotOf(notification, Date.now())
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNotification(notification) {
|
function handleNotification(notification) {
|
||||||
@@ -384,12 +360,7 @@ Item {
|
|||||||
// and skip them for v1.
|
// and skip them for v1.
|
||||||
|
|
||||||
function imageExtension(srcPath) {
|
function imageExtension(srcPath) {
|
||||||
var lower = srcPath.toLowerCase()
|
return NotificationLogic.imageExtension(srcPath)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeCacheImage(snapshot) {
|
function maybeCacheImage(snapshot) {
|
||||||
@@ -541,78 +512,31 @@ Item {
|
|||||||
// guard, the second fire appends a second copy of every persisted row
|
// guard, the second fire appends a second copy of every persisted row
|
||||||
// to the in-memory model.
|
// to the in-memory model.
|
||||||
if (service.historyLoaded) return
|
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) {
|
var parsed = NotificationLogic.parseHistory(raw, NotificationUrgency.Normal, service.historyCap)
|
||||||
return {
|
if (parsed.empty) {
|
||||||
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)
|
|
||||||
service.historyLoaded = true
|
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() {
|
function flushHistory() {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import QtQuick
|
|||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
|
import "../NotificationLogic.js" as NotificationLogic
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: root
|
id: root
|
||||||
@@ -37,15 +38,9 @@ Rectangle {
|
|||||||
readonly property string smallIconSource: image.length > 0 ? image : iconSource(appIcon)
|
readonly property string smallIconSource: image.length > 0 ? image : iconSource(appIcon)
|
||||||
readonly property bool hasGlyph: glyph.length > 0
|
readonly property bool hasGlyph: glyph.length > 0
|
||||||
readonly property bool hasSmallIcon: smallIconSource.length > 0 || hasGlyph
|
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 singleLineToast: sanitizedBody.length === 0
|
||||||
readonly property bool collapseRedundantIcon: singleLineToast && !hasGlyph && summaryStartsWithGlyph
|
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 sanitizedBody: sanitizeBody(body)
|
||||||
readonly property string styledBody: sanitizedBody.replace(/\r\n|\r|\n/g, "<br/>")
|
readonly property string styledBody: sanitizedBody.replace(/\r\n|\r|\n/g, "<br/>")
|
||||||
|
|
||||||
@@ -54,15 +49,7 @@ Rectangle {
|
|||||||
readonly property color accentColor: urgency === 2 ? Color.urgent : (urgency === 0 ? dimColor : Color.notifications.countdown)
|
readonly property color accentColor: urgency === 2 ? Color.urgent : (urgency === 0 ? dimColor : Color.notifications.countdown)
|
||||||
|
|
||||||
function sanitizeBody(s) {
|
function sanitizeBody(s) {
|
||||||
var text = String(s).replace(/<img[^>]*>/gi, "")
|
return NotificationLogic.sanitizeBody(s, app, appIcon)
|
||||||
if (!chromiumDerived) return text
|
|
||||||
|
|
||||||
// Chromium web notifications often prefix the body with the sending
|
|
||||||
// origin, sometimes as a hyperlink. The browser icon already identifies
|
|
||||||
// the source, so drop only that leading URL/domain.
|
|
||||||
return text
|
|
||||||
.replace(/^\s*<a\b[^>]*>\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^<\s]*)?\s*<\/a>\s*/i, "")
|
|
||||||
.replace(/^\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?\s+/i, "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function iconSource(icon) {
|
function iconSource(icon) {
|
||||||
|
|||||||
+10
-31
@@ -3,6 +3,7 @@ import Quickshell
|
|||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
|
import "OsdModel.js" as OsdModel
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
@@ -22,40 +23,18 @@ Item {
|
|||||||
readonly property bool mediaOsd: iconKey.indexOf("media") === 0 || iconKey.indexOf("player") === 0
|
readonly property bool mediaOsd: iconKey.indexOf("media") === 0 || iconKey.indexOf("player") === 0
|
||||||
|
|
||||||
function iconFor(name, percent) {
|
function iconFor(name, percent) {
|
||||||
var n = String(name || "").toLowerCase()
|
return OsdModel.iconFor(name, percent)
|
||||||
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 show(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration) {
|
function show(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration) {
|
||||||
iconKey = String(iconName || "").toLowerCase()
|
var next = OsdModel.stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration)
|
||||||
maxValue = Math.max(1, parseInt(rawMax || "100", 10))
|
iconKey = next.iconKey
|
||||||
var parsed = parseInt(rawValue || "0", 10)
|
maxValue = next.maxValue
|
||||||
hasProgress = rawValue !== "" && !isNaN(parsed) && rawMessage === ""
|
hasProgress = next.hasProgress
|
||||||
value = hasProgress ? Util.clamp(parsed, 0, maxValue) : 0
|
value = next.value
|
||||||
message = String(rawMessage || (hasProgress ? (rawProgressText || Math.round(value * 100 / maxValue) + "%") : ""))
|
message = next.message
|
||||||
icon = iconFor(iconName, hasProgress ? Math.round(value * 100 / maxValue) : -1)
|
icon = next.icon
|
||||||
var parsedDuration = parseInt(rawDuration || "1200", 10)
|
duration = next.duration
|
||||||
duration = isNaN(parsedDuration) ? 1200 : Math.max(0, parsedDuration)
|
|
||||||
opened = true
|
opened = true
|
||||||
if (duration > 0) hideTimer.restart()
|
if (duration > 0) hideTimer.restart()
|
||||||
else hideTimer.stop()
|
else hideTimer.stop()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import Quickshell.Io
|
|||||||
import Quickshell.Services.Polkit
|
import Quickshell.Services.Polkit
|
||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
|
import "PolkitModel.js" as PolkitModel
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
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
|
readonly property int cardHeight: panel.height > 0 ? Math.min(fieldHeight + contentMargin * 2, panel.height - Style.gapsOut * 2) : fieldHeight + contentMargin * 2
|
||||||
|
|
||||||
function promptLooksFingerprint(text) {
|
function promptLooksFingerprint(text) {
|
||||||
var s = String(text || "").toLowerCase()
|
return PolkitModel.promptLooksFingerprint(text)
|
||||||
return s.indexOf("finger") !== -1 || s.indexOf("fprint") !== -1 || s.indexOf("swipe") !== -1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadPamConfig(raw) {
|
function loadPamConfig(raw) {
|
||||||
fingerprintFirst = false
|
fingerprintFirst = PolkitModel.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
|
|
||||||
fingerprintFirst = line.indexOf("pam_fprintd.so") !== -1
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetSnapshot() {
|
function resetSnapshot() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import Quickshell.Io
|
|||||||
import Quickshell.Services.UPower
|
import Quickshell.Services.UPower
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
import qs.Ui
|
import qs.Ui
|
||||||
|
import "PowerModel.js" as PowerModel
|
||||||
|
|
||||||
Panel {
|
Panel {
|
||||||
id: root
|
id: root
|
||||||
@@ -20,9 +21,17 @@ Panel {
|
|||||||
return !!(device && device.isPresent)
|
return !!(device && device.isPresent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upowerStates() {
|
||||||
|
return {
|
||||||
|
Charging: UPowerDeviceState.Charging,
|
||||||
|
Discharging: UPowerDeviceState.Discharging,
|
||||||
|
FullyCharged: UPowerDeviceState.FullyCharged,
|
||||||
|
PendingCharge: UPowerDeviceState.PendingCharge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function selectProfileByDelta(delta) {
|
function selectProfileByDelta(delta) {
|
||||||
if (profiles.length === 0) { profileIndex = 0; return }
|
profileIndex = PowerModel.selectProfileIndex(profileIndex, delta, profiles)
|
||||||
profileIndex = Math.max(0, Math.min(profiles.length - 1, profileIndex + delta))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function activateSelectedProfile() {
|
function activateSelectedProfile() {
|
||||||
@@ -32,41 +41,16 @@ Panel {
|
|||||||
|
|
||||||
function batteryIcon() {
|
function batteryIcon() {
|
||||||
var device = UPower.displayDevice
|
var device = UPower.displayDevice
|
||||||
if (!root.batteryPresent) return ""
|
return PowerModel.batteryIcon(device, UPower.onBattery, upowerStates())
|
||||||
|
|
||||||
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]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function modeLabel() {
|
function modeLabel() {
|
||||||
var device = UPower.displayDevice
|
var device = UPower.displayDevice
|
||||||
if (!root.batteryPresent) return ""
|
return PowerModel.modeLabel(device, UPower.onBattery, upowerStates())
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function profileIcon(name) {
|
function profileIcon(name) {
|
||||||
if (name === "power-saver") return ""
|
return PowerModel.profileIcon(name)
|
||||||
if (name === "balanced") return ""
|
|
||||||
if (name === "performance") return ""
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property bool fullyCharged: {
|
readonly property bool fullyCharged: {
|
||||||
@@ -75,14 +59,7 @@ Panel {
|
|||||||
}
|
}
|
||||||
readonly property bool chargeThresholdActive: {
|
readonly property bool chargeThresholdActive: {
|
||||||
var device = UPower.displayDevice
|
var device = UPower.displayDevice
|
||||||
if (!(device && device.isPresent && !UPower.onBattery)) return false
|
return PowerModel.chargeThresholdActive(device, UPower.onBattery, upowerStates())
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
readonly property bool batteryFull: fullyCharged || (!UPower.onBattery && batteryFraction >= 1)
|
readonly property bool batteryFull: fullyCharged || (!UPower.onBattery && batteryFraction >= 1)
|
||||||
readonly property bool batteryFlowIdle: batteryFull || chargeThresholdActive
|
readonly property bool batteryFlowIdle: batteryFull || chargeThresholdActive
|
||||||
@@ -90,7 +67,7 @@ Panel {
|
|||||||
// 0..1 charge level, used by the visual progress bar.
|
// 0..1 charge level, used by the visual progress bar.
|
||||||
readonly property real batteryFraction: {
|
readonly property real batteryFraction: {
|
||||||
var d = UPower.displayDevice
|
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
|
readonly property bool batteryLow: UPower.onBattery && batteryFraction > 0 && batteryFraction <= 0.2
|
||||||
@@ -154,13 +131,7 @@ Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateKeyValue(raw, targetName) {
|
function updateKeyValue(raw, targetName) {
|
||||||
var next = {}
|
var next = PowerModel.parseKeyValue(raw)
|
||||||
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()
|
|
||||||
}
|
|
||||||
// Keep last known good data if a refresh briefly returns nothing — happens
|
// Keep last known good data if a refresh briefly returns nothing — happens
|
||||||
// around AC plug/unplug events. Avoids the section collapsing mid-transition.
|
// around AC plug/unplug events. Avoids the section collapsing mid-transition.
|
||||||
if (Object.keys(next).length === 0) return
|
if (Object.keys(next).length === 0) return
|
||||||
@@ -169,22 +140,13 @@ Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateProfiles(raw) {
|
function updateProfiles(raw) {
|
||||||
var lines = String(raw || "").split("\n")
|
var parsed = PowerModel.parseProfiles(raw, profileIndex)
|
||||||
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]
|
|
||||||
}
|
|
||||||
// Same guard as battery: preserve the last known profile list across
|
// Same guard as battery: preserve the last known profile list across
|
||||||
// transient empty payloads so the buttons don't blink out.
|
// transient empty payloads so the buttons don't blink out.
|
||||||
if (list.length === 0) return
|
if (parsed.profiles.length === 0) return
|
||||||
profiles = list
|
profiles = parsed.profiles
|
||||||
activeProfile = active
|
activeProfile = parsed.activeProfile
|
||||||
if (profileIndex >= profiles.length) profileIndex = Math.max(0, profiles.length - 1)
|
profileIndex = parsed.profileIndex
|
||||||
if (opened && activeProfile !== "") {
|
if (opened && activeProfile !== "") {
|
||||||
var idx = profiles.indexOf(activeProfile)
|
var idx = profiles.indexOf(activeProfile)
|
||||||
if (idx >= 0) profileIndex = idx
|
if (idx >= 0) profileIndex = idx
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ import Quickshell
|
|||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
|
import "ReminderFlowModel.js" as ReminderFlowModel
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
|
||||||
property var shell: null
|
property var shell: null
|
||||||
property var manifest: null
|
property var manifest: null
|
||||||
|
|
||||||
@@ -62,15 +64,15 @@ Item {
|
|||||||
var selection = root.filterText
|
var selection = root.filterText
|
||||||
|
|
||||||
if (root.step === "minutes") {
|
if (root.step === "minutes") {
|
||||||
var nextMinutes = selection.trim()
|
var nextMinutes = ReminderFlowModel.validMinutes(selection)
|
||||||
|
|
||||||
if (!nextMinutes) {
|
if (!selection.trim()) {
|
||||||
root.dismiss()
|
root.dismiss()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^[0-9]+$/.test(nextMinutes) || Number(nextMinutes) <= 0) {
|
if (!nextMinutes) {
|
||||||
Quickshell.execDetached(["bash", "-lc", "omarchy-notification-send 'Invalid reminder' 'Enter the number of minutes'"])
|
Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-notification-send", "Invalid reminder", "Enter the number of minutes"])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,12 +84,9 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (root.step === "message") {
|
if (root.step === "message") {
|
||||||
var command = "omarchy-reminder " + Util.shellQuote(root.minutes)
|
var args = [root.omarchyPath + "/bin/omarchy-reminder"].concat(ReminderFlowModel.reminderArgs(root.minutes, selection))
|
||||||
if (selection.length > 0)
|
|
||||||
command += " " + Util.shellQuote(selection)
|
|
||||||
|
|
||||||
root.dismiss()
|
root.dismiss()
|
||||||
Quickshell.execDetached(["bash", "-lc", command])
|
Quickshell.execDetached(args)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
function validMinutes(value) {
|
||||||
|
var minutes = String(value || "").trim()
|
||||||
|
return /^[0-9]+$/.test(minutes) && Number(minutes) > 0 ? minutes : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function reminderArgs(minutes, message) {
|
||||||
|
var valid = validMinutes(minutes)
|
||||||
|
if (!valid) return []
|
||||||
|
|
||||||
|
var args = [valid]
|
||||||
|
var text = String(message || "")
|
||||||
|
if (text.length > 0) args.push(text)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== "undefined") {
|
||||||
|
module.exports = {
|
||||||
|
validMinutes: validMinutes,
|
||||||
|
reminderArgs: reminderArgs
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
function batteryPercentage(device) {
|
||||||
|
if (!device || !device.isPresent) return -1
|
||||||
|
return Math.round(Number(device.percentage || 0) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDischarging(device, onBattery, dischargingState) {
|
||||||
|
return !!(device && device.isPresent && onBattery && device.state === dischargingState)
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldWarnLowBattery(device, onBattery, dischargingState, threshold, alreadyNotified) {
|
||||||
|
var level = batteryPercentage(device)
|
||||||
|
if (level < 0) return { level: level, notify: false, notifiedLowBattery: false }
|
||||||
|
|
||||||
|
var low = isDischarging(device, onBattery, dischargingState) && level <= threshold
|
||||||
|
return {
|
||||||
|
level: level,
|
||||||
|
notify: low && !alreadyNotified,
|
||||||
|
notifiedLowBattery: low
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== "undefined") {
|
||||||
|
module.exports = {
|
||||||
|
batteryPercentage: batteryPercentage,
|
||||||
|
isDischarging: isDischarging,
|
||||||
|
shouldWarnLowBattery: shouldWarnLowBattery
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import QtQuick
|
|||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import Quickshell.Services.UPower
|
import Quickshell.Services.UPower
|
||||||
|
import "BatteryModel.js" as BatteryModel
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
@@ -18,33 +19,17 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function batteryPercentage() {
|
function batteryPercentage() {
|
||||||
var device = UPower.displayDevice
|
return BatteryModel.batteryPercentage(UPower.displayDevice)
|
||||||
if (!device || !device.isPresent) return -1
|
|
||||||
return Math.round(Number(device.percentage || 0) * 100)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDischarging() {
|
function isDischarging() {
|
||||||
var device = UPower.displayDevice
|
return BatteryModel.isDischarging(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging)
|
||||||
return !!(device && device.isPresent
|
|
||||||
&& UPower.onBattery
|
|
||||||
&& device.state === UPowerDeviceState.Discharging)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkBattery() {
|
function checkBattery() {
|
||||||
var level = batteryPercentage()
|
var state = BatteryModel.shouldWarnLowBattery(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging, batteryThreshold, persisted.notifiedLowBattery)
|
||||||
if (level < 0) {
|
persisted.notifiedLowBattery = state.notifiedLowBattery
|
||||||
persisted.notifiedLowBattery = false
|
if (state.notify) sendLowBatteryWarning(state.level)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDischarging() && level <= batteryThreshold) {
|
|
||||||
if (!persisted.notifiedLowBattery) {
|
|
||||||
persisted.notifiedLowBattery = true
|
|
||||||
sendLowBatteryWarning(level)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
persisted.notifiedLowBattery = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendLowBatteryWarning(level) {
|
function sendLowBatteryWarning(level) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import Quickshell
|
|||||||
import Quickshell.Hyprland
|
import Quickshell.Hyprland
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
|
import "IdleModel.js" as IdleModel
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
@@ -36,9 +37,7 @@ Item {
|
|||||||
property int screensaverWindowCount: 0
|
property int screensaverWindowCount: 0
|
||||||
|
|
||||||
function secondsFromConfig(value, fallback) {
|
function secondsFromConfig(value, fallback) {
|
||||||
var n = Number(value)
|
return IdleModel.secondsFromConfig(value, fallback)
|
||||||
if (!isFinite(n) || n < 0) return fallback
|
|
||||||
return Math.floor(n)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
@@ -117,25 +116,9 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setScreensaverWindow(address, visible) {
|
function setScreensaverWindow(address, visible) {
|
||||||
var key = String(address || "")
|
var next = IdleModel.screensaverWindowsAfter(root.screensaverWindows, address, visible)
|
||||||
if (!key) return
|
root.screensaverWindows = next.windows
|
||||||
|
root.screensaverWindowCount = next.count
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScreensaverWindowOpened(address) {
|
function handleScreensaverWindowOpened(address) {
|
||||||
@@ -156,10 +139,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function eventParts(event, count) {
|
function eventParts(event, count) {
|
||||||
try {
|
return IdleModel.eventParts(event, count)
|
||||||
if (event && event.parse) return event.parse(count)
|
|
||||||
} catch (error) {}
|
|
||||||
return String(event && event.data ? event.data : "").split(",")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleHyprlandEvent(event) {
|
function handleHyprlandEvent(event) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import Quickshell
|
|||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import Quickshell.Services.Mpris
|
import Quickshell.Services.Mpris
|
||||||
import Quickshell.Services.Pipewire
|
import Quickshell.Services.Pipewire
|
||||||
|
import "MediaModel.js" as MediaModel
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
@@ -33,97 +34,55 @@ Item {
|
|||||||
readonly property string identity: activePlayer ? (activePlayer.identity || activePlayer.desktopEntry || "") : ""
|
readonly property string identity: activePlayer ? (activePlayer.identity || activePlayer.desktopEntry || "") : ""
|
||||||
|
|
||||||
function isProxyPlayer(player) {
|
function isProxyPlayer(player) {
|
||||||
var dbusName = String(player && player.dbusName || "").toLowerCase()
|
return MediaModel.isProxyPlayer(player)
|
||||||
var desktopEntry = String(player && player.desktopEntry || "").toLowerCase()
|
|
||||||
return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasMetadata(player) {
|
function hasMetadata(player) {
|
||||||
return !!(player && (player.trackTitle || player.trackArtist || player.identity || player.desktopEntry))
|
return MediaModel.hasMetadata(player)
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasTrackMetadata(player) {
|
function hasTrackMetadata(player) {
|
||||||
return !!(player && (player.trackTitle || player.trackArtist || player.trackAlbum || player.trackArtUrl))
|
return MediaModel.hasTrackMetadata(player)
|
||||||
}
|
}
|
||||||
|
|
||||||
function playerCanControl(player) {
|
function playerCanControl(player) {
|
||||||
return !!(player && (player.canTogglePlaying || player.canPlay || player.canPause || player.canGoNext || player.canGoPrevious))
|
return MediaModel.playerCanControl(player)
|
||||||
}
|
}
|
||||||
|
|
||||||
function canHandleAction(player, action) {
|
function canHandleAction(player, action) {
|
||||||
if (!player) return false
|
return MediaModel.canHandleAction(player, action)
|
||||||
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) {
|
function canCycleSource(player) {
|
||||||
return !!(player && hasMetadata(player) && (player.isPlaying || player.canPlay))
|
return MediaModel.canCycleSource(player)
|
||||||
}
|
}
|
||||||
|
|
||||||
function nodeProps(node) {
|
function nodeProps(node) {
|
||||||
return node && node.ready && node.properties ? node.properties : {}
|
return MediaModel.nodeProps(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPlaybackStream(node) {
|
function isPlaybackStream(node) {
|
||||||
if (!node || !node.isStream) return false
|
return MediaModel.isPlaybackStream(node)
|
||||||
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) {
|
function streamLabelKey(label) {
|
||||||
var key = String(label || "").toLowerCase()
|
return MediaModel.streamLabelKey(label)
|
||||||
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) {
|
function rawStreamLabel(node) {
|
||||||
if (!node) return ""
|
return MediaModel.rawStreamLabel(node)
|
||||||
var p = nodeProps(node)
|
|
||||||
return p["application.name"]
|
|
||||||
|| node.description
|
|
||||||
|| p["media.name"]
|
|
||||||
|| p["node.name"]
|
|
||||||
|| node.name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function playerAppLabel(player) {
|
function playerAppLabel(player) {
|
||||||
if (!player) return ""
|
return MediaModel.playerAppLabel(player)
|
||||||
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) {
|
function playerHasPlaybackStream(player) {
|
||||||
var playerKey = streamLabelKey(playerAppLabel(player))
|
return MediaModel.playerHasPlaybackStream(player, playbackStreams)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function playerKey(player) {
|
function playerKey(player) {
|
||||||
if (!player) return ""
|
return MediaModel.playerKey(player)
|
||||||
return String(player.dbusName || player.desktopEntry || player.identity || "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function playerForKey(key) {
|
function playerForKey(key) {
|
||||||
@@ -272,15 +231,11 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function labelFor(player) {
|
function labelFor(player) {
|
||||||
if (!player) return ""
|
return MediaModel.labelFor(player)
|
||||||
return player.trackTitle || player.identity || player.desktopEntry || ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function osdMessage(player, fallback) {
|
function osdMessage(player, fallback) {
|
||||||
if (!player) return fallback
|
return MediaModel.osdMessage(player, fallback)
|
||||||
var label = labelFor(player)
|
|
||||||
if (label && player.trackArtist) return label + " - " + player.trackArtist
|
|
||||||
return label || fallback
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showOsd(actionLabel, iconName, player) {
|
function showOsd(actionLabel, iconName, player) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Quickshell
|
|||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
import qs.Ui
|
import qs.Ui
|
||||||
|
import "WeatherModel.js" as WeatherModel
|
||||||
|
|
||||||
Panel {
|
Panel {
|
||||||
id: root
|
id: root
|
||||||
@@ -31,10 +32,9 @@ Panel {
|
|||||||
property string klass: ""
|
property string klass: ""
|
||||||
|
|
||||||
function updateWeather(raw) {
|
function updateWeather(raw) {
|
||||||
var data
|
var data = WeatherModel.parseWeatherStatus(raw)
|
||||||
try { data = JSON.parse(raw || "{}") } catch (e) { data = {} }
|
label = data.label
|
||||||
label = data.text || ""
|
klass = data.klass
|
||||||
klass = data.class || ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property var current: report && report.current_condition && report.current_condition[0] ? report.current_condition[0] : null
|
readonly property var current: report && report.current_condition && report.current_condition[0] ? report.current_condition[0] : null
|
||||||
@@ -83,126 +83,54 @@ Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildForecastDays() {
|
function buildForecastDays() {
|
||||||
var days = openMeteoForecastDays()
|
return WeatherModel.buildForecastDays(report, dailyForecastReport, Qt.formatDate(new Date(), "yyyy-MM-dd"))
|
||||||
return days.length > 0 ? days : wttrNextForecastDays()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openMeteoForecastDays() {
|
function openMeteoForecastDays() {
|
||||||
var daily = dailyForecastReport && dailyForecastReport.daily ? dailyForecastReport.daily : null
|
return WeatherModel.openMeteoForecastDays(dailyForecastReport, Qt.formatDate(new Date(), "yyyy-MM-dd"))
|
||||||
if (!daily || !daily.time) return []
|
|
||||||
|
|
||||||
var result = []
|
|
||||||
for (var i = 0; i < daily.time.length && result.length < 3; ++i) {
|
|
||||||
var date = daily.time[i]
|
|
||||||
if (!isFutureForecastDate(date)) continue
|
|
||||||
|
|
||||||
var maxC = daily.temperature_2m_max ? daily.temperature_2m_max[i] : ""
|
|
||||||
var minC = daily.temperature_2m_min ? daily.temperature_2m_min[i] : ""
|
|
||||||
result.push({
|
|
||||||
date: date,
|
|
||||||
maxtempC: roundedTemp(maxC),
|
|
||||||
mintempC: roundedTemp(minC),
|
|
||||||
maxtempF: roundedTemp(celsiusToFahrenheit(maxC)),
|
|
||||||
mintempF: roundedTemp(celsiusToFahrenheit(minC)),
|
|
||||||
openMeteoWeatherCode: daily.weather_code ? daily.weather_code[i] : null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function wttrNextForecastDays() {
|
function wttrNextForecastDays() {
|
||||||
var days = report && report.weather ? report.weather : []
|
return WeatherModel.wttrNextForecastDays(report, Qt.formatDate(new Date(), "yyyy-MM-dd"))
|
||||||
var result = []
|
|
||||||
for (var i = 0; i < days.length && result.length < 3; ++i) {
|
|
||||||
if (isFutureForecastDate(days[i].date)) result.push(days[i])
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFutureForecastDate(dateString) {
|
function isFutureForecastDate(dateString) {
|
||||||
if (!dateString) return false
|
return WeatherModel.isFutureForecastDate(dateString, Qt.formatDate(new Date(), "yyyy-MM-dd"))
|
||||||
return String(dateString).slice(0, 10) > Qt.formatDate(new Date(), "yyyy-MM-dd")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function roundedTemp(value) {
|
function roundedTemp(value) {
|
||||||
if (value === undefined || value === null || value === "") return ""
|
return WeatherModel.roundedTemp(value)
|
||||||
var n = parseFloat(String(value))
|
|
||||||
return isNaN(n) ? "" : String(Math.round(n))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function celsiusToFahrenheit(value) {
|
function celsiusToFahrenheit(value) {
|
||||||
if (value === undefined || value === null || value === "") return ""
|
return WeatherModel.celsiusToFahrenheit(value)
|
||||||
var n = parseFloat(String(value))
|
|
||||||
return isNaN(n) ? "" : (n * 9 / 5) + 32
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTemp(value) {
|
function formatTemp(value) {
|
||||||
if (value === undefined || value === null || value === "") return ""
|
return WeatherModel.formatTemp(value, useImperial)
|
||||||
return value + "°" + (useImperial ? "F" : "C")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function dayName(dateString) {
|
function dayName(dateString) {
|
||||||
if (!dateString) return ""
|
return WeatherModel.dayName(dateString, function(date) { return Qt.formatDate(date, "dddd") })
|
||||||
var d = new Date(dateString + "T12:00:00")
|
|
||||||
if (isNaN(d.getTime())) return ""
|
|
||||||
return Qt.formatDate(d, "dddd")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bare degree value (no unit letter), used in the forecast row.
|
// Bare degree value (no unit letter), used in the forecast row.
|
||||||
function bareTempForDay(day, kind) {
|
function bareTempForDay(day, kind) {
|
||||||
if (!day) return ""
|
return WeatherModel.bareTempForDay(day, kind, useImperial)
|
||||||
var v = useImperial
|
|
||||||
? (kind === "max" ? day.maxtempF : day.mintempF)
|
|
||||||
: (kind === "max" ? day.maxtempC : day.mintempC)
|
|
||||||
if (v === undefined || v === null || v === "") return ""
|
|
||||||
return v + "°"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Representative icon for a forecast day: the hourly entry nearest noon.
|
// Representative icon for a forecast day: the hourly entry nearest noon.
|
||||||
function dayIcon(day) {
|
function dayIcon(day) {
|
||||||
if (!day) return ""
|
return WeatherModel.dayIcon(day)
|
||||||
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) {
|
function iconForOpenMeteoCode(code) {
|
||||||
var c = parseInt(String(code || "0"), 10)
|
return WeatherModel.iconForOpenMeteoCode(code)
|
||||||
if (c === 0) return iconForCode(113, false)
|
|
||||||
if (c === 1 || c === 2) return iconForCode(116, false)
|
|
||||||
if (c === 3) return iconForCode(119, false)
|
|
||||||
if (c === 45 || c === 48) return iconForCode(143, false)
|
|
||||||
if (c === 51 || c === 53 || c === 55 || c === 56 || c === 57 || c === 61) return iconForCode(266, false)
|
|
||||||
if (c === 63 || c === 65 || c === 66 || c === 67 || c === 80 || c === 81 || c === 82) return iconForCode(308, false)
|
|
||||||
if (c === 71 || c === 73 || c === 75 || c === 77 || c === 85 || c === 86) return iconForCode(338, false)
|
|
||||||
if (c === 95 || c === 96 || c === 99) return iconForCode(389, false)
|
|
||||||
return iconForCode(119, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors omarchy-weather-icon's wttr.in code → nerd-font glyph mapping.
|
// Mirrors omarchy-weather-icon's wttr.in code → nerd-font glyph mapping.
|
||||||
function iconForCode(code, night) {
|
function iconForCode(code, night) {
|
||||||
var c = parseInt(String(code || "0"), 10)
|
return WeatherModel.iconForCode(code, night)
|
||||||
switch (c) {
|
|
||||||
case 113: return night ? "" : ""
|
|
||||||
case 116: return night ? "" : ""
|
|
||||||
case 119: case 122: return ""
|
|
||||||
case 143: case 248: case 260: return ""
|
|
||||||
case 176: case 263: case 353: return night ? "" : ""
|
|
||||||
case 179: case 227: case 230: case 323: case 326: case 368: return night ? "" : ""
|
|
||||||
case 182: case 185: case 281: case 284: case 311: case 314:
|
|
||||||
case 317: case 320: case 350: case 362: case 365: case 374: case 377: return ""
|
|
||||||
case 200: case 386: case 389: case 392: case 395: return ""
|
|
||||||
case 266: case 293: case 296: case 299: case 302: case 305: case 308: case 356: case 359: return ""
|
|
||||||
case 329: case 332: case 335: case 338: case 371: return ""
|
|
||||||
default: return ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Regular → Executable
Regular → Executable
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
TEST_DIR="$ROOT/test/shell"
|
TEST_DIR="$ROOT/test/shell.d"
|
||||||
|
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
tests=()
|
tests=()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||||
SHELL_TEST_DIR="$ROOT/test/shell"
|
SHELL_TEST_DIR="$ROOT/test/shell.d"
|
||||||
|
|
||||||
export ROOT
|
export ROOT
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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('<img src="x">Hello', 'Slack', ''),
|
||||||
|
'Hello',
|
||||||
|
'notifications strip inline image tags'
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEqual(
|
||||||
|
notifications.sanitizeBody('<a href="https://example.com">example.com</a> 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user