Add shell plugin model tests

This commit is contained in:
David Heinemeier Hansson
2026-05-25 14:18:39 +02:00
parent 4277f5346b
commit 829c1fa4f7
61 changed files with 3236 additions and 1168 deletions
@@ -0,0 +1,28 @@
function batteryPercentage(device) {
if (!device || !device.isPresent) return -1
return Math.round(Number(device.percentage || 0) * 100)
}
function isDischarging(device, onBattery, dischargingState) {
return !!(device && device.isPresent && onBattery && device.state === dischargingState)
}
function shouldWarnLowBattery(device, onBattery, dischargingState, threshold, alreadyNotified) {
var level = batteryPercentage(device)
if (level < 0) return { level: level, notify: false, notifiedLowBattery: false }
var low = isDischarging(device, onBattery, dischargingState) && level <= threshold
return {
level: level,
notify: low && !alreadyNotified,
notifiedLowBattery: low
}
}
if (typeof module !== "undefined") {
module.exports = {
batteryPercentage: batteryPercentage,
isDischarging: isDischarging,
shouldWarnLowBattery: shouldWarnLowBattery
}
}
+6 -21
View File
@@ -2,6 +2,7 @@ import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.UPower
import "BatteryModel.js" as BatteryModel
Item {
id: root
@@ -18,33 +19,17 @@ Item {
}
function batteryPercentage() {
var device = UPower.displayDevice
if (!device || !device.isPresent) return -1
return Math.round(Number(device.percentage || 0) * 100)
return BatteryModel.batteryPercentage(UPower.displayDevice)
}
function isDischarging() {
var device = UPower.displayDevice
return !!(device && device.isPresent
&& UPower.onBattery
&& device.state === UPowerDeviceState.Discharging)
return BatteryModel.isDischarging(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging)
}
function checkBattery() {
var level = batteryPercentage()
if (level < 0) {
persisted.notifiedLowBattery = false
return
}
if (isDischarging() && level <= batteryThreshold) {
if (!persisted.notifiedLowBattery) {
persisted.notifiedLowBattery = true
sendLowBatteryWarning(level)
}
} else {
persisted.notifiedLowBattery = false
}
var state = BatteryModel.shouldWarnLowBattery(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging, batteryThreshold, persisted.notifiedLowBattery)
persisted.notifiedLowBattery = state.notifiedLowBattery
if (state.notify) sendLowBatteryWarning(state.level)
}
function sendLowBatteryWarning(level) {
+52
View File
@@ -0,0 +1,52 @@
function secondsFromConfig(value, fallback) {
var n = Number(value)
if (!isFinite(n) || n < 0) return fallback
return Math.floor(n)
}
function eventParts(event, count) {
try {
if (event && event.parse) return event.parse(count)
} catch (error) {
}
return String(event && event.data ? event.data : "").split(",")
}
function screensaverWindowsAfter(windows, address, visible) {
var key = String(address || "")
if (!key) {
var current = windows || {}
var existingCount = 0
for (var currentKey in current) {
if (current[currentKey]) existingCount++
}
return { windows: current, count: existingCount }
}
var next = {}
var count = 0
for (var existing in windows || {}) {
if (existing !== key && windows[existing]) {
next[existing] = true
count++
}
}
if (visible) {
next[key] = true
count++
}
return {
windows: next,
count: count
}
}
if (typeof module !== "undefined") {
module.exports = {
secondsFromConfig: secondsFromConfig,
eventParts: eventParts,
screensaverWindowsAfter: screensaverWindowsAfter
}
}
+6 -26
View File
@@ -3,6 +3,7 @@ import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import Quickshell.Wayland
import "IdleModel.js" as IdleModel
Item {
id: root
@@ -36,9 +37,7 @@ Item {
property int screensaverWindowCount: 0
function secondsFromConfig(value, fallback) {
var n = Number(value)
if (!isFinite(n) || n < 0) return fallback
return Math.floor(n)
return IdleModel.secondsFromConfig(value, fallback)
}
function nowIso() {
@@ -117,25 +116,9 @@ Item {
}
function setScreensaverWindow(address, visible) {
var key = String(address || "")
if (!key) return
var next = {}
var count = 0
for (var existing in root.screensaverWindows) {
if (existing !== key && root.screensaverWindows[existing]) {
next[existing] = true
count++
}
}
if (visible) {
next[key] = true
count++
}
root.screensaverWindows = next
root.screensaverWindowCount = count
var next = IdleModel.screensaverWindowsAfter(root.screensaverWindows, address, visible)
root.screensaverWindows = next.windows
root.screensaverWindowCount = next.count
}
function handleScreensaverWindowOpened(address) {
@@ -156,10 +139,7 @@ Item {
}
function eventParts(event, count) {
try {
if (event && event.parse) return event.parse(count)
} catch (error) {}
return String(event && event.data ? event.data : "").split(",")
return IdleModel.eventParts(event, count)
}
function handleHyprlandEvent(event) {
+126
View File
@@ -0,0 +1,126 @@
function isProxyPlayer(player) {
var dbusName = String(player && player.dbusName || "").toLowerCase()
var desktopEntry = String(player && player.desktopEntry || "").toLowerCase()
return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld"
}
function hasMetadata(player) {
return !!(player && (player.trackTitle || player.trackArtist || player.identity || player.desktopEntry))
}
function hasTrackMetadata(player) {
return !!(player && (player.trackTitle || player.trackArtist || player.trackAlbum || player.trackArtUrl))
}
function playerCanControl(player) {
return !!(player && (player.canTogglePlaying || player.canPlay || player.canPause || player.canGoNext || player.canGoPrevious))
}
function canHandleAction(player, action) {
if (!player) return false
if (action === "next") return !!player.canGoNext
if (action === "previous") return !!player.canGoPrevious
if (action === "play") return !!(player.canPlay || player.canTogglePlaying)
if (action === "pause") return !!(player.canPause || player.canTogglePlaying)
if (action === "playPause") return !!(player.canTogglePlaying || player.canPlay || player.canPause)
return false
}
function canCycleSource(player) {
return !!(player && hasMetadata(player) && (player.isPlaying || player.canPlay))
}
function nodeProps(node) {
return node && node.ready && node.properties ? node.properties : {}
}
function isPlaybackStream(node) {
if (!node || !node.isStream) return false
if (node.isSink === true) return true
var mediaClass = String(node.type || "")
return mediaClass.indexOf("Stream/Output/Audio") !== -1
|| mediaClass.indexOf("AudioOutStream") !== -1
|| mediaClass.indexOf("Output") !== -1
}
function streamLabelKey(label) {
var key = String(label || "").toLowerCase()
key = key.replace(/^pipewire alsa \[/, "")
key = key.replace(/\]$/, "")
key = key.replace(/^alsa playback \[/, "")
key = key.replace(/[^a-z0-9]+/g, "")
return key
}
function rawStreamLabel(node) {
if (!node) return ""
var p = nodeProps(node)
return p["application.name"]
|| node.description
|| p["media.name"]
|| p["node.name"]
|| node.name
}
function playerAppLabel(player) {
if (!player) return ""
var dbus = String(player.dbusName || "")
dbus = dbus.replace(/^org\.mpris\.MediaPlayer2\./, "")
dbus = dbus.replace(/\.instance[0-9]+$/, "")
return player.desktopEntry || player.identity || dbus
}
function playerHasPlaybackStream(player, playbackStreams) {
var playerKey = streamLabelKey(playerAppLabel(player))
if (!playerKey) return false
var streams = Array.isArray(playbackStreams) ? playbackStreams : []
for (var i = 0; i < streams.length; i++) {
var streamKey = streamLabelKey(rawStreamLabel(streams[i]))
if (!streamKey) continue
if (streamKey === playerKey
|| streamKey.indexOf(playerKey) !== -1
|| playerKey.indexOf(streamKey) !== -1)
return true
}
return false
}
function playerKey(player) {
if (!player) return ""
return String(player.dbusName || player.desktopEntry || player.identity || "")
}
function labelFor(player) {
if (!player) return ""
return player.trackTitle || player.identity || player.desktopEntry || ""
}
function osdMessage(player, fallback) {
if (!player) return fallback
var label = labelFor(player)
if (label && player.trackArtist) return label + " - " + player.trackArtist
return label || fallback
}
if (typeof module !== "undefined") {
module.exports = {
isProxyPlayer: isProxyPlayer,
hasMetadata: hasMetadata,
hasTrackMetadata: hasTrackMetadata,
playerCanControl: playerCanControl,
canHandleAction: canHandleAction,
canCycleSource: canCycleSource,
nodeProps: nodeProps,
isPlaybackStream: isPlaybackStream,
streamLabelKey: streamLabelKey,
rawStreamLabel: rawStreamLabel,
playerAppLabel: playerAppLabel,
playerHasPlaybackStream: playerHasPlaybackStream,
playerKey: playerKey,
labelFor: labelFor,
osdMessage: osdMessage
}
}
+16 -61
View File
@@ -3,6 +3,7 @@ import Quickshell
import Quickshell.Io
import Quickshell.Services.Mpris
import Quickshell.Services.Pipewire
import "MediaModel.js" as MediaModel
Item {
id: root
@@ -33,97 +34,55 @@ Item {
readonly property string identity: activePlayer ? (activePlayer.identity || activePlayer.desktopEntry || "") : ""
function isProxyPlayer(player) {
var dbusName = String(player && player.dbusName || "").toLowerCase()
var desktopEntry = String(player && player.desktopEntry || "").toLowerCase()
return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld"
return MediaModel.isProxyPlayer(player)
}
function hasMetadata(player) {
return !!(player && (player.trackTitle || player.trackArtist || player.identity || player.desktopEntry))
return MediaModel.hasMetadata(player)
}
function hasTrackMetadata(player) {
return !!(player && (player.trackTitle || player.trackArtist || player.trackAlbum || player.trackArtUrl))
return MediaModel.hasTrackMetadata(player)
}
function playerCanControl(player) {
return !!(player && (player.canTogglePlaying || player.canPlay || player.canPause || player.canGoNext || player.canGoPrevious))
return MediaModel.playerCanControl(player)
}
function canHandleAction(player, action) {
if (!player) return false
if (action === "next") return !!player.canGoNext
if (action === "previous") return !!player.canGoPrevious
if (action === "play") return !!(player.canPlay || player.canTogglePlaying)
if (action === "pause") return !!(player.canPause || player.canTogglePlaying)
if (action === "playPause") return !!(player.canTogglePlaying || player.canPlay || player.canPause)
return false
return MediaModel.canHandleAction(player, action)
}
function canCycleSource(player) {
return !!(player && hasMetadata(player) && (player.isPlaying || player.canPlay))
return MediaModel.canCycleSource(player)
}
function nodeProps(node) {
return node && node.ready && node.properties ? node.properties : {}
return MediaModel.nodeProps(node)
}
function isPlaybackStream(node) {
if (!node || !node.isStream) return false
if (node.isSink === true) return true
var mediaClass = String(node.type || "")
return mediaClass.indexOf("Stream/Output/Audio") !== -1
|| mediaClass.indexOf("AudioOutStream") !== -1
|| mediaClass.indexOf("Output") !== -1
return MediaModel.isPlaybackStream(node)
}
function streamLabelKey(label) {
var key = String(label || "").toLowerCase()
key = key.replace(/^pipewire alsa \[/, "")
key = key.replace(/\]$/, "")
key = key.replace(/^alsa playback \[/, "")
key = key.replace(/[^a-z0-9]+/g, "")
return key
return MediaModel.streamLabelKey(label)
}
function rawStreamLabel(node) {
if (!node) return ""
var p = nodeProps(node)
return p["application.name"]
|| node.description
|| p["media.name"]
|| p["node.name"]
|| node.name
return MediaModel.rawStreamLabel(node)
}
function playerAppLabel(player) {
if (!player) return ""
var dbus = String(player.dbusName || "")
dbus = dbus.replace(/^org\.mpris\.MediaPlayer2\./, "")
dbus = dbus.replace(/\.instance[0-9]+$/, "")
return player.desktopEntry || player.identity || dbus
return MediaModel.playerAppLabel(player)
}
function playerHasPlaybackStream(player) {
var playerKey = streamLabelKey(playerAppLabel(player))
if (!playerKey) return false
for (var i = 0; i < playbackStreams.length; i++) {
var streamKey = streamLabelKey(rawStreamLabel(playbackStreams[i]))
if (!streamKey) continue
if (streamKey === playerKey
|| streamKey.indexOf(playerKey) !== -1
|| playerKey.indexOf(streamKey) !== -1)
return true
}
return false
return MediaModel.playerHasPlaybackStream(player, playbackStreams)
}
function playerKey(player) {
if (!player) return ""
return String(player.dbusName || player.desktopEntry || player.identity || "")
return MediaModel.playerKey(player)
}
function playerForKey(key) {
@@ -272,15 +231,11 @@ Item {
}
function labelFor(player) {
if (!player) return ""
return player.trackTitle || player.identity || player.desktopEntry || ""
return MediaModel.labelFor(player)
}
function osdMessage(player, fallback) {
if (!player) return fallback
var label = labelFor(player)
if (label && player.trackArtist) return label + " - " + player.trackArtist
return label || fallback
return MediaModel.osdMessage(player, fallback)
}
function showOsd(actionLabel, iconName, player) {