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