From 829c1fa4f7872eea7b7333845a5d5e6a2b013181 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 25 May 2026 14:18:39 +0200 Subject: [PATCH] Add shell plugin model tests --- AGENTS.md | 7 +- shell/plugins/audio/AudioModel.js | 262 ++++++++++++++++ shell/plugins/audio/BarWidget.qml | 188 ++--------- shell/plugins/bar/Bar.qml | 73 +---- shell/plugins/bar/BarModel.js | 114 +++++++ shell/plugins/bluetooth/BarWidget.qml | 80 +---- shell/plugins/bluetooth/BluetoothModel.js | 100 ++++++ shell/plugins/clipboard/Clipboard.qml | 85 +---- shell/plugins/clipboard/ClipboardHistory.js | 129 ++++++++ shell/plugins/image-picker/ImagePicker.qml | 60 +--- .../plugins/image-picker/ImagePickerModel.js | 97 ++++++ shell/plugins/menu/Menu.qml | 219 ++----------- shell/plugins/menu/MenuModel.js | 293 ++++++++++++++++++ shell/plugins/monitor/BarWidget.qml | 32 +- shell/plugins/monitor/MonitorModel.js | 52 ++++ shell/plugins/network/BarWidget.qml | 140 +++------ shell/plugins/network/NetworkModel.js | 173 +++++++++++ shell/plugins/notifications/BarWidget.qml | 13 +- .../notifications/NotificationLogic.js | 178 +++++++++++ shell/plugins/notifications/Service.qml | 128 ++------ .../components/NotificationCard.qml | 19 +- shell/plugins/osd/Osd.qml | 41 +-- shell/plugins/osd/OsdModel.js | 54 ++++ shell/plugins/polkit/PolkitAgent.qml | 14 +- shell/plugins/polkit/PolkitModel.js | 22 ++ shell/plugins/power/BarWidget.qml | 82 ++--- shell/plugins/power/PowerModel.js | 104 +++++++ shell/plugins/reminders/ReminderFlow.qml | 17 +- shell/plugins/reminders/ReminderFlowModel.js | 21 ++ .../plugins/services/battery/BatteryModel.js | 28 ++ shell/plugins/services/battery/Service.qml | 27 +- shell/plugins/services/idle/IdleModel.js | 52 ++++ shell/plugins/services/idle/Service.qml | 32 +- shell/plugins/services/media/MediaModel.js | 126 ++++++++ shell/plugins/services/media/Service.qml | 77 +---- shell/plugins/weather/Panel.qml | 104 +------ shell/plugins/weather/WeatherModel.js | 155 +++++++++ test/all | 14 + test/{cli.sh => cli} | 0 test/{shell.sh => shell} | 2 +- test/shell.d/audio-test.sh | 49 +++ test/shell.d/bar-test.sh | 36 +++ test/{shell => shell.d}/base-test.sh | 4 +- test/shell.d/battery-test.sh | 31 ++ test/shell.d/bluetooth-test.sh | 36 +++ test/shell.d/clipboard-test.sh | 62 ++++ test/{shell => shell.d}/emojis-test.sh | 0 test/shell.d/idle-test.sh | 36 +++ test/shell.d/image-picker-test.sh | 43 +++ .../launcher-search-test.sh | 0 test/shell.d/media-test.sh | 39 +++ test/shell.d/menu-test.sh | 88 ++++++ test/shell.d/monitor-test.sh | 39 +++ test/shell.d/network-test.sh | 53 ++++ test/shell.d/notifications-test.sh | 125 ++++++++ test/shell.d/osd-test.sh | 41 +++ test/shell.d/plugins-test.sh | 151 +++++++++ test/shell.d/polkit-test.sh | 30 ++ test/shell.d/power-test.sh | 30 ++ test/shell.d/reminders-test.sh | 34 ++ test/shell.d/weather-test.sh | 63 ++++ 61 files changed, 3236 insertions(+), 1168 deletions(-) create mode 100644 shell/plugins/audio/AudioModel.js create mode 100644 shell/plugins/bar/BarModel.js create mode 100644 shell/plugins/bluetooth/BluetoothModel.js create mode 100644 shell/plugins/clipboard/ClipboardHistory.js create mode 100644 shell/plugins/image-picker/ImagePickerModel.js create mode 100644 shell/plugins/menu/MenuModel.js create mode 100644 shell/plugins/monitor/MonitorModel.js create mode 100644 shell/plugins/network/NetworkModel.js create mode 100644 shell/plugins/notifications/NotificationLogic.js create mode 100644 shell/plugins/osd/OsdModel.js create mode 100644 shell/plugins/polkit/PolkitModel.js create mode 100644 shell/plugins/power/PowerModel.js create mode 100644 shell/plugins/reminders/ReminderFlowModel.js create mode 100644 shell/plugins/services/battery/BatteryModel.js create mode 100644 shell/plugins/services/idle/IdleModel.js create mode 100644 shell/plugins/services/media/MediaModel.js create mode 100644 shell/plugins/weather/WeatherModel.js create mode 100755 test/all rename test/{cli.sh => cli} (100%) mode change 100644 => 100755 rename test/{shell.sh => shell} (93%) mode change 100644 => 100755 create mode 100644 test/shell.d/audio-test.sh create mode 100644 test/shell.d/bar-test.sh rename test/{shell => shell.d}/base-test.sh (92%) create mode 100644 test/shell.d/battery-test.sh create mode 100644 test/shell.d/bluetooth-test.sh create mode 100644 test/shell.d/clipboard-test.sh rename test/{shell => shell.d}/emojis-test.sh (100%) create mode 100644 test/shell.d/idle-test.sh create mode 100644 test/shell.d/image-picker-test.sh rename test/{shell => shell.d}/launcher-search-test.sh (100%) create mode 100644 test/shell.d/media-test.sh create mode 100644 test/shell.d/menu-test.sh create mode 100644 test/shell.d/monitor-test.sh create mode 100644 test/shell.d/network-test.sh create mode 100644 test/shell.d/notifications-test.sh create mode 100644 test/shell.d/osd-test.sh create mode 100644 test/shell.d/plugins-test.sh create mode 100644 test/shell.d/polkit-test.sh create mode 100644 test/shell.d/power-test.sh create mode 100644 test/shell.d/reminders-test.sh create mode 100644 test/shell.d/weather-test.sh diff --git a/AGENTS.md b/AGENTS.md index b6c2ea26..15bc29d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,10 +101,11 @@ Exceptions are allowed for bootstrap, preflight, migration, and package-helper s Run focused automated tests for the area you changed. Current test entry points: -- `bash test/cli.sh` - CLI routing, command metadata, theme helpers, and safe dispatch coverage -- `bash test/shell.sh` - all Omarchy shell tests under `test/shell/` +- `./test/all` - aggregate runner for CLI and shell tests +- `./test/cli` - CLI routing, command metadata, theme helpers, and safe dispatch coverage +- `./test/shell` - all Omarchy shell tests under `test/shell.d/` -New Omarchy shell tests should live in `test/shell/*-test.sh` so `test/shell.sh` picks them up automatically. Source `test/shell/base-test.sh` for shared root-path discovery, assertions, and Node test helpers. +New Omarchy shell tests should live in `test/shell.d/*-test.sh` so `./test/shell` picks them up automatically. Source `test/shell.d/base-test.sh` for shared root-path discovery, assertions, and Node test helpers. For visual changes, such as omarchy-shell styling, desktop appearance, screenshots, or screen recording flows, verify with the running UI in addition to automated tests. Take and analyze screenshots with `omarchy capture screenshot fullscreen save`. For animation, transitions, capture, or screen recording behavior, make a short recording with `omarchy screenrecord --fullscreen`, stop it with `omarchy screenrecord --stop-recording`, and review the output before finishing. diff --git a/shell/plugins/audio/AudioModel.js b/shell/plugins/audio/AudioModel.js new file mode 100644 index 00000000..94b7cda3 --- /dev/null +++ b/shell/plugins/audio/AudioModel.js @@ -0,0 +1,262 @@ +function isPlaybackStream(node) { + if (!node || !node.isStream) return false + if (node.isSink === true) return true + + var mediaClass = String(node.type || "") + return mediaClass.indexOf("Stream/Output/Audio") !== -1 + || mediaClass.indexOf("AudioOutStream") !== -1 + || mediaClass.indexOf("Output") !== -1 +} + +function isAudioSource(node) { + if (!node) return false + if (node.audio) return true + + var mediaClass = String(node.type || "") + return mediaClass.indexOf("Audio/Source") !== -1 + || mediaClass.indexOf("AudioSource") !== -1 + || mediaClass.indexOf("Source") !== -1 +} + +function listSnapshot(list) { + return list && list.slice ? list.slice() : [] +} + +function outputVolumeName(volume, muted) { + if (muted) return "Muted" + var p = Math.round(volume * 100) + if (p === 0) return "Silenced" + if (p >= 100) return "Concert hall" + if (p >= 85) return "Party mode" + if (p >= 70) return "Cranked up" + if (p >= 50) return "Steady groove" + if (p >= 30) return "Easy listening" + if (p >= 15) return "Murmur" + return "Whisper" +} + +function parseSinkAvailability(raw) { + var next = {} + var lines = String(raw || "").split("\n") + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim() + if (!line) continue + var parts = line.split("\t") + if (parts.length >= 2) next[parts[0]] = parts[1] !== "0" + } + return next +} + +function friendlyDeviceLabel(text) { + var label = String(text || "").trim() + label = label.replace(/^sof-soundwire\s+/i, "") + label = label.replace(/^built-?in audio\s+/i, "") + label = label.replace(/\s+Output$/i, "") + label = label.replace(/\s+Input$/i, "") + label = label.replace(/\bMicrophones\b/g, "Microphone") + return label +} + +function nodeProps(node) { + return node && node.ready && node.properties ? node.properties : {} +} + +function nodeLabel(node) { + if (!node) return "Unknown" + var p = nodeProps(node) + var nickname = friendlyDeviceLabel(node.nickname || node.nick || p["node.nick"] || p["device.profile.description"] || "") + if (nickname) return nickname + return friendlyDeviceLabel(node.description || p["node.description"] || node.name || "Unknown") +} + +function isHeadphones(node) { + if (!node) return false + var p = nodeProps(node) + var blob = String([ + node.name, node.description, node.nickname, + p["device.icon-name"] || "", + p["device.product.name"] || "", + p["node.description"] || "", + p["node.nick"] || "" + ].join(" ")).toLowerCase() + return blob.indexOf("headphone") !== -1 + || blob.indexOf("headset") !== -1 + || blob.indexOf("earbud") !== -1 + || blob.indexOf("earphone") !== -1 + || blob.indexOf("airpod") !== -1 +} + +function sinkGlyph(node) { + if (!node) return "󰓃" + if (isHeadphones(node)) return "󰋋" + var p = nodeProps(node) + var blob = String([ + node.name, node.description, node.nickname, + p["device.icon-name"] || "", + p["device.product.name"] || "" + ].join(" ")).toLowerCase() + if (blob.indexOf("bluetooth") !== -1) return "󰂯" + if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return "󰍹" + return "󰓃" +} + +function sourceGlyph(node) { + if (!node) return "󰍬" + var p = nodeProps(node) + var blob = String([ + node.name, node.description, node.nickname, + p["device.icon-name"] || "" + ].join(" ")).toLowerCase() + if (blob.indexOf("headset") !== -1) return "󰋋" + if (blob.indexOf("bluetooth") !== -1) return "󰂯" + if (blob.indexOf("webcam") !== -1 || blob.indexOf("camera") !== -1) return "󰄀" + return "󰍬" +} + +function friendlyStreamLabel(label) { + label = String(label || "").trim() + if (!label) return "" + + var known = { + "spotify": "Spotify" + } + var normalized = label.toLowerCase() + return known[normalized] || label +} + +function streamLabelKey(label) { + return String(label || "").trim().toLowerCase() +} + +function streamLabelIsGeneric(label) { + return streamLabelKey(label) === "audio-src" +} + +function rawStreamLabel(node) { + if (!node) return "" + var p = nodeProps(node) + return p["application.name"] + || node.description + || p["media.name"] + || p["node.name"] + || node.name +} + +function mprisPlayerLabel(player) { + if (!player) return "" + return friendlyStreamLabel(player.identity || player.desktopEntry || "") +} + +function mprisPlayerIsProxy(player) { + var dbusName = String(player && player.dbusName || "").toLowerCase() + var desktopEntry = String(player && player.desktopEntry || "").toLowerCase() + return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld" +} + +function streamRepresentsMprisPlayer(streamLabel, playerLabel) { + var streamKey = streamLabelKey(friendlyStreamLabel(streamLabel)) + var playerKey = streamLabelKey(playerLabel) + if (!streamKey || !playerKey) return false + return streamKey === playerKey + || streamKey.indexOf(playerKey) !== -1 + || playerKey.indexOf(streamKey) !== -1 +} + +function mprisLabelsFor(players, predicate) { + var values = Array.isArray(players) ? players : [] + var playingCandidates = [] + var candidates = [] + var playingProxyCandidates = [] + var proxyCandidates = [] + + for (var i = 0; i < values.length; i++) { + var player = values[i] + if (!player) continue + if (!player.isPlaying && !player.canPlay) continue + + var playerLabel = mprisPlayerLabel(player) + if (!playerLabel || !predicate(playerLabel)) continue + + if (mprisPlayerIsProxy(player)) { + if (player.isPlaying) playingProxyCandidates.push(playerLabel) + proxyCandidates.push(playerLabel) + } else { + if (player.isPlaying) playingCandidates.push(playerLabel) + candidates.push(playerLabel) + } + } + + if (playingCandidates.length === 1) return playingCandidates[0] + if (playingCandidates.length === 0 && playingProxyCandidates.length === 1) return playingProxyCandidates[0] + if (candidates.length === 1) return candidates[0] + if (candidates.length === 0 && proxyCandidates.length === 1) return proxyCandidates[0] + return "" +} + +function matchingMprisStreamLabel(label, players) { + if (streamLabelIsGeneric(label)) return "" + return mprisLabelsFor(players, function(playerLabel) { + return streamRepresentsMprisPlayer(label, playerLabel) + }) +} + +function unmatchedMprisStreamLabel(label, players, streams) { + if (!streamLabelIsGeneric(label)) return "" + + return mprisLabelsFor(players, function(playerLabel) { + var values = Array.isArray(streams) ? streams : [] + for (var i = 0; i < values.length; i++) { + var stream = values[i] + var streamLabel = rawStreamLabel(stream) + if (!streamLabelIsGeneric(streamLabel) && streamRepresentsMprisPlayer(streamLabel, playerLabel)) + return false + } + return true + }) +} + +function streamLabel(node, players, streams) { + if (!node) return "Stream" + var label = rawStreamLabel(node) + return friendlyStreamLabel(matchingMprisStreamLabel(label, players) + || unmatchedMprisStreamLabel(label, players, streams) + || label) || "Stream" +} + +function streamRepresentsPlayer(node, player, players, streams) { + if (!node || !player) return false + var playerLabel = mprisPlayerLabel(player) + if (!playerLabel) return false + + var label = rawStreamLabel(node) + if (!streamLabelIsGeneric(label)) return streamRepresentsMprisPlayer(label, playerLabel) + return streamRepresentsMprisPlayer(streamLabel(node, players, streams), playerLabel) +} + +if (typeof module !== "undefined") { + module.exports = { + isPlaybackStream: isPlaybackStream, + isAudioSource: isAudioSource, + listSnapshot: listSnapshot, + outputVolumeName: outputVolumeName, + parseSinkAvailability: parseSinkAvailability, + friendlyDeviceLabel: friendlyDeviceLabel, + nodeProps: nodeProps, + nodeLabel: nodeLabel, + isHeadphones: isHeadphones, + sinkGlyph: sinkGlyph, + sourceGlyph: sourceGlyph, + friendlyStreamLabel: friendlyStreamLabel, + streamLabelKey: streamLabelKey, + streamLabelIsGeneric: streamLabelIsGeneric, + rawStreamLabel: rawStreamLabel, + mprisPlayerLabel: mprisPlayerLabel, + mprisPlayerIsProxy: mprisPlayerIsProxy, + streamRepresentsMprisPlayer: streamRepresentsMprisPlayer, + mprisLabelsFor: mprisLabelsFor, + matchingMprisStreamLabel: matchingMprisStreamLabel, + unmatchedMprisStreamLabel: unmatchedMprisStreamLabel, + streamLabel: streamLabel, + streamRepresentsPlayer: streamRepresentsPlayer + } +} diff --git a/shell/plugins/audio/BarWidget.qml b/shell/plugins/audio/BarWidget.qml index 0f98ae40..1c46334f 100644 --- a/shell/plugins/audio/BarWidget.qml +++ b/shell/plugins/audio/BarWidget.qml @@ -6,6 +6,7 @@ import Quickshell.Services.Mpris import Quickshell.Services.Pipewire import qs.Ui import qs.Commons +import "AudioModel.js" as AudioModel Panel { id: root @@ -61,23 +62,11 @@ Panel { // playback streams consistently accept audio input from clients and publish // `isSink: true`; capture streams publish as stream sources. function isPlaybackStream(node) { - if (!node || !node.isStream) return false - if (node.isSink === true) return true - - var mediaClass = String(node.type || "") - return mediaClass.indexOf("Stream/Output/Audio") !== -1 - || mediaClass.indexOf("AudioOutStream") !== -1 - || mediaClass.indexOf("Output") !== -1 + return AudioModel.isPlaybackStream(node) } function isAudioSource(node) { - if (!node) return false - if (node.audio) return true - - var mediaClass = String(node.type || "") - return mediaClass.indexOf("Audio/Source") !== -1 - || mediaClass.indexOf("AudioSource") !== -1 - || mediaClass.indexOf("Source") !== -1 + return AudioModel.isAudioSource(node) } property var cachedAudioSinks: [] @@ -274,7 +263,7 @@ Panel { onAudioStreamsChanged: scheduleDisplayAudioModelRefresh() function listSnapshot(list) { - return list && list.slice ? list.slice() : [] + return AudioModel.listSnapshot(list) } function refreshDisplayAudioModels() { @@ -365,16 +354,7 @@ Panel { // panel's brightnessName ladder; bands are wide enough that small // tweaks don't rename the room you're in. function outputVolumeName(volume, muted) { - if (muted) return "Muted" - var p = Math.round(volume * 100) - if (p === 0) return "Silenced" - if (p >= 100) return "Concert hall" - if (p >= 85) return "Party mode" - if (p >= 70) return "Cranked up" - if (p >= 50) return "Steady groove" - if (p >= 30) return "Easy listening" - if (p >= 15) return "Murmur" - return "Whisper" + return AudioModel.outputVolumeName(volume, muted) } function setOutputVolume(v) { @@ -426,203 +406,83 @@ Panel { } function updateSinkAvailability(raw) { - var next = {} - var lines = String(raw || "").split("\n") - for (var i = 0; i < lines.length; i++) { - var line = lines[i].trim() - if (!line) continue - var parts = line.split("\t") - if (parts.length >= 2) next[parts[0]] = parts[1] !== "0" - } - sinkAvailability = next + sinkAvailability = AudioModel.parseSinkAvailability(raw) sinkAvailabilityLoaded = true } function friendlyDeviceLabel(text) { - var label = String(text || "").trim() - label = label.replace(/^sof-soundwire\s+/i, "") - label = label.replace(/^built-?in audio\s+/i, "") - label = label.replace(/\s+Output$/i, "") - label = label.replace(/\s+Input$/i, "") - label = label.replace(/\bMicrophones\b/g, "Microphone") - return label + return AudioModel.friendlyDeviceLabel(text) } function nodeLabel(node) { - if (!node) return "Unknown" - var p = nodeProps(node) - var nickname = friendlyDeviceLabel(node.nickname || node.nick || p["node.nick"] || p["device.profile.description"] || "") - if (nickname) return nickname - return friendlyDeviceLabel(node.description || p["node.description"] || node.name || "Unknown") + return AudioModel.nodeLabel(node) } function nodeProps(node) { - return node && node.ready && node.properties ? node.properties : {} + return AudioModel.nodeProps(node) } function isHeadphones(node) { - if (!node) return false - var p = nodeProps(node) - var blob = String([ - node.name, node.description, node.nickname, - p["device.icon-name"] || "", - p["device.product.name"] || "", - p["node.description"] || "", - p["node.nick"] || "" - ].join(" ")).toLowerCase() - return blob.indexOf("headphone") !== -1 - || blob.indexOf("headset") !== -1 - || blob.indexOf("earbud") !== -1 - || blob.indexOf("earphone") !== -1 - || blob.indexOf("airpod") !== -1 + return AudioModel.isHeadphones(node) } function sinkGlyph(node) { - if (!node) return "󰓃" - if (isHeadphones(node)) return "󰋋" - var p = nodeProps(node) - var blob = String([ - node.name, node.description, node.nickname, - p["device.icon-name"] || "", - p["device.product.name"] || "" - ].join(" ")).toLowerCase() - if (blob.indexOf("bluetooth") !== -1) return "󰂯" - if (blob.indexOf("hdmi") !== -1 || blob.indexOf("display") !== -1) return "󰍹" - return "󰓃" + return AudioModel.sinkGlyph(node) } function sourceGlyph(node) { - if (!node) return "󰍬" - var p = nodeProps(node) - var blob = String([ - node.name, node.description, node.nickname, - p["device.icon-name"] || "" - ].join(" ")).toLowerCase() - if (blob.indexOf("headset") !== -1) return "󰋋" - if (blob.indexOf("bluetooth") !== -1) return "󰂯" - if (blob.indexOf("webcam") !== -1 || blob.indexOf("camera") !== -1) return "󰄀" - return "󰍬" + return AudioModel.sourceGlyph(node) } function friendlyStreamLabel(label) { - label = String(label || "").trim() - if (!label) return "" - - var known = { - "spotify": "Spotify" - } - var normalized = label.toLowerCase() - return known[normalized] || label + return AudioModel.friendlyStreamLabel(label) } function streamLabelKey(label) { - return String(label || "").trim().toLowerCase() + return AudioModel.streamLabelKey(label) } function streamLabelIsGeneric(label) { - return streamLabelKey(label) === "audio-src" + return AudioModel.streamLabelIsGeneric(label) } function rawStreamLabel(node) { - if (!node) return "" - var p = nodeProps(node) - return p["application.name"] - || node.description - || p["media.name"] - || p["node.name"] - || node.name + return AudioModel.rawStreamLabel(node) } function mprisPlayerLabel(player) { - if (!player) return "" - return friendlyStreamLabel(player.identity || player.desktopEntry || "") + return AudioModel.mprisPlayerLabel(player) } function mprisPlayerIsProxy(player) { - var dbusName = String(player && player.dbusName || "").toLowerCase() - var desktopEntry = String(player && player.desktopEntry || "").toLowerCase() - return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld" + return AudioModel.mprisPlayerIsProxy(player) } function streamRepresentsMprisPlayer(streamLabel, playerLabel) { - var streamKey = streamLabelKey(friendlyStreamLabel(streamLabel)) - var playerKey = streamLabelKey(playerLabel) - if (!streamKey || !playerKey) return false - return streamKey === playerKey - || streamKey.indexOf(playerKey) !== -1 - || playerKey.indexOf(streamKey) !== -1 + return AudioModel.streamRepresentsMprisPlayer(streamLabel, playerLabel) } function mprisLabelsFor(predicate) { - var playingCandidates = [] - var candidates = [] - var playingProxyCandidates = [] - var proxyCandidates = [] - for (var i = 0; i < mprisPlayers.length; i++) { - var player = mprisPlayers[i] - if (!player) continue - if (!player.isPlaying && !player.canPlay) continue - - var playerLabel = mprisPlayerLabel(player) - if (!playerLabel || !predicate(playerLabel)) continue - - if (mprisPlayerIsProxy(player)) { - if (player.isPlaying) playingProxyCandidates.push(playerLabel) - proxyCandidates.push(playerLabel) - } else { - if (player.isPlaying) playingCandidates.push(playerLabel) - candidates.push(playerLabel) - } - } - - if (playingCandidates.length === 1) return playingCandidates[0] - if (playingCandidates.length === 0 && playingProxyCandidates.length === 1) return playingProxyCandidates[0] - if (candidates.length === 1) return candidates[0] - if (candidates.length === 0 && proxyCandidates.length === 1) return proxyCandidates[0] - return "" + return AudioModel.mprisLabelsFor(mprisPlayers, predicate) } function matchingMprisStreamLabel(label) { - if (streamLabelIsGeneric(label)) return "" - return mprisLabelsFor(function(playerLabel) { - return streamRepresentsMprisPlayer(label, playerLabel) - }) + return AudioModel.matchingMprisStreamLabel(label, mprisPlayers) } function unmatchedMprisStreamLabel(label) { // Spotify exposes its PipeWire stream as "audio-src". For generic stream // names, use the one MPRIS player not already represented by another audio // stream (e.g. Chromium, or ALSA apps like cliamp). - if (!streamLabelIsGeneric(label)) return "" - - return mprisLabelsFor(function(playerLabel) { - for (var i = 0; i < displayAudioStreams.length; i++) { - var stream = displayAudioStreams[i] - var streamLabel = rawStreamLabel(stream) - if (!streamLabelIsGeneric(streamLabel) - && streamRepresentsMprisPlayer(streamLabel, playerLabel)) - return false - } - return true - }) + return AudioModel.unmatchedMprisStreamLabel(label, mprisPlayers, displayAudioStreams) } function streamLabel(node) { - if (!node) return "Stream" - var label = rawStreamLabel(node) - return friendlyStreamLabel(matchingMprisStreamLabel(label) - || unmatchedMprisStreamLabel(label) - || label) || "Stream" + return AudioModel.streamLabel(node, mprisPlayers, displayAudioStreams) } function streamRepresentsPlayer(node, player) { - if (!node || !player) return false - var playerLabel = mprisPlayerLabel(player) - if (!playerLabel) return false - - var label = rawStreamLabel(node) - if (!streamLabelIsGeneric(label)) return streamRepresentsMprisPlayer(label, playerLabel) - return streamRepresentsMprisPlayer(streamLabel(node), playerLabel) + return AudioModel.streamRepresentsPlayer(node, player, mprisPlayers, displayAudioStreams) } implicitWidth: button.implicitWidth diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index 09c9fbea..f011e242 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -5,6 +5,7 @@ import QtQuick import QtQuick.Layouts import qs.Commons import qs.Ui +import "BarModel.js" as BarModel Item { id: root @@ -110,8 +111,7 @@ Item { readonly property int barSize: vertical ? Style.bar.sizeVertical : Style.bar.sizeHorizontal function normalizePosition(value) { - var next = String(value || "").trim() - return /^(top|bottom|left|right)$/.test(next) ? next : "top" + return BarModel.normalizePosition(value) } // Apply tray-pinning on top of the shared layout normalization so the @@ -130,17 +130,7 @@ Item { // sections. The drawer's reserved space then sits next to the bar center, // not stranded mid-section. function pinTrayToInner(entries, section) { - var trayEntry = null - var result = [] - for (var i = 0; i < entries.length; i++) { - if (entryId(entries[i]) === "omarchy.tray") trayEntry = entries[i] - else result.push(entries[i]) - } - if (trayEntry) { - if (section === "right") result.unshift(trayEntry) - else result.push(trayEntry) - } - return result + return BarModel.pinTrayToInner(entries, section) } function applyBarConfig() { @@ -162,49 +152,27 @@ Item { } function entrySettings(entry) { - if (!Util.isPlainObject(entry)) return {} - var copy = {} - for (var key in entry) { - if (key === "id") continue - copy[key] = entry[key] - } - return copy + return BarModel.entrySettings(entry) } function entryId(entry) { - if (typeof entry === "string") return entry - if (Util.isPlainObject(entry)) { - var id = entry["id"] - if (id !== undefined && id !== null && String(id) !== "") return String(id) - } - return "" + return BarModel.entryId(entry) } function moduleString(entry, key, fallback) { - var settings = entrySettings(entry) - var value = settings[key] - return value === undefined || value === null ? fallback : String(value) + return BarModel.moduleString(entry, key, fallback) } function entryIndex(entries, name) { - if (!Array.isArray(entries)) return -1 - - for (var i = 0; i < entries.length; i++) { - if (entryId(entries[i]) === name) - return i - } - - return -1 + return BarModel.entryIndex(entries, name) } function entriesBefore(entries, name) { - var index = entryIndex(entries, name) - return index <= 0 ? [] : entries.slice(0, index) + return BarModel.entriesBefore(entries, name) } function entriesAfter(entries, name) { - var index = entryIndex(entries, name) - return index === -1 ? [] : entries.slice(index + 1) + return BarModel.entriesAfter(entries, name) } function canonicalWidgetId(name) { @@ -212,34 +180,19 @@ Item { } function expandPath(path) { - var value = String(path || "") - if (value === "") return "" - if (value.indexOf("~/") === 0) return home + value.substring(1) - if (value.indexOf("$HOME/") === 0) return home + value.substring(5) - return value + return BarModel.expandPath(path, home) } function customModuleSafeName(name) { - var value = String(name || "") - return value !== "" && value.indexOf("..") === -1 && value[0] !== "/" + return BarModel.customModuleSafeName(name) } function customModuleType(entry) { - var settings = entrySettings(entry) - var type = String(settings.type || "") - if (type) return type - if (settings.exec) return "command" - if (settings.source) return "qml" - return "" + return BarModel.customModuleType(entry) } function customModuleSource(entry) { - var settings = entrySettings(entry) - var name = entryId(entry) - var source = settings.source ? expandPath(settings.source) : "" - if (!source && customModuleSafeName(name)) - source = omarchyConfigDir + "/bar/modules/" + String(name) + ".qml" - + var source = BarModel.customModulePath(entry, home, omarchyConfigDir) return source ? Util.fileUrl(source) : "" } diff --git a/shell/plugins/bar/BarModel.js b/shell/plugins/bar/BarModel.js new file mode 100644 index 00000000..e94d8a4f --- /dev/null +++ b/shell/plugins/bar/BarModel.js @@ -0,0 +1,114 @@ +function isPlainObject(value) { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function normalizePosition(value) { + var next = String(value || "").trim() + return /^(top|bottom|left|right)$/.test(next) ? next : "top" +} + +function entrySettings(entry) { + if (!isPlainObject(entry)) return {} + var copy = {} + for (var key in entry) { + if (key === "id") continue + copy[key] = entry[key] + } + return copy +} + +function entryId(entry) { + if (typeof entry === "string") return entry + if (isPlainObject(entry)) { + var id = entry["id"] + if (id !== undefined && id !== null && String(id) !== "") return String(id) + } + return "" +} + +function pinTrayToInner(entries, section) { + var trayEntry = null + var result = [] + var values = Array.isArray(entries) ? entries : [] + for (var i = 0; i < values.length; i++) { + if (entryId(values[i]) === "omarchy.tray") trayEntry = values[i] + else result.push(values[i]) + } + if (trayEntry) { + if (section === "right") result.unshift(trayEntry) + else result.push(trayEntry) + } + return result +} + +function moduleString(entry, key, fallback) { + var settings = entrySettings(entry) + var value = settings[key] + return value === undefined || value === null ? fallback : String(value) +} + +function entryIndex(entries, name) { + if (!Array.isArray(entries)) return -1 + for (var i = 0; i < entries.length; i++) { + if (entryId(entries[i]) === name) return i + } + return -1 +} + +function entriesBefore(entries, name) { + var index = entryIndex(entries, name) + return index <= 0 ? [] : entries.slice(0, index) +} + +function entriesAfter(entries, name) { + var index = entryIndex(entries, name) + return index === -1 ? [] : entries.slice(index + 1) +} + +function expandPath(value, home) { + var path = String(value || "") + if (path === "") return "" + if (path.indexOf("~/") === 0) return home + path.substring(1) + if (path.indexOf("$HOME/") === 0) return home + path.substring(5) + return path +} + +function customModuleSafeName(name) { + var value = String(name || "") + return value !== "" && value.indexOf("..") === -1 && value[0] !== "/" +} + +function customModuleType(entry) { + var settings = entrySettings(entry) + var type = String(settings.type || "") + if (type) return type + if (settings.exec) return "command" + if (settings.source) return "qml" + return "" +} + +function customModulePath(entry, home, configDir) { + var settings = entrySettings(entry) + var name = entryId(entry) + var source = settings.source ? expandPath(settings.source, home) : "" + if (!source && customModuleSafeName(name)) + source = String(configDir || "") + "/bar/modules/" + String(name) + ".qml" + return source +} + +if (typeof module !== "undefined") { + module.exports = { + normalizePosition: normalizePosition, + entrySettings: entrySettings, + entryId: entryId, + pinTrayToInner: pinTrayToInner, + moduleString: moduleString, + entryIndex: entryIndex, + entriesBefore: entriesBefore, + entriesAfter: entriesAfter, + expandPath: expandPath, + customModuleSafeName: customModuleSafeName, + customModuleType: customModuleType, + customModulePath: customModulePath + } +} diff --git a/shell/plugins/bluetooth/BarWidget.qml b/shell/plugins/bluetooth/BarWidget.qml index 88a58894..02596aba 100644 --- a/shell/plugins/bluetooth/BarWidget.qml +++ b/shell/plugins/bluetooth/BarWidget.qml @@ -5,6 +5,7 @@ import Quickshell.Io import Quickshell.Bluetooth import qs.Ui import qs.Commons +import "BluetoothModel.js" as BluetoothModel Panel { id: root @@ -20,66 +21,25 @@ Panel { readonly property var devices: Bluetooth.devices ? Bluetooth.devices.values : [] function deviceLabel(device) { - if (!device) return "" - return String(device.deviceName || device.name || "").trim() + return BluetoothModel.deviceLabel(device) } function isUuidLike(value) { - var text = (value || "").trim() - if (text === "") return false - return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text) - || /^[0-9a-f]{32}$/i.test(text) - || /^0x[0-9a-f]{4,32}$/i.test(text) - || /^0000[0-9a-f]{4}-0000-1000-8000-00805f9b34fb$/i.test(text) + return BluetoothModel.isUuidLike(value) } function isAddressLike(value) { - var text = (value || "").trim() - return /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(text) + return BluetoothModel.isAddressLike(value) } function hasHumanName(device) { - var label = deviceLabel(device) - return label !== "" && !isUuidLike(label) && !isAddressLike(label) + return BluetoothModel.hasHumanName(device) } - readonly property var connectedDevices: { - var list = [] - for (var i = 0; i < devices.length; i++) { - var d = devices[i] - if (d && d.connected && hasHumanName(d)) list.push(d) - } - list.sort(function(a, b) { - return deviceLabel(a).localeCompare(deviceLabel(b)) - }) - return list - } - - readonly property var knownDevices: { - var list = [] - for (var i = 0; i < devices.length; i++) { - var d = devices[i] - if (d && hasHumanName(d) && !d.connected && (d.paired || d.bonded || d.trusted)) list.push(d) - } - list.sort(function(a, b) { - return deviceLabel(a).localeCompare(deviceLabel(b)) - }) - return list - } - - readonly property var discoveredDevices: { - var list = [] - for (var i = 0; i < devices.length; i++) { - var d = devices[i] - if (!d || !hasHumanName(d)) continue - if (d.connected || d.paired || d.bonded || d.trusted) continue - list.push(d) - } - list.sort(function(a, b) { - return deviceLabel(a).localeCompare(deviceLabel(b)) - }) - return list - } + readonly property var deviceGroups: BluetoothModel.deviceLists(devices) + readonly property var connectedDevices: deviceGroups.connected || [] + readonly property var knownDevices: deviceGroups.known || [] + readonly property var discoveredDevices: deviceGroups.discovered || [] readonly property string icon: { if (!adapter) return "" @@ -145,18 +105,11 @@ Panel { } readonly property var visibleSections: { - var list = [] - if (sectionVisible("connected")) list.push("connected") - if (sectionVisible("known")) list.push("known") - if (sectionVisible("discovered")) list.push("discovered") - return list + return BluetoothModel.visibleSections(deviceGroups, adapter && adapter.discovering) } function devicesForSection(section) { - if (section === "connected") return connectedDevices - if (section === "known") return knownDevices - if (section === "discovered") return discoveredDevices - return [] + return BluetoothModel.sectionDevices(deviceGroups, section) } function deviceAt(section, index) { @@ -165,21 +118,16 @@ Panel { } function cloneMap(map) { - var next = ({}) - for (var key in map) next[key] = map[key] - return next + return BluetoothModel.cloneMap(map) } function pendingAction(address) { - return address && pendingActions[address] ? pendingActions[address] : "" + return BluetoothModel.pendingAction(pendingActions, address) } function setPendingAction(address, action) { if (!address) return - var next = cloneMap(pendingActions) - if (action) next[address] = action - else delete next[address] - pendingActions = next + pendingActions = BluetoothModel.withPendingAction(pendingActions, address, action) if (action) pendingTimeout.restart() } diff --git a/shell/plugins/bluetooth/BluetoothModel.js b/shell/plugins/bluetooth/BluetoothModel.js new file mode 100644 index 00000000..bb6f7184 --- /dev/null +++ b/shell/plugins/bluetooth/BluetoothModel.js @@ -0,0 +1,100 @@ +function deviceLabel(device) { + if (!device) return "" + return String(device.deviceName || device.name || "").trim() +} + +function isUuidLike(value) { + var text = String(value || "").trim() + if (text === "") return false + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text) + || /^[0-9a-f]{32}$/i.test(text) + || /^0x[0-9a-f]{4,32}$/i.test(text) + || /^0000[0-9a-f]{4}-0000-1000-8000-00805f9b34fb$/i.test(text) +} + +function isAddressLike(value) { + var text = String(value || "").trim() + return /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(text) +} + +function hasHumanName(device) { + var label = deviceLabel(device) + return label !== "" && !isUuidLike(label) && !isAddressLike(label) +} + +function sortedByLabel(devices) { + var list = Array.isArray(devices) ? devices.slice() : [] + list.sort(function(a, b) { return deviceLabel(a).localeCompare(deviceLabel(b)) }) + return list +} + +function deviceLists(devices) { + var values = Array.isArray(devices) ? devices : [] + var connected = [] + var known = [] + var discovered = [] + + for (var i = 0; i < values.length; i++) { + var d = values[i] + if (!d || !hasHumanName(d)) continue + if (d.connected) connected.push(d) + else if (d.paired || d.bonded || d.trusted) known.push(d) + else discovered.push(d) + } + + return { + connected: sortedByLabel(connected), + known: sortedByLabel(known), + discovered: sortedByLabel(discovered) + } +} + +function cloneMap(map) { + var next = ({}) + for (var key in map || {}) next[key] = map[key] + return next +} + +function pendingAction(actions, address) { + return address && actions && actions[address] ? actions[address] : "" +} + +function withPendingAction(actions, address, action) { + var next = cloneMap(actions) + if (!address) return next + if (action) next[address] = action + else delete next[address] + return next +} + +function visibleSections(lists, discovering) { + var sections = [] + if (lists && lists.connected && lists.connected.length > 0) sections.push("connected") + if (lists && lists.known && lists.known.length > 0) sections.push("known") + if (discovering && lists && lists.discovered && lists.discovered.length > 0) sections.push("discovered") + return sections +} + +function sectionDevices(lists, section) { + if (!lists) return [] + if (section === "connected") return lists.connected || [] + if (section === "known") return lists.known || [] + if (section === "discovered") return lists.discovered || [] + return [] +} + +if (typeof module !== "undefined") { + module.exports = { + deviceLabel: deviceLabel, + isUuidLike: isUuidLike, + isAddressLike: isAddressLike, + hasHumanName: hasHumanName, + sortedByLabel: sortedByLabel, + deviceLists: deviceLists, + cloneMap: cloneMap, + pendingAction: pendingAction, + withPendingAction: withPendingAction, + visibleSections: visibleSections, + sectionDevices: sectionDevices + } +} diff --git a/shell/plugins/clipboard/Clipboard.qml b/shell/plugins/clipboard/Clipboard.qml index 848e4847..6a7bc7b9 100644 --- a/shell/plugins/clipboard/Clipboard.qml +++ b/shell/plugins/clipboard/Clipboard.qml @@ -3,6 +3,7 @@ import Quickshell.Io import Quickshell.Wayland import QtQuick import qs.Commons +import "ClipboardHistory.js" as ClipboardHistory Item { id: root @@ -55,48 +56,15 @@ Item { } function normalizeEntry(value) { - if (typeof value === "string") { - return value.length > 0 ? { type: "text", text: value } : null - } - if (!value || typeof value !== "object") return null - - var type = String(value.type || value.kind || "") - if (type === "text") { - var text = String(value.text || "") - return text.length > 0 ? { type: "text", text: text } : null - } - if (type === "image") { - var path = String(value.path || "") - if (!path) return null - return { - type: "image", - path: path, - mime: String(value.mime || "image/png") - } - } - return null + return ClipboardHistory.normalizeEntry(value) } function entryKey(entry) { - if (!entry) return "" - if (entry.type === "image") return "image:" + String(entry.path || "") - return "text:" + String(entry.text || "") + return ClipboardHistory.entryKey(entry) } function loadHistory(raw) { - try { - var parsed = JSON.parse(String(raw || "[]")) - var next = [] - if (Array.isArray(parsed)) { - for (var i = 0; i < parsed.length; i++) { - var entry = root.normalizeEntry(parsed[i]) - if (entry) next.push(entry) - } - } - root.history = next - } catch (e) { - root.history = [] - } + root.history = ClipboardHistory.parseHistory(raw) if (root.opened) root.rebuildDisplay() } @@ -105,52 +73,33 @@ Item { } function addClipboardEntry(entry) { - var normalized = root.normalizeEntry(entry) + var normalized = ClipboardHistory.normalizeEntry(entry) if (!normalized) return - var key = root.entryKey(normalized) - var next = [normalized] - for (var i = 0; i < root.history.length && next.length < 100; i++) { - var existing = root.normalizeEntry(root.history[i]) - if (!existing || root.entryKey(existing) === key) continue - next.push(existing) - } - root.history = next + root.history = ClipboardHistory.addEntry(root.history, normalized, 100) root.saveHistory() if (root.opened) root.rebuildDisplay() } function addClipboardJson(line) { - var raw = String(line || "").trim() - if (!raw) return - try { root.addClipboardEntry(JSON.parse(raw)) } catch (e) {} + root.addClipboardEntry(ClipboardHistory.parseEntryJson(line)) } function rebuildDisplay() { - var query = root.filterText.trim().toLowerCase() + var rows = ClipboardHistory.displayRows(root.history, root.filterText, 50) displayModel.clear() - var outCount = 0 - - for (var i = 0; i < root.history.length; i++) { - var entry = root.normalizeEntry(root.history[i]) - if (!entry) continue - - var isImage = entry.type === "image" - var searchable = isImage ? ("image " + String(entry.mime || "")) : String(entry.text || "") - if (query && searchable.toLowerCase().indexOf(query) < 0) continue - + for (var i = 0; i < rows.length; i++) { + var row = rows[i] displayModel.append({ - entryType: entry.type, - fullText: isImage ? "" : String(entry.text || ""), - previewText: isImage ? "Image" : String(entry.text || "").replace(/\s+/g, " "), - previewImage: isImage ? Util.fileUrl(entry.path) : "", - path: isImage ? String(entry.path || "") : "", - mime: isImage ? String(entry.mime || "image/png") : "text/plain", - index: outCount + entryType: row.entryType, + fullText: row.fullText, + previewText: row.previewText, + previewImage: row.previewImage ? Util.fileUrl(row.previewImage) : "", + path: row.path, + mime: row.mime, + index: row.index }) - outCount++ - if (outCount >= 50) break } if (displayModel.count === 0) selectedIndex = 0 diff --git a/shell/plugins/clipboard/ClipboardHistory.js b/shell/plugins/clipboard/ClipboardHistory.js new file mode 100644 index 00000000..6fb0d5b2 --- /dev/null +++ b/shell/plugins/clipboard/ClipboardHistory.js @@ -0,0 +1,129 @@ +function normalizeEntry(value) { + if (typeof value === "string") + return value.length > 0 ? { type: "text", text: value } : null + + if (!value || typeof value !== "object") return null + + var type = String(value.type || value.kind || "") + if (type === "text") { + var text = String(value.text || "") + return text.length > 0 ? { type: "text", text: text } : null + } + + if (type === "image") { + var path = String(value.path || "") + if (!path) return null + return { + type: "image", + path: path, + mime: String(value.mime || "image/png") + } + } + + return null +} + +function entryKey(entry) { + if (!entry) return "" + if (entry.type === "image") return "image:" + String(entry.path || "") + return "text:" + String(entry.text || "") +} + +function parseHistory(raw) { + try { + var parsed = JSON.parse(String(raw || "[]")) + var next = [] + if (!Array.isArray(parsed)) return next + + for (var i = 0; i < parsed.length; i++) { + var entry = normalizeEntry(parsed[i]) + if (entry) next.push(entry) + } + return next + } catch (e) { + return [] + } +} + +function addEntry(history, entry, limit) { + var normalized = normalizeEntry(entry) + var max = limit === undefined || limit === null ? 100 : Number(limit) + if (isNaN(max)) max = 100 + max = Math.max(0, max) + if (!normalized) return Array.isArray(history) ? history.slice(0, max) : [] + if (max === 0) return [] + + var key = entryKey(normalized) + var next = [normalized] + var values = Array.isArray(history) ? history : [] + + for (var i = 0; i < values.length && next.length < max; i++) { + var existing = normalizeEntry(values[i]) + if (!existing || entryKey(existing) === key) continue + next.push(existing) + } + + return next +} + +function parseEntryJson(line) { + var raw = String(line || "").trim() + if (!raw) return null + try { return normalizeEntry(JSON.parse(raw)) } catch (e) { return null } +} + +function searchableText(entry) { + if (!entry) return "" + if (entry.type === "image") return "image " + String(entry.mime || "") + return String(entry.text || "") +} + +function previewText(entry) { + if (!entry) return "" + if (entry.type === "image") return "Image" + return String(entry.text || "").replace(/\s+/g, " ") +} + +function displayRows(history, query, limit) { + var values = Array.isArray(history) ? history : [] + var needle = String(query || "").trim().toLowerCase() + var max = limit === undefined || limit === null ? 50 : Number(limit) + if (isNaN(max)) max = 50 + max = Math.max(0, max) + if (max === 0) return [] + + var rows = [] + + for (var i = 0; i < values.length; i++) { + var entry = normalizeEntry(values[i]) + if (!entry) continue + if (needle && searchableText(entry).toLowerCase().indexOf(needle) < 0) continue + + var isImage = entry.type === "image" + rows.push({ + entryType: entry.type, + fullText: isImage ? "" : String(entry.text || ""), + previewText: previewText(entry), + previewImage: isImage ? String(entry.path || "") : "", + path: isImage ? String(entry.path || "") : "", + mime: isImage ? String(entry.mime || "image/png") : "text/plain", + index: rows.length + }) + if (rows.length >= max) break + } + + return rows +} + +if (typeof module !== "undefined") { + module.exports = { + normalizeEntry: normalizeEntry, + entryKey: entryKey, + parseHistory: parseHistory, + addEntry: addEntry, + parseEntryJson: parseEntryJson, + searchableText: searchableText, + previewText: previewText, + displayRows: displayRows + } +} diff --git a/shell/plugins/image-picker/ImagePicker.qml b/shell/plugins/image-picker/ImagePicker.qml index 5e66056e..bb3c3fe6 100644 --- a/shell/plugins/image-picker/ImagePicker.qml +++ b/shell/plugins/image-picker/ImagePicker.qml @@ -5,6 +5,7 @@ import QtQuick import QtQuick.Effects import QtQuick.Shapes import qs.Commons +import "ImagePickerModel.js" as ImagePickerModel Item { id: root @@ -70,11 +71,11 @@ Item { } function nameForPath(path) { - return path.split("/").pop().replace(/\.[^/.]+$/, "") + return ImagePickerModel.nameForPath(path) } function labelForPath(path) { - return nameForPath(path).replace(/[-_]+/g, " ").replace(/\b\w/g, function(match) { return match.toUpperCase() }) + return ImagePickerModel.labelForPath(path) } function currentLabel() { @@ -85,37 +86,19 @@ Item { } function itemMatches(index) { - if (index < 0 || index >= imageArray.length) return false - if (!filterText) return true - - var path = imageArray[index].filePath - var needle = filterText.toLowerCase() - return nameForPath(path).toLowerCase().indexOf(needle) !== -1 || labelForPath(path).toLowerCase().indexOf(needle) !== -1 + return ImagePickerModel.itemMatches(imageArray, index, filterText) } function firstMatchingIndex() { - for (var i = 0; i < imageArray.length; i++) { - if (itemMatches(i)) return i - } - - return -1 + return ImagePickerModel.firstMatchingIndex(imageArray, filterText) } function filteredPosition(index) { - if (!filterText) return index - - var position = 0 - for (var i = 0; i < index; i++) { - if (itemMatches(i)) position++ - } - - return position + return ImagePickerModel.filteredPosition(imageArray, index, filterText) } function selectedFilteredPosition() { - if (!filterText) return selectedIndex - - return itemMatches(selectedIndex) ? filteredPosition(selectedIndex) : 0 + return ImagePickerModel.selectedFilteredPosition(imageArray, selectedIndex, filterText) } function select(index, immediate) { @@ -146,7 +129,7 @@ Item { filterText = nextFilterText if (!itemMatches(selectedIndex)) { - var first = firstMatchingIndex() + var first = ImagePickerModel.nextSelectedIndexForFilter(imageArray, selectedIndex, filterText) if (first >= 0) selectedIndex = first } } @@ -210,25 +193,7 @@ Item { } function loadRows(rows, reveal) { - var newImages = [] - var seen = {} - var paths = rows.split("\n") - for (var i = 0; i < paths.length; i++) { - var row = paths[i] - if (!row) continue - - var columns = row.split("\t") - var path = columns[0] - if (!path) continue - var fileName = path.split("/").pop() - if (seen[fileName]) continue - seen[fileName] = true - newImages.push({ - filePath: path, - fileName: fileName, - thumbnailPath: columns[1] || path - }) - } + var newImages = ImagePickerModel.loadRows(rows) root.loadedImageRows = rows root.selectedIndex = root.indexForSelectedImage(newImages) @@ -304,12 +269,7 @@ Item { } function indexForSelectedImage(images) { - for (var i = 0; i < images.length; i++) { - if (images[i].filePath === selectedImage) - return i - } - - return 0 + return ImagePickerModel.indexForSelectedImage(images, selectedImage) } function selectedImageIndex() { diff --git a/shell/plugins/image-picker/ImagePickerModel.js b/shell/plugins/image-picker/ImagePickerModel.js new file mode 100644 index 00000000..f34fdcab --- /dev/null +++ b/shell/plugins/image-picker/ImagePickerModel.js @@ -0,0 +1,97 @@ +function nameForPath(path) { + return String(path || "").split("/").pop().replace(/\.[^/.]+$/, "") +} + +function labelForPath(path) { + return nameForPath(path).replace(/[-_]+/g, " ").replace(/\b\w/g, function(match) { return match.toUpperCase() }) +} + +function loadRows(rows) { + var images = [] + var seen = {} + var paths = String(rows || "").split("\n") + + for (var i = 0; i < paths.length; i++) { + var row = paths[i] + if (!row) continue + + var columns = row.split("\t") + var path = columns[0] + if (!path) continue + + var fileName = path.split("/").pop() + if (seen[fileName]) continue + seen[fileName] = true + + images.push({ + filePath: path, + fileName: fileName, + thumbnailPath: columns[1] || path + }) + } + + return images +} + +function itemMatches(images, index, filterText) { + if (!Array.isArray(images) || index < 0 || index >= images.length) return false + var needle = String(filterText || "").toLowerCase() + if (!needle) return true + + var path = String(images[index].filePath || "") + return nameForPath(path).toLowerCase().indexOf(needle) !== -1 + || labelForPath(path).toLowerCase().indexOf(needle) !== -1 +} + +function firstMatchingIndex(images, filterText) { + var values = Array.isArray(images) ? images : [] + for (var i = 0; i < values.length; i++) { + if (itemMatches(values, i, filterText)) return i + } + + return -1 +} + +function filteredPosition(images, index, filterText) { + if (!filterText) return index + + var position = 0 + for (var i = 0; i < index; i++) { + if (itemMatches(images, i, filterText)) position++ + } + + return position +} + +function selectedFilteredPosition(images, selectedIndex, filterText) { + if (!filterText) return selectedIndex + return itemMatches(images, selectedIndex, filterText) ? filteredPosition(images, selectedIndex, filterText) : 0 +} + +function indexForSelectedImage(images, selectedImage) { + var values = Array.isArray(images) ? images : [] + for (var i = 0; i < values.length; i++) { + if (values[i].filePath === selectedImage) return i + } + + return 0 +} + +function nextSelectedIndexForFilter(images, selectedIndex, filterText) { + if (itemMatches(images, selectedIndex, filterText)) return selectedIndex + return firstMatchingIndex(images, filterText) +} + +if (typeof module !== "undefined") { + module.exports = { + nameForPath: nameForPath, + labelForPath: labelForPath, + loadRows: loadRows, + itemMatches: itemMatches, + firstMatchingIndex: firstMatchingIndex, + filteredPosition: filteredPosition, + selectedFilteredPosition: selectedFilteredPosition, + indexForSelectedImage: indexForSelectedImage, + nextSelectedIndexForFilter: nextSelectedIndexForFilter + } +} diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index 1e06c4ab..b8958699 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -3,6 +3,7 @@ import Quickshell.Io import Quickshell.Wayland import QtQuick import qs.Commons +import "MenuModel.js" as MenuModel Item { id: root @@ -169,112 +170,35 @@ Item { // ------------------------------------------------------------------ function stripJsonc(raw) { - // Match what the bash bin's jq pipeline accepts: full-line `//` comments - // plus trailing commas before } or ]. Anything fancier should land in - // valid JSON anyway. - return String(raw || "") - .replace(/^\s*\/\/[^\n]*(\n|$)/gm, "") - .replace(/,(\s*[}\]])/g, "$1") + return MenuModel.stripJsonc(raw) } function normalizeAliases(value) { - if (Array.isArray(value)) return value.filter(function(v) { return v }) - if (typeof value === "string" && value) return [value] - return [] + return MenuModel.normalizeAliases(value) } function normalizeKeywords(id, aliases, raw) { - var parts = String(raw || "").split(/\s+/) - var seen = {} - var out = [] - for (var i = 0; i < parts.length; i++) { - var p = parts[i] - if (!p || seen[p]) continue - seen[p] = true - out.push(p) - } - return out.join(" ") + return MenuModel.normalizeKeywords(id, aliases, raw) } function normalizeItem(id, raw) { - var aliases = normalizeAliases(raw.aliases) - var parent = raw.parent - if (parent === undefined) { - parent = id.indexOf(".") >= 0 ? id.split(".").slice(0, -1).join(".") : "root" - } - if (id === "root") parent = "" - - var kind = raw.action ? "action" : (raw.target ? "link" : "menu") - - return { - id: id, - parent: parent, - kind: kind, - icon: raw.icon || "", - label: raw.label || id, - target: raw.target || "", - keywords: normalizeKeywords(id, aliases, raw.keywords), - description: raw.description || "", - action: raw.action || "", - provider: raw.provider || "", - aliases: aliases, - when: raw.when || "", - checked: raw.checked || "" - } + return MenuModel.normalizeItem(id, raw) } function parseMenuJsonc(raw) { - var stripped = stripJsonc(raw) - if (!stripped.trim()) return [] - var parsed - try { parsed = JSON.parse(stripped) } catch (e) { - console.warn("omarchy-menu: JSONC parse failed:", e) - return [] - } - if (typeof parsed !== "object" || parsed === null) return [] - var source = (parsed.items && typeof parsed.items === "object" && !Array.isArray(parsed.items)) - ? parsed.items - : parsed - var out = [] - for (var id in source) { - var entry = source[id] - if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue - out.push(normalizeItem(id, entry)) - } - return out + return MenuModel.parseMenuJsonc(raw) } // Merge defaults + user extension. Later entries override earlier ones // on a per-key basis (so the user can tweak label/icon/action without // re-declaring the whole row). function rebuildItemsFromSources() { - var nextItems = ({}) - var nextOrder = [] - var sources = [root.defaultMenuItems || [], root.userMenuItems || []] - for (var s = 0; s < sources.length; s++) { - var src = sources[s] - for (var i = 0; i < src.length; i++) { - var entry = src[i] - if (!entry || !entry.id) continue - if (!nextItems[entry.id]) nextOrder.push(entry.id) - var prior = nextItems[entry.id] || {} - var merged = {} - for (var k in prior) merged[k] = prior[k] - for (var k2 in entry) merged[k2] = entry[k2] - merged.id = entry.id - nextItems[entry.id] = merged - } - } - if (!nextItems.root) { - nextItems.root = { id: "root", parent: "", kind: "menu", icon: "", label: "Go", target: "", keywords: "", description: "", aliases: [], when: "", checked: "", action: "", provider: "" } - nextOrder.unshift("root") - } - for (var k3 = 0; k3 < nextOrder.length; k3++) nextItems[nextOrder[k3]].order = k3 + var mergedMenu = MenuModel.mergeMenuSources(root.defaultMenuItems, root.userMenuItems) root.providerRevision += 1 root.providersLoaded = ({}) root.providerQueue = [] - root.items = nextItems - root.itemOrder = nextOrder + root.items = mergedMenu.items + root.itemOrder = mergedMenu.itemOrder root.rowsLoaded = true root.evaluateGuards() if (root.opened) { @@ -305,7 +229,7 @@ Item { }) function slugify(value) { - return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item" + return MenuModel.slugify(value) } function startProviderForMenu(id) { @@ -399,62 +323,23 @@ Item { } function depthFor(id) { - var depth = 0 - var current = root.item(id) - var guard = 0 - - while (current && current.parent && current.parent !== "root" && guard < 32) { - depth += 1 - current = root.item(current.parent) - guard += 1 - } - - return depth + return MenuModel.depthFor(root.items, id) } function pathFor(id) { - var labels = [] - var current = root.item(id) - var guard = 0 - - while (current && current.id !== "root" && guard < 32) { - labels.unshift(current.label) - current = root.item(current.parent) - guard += 1 - } - - return labels.join(" › ") + return MenuModel.pathFor(root.items, id) } function parentPathFor(id) { - var entry = root.item(id) - if (!entry || !entry.parent || entry.parent === "root") return "" - - return root.pathFor(entry.parent) + return MenuModel.parentPathFor(root.items, id) } function isDescendantOf(id, ancestorId) { - if (ancestorId === "root") return id !== "root" - - var current = root.item(id) - var guard = 0 - while (current && current.parent && guard < 32) { - if (current.parent === ancestorId) return true - current = root.item(current.parent) - guard += 1 - } - - return false + return MenuModel.isDescendantOf(root.items, id, ancestorId) } function childCount(id) { - var count = 0 - for (var i = 0; i < root.itemOrder.length; i++) { - var entry = root.item(root.itemOrder[i]) - if (entry && entry.parent === id) count += 1 - } - - return count + return MenuModel.childCount(root.items, root.itemOrder, id) } // Items whose `when:` evaluated to false are hidden everywhere — nav, @@ -468,95 +353,39 @@ Item { // Label with the ✓ marker baked in when `checked:` evaluated truthy. function labelFor(entry) { - if (!entry) return "" - if (entry.checked && root.checkedResults[entry.id]) return entry.label + " ✓" - return entry.label + return MenuModel.labelFor(entry, root.checkedResults) } function searchableToken(value) { - return String(value || "").replace(/[._-]+/g, " ") + return MenuModel.searchableToken(value) } function leafIdFor(id) { - var parts = String(id || "").split(".") - return parts.length > 0 ? parts[parts.length - 1] : id + return MenuModel.leafIdFor(id) } function nameSearchText(entry) { - if (!entry) return "" - var aliases = [] - for (var i = 0; i < entry.aliases.length; i++) aliases.push(root.searchableToken(entry.aliases[i])) - return [entry.label, root.searchableToken(root.leafIdFor(entry.id)), aliases.join(" ")].join(" ").toLowerCase() + return MenuModel.nameSearchText(entry) } function termInSearchWords(term, text) { - var words = String(text || "").toLowerCase().split(/\s+/) - for (var i = 0; i < words.length; i++) { - if (words[i] === term) return true - } - return false + return MenuModel.termInSearchWords(term, text) } function keywordTextMatches(query, text) { - var terms = query.toLowerCase().trim().split(/\s+/) - for (var i = 0; i < terms.length; i++) { - if (terms[i] && !root.termInSearchWords(terms[i], text)) return false - } - return true + return MenuModel.keywordTextMatches(query, text) } function matchesQuery(entry, query) { - if (!entry || entry.id === "root") return false - if (!root.isVisible(entry)) return false - - var nameText = root.nameSearchText(entry) - var keywordText = (entry.keywords + " " + entry.description).toLowerCase() - var terms = query.toLowerCase().trim().split(/\s+/) - - for (var i = 0; i < terms.length; i++) { - if (!terms[i]) continue - if (nameText.indexOf(terms[i]) >= 0) continue - if (root.termInSearchWords(terms[i], keywordText)) continue - return false - } - - return true + return MenuModel.matchesQuery(entry, query, root.isVisible(entry)) } function searchScore(entry, query) { - var needle = query.toLowerCase().trim() - var label = entry.label.toLowerCase() - var nameText = root.nameSearchText(entry) - var keywordText = (entry.keywords + " " + entry.description).toLowerCase() - var score = 80 - - if (label === needle) score = entry.parent === "root" ? 2 : 0 - else if (label.indexOf(needle) === 0) score = 10 - else if (label.indexOf(needle) >= 0) score = 30 - else if (nameText.indexOf(needle) >= 0) score = 40 - else if (root.keywordTextMatches(needle, keywordText)) score = 60 - - if (entry.kind === "menu" || entry.kind === "link") score -= 2 - - return score * 1000 + root.depthFor(entry.id) * 25 + entry.order + return MenuModel.searchScore(root.items, entry, query) } function displayRow(entry, detail, score, section) { - var target = entry.kind === "link" ? entry.target : entry.id - return { - itemId: entry.id, - kind: entry.kind, - icon: entry.icon, - label: root.labelFor(entry), - target: target, - detail: detail || "", - path: root.pathFor(entry.id), - childCount: (entry.kind === "menu" || entry.kind === "link") ? root.childCount(target) : 0, - action: entry.action || "", - provider: entry.provider || "", - score: score || 0, - section: section || "" - } + return MenuModel.displayRow(root.items, root.itemOrder, root.checkedResults, entry, detail, score, section) } function rebuildDmenuDisplay() { diff --git a/shell/plugins/menu/MenuModel.js b/shell/plugins/menu/MenuModel.js new file mode 100644 index 00000000..2d8cb0bc --- /dev/null +++ b/shell/plugins/menu/MenuModel.js @@ -0,0 +1,293 @@ +function stripJsonc(raw) { + return String(raw || "") + .replace(/^\s*\/\/[^\n]*(\n|$)/gm, "") + .replace(/,(\s*[}\]])/g, "$1") +} + +function normalizeAliases(value) { + if (Array.isArray(value)) return value.filter(function(v) { return v }) + if (typeof value === "string" && value) return [value] + return [] +} + +function normalizeKeywords(id, aliases, raw) { + var parts = String(raw || "").split(/\s+/) + var seen = {} + var out = [] + for (var i = 0; i < parts.length; i++) { + var p = parts[i] + if (!p || seen[p]) continue + seen[p] = true + out.push(p) + } + return out.join(" ") +} + +function normalizeItem(id, raw) { + var value = raw || {} + var aliases = normalizeAliases(value.aliases) + var parent = value.parent + if (parent === undefined) + parent = id.indexOf(".") >= 0 ? id.split(".").slice(0, -1).join(".") : "root" + if (id === "root") parent = "" + + var kind = value.action ? "action" : (value.target ? "link" : "menu") + + return { + id: id, + parent: parent, + kind: kind, + icon: value.icon || "", + label: value.label || id, + target: value.target || "", + keywords: normalizeKeywords(id, aliases, value.keywords), + description: value.description || "", + action: value.action || "", + provider: value.provider || "", + aliases: aliases, + when: value.when || "", + checked: value.checked || "" + } +} + +function parseMenuJsonc(raw) { + var stripped = stripJsonc(raw) + if (!stripped.trim()) return [] + + var parsed + try { + parsed = JSON.parse(stripped) + } catch (e) { + return [] + } + if (typeof parsed !== "object" || parsed === null) return [] + + var source = (parsed.items && typeof parsed.items === "object" && !Array.isArray(parsed.items)) + ? parsed.items + : parsed + var out = [] + for (var id in source) { + var entry = source[id] + if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue + out.push(normalizeItem(id, entry)) + } + return out +} + +function mergeMenuSources(defaultItems, userItems) { + var nextItems = ({}) + var nextOrder = [] + var sources = [defaultItems || [], userItems || []] + + for (var s = 0; s < sources.length; s++) { + var src = sources[s] + for (var i = 0; i < src.length; i++) { + var entry = src[i] + if (!entry || !entry.id) continue + if (!nextItems[entry.id]) nextOrder.push(entry.id) + var prior = nextItems[entry.id] || {} + var merged = {} + for (var k in prior) merged[k] = prior[k] + for (var k2 in entry) merged[k2] = entry[k2] + merged.id = entry.id + nextItems[entry.id] = merged + } + } + + if (!nextItems.root) { + nextItems.root = { id: "root", parent: "", kind: "menu", icon: "", label: "Go", target: "", keywords: "", description: "", aliases: [], when: "", checked: "", action: "", provider: "" } + nextOrder.unshift("root") + } + for (var k3 = 0; k3 < nextOrder.length; k3++) nextItems[nextOrder[k3]].order = k3 + + return { + items: nextItems, + itemOrder: nextOrder + } +} + +function item(items, id) { + return items && items[id] ? items[id] : null +} + +function slugify(value) { + return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item" +} + +function depthFor(items, id) { + var depth = 0 + var current = item(items, id) + var guard = 0 + + while (current && current.parent && current.parent !== "root" && guard < 32) { + depth += 1 + current = item(items, current.parent) + guard += 1 + } + + return depth +} + +function pathFor(items, id) { + var labels = [] + var current = item(items, id) + var guard = 0 + + while (current && current.id !== "root" && guard < 32) { + labels.unshift(current.label) + current = item(items, current.parent) + guard += 1 + } + + return labels.join(" › ") +} + +function parentPathFor(items, id) { + var entry = item(items, id) + if (!entry || !entry.parent || entry.parent === "root") return "" + return pathFor(items, entry.parent) +} + +function isDescendantOf(items, id, ancestorId) { + if (ancestorId === "root") return id !== "root" + + var current = item(items, id) + var guard = 0 + while (current && current.parent && guard < 32) { + if (current.parent === ancestorId) return true + current = item(items, current.parent) + guard += 1 + } + + return false +} + +function childCount(items, itemOrder, id) { + var count = 0 + var order = Array.isArray(itemOrder) ? itemOrder : [] + for (var i = 0; i < order.length; i++) { + var entry = item(items, order[i]) + if (entry && entry.parent === id) count += 1 + } + return count +} + +function labelFor(entry, checkedResults) { + if (!entry) return "" + if (entry.checked && checkedResults && checkedResults[entry.id]) return entry.label + " ✓" + return entry.label +} + +function searchableToken(value) { + return String(value || "").replace(/[._-]+/g, " ") +} + +function leafIdFor(id) { + var parts = String(id || "").split(".") + return parts.length > 0 ? parts[parts.length - 1] : id +} + +function nameSearchText(entry) { + if (!entry) return "" + var aliases = [] + var values = Array.isArray(entry.aliases) ? entry.aliases : [] + for (var i = 0; i < values.length; i++) aliases.push(searchableToken(values[i])) + return [entry.label, searchableToken(leafIdFor(entry.id)), aliases.join(" ")].join(" ").toLowerCase() +} + +function termInSearchWords(term, text) { + var words = String(text || "").toLowerCase().split(/\s+/) + for (var i = 0; i < words.length; i++) { + if (words[i] === term) return true + } + return false +} + +function keywordTextMatches(query, text) { + var terms = String(query || "").toLowerCase().trim().split(/\s+/) + for (var i = 0; i < terms.length; i++) { + if (terms[i] && !termInSearchWords(terms[i], text)) return false + } + return true +} + +function matchesQuery(entry, query, visible) { + if (!entry || entry.id === "root") return false + if (!visible) return false + + var nameText = nameSearchText(entry) + var keywordText = (entry.keywords + " " + entry.description).toLowerCase() + var terms = String(query || "").toLowerCase().trim().split(/\s+/) + + for (var i = 0; i < terms.length; i++) { + if (!terms[i]) continue + if (nameText.indexOf(terms[i]) >= 0) continue + if (termInSearchWords(terms[i], keywordText)) continue + return false + } + + return true +} + +function searchScore(items, entry, query) { + var needle = String(query || "").toLowerCase().trim() + var label = entry.label.toLowerCase() + var nameText = nameSearchText(entry) + var keywordText = (entry.keywords + " " + entry.description).toLowerCase() + var score = 80 + + if (label === needle) score = entry.parent === "root" ? 2 : 0 + else if (label.indexOf(needle) === 0) score = 10 + else if (label.indexOf(needle) >= 0) score = 30 + else if (nameText.indexOf(needle) >= 0) score = 40 + else if (keywordTextMatches(needle, keywordText)) score = 60 + + if (entry.kind === "menu" || entry.kind === "link") score -= 2 + + return score * 1000 + depthFor(items, entry.id) * 25 + entry.order +} + +function displayRow(items, itemOrder, checkedResults, entry, detail, score, section) { + var target = entry.kind === "link" ? entry.target : entry.id + return { + itemId: entry.id, + kind: entry.kind, + icon: entry.icon, + label: labelFor(entry, checkedResults), + target: target, + detail: detail || "", + path: pathFor(items, entry.id), + childCount: (entry.kind === "menu" || entry.kind === "link") ? childCount(items, itemOrder, target) : 0, + action: entry.action || "", + provider: entry.provider || "", + score: score || 0, + section: section || "" + } +} + +if (typeof module !== "undefined") { + module.exports = { + stripJsonc: stripJsonc, + normalizeAliases: normalizeAliases, + normalizeKeywords: normalizeKeywords, + normalizeItem: normalizeItem, + parseMenuJsonc: parseMenuJsonc, + mergeMenuSources: mergeMenuSources, + item: item, + slugify: slugify, + depthFor: depthFor, + pathFor: pathFor, + parentPathFor: parentPathFor, + isDescendantOf: isDescendantOf, + childCount: childCount, + labelFor: labelFor, + searchableToken: searchableToken, + leafIdFor: leafIdFor, + nameSearchText: nameSearchText, + termInSearchWords: termInSearchWords, + keywordTextMatches: keywordTextMatches, + matchesQuery: matchesQuery, + searchScore: searchScore, + displayRow: displayRow + } +} diff --git a/shell/plugins/monitor/BarWidget.qml b/shell/plugins/monitor/BarWidget.qml index 619749d9..dbe6a1b1 100644 --- a/shell/plugins/monitor/BarWidget.qml +++ b/shell/plugins/monitor/BarWidget.qml @@ -4,6 +4,7 @@ import Quickshell import Quickshell.Io import qs.Ui import qs.Commons +import "MonitorModel.js" as MonitorModel Panel { id: root @@ -202,7 +203,7 @@ Panel { } function setBrightness(value) { - var percent = Math.max(1, Math.min(100, Math.round(value))) + var percent = MonitorModel.clampBrightness(value) root.brightnessPercent = percent root.pendingBrightnessPercent = percent @@ -217,42 +218,25 @@ Panel { } function previewBrightness(value) { - root.brightnessPercent = Math.max(1, Math.min(100, Math.round(value))) + root.brightnessPercent = MonitorModel.clampBrightness(value) brightnessDebounce.restart() } function normalizeScale(scale) { - var n = parseFloat(String(scale || "")) - if (!isFinite(n)) return "" - return String(Math.round(n * 100) / 100) + return MonitorModel.normalizeScale(scale) } // Playful mood-name for a given brightness percent. Bands intentionally // span ~10–20 points so casual tweaks change the label, while small // nudges within one band don't. function brightnessName(percent) { - var p = Math.round(percent) - if (p >= 95) return "Sun blast" - if (p >= 80) return "Solar flare" - if (p >= 65) return "Golden hour" - if (p >= 45) return "Even day" - if (p >= 30) return "Soft glow" - if (p >= 20) return "Lamp light" - if (p >= 10) return "Candlelit" - return "Night owl" + return MonitorModel.brightnessName(percent) } function updateDisplays(displaysJson) { - try { - root.displays = displaysJson ? JSON.parse(displaysJson) : [] - } catch(e) { - root.displays = [] - } - - var count = 0 - for (var i = 0; i < root.displays.length; i++) - if (root.displays[i] && root.displays[i].enabled) count++ - root.enabledDisplayCount = count + var parsed = MonitorModel.parseDisplays(displaysJson) + root.displays = parsed.displays + root.enabledDisplayCount = parsed.enabledDisplayCount } function toggleDisplay(name, enabled) { diff --git a/shell/plugins/monitor/MonitorModel.js b/shell/plugins/monitor/MonitorModel.js new file mode 100644 index 00000000..afe3efca --- /dev/null +++ b/shell/plugins/monitor/MonitorModel.js @@ -0,0 +1,52 @@ +function clampBrightness(value) { + var n = Number(value) + if (!isFinite(n)) return 1 + return Math.max(1, Math.min(100, Math.round(n))) +} + +function normalizeScale(scale) { + var n = parseFloat(String(scale || "")) + if (!isFinite(n)) return "" + return String(Math.round(n * 100) / 100) +} + +function brightnessName(percent) { + var p = Math.round(percent) + if (p >= 95) return "Sun blast" + if (p >= 80) return "Solar flare" + if (p >= 65) return "Golden hour" + if (p >= 45) return "Even day" + if (p >= 30) return "Soft glow" + if (p >= 20) return "Lamp light" + if (p >= 10) return "Candlelit" + return "Night owl" +} + +function parseDisplays(raw) { + var displays = [] + try { + displays = raw ? JSON.parse(String(raw)) : [] + } catch (e) { + displays = [] + } + if (!Array.isArray(displays)) displays = [] + + var count = 0 + for (var i = 0; i < displays.length; i++) { + if (displays[i] && displays[i].enabled) count++ + } + + return { + displays: displays, + enabledDisplayCount: count + } +} + +if (typeof module !== "undefined") { + module.exports = { + clampBrightness: clampBrightness, + normalizeScale: normalizeScale, + brightnessName: brightnessName, + parseDisplays: parseDisplays + } +} diff --git a/shell/plugins/network/BarWidget.qml b/shell/plugins/network/BarWidget.qml index 30cbe509..329e7032 100644 --- a/shell/plugins/network/BarWidget.qml +++ b/shell/plugins/network/BarWidget.qml @@ -5,6 +5,7 @@ import Quickshell.Io import Quickshell.Networking import qs.Ui import qs.Commons +import "NetworkModel.js" as NetworkModel Panel { id: root @@ -228,11 +229,11 @@ Panel { property string frequency: "" function updateNetwork(raw) { - var parts = String(raw || "disconnected\t\t\t").replace(/\r?\n+$/, "").split("\t") - kind = parts[0] || "disconnected" - label = parts[1] || "" - signalStrength = parts[2] ? parseInt(parts[2], 10) : -1 - frequency = parts[3] || "" + var parsed = NetworkModel.parseNetworkStatus(raw) + kind = parsed.kind + label = parsed.label + signalStrength = parsed.signalStrength + frequency = parsed.frequency } function copyToClipboard(value) { @@ -240,15 +241,7 @@ Panel { Quickshell.execDetached(["bash", "-lc", "printf %s " + Util.shellQuote(value) + " | wl-copy"]) } - readonly property string icon: { - if (kind === "wifi") { - var icons = ["󰤯", "󰤟", "󰤢", "󰤥", "󰤨"] - var index = Math.max(0, Math.min(4, Math.ceil(signalStrength / 20) - 1)) - return icons[index] - } - if (kind === "ethernet") return "󰈀" - return "󰤮" - } + readonly property string icon: NetworkModel.connectionIcon(kind, signalStrength) function refresh(scanWifi) { if (scanWifi === undefined) scanWifi = false @@ -270,79 +263,47 @@ Panel { } function formatHeaderSpeed(mbps) { - var v = parseInt(mbps, 10) - if (!v || v < 0) return "" - if (v >= 1000) return (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + "gbit" - return v + "mbit" + return NetworkModel.formatHeaderSpeed(mbps) } function formatHeaderFreq(mhz) { - var v = parseFloat(mhz) - if (!v) return "" - var ghz = v / 1000 - return ghz.toFixed(ghz % 1 === 0 ? 0 : 1) + "ghz" + return NetworkModel.formatHeaderFreq(mhz) } function headerDetail() { - if (info.type === "ethernet") return formatHeaderSpeed(info.speed || "") - if (info.type === "wifi") return formatHeaderFreq(info.freq || "") - return "" + return NetworkModel.headerDetail(info) } function updateDetails(raw) { - var next = {} - var lines = String(raw || "").split("\n") - for (var i = 0; i < lines.length; i++) { - var line = lines[i] - if (!line) continue - var idx = line.indexOf("\t") - if (idx === -1) continue - next[line.substring(0, idx)] = line.substring(idx + 1).trim() - } + var next = NetworkModel.parseKeyValue(raw) info = next updateThroughput(next) } function updateThroughput(next) { - var iface = next.iface || "" - var rx = parseFloat(next.rx_bytes || "0") - var tx = parseFloat(next.tx_bytes || "0") - var now = Date.now() / 1000 + var state = NetworkModel.throughputState({ + prevIface: prevIface, + prevRxBytes: prevRxBytes, + prevTxBytes: prevTxBytes, + prevSampleTime: prevSampleTime, + downloadRate: downloadRate, + uploadRate: uploadRate + }, next, Date.now() / 1000) - // Interface changed (or first sample) — reseed without emitting a rate; - // raw counters from two different NICs are meaningless to subtract. - if (iface !== prevIface || prevSampleTime === 0) { - prevIface = iface - prevRxBytes = rx - prevTxBytes = tx - prevSampleTime = now - downloadRate = 0 - uploadRate = 0 - return - } - - var dt = now - prevSampleTime - if (dt > 0) { - // Math.max guards against counter wrap or reset to 0 (interface flap). - downloadRate = Math.max(0, (rx - prevRxBytes) / dt) - uploadRate = Math.max(0, (tx - prevTxBytes) / dt) - } - prevRxBytes = rx - prevTxBytes = tx - prevSampleTime = now + prevIface = state.prevIface + prevRxBytes = state.prevRxBytes + prevTxBytes = state.prevTxBytes + prevSampleTime = state.prevSampleTime + downloadRate = state.downloadRate + uploadRate = state.uploadRate } function formatBytes(bytes) { - var n = Number(bytes) - if (!isFinite(n) || n < 0) n = 0 - if (n < 1024) return Math.round(n) + " B" - if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB" - if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB" - return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB" + return NetworkModel.formatBytes(bytes) } function formatRate(bytesPerSec) { - return formatBytes(bytesPerSec) + "/s" + return NetworkModel.formatRate(bytesPerSec) } function findDevice(type) { @@ -369,42 +330,20 @@ Panel { var network = networks[i] if (!network) continue checkActionCompletion(network) - var isConnected = network.connected - var ssid = network.name || "" - nets.push({ - network: network, - connected: isConnected, - known: network.known, - ssid: ssid, - signal: Math.round((network.signalStrength || 0) * 100), - security: network.security - }) + var row = NetworkModel.wifiRow(network) + if (row) nets.push(row) } - nets.sort(function(a, b) { - if (a.connected !== b.connected) return a.connected ? -1 : 1 - if (a.known !== b.known) return a.known ? -1 : 1 - return b.signal - a.signal - }) - wifiNetworks = nets + wifiNetworks = NetworkModel.sortWifiRows(nets) wifiStationAvailable = !!wifiDevice scanning = false } function wifiSectionTitle(index) { - if (index < 0 || index >= wifiNetworks.length) return "" - - var net = wifiNetworks[index] - if (!net) return "" - - if (net.known && index === 0) return "KNOWN NETWORKS" - if (!net.known && (index === 0 || (wifiNetworks[index - 1] && wifiNetworks[index - 1].known))) return "OTHER NETWORKS" - return "" + return NetworkModel.wifiSectionTitle(wifiNetworks, index) } function wifiIconFor(strength) { - var icons = ["󰤯", "󰤟", "󰤢", "󰤥", "󰤨"] - var index = Math.max(0, Math.min(4, Math.ceil(strength / 20) - 1)) - return icons[index] + return NetworkModel.wifiIconFor(strength) } function updateDns(raw) { @@ -435,7 +374,7 @@ Panel { } function isProtected(security) { - return security !== WifiSecurityType.Open + return NetworkModel.isProtected(security, WifiSecurityType.Open) } function openPasswordPrompt(ssid) { @@ -493,12 +432,13 @@ Panel { } function networkFailureReason(reason) { - if (reason === ConnectionFailReason.NoSecrets) return "Passphrase required" - if (reason === ConnectionFailReason.WifiAuthTimeout) return "Wrong password" - if (reason === ConnectionFailReason.WifiNetworkLost) return "Network lost" - if (reason === ConnectionFailReason.WifiClientDisconnected) return "Disconnected" - if (reason === ConnectionFailReason.WifiClientFailed) return "Connection failed" - return "Failed to connect" + return NetworkModel.networkFailureReason(reason, { + NoSecrets: ConnectionFailReason.NoSecrets, + WifiAuthTimeout: ConnectionFailReason.WifiAuthTimeout, + WifiNetworkLost: ConnectionFailReason.WifiNetworkLost, + WifiClientDisconnected: ConnectionFailReason.WifiClientDisconnected, + WifiClientFailed: ConnectionFailReason.WifiClientFailed + }) } function checkActionCompletion(network) { diff --git a/shell/plugins/network/NetworkModel.js b/shell/plugins/network/NetworkModel.js new file mode 100644 index 00000000..7f81b60c --- /dev/null +++ b/shell/plugins/network/NetworkModel.js @@ -0,0 +1,173 @@ +function parseNetworkStatus(raw) { + var parts = String(raw || "disconnected\t\t\t").replace(/\r?\n+$/, "").split("\t") + return { + kind: parts[0] || "disconnected", + label: parts[1] || "", + signalStrength: parts[2] ? parseInt(parts[2], 10) : -1, + frequency: parts[3] || "" + } +} + +function wifiIconFor(strength) { + var icons = ["󰤯", "󰤟", "󰤢", "󰤥", "󰤨"] + var index = Math.max(0, Math.min(4, Math.ceil(strength / 20) - 1)) + return icons[index] +} + +function connectionIcon(kind, signalStrength) { + if (kind === "wifi") return wifiIconFor(signalStrength) + if (kind === "ethernet") return "󰈀" + return "󰤮" +} + +function formatHeaderSpeed(mbps) { + var v = parseInt(mbps, 10) + if (!v || v < 0) return "" + if (v >= 1000) return (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + "gbit" + return v + "mbit" +} + +function formatHeaderFreq(mhz) { + var v = parseFloat(mhz) + if (!v) return "" + var ghz = v / 1000 + return ghz.toFixed(ghz % 1 === 0 ? 0 : 1) + "ghz" +} + +function headerDetail(info) { + var value = info || {} + if (value.type === "ethernet") return formatHeaderSpeed(value.speed || "") + if (value.type === "wifi") return formatHeaderFreq(value.freq || "") + return "" +} + +function parseKeyValue(raw) { + var next = {} + var lines = String(raw || "").split("\n") + for (var i = 0; i < lines.length; i++) { + var line = lines[i] + if (!line) continue + var idx = line.indexOf("\t") + if (idx === -1) continue + next[line.substring(0, idx)] = line.substring(idx + 1).trim() + } + return next +} + +function throughputState(previous, next, now) { + var prev = previous || {} + var sample = next || {} + var iface = sample.iface || "" + var rx = parseFloat(sample.rx_bytes || "0") + var tx = parseFloat(sample.tx_bytes || "0") + var previousTime = Number(prev.prevSampleTime || 0) + + if (iface !== (prev.prevIface || "") || previousTime === 0) { + return { + prevIface: iface, + prevRxBytes: rx, + prevTxBytes: tx, + prevSampleTime: now, + downloadRate: 0, + uploadRate: 0 + } + } + + var downloadRate = Number(prev.downloadRate || 0) + var uploadRate = Number(prev.uploadRate || 0) + var dt = now - previousTime + if (dt > 0) { + downloadRate = Math.max(0, (rx - Number(prev.prevRxBytes || 0)) / dt) + uploadRate = Math.max(0, (tx - Number(prev.prevTxBytes || 0)) / dt) + } + + return { + prevIface: iface, + prevRxBytes: rx, + prevTxBytes: tx, + prevSampleTime: now, + downloadRate: downloadRate, + uploadRate: uploadRate + } +} + +function formatBytes(bytes) { + var n = Number(bytes) + if (!isFinite(n) || n < 0) n = 0 + if (n < 1024) return Math.round(n) + " B" + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB" + if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB" + return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB" +} + +function formatRate(bytesPerSec) { + return formatBytes(bytesPerSec) + "/s" +} + +function wifiRow(network) { + if (!network) return null + return { + network: network, + connected: !!network.connected, + known: !!network.known, + ssid: network.name || "", + signal: Math.round((network.signalStrength || 0) * 100), + security: network.security + } +} + +function sortWifiRows(rows) { + var nets = Array.isArray(rows) ? rows.slice() : [] + nets.sort(function(a, b) { + if (a.connected !== b.connected) return a.connected ? -1 : 1 + if (a.known !== b.known) return a.known ? -1 : 1 + return b.signal - a.signal + }) + return nets +} + +function wifiSectionTitle(wifiNetworks, index) { + var networks = Array.isArray(wifiNetworks) ? wifiNetworks : [] + if (index < 0 || index >= networks.length) return "" + + var net = networks[index] + if (!net) return "" + + if (net.known && index === 0) return "KNOWN NETWORKS" + if (!net.known && (index === 0 || (networks[index - 1] && networks[index - 1].known))) return "OTHER NETWORKS" + return "" +} + +function isProtected(security, openSecurity) { + return security !== openSecurity +} + +function networkFailureReason(reason, reasons) { + var r = reasons || {} + if (reason === r.NoSecrets) return "Passphrase required" + if (reason === r.WifiAuthTimeout) return "Wrong password" + if (reason === r.WifiNetworkLost) return "Network lost" + if (reason === r.WifiClientDisconnected) return "Disconnected" + if (reason === r.WifiClientFailed) return "Connection failed" + return "Failed to connect" +} + +if (typeof module !== "undefined") { + module.exports = { + parseNetworkStatus: parseNetworkStatus, + wifiIconFor: wifiIconFor, + connectionIcon: connectionIcon, + formatHeaderSpeed: formatHeaderSpeed, + formatHeaderFreq: formatHeaderFreq, + headerDetail: headerDetail, + parseKeyValue: parseKeyValue, + throughputState: throughputState, + formatBytes: formatBytes, + formatRate: formatRate, + wifiRow: wifiRow, + sortWifiRows: sortWifiRows, + wifiSectionTitle: wifiSectionTitle, + isProtected: isProtected, + networkFailureReason: networkFailureReason + } +} diff --git a/shell/plugins/notifications/BarWidget.qml b/shell/plugins/notifications/BarWidget.qml index 19730ec2..a1217044 100644 --- a/shell/plugins/notifications/BarWidget.qml +++ b/shell/plugins/notifications/BarWidget.qml @@ -3,6 +3,7 @@ import QtQuick.Layouts import Quickshell import qs.Commons import qs.Ui +import "NotificationLogic.js" as NotificationLogic BarWidget { id: root @@ -29,19 +30,11 @@ BarWidget { : null function isChromiumDerived(app, appIcon) { - var source = (String(app || "") + "\n" + String(appIcon || "")).toLowerCase() - return source.indexOf("chrom") >= 0 || source.indexOf("brave") >= 0 || - source.indexOf("vivaldi") >= 0 || source.indexOf("microsoft-edge") >= 0 || - source.indexOf("opera") >= 0 + return NotificationLogic.isChromiumDerived(app, appIcon) } function sanitizeBody(s, app, appIcon) { - var text = String(s || "").replace(/]*>/gi, "") - if (!isChromiumDerived(app, appIcon)) return text - - return text - .replace(/^\s*]*>\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^<\s]*)?\s*<\/a>\s*/i, "") - .replace(/^\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?\s+/i, "") + return NotificationLogic.sanitizeBody(s, app, appIcon) } function notificationIconSource(icon) { diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js new file mode 100644 index 00000000..adf8099a --- /dev/null +++ b/shell/plugins/notifications/NotificationLogic.js @@ -0,0 +1,178 @@ +function isChromiumDerived(app, appIcon) { + var source = (String(app || "") + "\n" + String(appIcon || "")).toLowerCase() + return source.indexOf("chrom") >= 0 || source.indexOf("brave") >= 0 || + source.indexOf("vivaldi") >= 0 || source.indexOf("microsoft-edge") >= 0 || + source.indexOf("opera") >= 0 +} + +function sanitizeBody(body, app, appIcon) { + var text = String(body || "").replace(/]*>/gi, "") + if (!isChromiumDerived(app, appIcon)) return text + + return text + .replace(/^\s*]*>\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^<\s]*)?\s*<\/a>\s*/i, "") + .replace(/^\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?\s+/i, "") +} + +function summaryStartsWithGlyph(summary) { + var text = String(summary || "").replace(/^\s+/, "") + if (!text) return false + + var offset = 1 + var first = text.charCodeAt(0) + if (first >= 0xd800 && first <= 0xdbff && text.length > 1) offset = 2 + + var spaces = 0 + while (offset < text.length && text.charAt(offset) === " ") { + spaces++ + offset++ + } + + return spaces >= 2 +} + +function shouldBypassDnd(notification, criticalUrgency) { + var appName = String((notification && notification.appName) || "") + if (appName === "omarchy-action") return true + return appName === "notify-send" && notification && notification.urgency === criticalUrgency +} + +function glyphFromHints(hints) { + try { + if (hints) { + var glyph = hints["omarchy-glyph"] + if (glyph !== undefined && glyph !== null) return String(glyph) + } + } catch (e) { + } + return "" +} + +function snapshotOf(notification, timestamp) { + var n = notification || {} + var id = n.id || 0 + return { + id: id, + originalId: id, + app: n.appName || "", + appIcon: n.appIcon || "", + summary: String(n.summary || ""), + body: n.body || "", + image: n.image || "", + glyph: glyphFromHints(n.hints), + urgency: n.urgency, + timestamp: timestamp === undefined ? Date.now() : timestamp, + ref: notification + } +} + +function historyEntry(value, normalUrgency) { + var e = value || {} + return { + id: e.id || 0, + originalId: e.originalId || e.id || 0, + app: e.app || "", + appIcon: e.appIcon || "", + summary: e.summary || "", + body: e.body || "", + image: e.image || "", + glyph: e.glyph || "", + urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency, + timestamp: e.timestamp || 0, + ref: null + } +} + +function dedupeByOriginalId(rows) { + var values = Array.isArray(rows) ? rows : [] + var keep = {} + for (var i = 0; i < values.length; i++) { + var row = values[i] + if (!row) continue + var key = row.originalId + if (key === undefined || key === null) key = "_" + i + var prior = keep[key] + if (!prior || (row.timestamp || 0) >= (prior.timestamp || 0)) keep[key] = row + } + + var out = [] + for (var id in keep) out.push(keep[id]) + out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) }) + return out +} + +function parseHistory(raw, normalUrgency, historyCap) { + var text = String(raw || "").trim() + var cap = historyCap === undefined || historyCap === null ? 100 : Number(historyCap) + if (isNaN(cap)) cap = 100 + cap = Math.max(0, cap) + if (!text) return { empty: true, error: false, dnd: null, pending: [], past: [], hadDuplicates: false } + + try { + var parsed = JSON.parse(text) + var pendingRaw = (parsed && Array.isArray(parsed.pending)) ? parsed.pending : [] + var pastRaw = (parsed && Array.isArray(parsed.past)) ? parsed.past : [] + if (parsed && Array.isArray(parsed.entries)) pastRaw = pastRaw.concat(parsed.entries) + + var pendingDeduped = dedupeByOriginalId(pendingRaw) + var pastDeduped = dedupeByOriginalId(pastRaw) + + return { + empty: false, + error: false, + dnd: parsed && typeof parsed.dnd === "boolean" ? parsed.dnd : null, + pending: pendingDeduped.slice(0, cap).map(function(entry) { return historyEntry(entry, normalUrgency) }), + past: pastDeduped.slice(0, cap).map(function(entry) { return historyEntry(entry, normalUrgency) }), + hadDuplicates: pendingDeduped.length !== pendingRaw.length || pastDeduped.length !== pastRaw.length + } + } catch (e) { + return { empty: false, error: true, errorMessage: String(e), dnd: null, pending: [], past: [], hadDuplicates: false } + } +} + +function dumpRows(rows) { + var values = Array.isArray(rows) ? rows : [] + var out = [] + for (var i = 0; i < values.length; i++) { + var r = values[i] + if (!r) continue + out.push({ + id: r.id, + originalId: r.originalId, + app: r.app, + appIcon: r.appIcon, + summary: r.summary, + body: r.body, + image: r.image, + glyph: r.glyph || "", + urgency: r.urgency, + timestamp: r.timestamp + }) + } + return out +} + +function imageExtension(srcPath) { + var lower = String(srcPath || "").toLowerCase() + var dot = lower.lastIndexOf(".") + if (dot < 0) return "png" + var ext = lower.substring(dot + 1) + if (ext.length === 0 || ext.length > 5) return "png" + return ext +} + +if (typeof module !== "undefined") { + module.exports = { + isChromiumDerived: isChromiumDerived, + sanitizeBody: sanitizeBody, + summaryStartsWithGlyph: summaryStartsWithGlyph, + shouldBypassDnd: shouldBypassDnd, + glyphFromHints: glyphFromHints, + snapshotOf: snapshotOf, + historyEntry: historyEntry, + dedupeByOriginalId: dedupeByOriginalId, + parseHistory: parseHistory, + dumpRows: dumpRows, + imageExtension: imageExtension + } +} diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml index ebca0b32..a4ce58ad 100644 --- a/shell/plugins/notifications/Service.qml +++ b/shell/plugins/notifications/Service.qml @@ -9,6 +9,7 @@ import Quickshell.Services.Notifications import qs.Commons import "components" +import "NotificationLogic.js" as NotificationLogic Item { id: service @@ -112,36 +113,11 @@ Item { // chat apps set app_name to their brand (Discord/Slack/Vesktop), which // falls outside this rule. function shouldBypassDnd(notification) { - var appName = String(notification.appName || "") - if (appName === "omarchy-action") return true - if (appName === "notify-send" && notification.urgency === NotificationUrgency.Critical) return true - return false + return NotificationLogic.shouldBypassDnd(notification, NotificationUrgency.Critical) } function snapshotOf(notification) { - var glyph = "" - try { - if (notification.hints) { - var hintGlyph = notification.hints["omarchy-glyph"] - if (hintGlyph !== undefined && hintGlyph !== null) - glyph = String(hintGlyph) - } - } catch (e) { glyph = "" } - var summary = String(notification.summary || "") - - return { - id: notification.id, - originalId: notification.id, - app: notification.appName || "", - appIcon: notification.appIcon || "", - summary: summary, - body: notification.body || "", - image: notification.image || "", - glyph: glyph, - urgency: notification.urgency, - timestamp: Date.now(), - ref: notification - } + return NotificationLogic.snapshotOf(notification, Date.now()) } function handleNotification(notification) { @@ -384,12 +360,7 @@ Item { // and skip them for v1. function imageExtension(srcPath) { - var lower = srcPath.toLowerCase() - var dot = lower.lastIndexOf(".") - if (dot < 0) return "png" - var ext = lower.substring(dot + 1) - if (ext.length === 0 || ext.length > 5) return "png" - return ext + return NotificationLogic.imageExtension(srcPath) } function maybeCacheImage(snapshot) { @@ -541,78 +512,31 @@ Item { // guard, the second fire appends a second copy of every persisted row // to the in-memory model. if (service.historyLoaded) return - var text = String(raw || "").trim() - if (!text) { service.historyLoaded = true; return } - try { - var parsed = JSON.parse(text) - if (parsed && typeof parsed.dnd === "boolean") { - service._hydrating = true - persisted.doNotDisturb = parsed.dnd - service._hydrating = false - } - var pending = (parsed && Array.isArray(parsed.pending)) ? parsed.pending : [] - var past = (parsed && Array.isArray(parsed.past)) ? parsed.past : [] - // v1 backwards compat: the old schema had a single `entries` array. - // Treat all of those as past since the user already presumably saw - // them (and DND-suppressed notifications from before the split are - // a rare edge case). - if (parsed && Array.isArray(parsed.entries)) past = past.concat(parsed.entries) - function entryFor(e) { - return { - id: e.id || 0, - originalId: e.originalId || e.id || 0, - app: e.app || "", - appIcon: e.appIcon || "", - summary: e.summary || "", - body: e.body || "", - image: e.image || "", - glyph: e.glyph || "", - urgency: typeof e.urgency === "number" ? e.urgency : NotificationUrgency.Normal, - timestamp: e.timestamp || 0, - ref: null - } - } - // Older builds didn't dedupe chat-app replacements, so hydrated files - // can hold hundreds of identical rows (same originalId). Collapse on - // load — keep the newest occurrence (highest timestamp) and drop the - // rest. Save is rescheduled below so the disk file rewrites cleanly. - function dedupeByOriginalId(rows) { - var keep = {} - for (var k = 0; k < rows.length; k++) { - var r = rows[k] - if (!r) continue - var key = r.originalId - if (key === undefined || key === null) { keep["_" + k] = r; continue } - var prior = keep[key] - if (!prior || (r.timestamp || 0) >= (prior.timestamp || 0)) keep[key] = r - } - var out = [] - for (var id in keep) out.push(keep[id]) - out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) }) - return out - } - var pendingDeduped = dedupeByOriginalId(pending) - var pastDeduped = dedupeByOriginalId(past) - var hadDuplicates = pendingDeduped.length !== pending.length - || pastDeduped.length !== past.length - // Newest-first on disk; insert in order so models match. - Qt.callLater(function() { - for (var i = 0; i < pendingDeduped.length; i++) { - pendingModel.append(entryFor(pendingDeduped[i])) - if (pendingModel.count > service.historyCap) pendingModel.remove(pendingModel.count - 1) - } - for (var j = 0; j < pastDeduped.length; j++) { - pastModel.append(entryFor(pastDeduped[j])) - if (pastModel.count > service.historyCap) pastModel.remove(pastModel.count - 1) - } - service.historyLoaded = true - if (hadDuplicates) service.scheduleHistorySave() - }) - } catch (e) { - console.warn("notifications: history parse failed:", e) + var parsed = NotificationLogic.parseHistory(raw, NotificationUrgency.Normal, service.historyCap) + if (parsed.empty) { service.historyLoaded = true + return } + if (parsed.error) { + console.warn("notifications: history parse failed:", parsed.errorMessage || "") + service.historyLoaded = true + return + } + + if (parsed.dnd !== null) { + service._hydrating = true + persisted.doNotDisturb = parsed.dnd + service._hydrating = false + } + + // Newest-first on disk; append in order so models match. + Qt.callLater(function() { + for (var i = 0; i < parsed.pending.length; i++) pendingModel.append(parsed.pending[i]) + for (var j = 0; j < parsed.past.length; j++) pastModel.append(parsed.past[j]) + service.historyLoaded = true + if (parsed.hadDuplicates) service.scheduleHistorySave() + }) } function flushHistory() { diff --git a/shell/plugins/notifications/components/NotificationCard.qml b/shell/plugins/notifications/components/NotificationCard.qml index adfabd21..db40c6b0 100644 --- a/shell/plugins/notifications/components/NotificationCard.qml +++ b/shell/plugins/notifications/components/NotificationCard.qml @@ -6,6 +6,7 @@ import QtQuick import QtQuick.Layouts import Quickshell import qs.Commons +import "../NotificationLogic.js" as NotificationLogic Rectangle { id: root @@ -37,15 +38,9 @@ Rectangle { readonly property string smallIconSource: image.length > 0 ? image : iconSource(appIcon) readonly property bool hasGlyph: glyph.length > 0 readonly property bool hasSmallIcon: smallIconSource.length > 0 || hasGlyph - readonly property bool summaryStartsWithGlyph: /^\s*\S\s{2,}/.test(summary) + readonly property bool summaryStartsWithGlyph: NotificationLogic.summaryStartsWithGlyph(summary) readonly property bool singleLineToast: sanitizedBody.length === 0 readonly property bool collapseRedundantIcon: singleLineToast && !hasGlyph && summaryStartsWithGlyph - readonly property bool chromiumDerived: { - var source = (app + "\n" + appIcon).toLowerCase() - return source.indexOf("chrom") >= 0 || source.indexOf("brave") >= 0 || - source.indexOf("vivaldi") >= 0 || source.indexOf("microsoft-edge") >= 0 || - source.indexOf("opera") >= 0 - } readonly property string sanitizedBody: sanitizeBody(body) readonly property string styledBody: sanitizedBody.replace(/\r\n|\r|\n/g, "
") @@ -54,15 +49,7 @@ Rectangle { readonly property color accentColor: urgency === 2 ? Color.urgent : (urgency === 0 ? dimColor : Color.notifications.countdown) function sanitizeBody(s) { - var text = String(s).replace(/]*>/gi, "") - if (!chromiumDerived) return text - - // Chromium web notifications often prefix the body with the sending - // origin, sometimes as a hyperlink. The browser icon already identifies - // the source, so drop only that leading URL/domain. - return text - .replace(/^\s*]*>\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^<\s]*)?\s*<\/a>\s*/i, "") - .replace(/^\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?\s+/i, "") + return NotificationLogic.sanitizeBody(s, app, appIcon) } function iconSource(icon) { diff --git a/shell/plugins/osd/Osd.qml b/shell/plugins/osd/Osd.qml index 345499de..a571b932 100644 --- a/shell/plugins/osd/Osd.qml +++ b/shell/plugins/osd/Osd.qml @@ -3,6 +3,7 @@ import Quickshell import Quickshell.Io import Quickshell.Wayland import qs.Commons +import "OsdModel.js" as OsdModel Item { id: root @@ -22,40 +23,18 @@ Item { readonly property bool mediaOsd: iconKey.indexOf("media") === 0 || iconKey.indexOf("player") === 0 function iconFor(name, percent) { - var n = String(name || "").toLowerCase() - if (n === "volume-muted" || n === "volume-mute" || n === "muted" || n === "mute") return "" - if (n === "volume-low") return "" - if (n === "volume-medium") return "" - if (n === "volume-high" || n === "volume") return "" - if (n === "microphone-muted" || n === "microphone-off" || n === "mic-muted" || n === "mic-off") return "󰍭" - if (n === "microphone" || n === "mic") return "󰍬" - if (n === "keyboard") return "󰌌" - if (n === "brightness" || n === "display") return "󰍹" - if (n === "touchpad") return "󰟸" - if (n === "touch" || n === "touchscreen") return "󰜉" - if (n === "media" || n === "player") return "󰝚" - if (n === "media-source" || n === "player-source") return "󰝚" - if (n === "media-play" || n === "player-play") return "󰐊" - if (n === "media-pause" || n === "player-pause") return "󰏤" - if (n === "media-next" || n === "player-next") return "󰒭" - if (n === "media-previous" || n === "player-previous") return "󰒮" - if (n.length > 0) return name - if (percent <= 0) return "" - if (percent <= 33) return "" - if (percent <= 66) return "" - return "" + return OsdModel.iconFor(name, percent) } function show(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration) { - iconKey = String(iconName || "").toLowerCase() - maxValue = Math.max(1, parseInt(rawMax || "100", 10)) - var parsed = parseInt(rawValue || "0", 10) - hasProgress = rawValue !== "" && !isNaN(parsed) && rawMessage === "" - value = hasProgress ? Util.clamp(parsed, 0, maxValue) : 0 - message = String(rawMessage || (hasProgress ? (rawProgressText || Math.round(value * 100 / maxValue) + "%") : "")) - icon = iconFor(iconName, hasProgress ? Math.round(value * 100 / maxValue) : -1) - var parsedDuration = parseInt(rawDuration || "1200", 10) - duration = isNaN(parsedDuration) ? 1200 : Math.max(0, parsedDuration) + var next = OsdModel.stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration) + iconKey = next.iconKey + maxValue = next.maxValue + hasProgress = next.hasProgress + value = next.value + message = next.message + icon = next.icon + duration = next.duration opened = true if (duration > 0) hideTimer.restart() else hideTimer.stop() diff --git a/shell/plugins/osd/OsdModel.js b/shell/plugins/osd/OsdModel.js new file mode 100644 index 00000000..daf12464 --- /dev/null +++ b/shell/plugins/osd/OsdModel.js @@ -0,0 +1,54 @@ +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)) +} + +function iconFor(name, percent) { + var n = String(name || "").toLowerCase() + if (n === "volume-muted" || n === "volume-mute" || n === "muted" || n === "mute") return "" + if (n === "volume-low") return "" + if (n === "volume-medium") return "" + if (n === "volume-high" || n === "volume") return "" + if (n === "microphone-muted" || n === "microphone-off" || n === "mic-muted" || n === "mic-off") return "󰍭" + if (n === "microphone" || n === "mic") return "󰍬" + if (n === "keyboard") return "󰌌" + if (n === "brightness" || n === "display") return "󰍹" + if (n === "touchpad") return "󰟸" + if (n === "touch" || n === "touchscreen") return "󰜉" + if (n === "media" || n === "player") return "󰝚" + if (n === "media-source" || n === "player-source") return "󰝚" + if (n === "media-play" || n === "player-play") return "󰐊" + if (n === "media-pause" || n === "player-pause") return "󰏤" + if (n === "media-next" || n === "player-next") return "󰒭" + if (n === "media-previous" || n === "player-previous") return "󰒮" + if (n.length > 0) return name + if (percent <= 0) return "" + if (percent <= 33) return "" + if (percent <= 66) return "" + return "" +} + +function stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration) { + var maxValue = Math.max(1, parseInt(rawMax || "100", 10)) + var parsedValue = parseInt(rawValue || "0", 10) + var hasProgress = rawValue !== "" && !isNaN(parsedValue) && rawMessage === "" + var value = hasProgress ? clamp(parsedValue, 0, maxValue) : 0 + var percent = hasProgress ? Math.round(value * 100 / maxValue) : -1 + var parsedDuration = parseInt(rawDuration || "1200", 10) + + return { + iconKey: String(iconName || "").toLowerCase(), + maxValue: maxValue, + hasProgress: hasProgress, + value: value, + message: String(rawMessage || (hasProgress ? (rawProgressText || percent + "%") : "")), + icon: iconFor(iconName, percent), + duration: isNaN(parsedDuration) ? 1200 : Math.max(0, parsedDuration) + } +} + +if (typeof module !== "undefined") { + module.exports = { + iconFor: iconFor, + stateForShow: stateForShow + } +} diff --git a/shell/plugins/polkit/PolkitAgent.qml b/shell/plugins/polkit/PolkitAgent.qml index a1a62edf..dc42d235 100644 --- a/shell/plugins/polkit/PolkitAgent.qml +++ b/shell/plugins/polkit/PolkitAgent.qml @@ -4,6 +4,7 @@ import Quickshell.Io import Quickshell.Services.Polkit import Quickshell.Wayland import qs.Commons +import "PolkitModel.js" as PolkitModel Item { id: root @@ -38,20 +39,11 @@ Item { readonly property int cardHeight: panel.height > 0 ? Math.min(fieldHeight + contentMargin * 2, panel.height - Style.gapsOut * 2) : fieldHeight + contentMargin * 2 function promptLooksFingerprint(text) { - var s = String(text || "").toLowerCase() - return s.indexOf("finger") !== -1 || s.indexOf("fprint") !== -1 || s.indexOf("swipe") !== -1 + return PolkitModel.promptLooksFingerprint(text) } function loadPamConfig(raw) { - fingerprintFirst = false - var lines = String(raw || "").split("\n") - for (var i = 0; i < lines.length; i++) { - var line = lines[i].replace(/^\s+|\s+$/g, "") - if (!line || line.charAt(0) === "#") continue - if (!line.match(/^auth\s+/)) continue - fingerprintFirst = line.indexOf("pam_fprintd.so") !== -1 - return - } + fingerprintFirst = PolkitModel.fingerprintFirstFromPamConfig(raw) } function resetSnapshot() { diff --git a/shell/plugins/polkit/PolkitModel.js b/shell/plugins/polkit/PolkitModel.js new file mode 100644 index 00000000..02444d55 --- /dev/null +++ b/shell/plugins/polkit/PolkitModel.js @@ -0,0 +1,22 @@ +function promptLooksFingerprint(text) { + var s = String(text || "").toLowerCase() + return s.indexOf("finger") !== -1 || s.indexOf("fprint") !== -1 || s.indexOf("swipe") !== -1 +} + +function fingerprintFirstFromPamConfig(raw) { + var lines = String(raw || "").split("\n") + for (var i = 0; i < lines.length; i++) { + var line = lines[i].replace(/^\s+|\s+$/g, "") + if (!line || line.charAt(0) === "#") continue + if (!line.match(/^auth\s+/)) continue + return line.indexOf("pam_fprintd.so") !== -1 + } + return false +} + +if (typeof module !== "undefined") { + module.exports = { + promptLooksFingerprint: promptLooksFingerprint, + fingerprintFirstFromPamConfig: fingerprintFirstFromPamConfig + } +} diff --git a/shell/plugins/power/BarWidget.qml b/shell/plugins/power/BarWidget.qml index e4fdb811..46f2975c 100644 --- a/shell/plugins/power/BarWidget.qml +++ b/shell/plugins/power/BarWidget.qml @@ -4,6 +4,7 @@ import Quickshell.Io import Quickshell.Services.UPower import qs.Commons import qs.Ui +import "PowerModel.js" as PowerModel Panel { id: root @@ -20,9 +21,17 @@ Panel { return !!(device && device.isPresent) } + function upowerStates() { + return { + Charging: UPowerDeviceState.Charging, + Discharging: UPowerDeviceState.Discharging, + FullyCharged: UPowerDeviceState.FullyCharged, + PendingCharge: UPowerDeviceState.PendingCharge + } + } + function selectProfileByDelta(delta) { - if (profiles.length === 0) { profileIndex = 0; return } - profileIndex = Math.max(0, Math.min(profiles.length - 1, profileIndex + delta)) + profileIndex = PowerModel.selectProfileIndex(profileIndex, delta, profiles) } function activateSelectedProfile() { @@ -32,41 +41,16 @@ Panel { function batteryIcon() { var device = UPower.displayDevice - if (!root.batteryPresent) return "" - - var chargingIcons = ["󰢜", "󰂆", "󰂇", "󰂈", "󰢝", "󰂉", "󰢞", "󰂊", "󰂋", "󰂅"] - var defaultIcons = ["󰁺", "󰁻", "󰁼", "󰁽", "󰁾", "󰁿", "󰂀", "󰂁", "󰂂", "󰁹"] - var index = Math.max(0, Math.min(9, Math.floor(device.percentage * 10))) - - if (root.chargeThresholdActive) return defaultIcons[index] - if (device.state === UPowerDeviceState.FullyCharged) return "󰂅" - if (device.state === UPowerDeviceState.Charging) return chargingIcons[index] - if (!UPower.onBattery) return "" - return defaultIcons[index] + return PowerModel.batteryIcon(device, UPower.onBattery, upowerStates()) } function modeLabel() { var device = UPower.displayDevice - if (!root.batteryPresent) return "" - - var percentage = device && device.isPresent ? device.percentage : 0 - - if (chargeThresholdActive) { - return "Threshold" - } else if (!UPower.onBattery && percentage >= 1) { - return "Fully charged" - } else if (UPower.onBattery) { - return "On battery" - } else { - return "Charging" - } + return PowerModel.modeLabel(device, UPower.onBattery, upowerStates()) } function profileIcon(name) { - if (name === "power-saver") return "󰌪" - if (name === "balanced") return "󰊚" - if (name === "performance") return "󰓅" - return "󰂄" + return PowerModel.profileIcon(name) } readonly property bool fullyCharged: { @@ -75,14 +59,7 @@ Panel { } readonly property bool chargeThresholdActive: { var device = UPower.displayDevice - if (!(device && device.isPresent && !UPower.onBattery)) return false - - var fraction = Math.max(0, Math.min(1, device.percentage)) - if (device.state === UPowerDeviceState.Discharging || device.state === UPowerDeviceState.PendingCharge) return true - if (device.state === UPowerDeviceState.FullyCharged && fraction < 0.99) return true - if (device.state !== UPowerDeviceState.Charging || fraction >= 0.99) return false - - return Number(device.changeRate || 0) <= 0.2 || Number(device.timeToFull || 0) >= 8 * 60 * 60 + return PowerModel.chargeThresholdActive(device, UPower.onBattery, upowerStates()) } readonly property bool batteryFull: fullyCharged || (!UPower.onBattery && batteryFraction >= 1) readonly property bool batteryFlowIdle: batteryFull || chargeThresholdActive @@ -90,7 +67,7 @@ Panel { // 0..1 charge level, used by the visual progress bar. readonly property real batteryFraction: { var d = UPower.displayDevice - return d && d.isPresent ? Math.max(0, Math.min(1, d.percentage)) : 0 + return PowerModel.batteryFraction(d) } readonly property bool batteryLow: UPower.onBattery && batteryFraction > 0 && batteryFraction <= 0.2 @@ -154,13 +131,7 @@ Panel { } function updateKeyValue(raw, targetName) { - var next = {} - var lines = String(raw || "").split("\n") - for (var i = 0; i < lines.length; i++) { - var idx = lines[i].indexOf("\t") - if (idx <= 0) continue - next[lines[i].substring(0, idx)] = lines[i].substring(idx + 1).trim() - } + var next = PowerModel.parseKeyValue(raw) // Keep last known good data if a refresh briefly returns nothing — happens // around AC plug/unplug events. Avoids the section collapsing mid-transition. if (Object.keys(next).length === 0) return @@ -169,22 +140,13 @@ Panel { } function updateProfiles(raw) { - var lines = String(raw || "").split("\n") - var list = [] - var active = "" - for (var i = 0; i < lines.length; i++) { - var line = lines[i].trim() - if (!line) continue - var parts = line.split("\t") - list.push(parts[0]) - if (parts[1] === "1") active = parts[0] - } + var parsed = PowerModel.parseProfiles(raw, profileIndex) // Same guard as battery: preserve the last known profile list across // transient empty payloads so the buttons don't blink out. - if (list.length === 0) return - profiles = list - activeProfile = active - if (profileIndex >= profiles.length) profileIndex = Math.max(0, profiles.length - 1) + if (parsed.profiles.length === 0) return + profiles = parsed.profiles + activeProfile = parsed.activeProfile + profileIndex = parsed.profileIndex if (opened && activeProfile !== "") { var idx = profiles.indexOf(activeProfile) if (idx >= 0) profileIndex = idx diff --git a/shell/plugins/power/PowerModel.js b/shell/plugins/power/PowerModel.js new file mode 100644 index 00000000..11901fe8 --- /dev/null +++ b/shell/plugins/power/PowerModel.js @@ -0,0 +1,104 @@ +function clampIndex(index, length) { + if (length <= 0) return 0 + return Math.max(0, Math.min(length - 1, index)) +} + +function selectProfileIndex(index, delta, profiles) { + var values = Array.isArray(profiles) ? profiles : [] + if (values.length === 0) return 0 + return clampIndex(index + delta, values.length) +} + +function parseKeyValue(raw) { + var next = {} + var lines = String(raw || "").split("\n") + for (var i = 0; i < lines.length; i++) { + var idx = lines[i].indexOf("\t") + if (idx <= 0) continue + next[lines[i].substring(0, idx)] = lines[i].substring(idx + 1).trim() + } + return next +} + +function parseProfiles(raw, previousIndex) { + var lines = String(raw || "").split("\n") + var list = [] + var active = "" + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim() + if (!line) continue + var parts = line.split("\t") + list.push(parts[0]) + if (parts[1] === "1") active = parts[0] + } + return { + profiles: list, + activeProfile: active, + profileIndex: clampIndex(previousIndex || 0, list.length) + } +} + +function profileIcon(name) { + if (name === "power-saver") return "󰌪" + if (name === "balanced") return "󰊚" + if (name === "performance") return "󰓅" + return "󰂄" +} + +function batteryFraction(device) { + return device && device.isPresent ? Math.max(0, Math.min(1, device.percentage)) : 0 +} + +function chargeThresholdActive(device, onBattery, states) { + var d = device || {} + var s = states || {} + if (!(d && d.isPresent && !onBattery)) return false + + var fraction = batteryFraction(d) + if (d.state === s.Discharging || d.state === s.PendingCharge) return true + if (d.state === s.FullyCharged && fraction < 0.99) return true + if (d.state !== s.Charging || fraction >= 0.99) return false + + return Number(d.changeRate || 0) <= 0.2 || Number(d.timeToFull || 0) >= 8 * 60 * 60 +} + +function batteryIcon(device, onBattery, states) { + var d = device || {} + if (!d.isPresent) return "" + + var chargingIcons = ["󰢜", "󰂆", "󰂇", "󰂈", "󰢝", "󰂉", "󰢞", "󰂊", "󰂋", "󰂅"] + var defaultIcons = ["󰁺", "󰁻", "󰁼", "󰁽", "󰁾", "󰁿", "󰂀", "󰂁", "󰂂", "󰁹"] + var index = Math.max(0, Math.min(9, Math.floor(d.percentage * 10))) + var threshold = chargeThresholdActive(d, onBattery, states) + + if (threshold) return defaultIcons[index] + if (d.state === states.FullyCharged) return "󰂅" + if (d.state === states.Charging) return chargingIcons[index] + if (!onBattery) return "" + return defaultIcons[index] +} + +function modeLabel(device, onBattery, states) { + var d = device || {} + if (!d.isPresent) return "" + + var percentage = d.isPresent ? d.percentage : 0 + if (chargeThresholdActive(d, onBattery, states)) return "Threshold" + if (!onBattery && percentage >= 1) return "Fully charged" + if (onBattery) return "On battery" + return "Charging" +} + +if (typeof module !== "undefined") { + module.exports = { + clampIndex: clampIndex, + selectProfileIndex: selectProfileIndex, + parseKeyValue: parseKeyValue, + parseProfiles: parseProfiles, + profileIcon: profileIcon, + batteryFraction: batteryFraction, + chargeThresholdActive: chargeThresholdActive, + batteryIcon: batteryIcon, + modeLabel: modeLabel + } +} diff --git a/shell/plugins/reminders/ReminderFlow.qml b/shell/plugins/reminders/ReminderFlow.qml index d6dbd0ca..779d535f 100644 --- a/shell/plugins/reminders/ReminderFlow.qml +++ b/shell/plugins/reminders/ReminderFlow.qml @@ -2,10 +2,12 @@ import Quickshell import Quickshell.Wayland import QtQuick import qs.Commons +import "ReminderFlowModel.js" as ReminderFlowModel Item { id: root + property string omarchyPath: Quickshell.env("OMARCHY_PATH") property var shell: null property var manifest: null @@ -62,15 +64,15 @@ Item { var selection = root.filterText if (root.step === "minutes") { - var nextMinutes = selection.trim() + var nextMinutes = ReminderFlowModel.validMinutes(selection) - if (!nextMinutes) { + if (!selection.trim()) { root.dismiss() return } - if (!/^[0-9]+$/.test(nextMinutes) || Number(nextMinutes) <= 0) { - Quickshell.execDetached(["bash", "-lc", "omarchy-notification-send 'Invalid reminder' 'Enter the number of minutes'"]) + if (!nextMinutes) { + Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-notification-send", "Invalid reminder", "Enter the number of minutes"]) return } @@ -82,12 +84,9 @@ Item { } if (root.step === "message") { - var command = "omarchy-reminder " + Util.shellQuote(root.minutes) - if (selection.length > 0) - command += " " + Util.shellQuote(selection) - + var args = [root.omarchyPath + "/bin/omarchy-reminder"].concat(ReminderFlowModel.reminderArgs(root.minutes, selection)) root.dismiss() - Quickshell.execDetached(["bash", "-lc", command]) + Quickshell.execDetached(args) } } diff --git a/shell/plugins/reminders/ReminderFlowModel.js b/shell/plugins/reminders/ReminderFlowModel.js new file mode 100644 index 00000000..05b94c8c --- /dev/null +++ b/shell/plugins/reminders/ReminderFlowModel.js @@ -0,0 +1,21 @@ +function validMinutes(value) { + var minutes = String(value || "").trim() + return /^[0-9]+$/.test(minutes) && Number(minutes) > 0 ? minutes : "" +} + +function reminderArgs(minutes, message) { + var valid = validMinutes(minutes) + if (!valid) return [] + + var args = [valid] + var text = String(message || "") + if (text.length > 0) args.push(text) + return args +} + +if (typeof module !== "undefined") { + module.exports = { + validMinutes: validMinutes, + reminderArgs: reminderArgs + } +} diff --git a/shell/plugins/services/battery/BatteryModel.js b/shell/plugins/services/battery/BatteryModel.js new file mode 100644 index 00000000..2aca5dbd --- /dev/null +++ b/shell/plugins/services/battery/BatteryModel.js @@ -0,0 +1,28 @@ +function batteryPercentage(device) { + if (!device || !device.isPresent) return -1 + return Math.round(Number(device.percentage || 0) * 100) +} + +function isDischarging(device, onBattery, dischargingState) { + return !!(device && device.isPresent && onBattery && device.state === dischargingState) +} + +function shouldWarnLowBattery(device, onBattery, dischargingState, threshold, alreadyNotified) { + var level = batteryPercentage(device) + if (level < 0) return { level: level, notify: false, notifiedLowBattery: false } + + var low = isDischarging(device, onBattery, dischargingState) && level <= threshold + return { + level: level, + notify: low && !alreadyNotified, + notifiedLowBattery: low + } +} + +if (typeof module !== "undefined") { + module.exports = { + batteryPercentage: batteryPercentage, + isDischarging: isDischarging, + shouldWarnLowBattery: shouldWarnLowBattery + } +} diff --git a/shell/plugins/services/battery/Service.qml b/shell/plugins/services/battery/Service.qml index 26796882..c2ab7625 100644 --- a/shell/plugins/services/battery/Service.qml +++ b/shell/plugins/services/battery/Service.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import Quickshell.Io import Quickshell.Services.UPower +import "BatteryModel.js" as BatteryModel Item { id: root @@ -18,33 +19,17 @@ Item { } function batteryPercentage() { - var device = UPower.displayDevice - if (!device || !device.isPresent) return -1 - return Math.round(Number(device.percentage || 0) * 100) + return BatteryModel.batteryPercentage(UPower.displayDevice) } function isDischarging() { - var device = UPower.displayDevice - return !!(device && device.isPresent - && UPower.onBattery - && device.state === UPowerDeviceState.Discharging) + return BatteryModel.isDischarging(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging) } function checkBattery() { - var level = batteryPercentage() - if (level < 0) { - persisted.notifiedLowBattery = false - return - } - - if (isDischarging() && level <= batteryThreshold) { - if (!persisted.notifiedLowBattery) { - persisted.notifiedLowBattery = true - sendLowBatteryWarning(level) - } - } else { - persisted.notifiedLowBattery = false - } + var state = BatteryModel.shouldWarnLowBattery(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging, batteryThreshold, persisted.notifiedLowBattery) + persisted.notifiedLowBattery = state.notifiedLowBattery + if (state.notify) sendLowBatteryWarning(state.level) } function sendLowBatteryWarning(level) { diff --git a/shell/plugins/services/idle/IdleModel.js b/shell/plugins/services/idle/IdleModel.js new file mode 100644 index 00000000..8e2315d0 --- /dev/null +++ b/shell/plugins/services/idle/IdleModel.js @@ -0,0 +1,52 @@ +function secondsFromConfig(value, fallback) { + var n = Number(value) + if (!isFinite(n) || n < 0) return fallback + return Math.floor(n) +} + +function eventParts(event, count) { + try { + if (event && event.parse) return event.parse(count) + } catch (error) { + } + return String(event && event.data ? event.data : "").split(",") +} + +function screensaverWindowsAfter(windows, address, visible) { + var key = String(address || "") + if (!key) { + var current = windows || {} + var existingCount = 0 + for (var currentKey in current) { + if (current[currentKey]) existingCount++ + } + return { windows: current, count: existingCount } + } + + var next = {} + var count = 0 + for (var existing in windows || {}) { + if (existing !== key && windows[existing]) { + next[existing] = true + count++ + } + } + + if (visible) { + next[key] = true + count++ + } + + return { + windows: next, + count: count + } +} + +if (typeof module !== "undefined") { + module.exports = { + secondsFromConfig: secondsFromConfig, + eventParts: eventParts, + screensaverWindowsAfter: screensaverWindowsAfter + } +} diff --git a/shell/plugins/services/idle/Service.qml b/shell/plugins/services/idle/Service.qml index 623e4f80..9542c10a 100644 --- a/shell/plugins/services/idle/Service.qml +++ b/shell/plugins/services/idle/Service.qml @@ -3,6 +3,7 @@ import Quickshell import Quickshell.Hyprland import Quickshell.Io import Quickshell.Wayland +import "IdleModel.js" as IdleModel Item { id: root @@ -36,9 +37,7 @@ Item { property int screensaverWindowCount: 0 function secondsFromConfig(value, fallback) { - var n = Number(value) - if (!isFinite(n) || n < 0) return fallback - return Math.floor(n) + return IdleModel.secondsFromConfig(value, fallback) } function nowIso() { @@ -117,25 +116,9 @@ Item { } function setScreensaverWindow(address, visible) { - var key = String(address || "") - if (!key) return - - var next = {} - var count = 0 - for (var existing in root.screensaverWindows) { - if (existing !== key && root.screensaverWindows[existing]) { - next[existing] = true - count++ - } - } - - if (visible) { - next[key] = true - count++ - } - - root.screensaverWindows = next - root.screensaverWindowCount = count + var next = IdleModel.screensaverWindowsAfter(root.screensaverWindows, address, visible) + root.screensaverWindows = next.windows + root.screensaverWindowCount = next.count } function handleScreensaverWindowOpened(address) { @@ -156,10 +139,7 @@ Item { } function eventParts(event, count) { - try { - if (event && event.parse) return event.parse(count) - } catch (error) {} - return String(event && event.data ? event.data : "").split(",") + return IdleModel.eventParts(event, count) } function handleHyprlandEvent(event) { diff --git a/shell/plugins/services/media/MediaModel.js b/shell/plugins/services/media/MediaModel.js new file mode 100644 index 00000000..00cc9130 --- /dev/null +++ b/shell/plugins/services/media/MediaModel.js @@ -0,0 +1,126 @@ +function isProxyPlayer(player) { + var dbusName = String(player && player.dbusName || "").toLowerCase() + var desktopEntry = String(player && player.desktopEntry || "").toLowerCase() + return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld" +} + +function hasMetadata(player) { + return !!(player && (player.trackTitle || player.trackArtist || player.identity || player.desktopEntry)) +} + +function hasTrackMetadata(player) { + return !!(player && (player.trackTitle || player.trackArtist || player.trackAlbum || player.trackArtUrl)) +} + +function playerCanControl(player) { + return !!(player && (player.canTogglePlaying || player.canPlay || player.canPause || player.canGoNext || player.canGoPrevious)) +} + +function canHandleAction(player, action) { + if (!player) return false + if (action === "next") return !!player.canGoNext + if (action === "previous") return !!player.canGoPrevious + if (action === "play") return !!(player.canPlay || player.canTogglePlaying) + if (action === "pause") return !!(player.canPause || player.canTogglePlaying) + if (action === "playPause") return !!(player.canTogglePlaying || player.canPlay || player.canPause) + return false +} + +function canCycleSource(player) { + return !!(player && hasMetadata(player) && (player.isPlaying || player.canPlay)) +} + +function nodeProps(node) { + return node && node.ready && node.properties ? node.properties : {} +} + +function isPlaybackStream(node) { + if (!node || !node.isStream) return false + if (node.isSink === true) return true + + var mediaClass = String(node.type || "") + return mediaClass.indexOf("Stream/Output/Audio") !== -1 + || mediaClass.indexOf("AudioOutStream") !== -1 + || mediaClass.indexOf("Output") !== -1 +} + +function streamLabelKey(label) { + var key = String(label || "").toLowerCase() + key = key.replace(/^pipewire alsa \[/, "") + key = key.replace(/\]$/, "") + key = key.replace(/^alsa playback \[/, "") + key = key.replace(/[^a-z0-9]+/g, "") + return key +} + +function rawStreamLabel(node) { + if (!node) return "" + var p = nodeProps(node) + return p["application.name"] + || node.description + || p["media.name"] + || p["node.name"] + || node.name +} + +function playerAppLabel(player) { + if (!player) return "" + var dbus = String(player.dbusName || "") + dbus = dbus.replace(/^org\.mpris\.MediaPlayer2\./, "") + dbus = dbus.replace(/\.instance[0-9]+$/, "") + return player.desktopEntry || player.identity || dbus +} + +function playerHasPlaybackStream(player, playbackStreams) { + var playerKey = streamLabelKey(playerAppLabel(player)) + if (!playerKey) return false + + var streams = Array.isArray(playbackStreams) ? playbackStreams : [] + for (var i = 0; i < streams.length; i++) { + var streamKey = streamLabelKey(rawStreamLabel(streams[i])) + if (!streamKey) continue + if (streamKey === playerKey + || streamKey.indexOf(playerKey) !== -1 + || playerKey.indexOf(streamKey) !== -1) + return true + } + + return false +} + +function playerKey(player) { + if (!player) return "" + return String(player.dbusName || player.desktopEntry || player.identity || "") +} + +function labelFor(player) { + if (!player) return "" + return player.trackTitle || player.identity || player.desktopEntry || "" +} + +function osdMessage(player, fallback) { + if (!player) return fallback + var label = labelFor(player) + if (label && player.trackArtist) return label + " - " + player.trackArtist + return label || fallback +} + +if (typeof module !== "undefined") { + module.exports = { + isProxyPlayer: isProxyPlayer, + hasMetadata: hasMetadata, + hasTrackMetadata: hasTrackMetadata, + playerCanControl: playerCanControl, + canHandleAction: canHandleAction, + canCycleSource: canCycleSource, + nodeProps: nodeProps, + isPlaybackStream: isPlaybackStream, + streamLabelKey: streamLabelKey, + rawStreamLabel: rawStreamLabel, + playerAppLabel: playerAppLabel, + playerHasPlaybackStream: playerHasPlaybackStream, + playerKey: playerKey, + labelFor: labelFor, + osdMessage: osdMessage + } +} diff --git a/shell/plugins/services/media/Service.qml b/shell/plugins/services/media/Service.qml index e1598c27..66f9d820 100644 --- a/shell/plugins/services/media/Service.qml +++ b/shell/plugins/services/media/Service.qml @@ -3,6 +3,7 @@ import Quickshell import Quickshell.Io import Quickshell.Services.Mpris import Quickshell.Services.Pipewire +import "MediaModel.js" as MediaModel Item { id: root @@ -33,97 +34,55 @@ Item { readonly property string identity: activePlayer ? (activePlayer.identity || activePlayer.desktopEntry || "") : "" function isProxyPlayer(player) { - var dbusName = String(player && player.dbusName || "").toLowerCase() - var desktopEntry = String(player && player.desktopEntry || "").toLowerCase() - return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld" + return MediaModel.isProxyPlayer(player) } function hasMetadata(player) { - return !!(player && (player.trackTitle || player.trackArtist || player.identity || player.desktopEntry)) + return MediaModel.hasMetadata(player) } function hasTrackMetadata(player) { - return !!(player && (player.trackTitle || player.trackArtist || player.trackAlbum || player.trackArtUrl)) + return MediaModel.hasTrackMetadata(player) } function playerCanControl(player) { - return !!(player && (player.canTogglePlaying || player.canPlay || player.canPause || player.canGoNext || player.canGoPrevious)) + return MediaModel.playerCanControl(player) } function canHandleAction(player, action) { - if (!player) return false - if (action === "next") return !!player.canGoNext - if (action === "previous") return !!player.canGoPrevious - if (action === "play") return !!(player.canPlay || player.canTogglePlaying) - if (action === "pause") return !!(player.canPause || player.canTogglePlaying) - if (action === "playPause") return !!(player.canTogglePlaying || player.canPlay || player.canPause) - return false + return MediaModel.canHandleAction(player, action) } function canCycleSource(player) { - return !!(player && hasMetadata(player) && (player.isPlaying || player.canPlay)) + return MediaModel.canCycleSource(player) } function nodeProps(node) { - return node && node.ready && node.properties ? node.properties : {} + return MediaModel.nodeProps(node) } function isPlaybackStream(node) { - if (!node || !node.isStream) return false - if (node.isSink === true) return true - - var mediaClass = String(node.type || "") - return mediaClass.indexOf("Stream/Output/Audio") !== -1 - || mediaClass.indexOf("AudioOutStream") !== -1 - || mediaClass.indexOf("Output") !== -1 + return MediaModel.isPlaybackStream(node) } function streamLabelKey(label) { - var key = String(label || "").toLowerCase() - key = key.replace(/^pipewire alsa \[/, "") - key = key.replace(/\]$/, "") - key = key.replace(/^alsa playback \[/, "") - key = key.replace(/[^a-z0-9]+/g, "") - return key + return MediaModel.streamLabelKey(label) } function rawStreamLabel(node) { - if (!node) return "" - var p = nodeProps(node) - return p["application.name"] - || node.description - || p["media.name"] - || p["node.name"] - || node.name + return MediaModel.rawStreamLabel(node) } function playerAppLabel(player) { - if (!player) return "" - var dbus = String(player.dbusName || "") - dbus = dbus.replace(/^org\.mpris\.MediaPlayer2\./, "") - dbus = dbus.replace(/\.instance[0-9]+$/, "") - return player.desktopEntry || player.identity || dbus + return MediaModel.playerAppLabel(player) } function playerHasPlaybackStream(player) { - var playerKey = streamLabelKey(playerAppLabel(player)) - if (!playerKey) return false - - for (var i = 0; i < playbackStreams.length; i++) { - var streamKey = streamLabelKey(rawStreamLabel(playbackStreams[i])) - if (!streamKey) continue - if (streamKey === playerKey - || streamKey.indexOf(playerKey) !== -1 - || playerKey.indexOf(streamKey) !== -1) - return true - } - - return false + return MediaModel.playerHasPlaybackStream(player, playbackStreams) } function playerKey(player) { - if (!player) return "" - return String(player.dbusName || player.desktopEntry || player.identity || "") + return MediaModel.playerKey(player) } function playerForKey(key) { @@ -272,15 +231,11 @@ Item { } function labelFor(player) { - if (!player) return "" - return player.trackTitle || player.identity || player.desktopEntry || "" + return MediaModel.labelFor(player) } function osdMessage(player, fallback) { - if (!player) return fallback - var label = labelFor(player) - if (label && player.trackArtist) return label + " - " + player.trackArtist - return label || fallback + return MediaModel.osdMessage(player, fallback) } function showOsd(actionLabel, iconName, player) { diff --git a/shell/plugins/weather/Panel.qml b/shell/plugins/weather/Panel.qml index dc57d8d0..33890c2b 100644 --- a/shell/plugins/weather/Panel.qml +++ b/shell/plugins/weather/Panel.qml @@ -3,6 +3,7 @@ import Quickshell import Quickshell.Io import qs.Commons import qs.Ui +import "WeatherModel.js" as WeatherModel Panel { id: root @@ -31,10 +32,9 @@ Panel { property string klass: "" function updateWeather(raw) { - var data - try { data = JSON.parse(raw || "{}") } catch (e) { data = {} } - label = data.text || "" - klass = data.class || "" + var data = WeatherModel.parseWeatherStatus(raw) + label = data.label + klass = data.klass } readonly property var current: report && report.current_condition && report.current_condition[0] ? report.current_condition[0] : null @@ -83,126 +83,54 @@ Panel { } function buildForecastDays() { - var days = openMeteoForecastDays() - return days.length > 0 ? days : wttrNextForecastDays() + return WeatherModel.buildForecastDays(report, dailyForecastReport, Qt.formatDate(new Date(), "yyyy-MM-dd")) } function openMeteoForecastDays() { - var daily = dailyForecastReport && dailyForecastReport.daily ? dailyForecastReport.daily : null - if (!daily || !daily.time) return [] - - var result = [] - for (var i = 0; i < daily.time.length && result.length < 3; ++i) { - var date = daily.time[i] - if (!isFutureForecastDate(date)) continue - - var maxC = daily.temperature_2m_max ? daily.temperature_2m_max[i] : "" - var minC = daily.temperature_2m_min ? daily.temperature_2m_min[i] : "" - result.push({ - date: date, - maxtempC: roundedTemp(maxC), - mintempC: roundedTemp(minC), - maxtempF: roundedTemp(celsiusToFahrenheit(maxC)), - mintempF: roundedTemp(celsiusToFahrenheit(minC)), - openMeteoWeatherCode: daily.weather_code ? daily.weather_code[i] : null - }) - } - return result + return WeatherModel.openMeteoForecastDays(dailyForecastReport, Qt.formatDate(new Date(), "yyyy-MM-dd")) } function wttrNextForecastDays() { - var days = report && report.weather ? report.weather : [] - var result = [] - for (var i = 0; i < days.length && result.length < 3; ++i) { - if (isFutureForecastDate(days[i].date)) result.push(days[i]) - } - return result + return WeatherModel.wttrNextForecastDays(report, Qt.formatDate(new Date(), "yyyy-MM-dd")) } function isFutureForecastDate(dateString) { - if (!dateString) return false - return String(dateString).slice(0, 10) > Qt.formatDate(new Date(), "yyyy-MM-dd") + return WeatherModel.isFutureForecastDate(dateString, Qt.formatDate(new Date(), "yyyy-MM-dd")) } function roundedTemp(value) { - if (value === undefined || value === null || value === "") return "" - var n = parseFloat(String(value)) - return isNaN(n) ? "" : String(Math.round(n)) + return WeatherModel.roundedTemp(value) } function celsiusToFahrenheit(value) { - if (value === undefined || value === null || value === "") return "" - var n = parseFloat(String(value)) - return isNaN(n) ? "" : (n * 9 / 5) + 32 + return WeatherModel.celsiusToFahrenheit(value) } function formatTemp(value) { - if (value === undefined || value === null || value === "") return "" - return value + "°" + (useImperial ? "F" : "C") + return WeatherModel.formatTemp(value, useImperial) } function dayName(dateString) { - if (!dateString) return "" - var d = new Date(dateString + "T12:00:00") - if (isNaN(d.getTime())) return "" - return Qt.formatDate(d, "dddd") + return WeatherModel.dayName(dateString, function(date) { return Qt.formatDate(date, "dddd") }) } // Bare degree value (no unit letter), used in the forecast row. function bareTempForDay(day, kind) { - if (!day) return "" - var v = useImperial - ? (kind === "max" ? day.maxtempF : day.mintempF) - : (kind === "max" ? day.maxtempC : day.mintempC) - if (v === undefined || v === null || v === "") return "" - return v + "°" + return WeatherModel.bareTempForDay(day, kind, useImperial) } // Representative icon for a forecast day: the hourly entry nearest noon. function dayIcon(day) { - if (!day) return "" - if (day.openMeteoWeatherCode !== undefined && day.openMeteoWeatherCode !== null) return iconForOpenMeteoCode(day.openMeteoWeatherCode) - if (!day.hourly || day.hourly.length === 0) return "" - var best = day.hourly[0] - var bestDist = 9999 - for (var i = 0; i < day.hourly.length; ++i) { - var t = parseInt(String(day.hourly[i].time || "0"), 10) - var dist = Math.abs(t - 1200) - if (dist < bestDist) { bestDist = dist; best = day.hourly[i] } - } - return iconForCode(best.weatherCode, false) + return WeatherModel.dayIcon(day) } function iconForOpenMeteoCode(code) { - var c = parseInt(String(code || "0"), 10) - if (c === 0) return iconForCode(113, false) - if (c === 1 || c === 2) return iconForCode(116, false) - if (c === 3) return iconForCode(119, false) - if (c === 45 || c === 48) return iconForCode(143, false) - if (c === 51 || c === 53 || c === 55 || c === 56 || c === 57 || c === 61) return iconForCode(266, false) - if (c === 63 || c === 65 || c === 66 || c === 67 || c === 80 || c === 81 || c === 82) return iconForCode(308, false) - if (c === 71 || c === 73 || c === 75 || c === 77 || c === 85 || c === 86) return iconForCode(338, false) - if (c === 95 || c === 96 || c === 99) return iconForCode(389, false) - return iconForCode(119, false) + return WeatherModel.iconForOpenMeteoCode(code) } // Mirrors omarchy-weather-icon's wttr.in code → nerd-font glyph mapping. function iconForCode(code, night) { - var c = parseInt(String(code || "0"), 10) - switch (c) { - case 113: return night ? "" : "" - case 116: return night ? "" : "" - case 119: case 122: return "" - case 143: case 248: case 260: return "" - case 176: case 263: case 353: return night ? "" : "" - case 179: case 227: case 230: case 323: case 326: case 368: return night ? "" : "" - case 182: case 185: case 281: case 284: case 311: case 314: - case 317: case 320: case 350: case 362: case 365: case 374: case 377: return "" - case 200: case 386: case 389: case 392: case 395: return "" - case 266: case 293: case 296: case 299: case 302: case 305: case 308: case 356: case 359: return "" - case 329: case 332: case 335: case 338: case 371: return "" - default: return "" - } + return WeatherModel.iconForCode(code, night) } Process { diff --git a/shell/plugins/weather/WeatherModel.js b/shell/plugins/weather/WeatherModel.js new file mode 100644 index 00000000..2ccb3321 --- /dev/null +++ b/shell/plugins/weather/WeatherModel.js @@ -0,0 +1,155 @@ +function parseWeatherStatus(raw) { + try { + var data = JSON.parse(String(raw || "{}")) + return { + label: data.text || "", + klass: data.class || "" + } + } catch (e) { + return { label: "", klass: "" } + } +} + +function isFutureForecastDate(dateString, todayString) { + if (!dateString) return false + return String(dateString).slice(0, 10) > String(todayString || "") +} + +function roundedTemp(value) { + if (value === undefined || value === null || value === "") return "" + var n = parseFloat(String(value)) + return isNaN(n) ? "" : String(Math.round(n)) +} + +function celsiusToFahrenheit(value) { + if (value === undefined || value === null || value === "") return "" + var n = parseFloat(String(value)) + return isNaN(n) ? "" : (n * 9 / 5) + 32 +} + +function formatTemp(value, useImperial) { + if (value === undefined || value === null || value === "") return "" + return value + "°" + (useImperial ? "F" : "C") +} + +function dayName(dateString, formatter) { + if (!dateString) return "" + var d = new Date(dateString + "T12:00:00") + if (isNaN(d.getTime())) return "" + if (formatter) return formatter(d) + return ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][d.getDay()] +} + +function openMeteoForecastDays(dailyForecastReport, todayString) { + var daily = dailyForecastReport && dailyForecastReport.daily ? dailyForecastReport.daily : null + if (!daily || !daily.time) return [] + + var result = [] + for (var i = 0; i < daily.time.length && result.length < 3; ++i) { + var date = daily.time[i] + if (!isFutureForecastDate(date, todayString)) continue + + var maxC = daily.temperature_2m_max ? daily.temperature_2m_max[i] : "" + var minC = daily.temperature_2m_min ? daily.temperature_2m_min[i] : "" + result.push({ + date: date, + maxtempC: roundedTemp(maxC), + mintempC: roundedTemp(minC), + maxtempF: roundedTemp(celsiusToFahrenheit(maxC)), + mintempF: roundedTemp(celsiusToFahrenheit(minC)), + openMeteoWeatherCode: daily.weather_code ? daily.weather_code[i] : null + }) + } + return result +} + +function wttrNextForecastDays(report, todayString) { + var days = report && report.weather ? report.weather : [] + var result = [] + for (var i = 0; i < days.length && result.length < 3; ++i) { + if (isFutureForecastDate(days[i].date, todayString)) result.push(days[i]) + } + return result +} + +function buildForecastDays(report, dailyForecastReport, todayString) { + var days = openMeteoForecastDays(dailyForecastReport, todayString) + return days.length > 0 ? days : wttrNextForecastDays(report, todayString) +} + +function bareTempForDay(day, kind, useImperial) { + if (!day) return "" + var v = useImperial + ? (kind === "max" ? day.maxtempF : day.mintempF) + : (kind === "max" ? day.maxtempC : day.mintempC) + if (v === undefined || v === null || v === "") return "" + return v + "°" +} + +function dayIcon(day) { + if (!day) return "" + if (day.openMeteoWeatherCode !== undefined && day.openMeteoWeatherCode !== null) + return iconForOpenMeteoCode(day.openMeteoWeatherCode) + if (!day.hourly || day.hourly.length === 0) return "" + + var best = day.hourly[0] + var bestDist = 9999 + for (var i = 0; i < day.hourly.length; ++i) { + var t = parseInt(String(day.hourly[i].time || "0"), 10) + var dist = Math.abs(t - 1200) + if (dist < bestDist) { + bestDist = dist + best = day.hourly[i] + } + } + return iconForCode(best.weatherCode, false) +} + +function iconForOpenMeteoCode(code) { + var c = parseInt(String(code || "0"), 10) + if (c === 0) return iconForCode(113, false) + if (c === 1 || c === 2) return iconForCode(116, false) + if (c === 3) return iconForCode(119, false) + if (c === 45 || c === 48) return iconForCode(143, false) + if (c === 51 || c === 53 || c === 55 || c === 56 || c === 57 || c === 61) return iconForCode(266, false) + if (c === 63 || c === 65 || c === 66 || c === 67 || c === 80 || c === 81 || c === 82) return iconForCode(308, false) + if (c === 71 || c === 73 || c === 75 || c === 77 || c === 85 || c === 86) return iconForCode(338, false) + if (c === 95 || c === 96 || c === 99) return iconForCode(389, false) + return iconForCode(119, false) +} + +function iconForCode(code, night) { + var c = parseInt(String(code || "0"), 10) + switch (c) { + case 113: return night ? "" : "" + case 116: return night ? "" : "" + case 119: case 122: return "" + case 143: case 248: case 260: return "" + case 176: case 263: case 353: return night ? "" : "" + case 179: case 227: case 230: case 323: case 326: case 368: return night ? "" : "" + case 182: case 185: case 281: case 284: case 311: case 314: + case 317: case 320: case 350: case 362: case 365: case 374: case 377: return "" + case 200: case 386: case 389: case 392: case 395: return "" + case 266: case 293: case 296: case 299: case 302: case 305: case 308: case 356: case 359: return "" + case 329: case 332: case 335: case 338: case 371: return "" + default: return "" + } +} + +if (typeof module !== "undefined") { + module.exports = { + parseWeatherStatus: parseWeatherStatus, + isFutureForecastDate: isFutureForecastDate, + roundedTemp: roundedTemp, + celsiusToFahrenheit: celsiusToFahrenheit, + formatTemp: formatTemp, + dayName: dayName, + openMeteoForecastDays: openMeteoForecastDays, + wttrNextForecastDays: wttrNextForecastDays, + buildForecastDays: buildForecastDays, + bareTempForDay: bareTempForDay, + dayIcon: dayIcon, + iconForOpenMeteoCode: iconForOpenMeteoCode, + iconForCode: iconForCode + } +} diff --git a/test/all b/test/all new file mode 100755 index 00000000..693a8c4e --- /dev/null +++ b/test/all @@ -0,0 +1,14 @@ +#!/bin/bash + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +tests=( + "$ROOT/test/cli" + "$ROOT/test/shell" +) + +for test in "${tests[@]}"; do + printf '==> %s\n' "${test#$ROOT/}" + "$test" +done diff --git a/test/cli.sh b/test/cli old mode 100644 new mode 100755 similarity index 100% rename from test/cli.sh rename to test/cli diff --git a/test/shell.sh b/test/shell old mode 100644 new mode 100755 similarity index 93% rename from test/shell.sh rename to test/shell index 6ff004a8..215b98aa --- a/test/shell.sh +++ b/test/shell @@ -3,7 +3,7 @@ set -euo pipefail ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) -TEST_DIR="$ROOT/test/shell" +TEST_DIR="$ROOT/test/shell.d" shopt -s nullglob tests=() diff --git a/test/shell.d/audio-test.sh b/test/shell.d/audio-test.sh new file mode 100644 index 00000000..91ccb861 --- /dev/null +++ b/test/shell.d/audio-test.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const audio = requireFromRoot('shell/plugins/audio/AudioModel.js') + +assert(audio.isPlaybackStream({ isStream: true, isSink: true }), 'audio detects sink-backed playback streams') +assert(audio.isPlaybackStream({ isStream: true, type: 'Stream/Output/Audio' }), 'audio detects typed playback streams') +assert(!audio.isPlaybackStream({ isStream: false, isSink: true }), 'audio rejects non-stream playback nodes') +assert(audio.isAudioSource({ audio: {} }), 'audio detects nodes with audio as sources') +assert(audio.isAudioSource({ type: 'Audio/Source' }), 'audio detects typed source nodes') + +assertEqual(audio.outputVolumeName(0, false), 'Silenced', 'audio labels silent output') +assertEqual(audio.outputVolumeName(0.9, false), 'Party mode', 'audio labels loud output') +assertEqual(audio.outputVolumeName(0.5, true), 'Muted', 'audio labels muted output') + +assertDeepEqual(audio.parseSinkAvailability('alsa_output\t1\nhdmi_output\t0\n'), { alsa_output: true, hdmi_output: false }, 'audio parses sink availability') +assertEqual(audio.friendlyDeviceLabel('Built-in Audio Speakers Output'), 'Speakers', 'audio cleans device labels') +assertEqual( + audio.nodeLabel({ ready: true, properties: { 'node.nick': 'Built-in Audio Microphones Input' }, name: 'alsa_input' }), + 'Microphone', + 'audio chooses friendly node labels' +) + +const headphones = { ready: true, name: 'bluez_output.airpods', properties: { 'device.product.name': 'AirPods Headphones' } } +assert(audio.isHeadphones(headphones), 'audio detects headphone devices') +assertEqual(audio.sinkGlyph(headphones), '󰋋', 'audio uses headphone sink glyph') +assert(audio.sourceGlyph({ ready: true, properties: { 'device.icon-name': 'camera-webcam' } }).length > 0, 'audio maps webcam source glyph') + +assertEqual(audio.friendlyStreamLabel('spotify'), 'Spotify', 'audio normalizes known stream labels') +assert(audio.streamRepresentsMprisPlayer('Chromium', 'Chromium Browser'), 'audio matches related stream and MPRIS labels') + +const players = [ + { identity: 'Spotify', canPlay: true, isPlaying: true, dbusName: 'org.mpris.MediaPlayer2.spotify' }, + { identity: 'Chromium', canPlay: true, isPlaying: false, dbusName: 'org.mpris.MediaPlayer2.chromium' } +] +const streams = [ + { ready: true, properties: { 'application.name': 'Chromium' } }, + { ready: true, properties: { 'application.name': 'audio-src' } } +] + +assertEqual(audio.matchingMprisStreamLabel('Chromium', players), 'Chromium', 'audio finds matching MPRIS labels') +assertEqual(audio.unmatchedMprisStreamLabel('audio-src', players, streams), 'Spotify', 'audio uses unmatched MPRIS player for generic streams') +assertEqual(audio.streamLabel(streams[1], players, streams), 'Spotify', 'audio labels generic streams from MPRIS') +assert(audio.streamRepresentsPlayer(streams[1], players[0], players, streams), 'audio links generic streams to active player') +JS diff --git a/test/shell.d/bar-test.sh b/test/shell.d/bar-test.sh new file mode 100644 index 00000000..f96a7988 --- /dev/null +++ b/test/shell.d/bar-test.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const bar = requireFromRoot('shell/plugins/bar/BarModel.js') + +assertEqual(bar.normalizePosition('left'), 'left', 'bar accepts valid positions') +assertEqual(bar.normalizePosition('sideways'), 'top', 'bar defaults invalid positions') +assertDeepEqual(bar.entrySettings({ id: 'omarchy.clock', format: 'HH:mm' }), { format: 'HH:mm' }, 'bar extracts entry settings') +assertEqual(bar.entryId({ id: 'omarchy.clock' }), 'omarchy.clock', 'bar extracts object entry ids') +assertEqual(bar.entryId('omarchy.clock'), 'omarchy.clock', 'bar extracts string entry ids') + +const entries = [{ id: 'a' }, { id: 'omarchy.tray' }, { id: 'b' }] +assertDeepEqual(bar.pinTrayToInner(entries, 'left').map(bar.entryId), ['a', 'b', 'omarchy.tray'], 'bar pins tray to left inner edge') +assertDeepEqual(bar.pinTrayToInner(entries, 'right').map(bar.entryId), ['omarchy.tray', 'a', 'b'], 'bar pins tray to right inner edge') + +assertEqual(bar.moduleString({ id: 'custom', label: 42 }, 'label', 'fallback'), '42', 'bar stringifies module settings') +assertEqual(bar.entryIndex(entries, 'b'), 2, 'bar finds entry indexes') +assertDeepEqual(bar.entriesBefore(entries, 'b').map(bar.entryId), ['a', 'omarchy.tray'], 'bar returns entries before target') +assertDeepEqual(bar.entriesAfter(entries, 'a').map(bar.entryId), ['omarchy.tray', 'b'], 'bar returns entries after target') + +assertEqual(bar.expandPath('~/module.qml', '/home/dhh'), '/home/dhh/module.qml', 'bar expands tilde paths') +assertEqual(bar.expandPath('$HOME/module.qml', '/home/dhh'), '/home/dhh/module.qml', 'bar expands HOME paths') +assert(bar.customModuleSafeName('local.weather'), 'bar accepts safe custom module names') +assert(!bar.customModuleSafeName('../escape'), 'bar rejects path traversal custom module names') +assertEqual(bar.customModuleType({ id: 'custom', exec: 'date' }), 'command', 'bar infers command custom modules') +assertEqual(bar.customModuleType({ id: 'custom', source: '~/Custom.qml' }), 'qml', 'bar infers qml custom modules') +assertEqual( + bar.customModulePath({ id: 'local.weather' }, '/home/dhh', '/home/dhh/.config/omarchy'), + '/home/dhh/.config/omarchy/bar/modules/local.weather.qml', + 'bar builds default custom module paths' +) +JS diff --git a/test/shell/base-test.sh b/test/shell.d/base-test.sh similarity index 92% rename from test/shell/base-test.sh rename to test/shell.d/base-test.sh index ecc8f8f4..7bb019a7 100644 --- a/test/shell/base-test.sh +++ b/test/shell.d/base-test.sh @@ -1,12 +1,12 @@ #!/bin/bash if [[ ${BASH_SOURCE[0]} == "$0" ]]; then - echo "source test/shell/base-test.sh from a shell test; do not run it directly" >&2 + echo "source test/shell.d/base-test.sh from a shell test; do not run it directly" >&2 exit 1 fi ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) -SHELL_TEST_DIR="$ROOT/test/shell" +SHELL_TEST_DIR="$ROOT/test/shell.d" export ROOT diff --git a/test/shell.d/battery-test.sh b/test/shell.d/battery-test.sh new file mode 100644 index 00000000..0447779a --- /dev/null +++ b/test/shell.d/battery-test.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const battery = requireFromRoot('shell/plugins/services/battery/BatteryModel.js') +const discharging = 1 + +assertEqual(battery.batteryPercentage({ isPresent: true, percentage: 0.126 }), 13, 'battery rounds display percentage') +assertEqual(battery.batteryPercentage({ isPresent: false, percentage: 0.5 }), -1, 'battery reports missing battery') +assert(battery.isDischarging({ isPresent: true, state: discharging }, true, discharging), 'battery detects discharging state') +assert(!battery.isDischarging({ isPresent: true, state: discharging }, false, discharging), 'battery requires on-battery state') + +assertDeepEqual( + battery.shouldWarnLowBattery({ isPresent: true, percentage: 0.08, state: discharging }, true, discharging, 10, false), + { level: 8, notify: true, notifiedLowBattery: true }, + 'battery warns once under threshold' +) +assertDeepEqual( + battery.shouldWarnLowBattery({ isPresent: true, percentage: 0.08, state: discharging }, true, discharging, 10, true), + { level: 8, notify: false, notifiedLowBattery: true }, + 'battery keeps low-battery notified state' +) +assertDeepEqual( + battery.shouldWarnLowBattery({ isPresent: true, percentage: 0.4, state: discharging }, true, discharging, 10, true), + { level: 40, notify: false, notifiedLowBattery: false }, + 'battery clears notified state after recovery' +) +JS diff --git a/test/shell.d/bluetooth-test.sh b/test/shell.d/bluetooth-test.sh new file mode 100644 index 00000000..9c1ac939 --- /dev/null +++ b/test/shell.d/bluetooth-test.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const bluetooth = requireFromRoot('shell/plugins/bluetooth/BluetoothModel.js') + +assert(bluetooth.isUuidLike('0000110b-0000-1000-8000-00805f9b34fb'), 'bluetooth detects UUID-like names') +assert(bluetooth.isAddressLike('AA:BB:CC:DD:EE:FF'), 'bluetooth detects address-like names') +assert(!bluetooth.hasHumanName({ name: 'AA:BB:CC:DD:EE:FF' }), 'bluetooth rejects address-only device labels') +assert(bluetooth.hasHumanName({ deviceName: 'MX Master 3S' }), 'bluetooth accepts human device labels') + +const devices = [ + { name: 'Speaker', connected: false, paired: true, address: '2' }, + { name: 'Headphones', connected: true, address: '1' }, + { name: 'Keyboard', connected: false, address: '3' }, + { name: 'AA:BB:CC:DD:EE:FF', connected: true, address: '4' }, + { name: 'Mouse', connected: false, trusted: true, address: '5' } +] + +const lists = bluetooth.deviceLists(devices) +assertDeepEqual(lists.connected.map(bluetooth.deviceLabel), ['Headphones'], 'bluetooth groups connected devices') +assertDeepEqual(lists.known.map(bluetooth.deviceLabel), ['Mouse', 'Speaker'], 'bluetooth groups known devices by label') +assertDeepEqual(lists.discovered.map(bluetooth.deviceLabel), ['Keyboard'], 'bluetooth groups discovered devices') +assertDeepEqual(bluetooth.visibleSections(lists, true), ['connected', 'known', 'discovered'], 'bluetooth shows discovered section while scanning') +assertDeepEqual(bluetooth.visibleSections(lists, false), ['connected', 'known'], 'bluetooth hides discovered section when not scanning') + +assertDeepEqual( + bluetooth.withPendingAction({ a: 'connecting' }, 'b', 'forgetting'), + { a: 'connecting', b: 'forgetting' }, + 'bluetooth adds pending actions immutably' +) +assertDeepEqual(bluetooth.withPendingAction({ a: 'connecting' }, 'a', ''), {}, 'bluetooth clears pending actions immutably') +JS diff --git a/test/shell.d/clipboard-test.sh b/test/shell.d/clipboard-test.sh new file mode 100644 index 00000000..5d05bd54 --- /dev/null +++ b/test/shell.d/clipboard-test.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const clipboard = requireFromRoot('shell/plugins/clipboard/ClipboardHistory.js') + +assertDeepEqual( + clipboard.normalizeEntry('hello'), + { type: 'text', text: 'hello' }, + 'clipboard normalizes string entries' +) + +assertDeepEqual( + clipboard.normalizeEntry({ kind: 'image', path: '/tmp/a.png' }), + { type: 'image', path: '/tmp/a.png', mime: 'image/png' }, + 'clipboard normalizes image entries with default mime' +) + +assertDeepEqual( + clipboard.parseHistory(JSON.stringify(['one', '', { type: 'text', text: 'two' }, { type: 'image', path: '/tmp/a.jpg', mime: 'image/jpeg' }])), + [ + { type: 'text', text: 'one' }, + { type: 'text', text: 'two' }, + { type: 'image', path: '/tmp/a.jpg', mime: 'image/jpeg' } + ], + 'clipboard history parser drops invalid entries' +) + +const history = [ + { type: 'text', text: 'old' }, + { type: 'text', text: 'new' }, + { type: 'image', path: '/tmp/a.png', mime: 'image/png' } +] + +assertDeepEqual( + clipboard.addEntry(history, { type: 'text', text: 'new' }, 100), + [ + { type: 'text', text: 'new' }, + { type: 'text', text: 'old' }, + { type: 'image', path: '/tmp/a.png', mime: 'image/png' } + ], + 'clipboard addEntry moves duplicate text to front' +) + +assertDeepEqual( + clipboard.displayRows(history, 'image', 50).map(row => ({ type: row.entryType, preview: row.previewText, mime: row.mime })), + [{ type: 'image', preview: 'Image', mime: 'image/png' }], + 'clipboard display rows search image metadata' +) + +assertDeepEqual( + clipboard.displayRows([{ type: 'text', text: 'line one\nline two' }], '', 50)[0].previewText, + 'line one line two', + 'clipboard display rows collapse text whitespace' +) + +assertDeepEqual(clipboard.displayRows(history, '', 0), [], 'clipboard display rows supports zero result limit') +assertDeepEqual(clipboard.addEntry(history, 'next', 0), [], 'clipboard addEntry supports zero history limit') +JS diff --git a/test/shell/emojis-test.sh b/test/shell.d/emojis-test.sh similarity index 100% rename from test/shell/emojis-test.sh rename to test/shell.d/emojis-test.sh diff --git a/test/shell.d/idle-test.sh b/test/shell.d/idle-test.sh new file mode 100644 index 00000000..3d6dc64c --- /dev/null +++ b/test/shell.d/idle-test.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const idle = requireFromRoot('shell/plugins/services/idle/IdleModel.js') + +assertEqual(idle.secondsFromConfig('42.9', 10), 42, 'idle floors configured seconds') +assertEqual(idle.secondsFromConfig('-1', 10), 10, 'idle rejects negative seconds') +assertEqual(idle.secondsFromConfig('nope', 10), 10, 'idle rejects invalid seconds') + +assertDeepEqual(idle.eventParts({ data: 'a,b,c' }, 2), ['a', 'b', 'c'], 'idle parses raw event data') +assertDeepEqual( + idle.eventParts({ parse: function(count) { return ['parsed', count] } }, 4), + ['parsed', 4], + 'idle prefers event parser when available' +) + +assertDeepEqual( + idle.screensaverWindowsAfter({ a: true }, 'b', true), + { windows: { a: true, b: true }, count: 2 }, + 'idle adds visible screensaver windows' +) +assertDeepEqual( + idle.screensaverWindowsAfter({ a: true, b: true }, 'a', false), + { windows: { b: true }, count: 1 }, + 'idle removes closed screensaver windows' +) +assertDeepEqual( + idle.screensaverWindowsAfter({ a: true }, '', false), + { windows: { a: true }, count: 1 }, + 'idle leaves screensaver windows unchanged without an address' +) +JS diff --git a/test/shell.d/image-picker-test.sh b/test/shell.d/image-picker-test.sh new file mode 100644 index 00000000..f52b90a2 --- /dev/null +++ b/test/shell.d/image-picker-test.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const picker = requireFromRoot('shell/plugins/image-picker/ImagePickerModel.js') + +assertEqual(picker.nameForPath('/themes/nord-river.png'), 'nord-river', 'image picker strips directory and extension') +assertEqual(picker.labelForPath('/themes/nord_river.png'), 'Nord River', 'image picker builds display labels') + +const rows = [ + '/themes/a/nord-river.png\t/cache/nord-river.jpg', + '/themes/b/nord-river.png\t/cache/duplicate.jpg', + '/themes/a/gruvbox-dark.jpeg', + '', + '\t/cache/no-path.jpg', + '/themes/a/plain' +].join('\n') + +const images = picker.loadRows(rows) +assertDeepEqual( + images, + [ + { filePath: '/themes/a/nord-river.png', fileName: 'nord-river.png', thumbnailPath: '/cache/nord-river.jpg' }, + { filePath: '/themes/a/gruvbox-dark.jpeg', fileName: 'gruvbox-dark.jpeg', thumbnailPath: '/themes/a/gruvbox-dark.jpeg' }, + { filePath: '/themes/a/plain', fileName: 'plain', thumbnailPath: '/themes/a/plain' } + ], + 'image picker parses rows and dedupes by file name' +) + +assert(picker.itemMatches(images, 0, 'river'), 'image picker matches file names') +assert(picker.itemMatches(images, 1, 'Gruvbox Dark'), 'image picker matches labels case-insensitively') +assert(!picker.itemMatches(images, 2, 'river'), 'image picker rejects non-matching filters') +assertEqual(picker.firstMatchingIndex(images, 'plain'), 2, 'image picker finds first matching index') +assertEqual(picker.indexForSelectedImage(images, '/themes/a/gruvbox-dark.jpeg'), 1, 'image picker finds selected image') +assertEqual(picker.indexForSelectedImage(images, '/missing.png'), 0, 'image picker defaults selected image to first row') + +assertEqual(picker.filteredPosition(images, 2, 'dark'), 1, 'image picker computes filtered position') +assertEqual(picker.selectedFilteredPosition(images, 2, 'dark'), 0, 'image picker selected filtered position falls back when selected is hidden') +assertEqual(picker.nextSelectedIndexForFilter(images, 0, 'dark'), 1, 'image picker moves selection to first match when filter hides current item') +JS diff --git a/test/shell/launcher-search-test.sh b/test/shell.d/launcher-search-test.sh similarity index 100% rename from test/shell/launcher-search-test.sh rename to test/shell.d/launcher-search-test.sh diff --git a/test/shell.d/media-test.sh b/test/shell.d/media-test.sh new file mode 100644 index 00000000..d7574e69 --- /dev/null +++ b/test/shell.d/media-test.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const media = requireFromRoot('shell/plugins/services/media/MediaModel.js') + +assert(media.isProxyPlayer({ dbusName: 'org.mpris.MediaPlayer2.playerctld' }), 'media detects playerctld proxy by DBus name') +assert(media.isProxyPlayer({ desktopEntry: 'playerctld' }), 'media detects playerctld proxy by desktop entry') +assert(media.hasMetadata({ identity: 'Spotify' }), 'media detects identity metadata') +assert(media.hasTrackMetadata({ trackTitle: 'Track' }), 'media detects track metadata') +assert(media.playerCanControl({ canGoNext: true }), 'media detects controllable players') +assert(media.canHandleAction({ canTogglePlaying: true }, 'playPause'), 'media maps playPause capability') +assert(media.canCycleSource({ identity: 'Spotify', canPlay: true }), 'media detects cycleable sources') + +assert(media.isPlaybackStream({ isStream: true, type: 'Stream/Output/Audio' }), 'media detects playback streams') +assertEqual(media.streamLabelKey('PipeWire ALSA [Chromium]'), 'chromium', 'media normalizes stream labels') +assertEqual( + media.rawStreamLabel({ ready: true, properties: { 'application.name': 'Chromium' }, name: 'fallback' }), + 'Chromium', + 'media extracts raw stream labels' +) +assertEqual( + media.playerAppLabel({ dbusName: 'org.mpris.MediaPlayer2.spotify.instance42' }), + 'spotify', + 'media derives player app labels from DBus names' +) +assert(media.playerHasPlaybackStream( + { desktopEntry: 'chromium' }, + [{ ready: true, properties: { 'application.name': 'Chromium' } }] +), 'media matches players to playback streams') + +assertEqual(media.playerKey({ dbusName: 'org.mpris.MediaPlayer2.spotify' }), 'org.mpris.MediaPlayer2.spotify', 'media derives stable player keys') +assertEqual(media.labelFor({ trackTitle: 'Song', identity: 'Spotify' }), 'Song', 'media labels players by track first') +assertEqual(media.osdMessage({ trackTitle: 'Song', trackArtist: 'Artist' }, 'Fallback'), 'Song - Artist', 'media builds OSD messages') +assertEqual(media.osdMessage(null, 'Fallback'), 'Fallback', 'media falls back OSD messages') +JS diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh new file mode 100644 index 00000000..6a03b91b --- /dev/null +++ b/test/shell.d/menu-test.sh @@ -0,0 +1,88 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const menu = requireFromRoot('shell/plugins/menu/MenuModel.js') + +const parsed = menu.parseMenuJsonc(` +{ + // comment + "items": { + "root": { "label": "Go" }, + "style": { "label": "Style" }, + "style.theme": { + "label": "Themes", + "aliases": "theme", + "keywords": "appearance appearance colors", + "action": "omarchy-theme-set" + }, + }, +} +`) + +assertEqual(parsed.length, 3, 'menu parses JSONC with comments and trailing commas') +assertDeepEqual( + parsed.find(item => item.id === 'style.theme'), + { + id: 'style.theme', + parent: 'style', + kind: 'action', + icon: '', + label: 'Themes', + target: '', + keywords: 'appearance colors', + description: '', + action: 'omarchy-theme-set', + provider: '', + aliases: ['theme'], + when: '', + checked: '' + }, + 'menu normalizes parsed items' +) + +const user = [ + menu.normalizeItem('style.theme', { label: 'Theme picker', aliases: ['theme', 'colors'], action: 'custom-theme' }), + menu.normalizeItem('tools', { label: 'Tools' }) +] +const merged = menu.mergeMenuSources(parsed, user) +assertEqual(merged.items['style.theme'].label, 'Theme picker', 'menu user entries override default entries') +assertEqual(merged.items['style.theme'].order, 2, 'menu preserves original order on override') +assert(merged.items.root, 'menu injects root when merging sources') + +assertEqual(menu.slugify('Power Saver!'), 'power-saver', 'menu slugifies provider rows') +assertEqual(menu.pathFor(merged.items, 'style.theme'), 'Style › Theme picker', 'menu builds item paths') +assertEqual(menu.parentPathFor(merged.items, 'style.theme'), 'Style', 'menu builds parent paths') +assert(menu.isDescendantOf(merged.items, 'style.theme', 'style'), 'menu detects descendants') +assertEqual(menu.childCount(merged.items, merged.itemOrder, 'style'), 1, 'menu counts children') +assertEqual(menu.labelFor({ id: 'style.theme', label: 'Theme', checked: 'cmd' }, { 'style.theme': true }), 'Theme ✓', 'menu appends checked marker') + +const entry = merged.items['style.theme'] +assert(menu.matchesQuery(entry, 'theme', true), 'menu matches labels and aliases') +assert(menu.matchesQuery(entry, 'colors', true), 'menu matches aliases') +assert(!menu.matchesQuery(entry, 'missing', true), 'menu rejects missing terms') +assert(!menu.matchesQuery(entry, 'theme', false), 'menu hides invisible matches') +assert(menu.searchScore(merged.items, entry, 'theme') < menu.searchScore(merged.items, entry, 'appearance'), 'menu scores name matches above keyword matches') + +assertDeepEqual( + menu.displayRow(merged.items, merged.itemOrder, {}, entry, 'Style', 12, 'search'), + { + itemId: 'style.theme', + kind: 'action', + icon: '', + label: 'Theme picker', + target: 'style.theme', + detail: 'Style', + path: 'Style › Theme picker', + childCount: 0, + action: 'custom-theme', + provider: '', + score: 12, + section: 'search' + }, + 'menu builds display rows' +) +JS diff --git a/test/shell.d/monitor-test.sh b/test/shell.d/monitor-test.sh new file mode 100644 index 00000000..3586e8e4 --- /dev/null +++ b/test/shell.d/monitor-test.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const monitor = requireFromRoot('shell/plugins/monitor/MonitorModel.js') + +assertEqual(monitor.clampBrightness(0), 1, 'monitor clamps minimum brightness') +assertEqual(monitor.clampBrightness(101), 100, 'monitor clamps maximum brightness') +assertEqual(monitor.clampBrightness(42.4), 42, 'monitor rounds brightness') +assertEqual(monitor.clampBrightness('nope'), 1, 'monitor rejects invalid brightness') + +assertEqual(monitor.normalizeScale('1.250'), '1.25', 'monitor normalizes fractional scale') +assertEqual(monitor.normalizeScale('nope'), '', 'monitor rejects invalid scale') + +assertEqual(monitor.brightnessName(96), 'Sun blast', 'monitor names very bright displays') +assertEqual(monitor.brightnessName(12), 'Candlelit', 'monitor names dim displays') + +assertDeepEqual( + monitor.parseDisplays(JSON.stringify([ + { name: 'eDP-1', enabled: true }, + { name: 'HDMI-A-1', enabled: false }, + { name: 'DP-1', enabled: true } + ])), + { + displays: [ + { name: 'eDP-1', enabled: true }, + { name: 'HDMI-A-1', enabled: false }, + { name: 'DP-1', enabled: true } + ], + enabledDisplayCount: 2 + }, + 'monitor parses display state' +) + +assertDeepEqual(monitor.parseDisplays('{'), { displays: [], enabledDisplayCount: 0 }, 'monitor handles invalid display JSON') +JS diff --git a/test/shell.d/network-test.sh b/test/shell.d/network-test.sh new file mode 100644 index 00000000..c671f620 --- /dev/null +++ b/test/shell.d/network-test.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const network = requireFromRoot('shell/plugins/network/NetworkModel.js') + +assertDeepEqual( + network.parseNetworkStatus('wifi\tCafe WiFi\t78\t5200\n'), + { kind: 'wifi', label: 'Cafe WiFi', signalStrength: 78, frequency: '5200' }, + 'network parses bar status' +) +assertEqual(network.connectionIcon('wifi', 80), network.wifiIconFor(80), 'network maps wifi icon from signal') +assertEqual(network.formatHeaderSpeed('1000'), '1gbit', 'network formats gigabit speed') +assertEqual(network.formatHeaderSpeed('2500'), '2.5gbit', 'network formats fractional gigabit speed') +assertEqual(network.formatHeaderFreq('5200'), '5.2ghz', 'network formats wifi frequency') +assertEqual(network.headerDetail({ type: 'ethernet', speed: '100' }), '100mbit', 'network header uses ethernet speed') + +assertDeepEqual( + network.parseKeyValue('iface\twlan0\nrx_bytes\t100\ntx_bytes\t50\n'), + { iface: 'wlan0', rx_bytes: '100', tx_bytes: '50' }, + 'network parses detail key values' +) +assertDeepEqual( + network.throughputState({ prevIface: '', prevSampleTime: 0 }, { iface: 'wlan0', rx_bytes: '100', tx_bytes: '50' }, 10), + { prevIface: 'wlan0', prevRxBytes: 100, prevTxBytes: 50, prevSampleTime: 10, downloadRate: 0, uploadRate: 0 }, + 'network seeds throughput state on first sample' +) +assertDeepEqual( + network.throughputState({ prevIface: 'wlan0', prevRxBytes: 100, prevTxBytes: 50, prevSampleTime: 10 }, { iface: 'wlan0', rx_bytes: '300', tx_bytes: '90' }, 12), + { prevIface: 'wlan0', prevRxBytes: 300, prevTxBytes: 90, prevSampleTime: 12, downloadRate: 100, uploadRate: 20 }, + 'network computes throughput deltas' +) + +assertEqual(network.formatBytes(1536), '1.5 KB', 'network formats bytes') +assertEqual(network.formatRate(1536), '1.5 KB/s', 'network formats rates') + +const rows = network.sortWifiRows([ + { ssid: 'Open', connected: false, known: false, signal: 95 }, + { ssid: 'Known', connected: false, known: true, signal: 10 }, + { ssid: 'Connected', connected: true, known: true, signal: 20 } +]) +assertDeepEqual(rows.map(row => row.ssid), ['Connected', 'Known', 'Open'], 'network sorts wifi rows by connection and known state') +assertEqual(network.wifiSectionTitle(rows, 0), 'KNOWN NETWORKS', 'network labels known wifi section') +assertEqual(network.wifiSectionTitle(rows, 2), 'OTHER NETWORKS', 'network labels other wifi section') + +const reasons = { NoSecrets: 1, WifiAuthTimeout: 2, WifiNetworkLost: 3, WifiClientDisconnected: 4, WifiClientFailed: 5 } +assertEqual(network.networkFailureReason(1, reasons), 'Passphrase required', 'network maps missing passphrase failures') +assertEqual(network.networkFailureReason(2, reasons), 'Wrong password', 'network maps auth timeout failures') +assertEqual(network.networkFailureReason(99, reasons), 'Failed to connect', 'network maps unknown failures') +JS diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh new file mode 100644 index 00000000..817f806f --- /dev/null +++ b/test/shell.d/notifications-test.sh @@ -0,0 +1,125 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const notifications = requireFromRoot('shell/plugins/notifications/NotificationLogic.js') + +assert(notifications.isChromiumDerived('Brave Browser', ''), 'notifications detect chromium-derived apps by name') +assert(notifications.isChromiumDerived('', 'microsoft-edge'), 'notifications detect chromium-derived apps by icon') +assert(!notifications.isChromiumDerived('Slack', ''), 'notifications do not treat unrelated apps as chromium-derived') + +assertEqual( + notifications.sanitizeBody('Hello', 'Slack', ''), + 'Hello', + 'notifications strip inline image tags' +) + +assertEqual( + notifications.sanitizeBody('example.com Message body', 'Chromium', ''), + 'Message body', + 'notifications strip chromium leading origin links' +) + +assertEqual( + notifications.sanitizeBody('https://example.com/path Message body', 'Chromium', ''), + 'Message body', + 'notifications strip chromium leading origin text' +) + +assertEqual( + notifications.sanitizeBody('https://example.com/path Message body', 'Slack', ''), + 'https://example.com/path Message body', + 'notifications keep non-browser leading origin text' +) + +assert(notifications.summaryStartsWithGlyph('󰂚 Silenced'), 'notifications detect glyph-prefixed summaries') +assert(!notifications.summaryStartsWithGlyph('Normal summary'), 'notifications ignore normal summaries as glyph-prefixed') + +assert(notifications.shouldBypassDnd({ appName: 'omarchy-action', urgency: 1 }, 2), 'omarchy action toasts bypass DND') +assert(notifications.shouldBypassDnd({ appName: 'notify-send', urgency: 2 }, 2), 'critical notify-send bypasses DND') +assert(!notifications.shouldBypassDnd({ appName: 'notify-send', urgency: 1 }, 2), 'normal notify-send does not bypass DND') +assert(!notifications.shouldBypassDnd({ appName: 'Slack', urgency: 2 }, 2), 'critical app notifications do not bypass DND') + +const notification = { + id: 12, + appName: 'Mail', + appIcon: 'mail', + summary: 42, + body: 'Body', + image: 'file:///tmp/mail.png', + hints: { 'omarchy-glyph': '!' }, + urgency: 1 +} +const snapshot = notifications.snapshotOf(notification, 12345) +assertDeepEqual( + { + id: snapshot.id, + originalId: snapshot.originalId, + app: snapshot.app, + appIcon: snapshot.appIcon, + summary: snapshot.summary, + body: snapshot.body, + image: snapshot.image, + glyph: snapshot.glyph, + urgency: snapshot.urgency, + timestamp: snapshot.timestamp + }, + { + id: 12, + originalId: 12, + app: 'Mail', + appIcon: 'mail', + summary: '42', + body: 'Body', + image: 'file:///tmp/mail.png', + glyph: '!', + urgency: 1, + timestamp: 12345 + }, + 'notifications create stable snapshots' +) + +const history = notifications.parseHistory(JSON.stringify({ + dnd: true, + pending: [ + { id: 1, originalId: 10, summary: 'old', timestamp: 100 }, + { id: 2, originalId: 10, summary: 'new', timestamp: 200 }, + { id: 3, originalId: 11, summary: 'other', timestamp: 150 } + ], + past: [ + { id: 4, summary: 'past', timestamp: 50 } + ], + entries: [ + { id: 5, summary: 'legacy', timestamp: 75 } + ] +}), 1, 100) + +assertEqual(history.dnd, true, 'notifications parse persisted DND state') +assertEqual(history.hadDuplicates, true, 'notifications report duplicate history rows') +assertDeepEqual( + history.pending.map(row => ({ id: row.id, originalId: row.originalId, summary: row.summary, urgency: row.urgency, timestamp: row.timestamp })), + [ + { id: 2, originalId: 10, summary: 'new', urgency: 1, timestamp: 200 }, + { id: 3, originalId: 11, summary: 'other', urgency: 1, timestamp: 150 } + ], + 'notifications dedupe pending history by original id' +) +assertDeepEqual( + history.past.map(row => row.summary), + ['legacy', 'past'], + 'notifications merge legacy entries into past history' +) +assertDeepEqual( + notifications.parseHistory(JSON.stringify({ pending: [{ id: 1, timestamp: 1 }] }), 1, 0).pending, + [], + 'notifications history parser supports zero result cap' +) +assert(notifications.parseHistory('{', 1, 100).error, 'notifications flag invalid history JSON') + +assertEqual(notifications.imageExtension('/tmp/screenshot.PNG'), 'png', 'notifications normalize image extensions') +assertEqual(notifications.imageExtension('/tmp/no-extension'), 'png', 'notifications default missing image extension') +assertEqual(notifications.imageExtension('/tmp/archive.reallylong'), 'png', 'notifications reject suspicious image extensions') +JS diff --git a/test/shell.d/osd-test.sh b/test/shell.d/osd-test.sh new file mode 100644 index 00000000..e791d626 --- /dev/null +++ b/test/shell.d/osd-test.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const osd = requireFromRoot('shell/plugins/osd/OsdModel.js') + +assertEqual(osd.iconFor('', 0), osd.iconFor('muted', 50), 'osd falls back to muted icon at zero percent') +assertEqual(osd.iconFor('volume-high', 1), osd.iconFor('', 100), 'osd maps high volume aliases') +assertEqual(osd.iconFor('custom-symbol', 50), 'custom-symbol', 'osd preserves unknown explicit icons') + +assertDeepEqual( + osd.stateForShow('volume', '', '75', '100', '', '800'), + { + iconKey: 'volume', + maxValue: 100, + hasProgress: true, + value: 75, + message: '75%', + icon: osd.iconFor('volume', 75), + duration: 800 + }, + 'osd builds progress state' +) + +assertDeepEqual( + osd.stateForShow('media-pause', 'Paused', '', '100', '', 'nope'), + { + iconKey: 'media-pause', + maxValue: 100, + hasProgress: false, + value: 0, + message: 'Paused', + icon: osd.iconFor('media-pause', -1), + duration: 1200 + }, + 'osd builds message state' +) +JS diff --git a/test/shell.d/plugins-test.sh b/test/shell.d/plugins-test.sh new file mode 100644 index 00000000..0372917d --- /dev/null +++ b/test/shell.d/plugins-test.sh @@ -0,0 +1,151 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const fs = require('fs') + +const pluginsDir = path.join(root, 'shell/plugins') +const kindEntryPoints = { + 'bar': 'bar', + 'bar-widget': 'barWidget', + 'menu': 'menu', + 'overlay': 'overlay', + 'panel': 'panel', + 'service': 'service' +} + +function isPlainObject(value) { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function walk(dir) { + const rows = [] + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + rows.push(...walk(fullPath)) + } else if (entry.isFile() && (entry.name === 'manifest.json' || entry.name.endsWith('.manifest.json'))) { + rows.push(fullPath) + } + } + return rows.sort() +} + +function relativeFromPlugins(filePath) { + return path.relative(pluginsDir, filePath).split(path.sep).join('/') +} + +function sourceDirForManifest(manifestPath) { + return path.dirname(manifestPath) +} + +const errors = [] + +function check(condition, detail) { + if (!condition) errors.push(detail) +} + +function assertSafeEntryPoint(manifest, manifestPath, key, value) { + const label = `${manifest.id} ${key} entry point` + check(typeof value === 'string' && value.length > 0, `${label} must be a non-empty string`) + check(!path.isAbsolute(value), `${label} must be relative`) + check(!String(value).split(/[\\/]+/).includes('..'), `${label} must stay inside plugin source`) + check(fs.existsSync(path.join(sourceDirForManifest(manifestPath), String(value))), `${label} file must exist`) +} + +const manifests = walk(pluginsDir) +const manifestPaths = manifests.map(relativeFromPlugins) +const manifestSet = new Set(manifestPaths) + +assert(manifests.length > 0, 'plugin manifests are present') + +for (const entry of fs.readdirSync(pluginsDir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === 'services') continue + check( + manifestSet.has(`${entry.name}/manifest.json`), + `top-level plugin ${entry.name} must have a manifest` + ) +} + +const serviceRoot = path.join(pluginsDir, 'services') +for (const entry of fs.readdirSync(serviceRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + check( + manifestSet.has(`services/${entry.name}/manifest.json`), + `service plugin ${entry.name} must have a manifest` + ) +} + +for (const manifestPath of manifestPaths) { + const depth = manifestPath.split('/').length + check(depth >= 2 && depth <= 3, `${manifestPath} must be discoverable by PluginRegistry`) +} + +const ids = new Set() +for (const manifestPath of manifests) { + const relativePath = relativeFromPlugins(manifestPath) + let manifest = null + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + } catch (error) { + errors.push(`${relativePath} must parse as JSON: ${error.message}`) + continue + } + + check(isPlainObject(manifest), `${relativePath} must parse to an object`) + if (!isPlainObject(manifest)) continue + + check(manifest.schemaVersion === 1, `${relativePath} must use schema version 1`) + + for (const field of ['id', 'name', 'version', 'description']) { + check(typeof manifest[field] === 'string' && manifest[field].length > 0, `${manifest.id || relativePath} must have ${field}`) + } + + check(String(manifest.id).startsWith('omarchy.'), `${manifest.id} must use the first-party namespace`) + check(!String(manifest.id).includes('/') && !String(manifest.id).includes('..'), `${manifest.id} must be safe as a plugin id`) + check(!ids.has(manifest.id), `${manifest.id} must be unique`) + ids.add(manifest.id) + + check(Array.isArray(manifest.kinds) && manifest.kinds.length > 0, `${manifest.id} must declare plugin kinds`) + check( + JSON.stringify([...new Set(manifest.kinds || [])]) === JSON.stringify(manifest.kinds || []), + `${manifest.id} must not duplicate plugin kinds` + ) + check(isPlainObject(manifest.entryPoints), `${manifest.id} must have an entryPoints object`) + + for (const kind of manifest.kinds || []) { + check(kindEntryPoints[kind], `${manifest.id} must use supported plugin kind ${kind}`) + const entryPointKey = kindEntryPoints[kind] + check(manifest.entryPoints && manifest.entryPoints[entryPointKey], `${manifest.id} must declare ${entryPointKey} entry point`) + } + + for (const key of Object.keys(manifest.entryPoints || {})) { + check(Object.values(kindEntryPoints).includes(key), `${manifest.id} entry point ${key} must be a supported key`) + assertSafeEntryPoint(manifest, manifestPath, key, manifest.entryPoints[key]) + } + + if (manifest.keepLoaded !== undefined) { + check(typeof manifest.keepLoaded === 'boolean', `${manifest.id} keepLoaded must be boolean when present`) + } + + if ((manifest.kinds || []).includes('bar-widget')) { + check(isPlainObject(manifest.barWidget), `${manifest.id} must have barWidget metadata`) + for (const field of ['displayName', 'description', 'category']) { + check( + manifest.barWidget && typeof manifest.barWidget[field] === 'string' && manifest.barWidget[field].length > 0, + `${manifest.id} barWidget metadata must have ${field}` + ) + } + check(manifest.barWidget && typeof manifest.barWidget.allowMultiple === 'boolean', `${manifest.id} barWidget allowMultiple must be boolean`) + } + + if (relativePath.endsWith('.manifest.json')) { + check(JSON.stringify(manifest.kinds) === JSON.stringify(['bar-widget']), `${manifest.id} sibling manifest must be a bar widget`) + } +} + +assert(errors.length === 0, 'plugin manifests match shell registry contract', errors.join('\n')) +JS diff --git a/test/shell.d/polkit-test.sh b/test/shell.d/polkit-test.sh new file mode 100644 index 00000000..1ca2a935 --- /dev/null +++ b/test/shell.d/polkit-test.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const polkit = requireFromRoot('shell/plugins/polkit/PolkitModel.js') + +assert(polkit.promptLooksFingerprint('Swipe your finger'), 'polkit detects fingerprint prompts') +assert(polkit.promptLooksFingerprint('fprintd verification'), 'polkit detects fprint prompts') +assert(!polkit.promptLooksFingerprint('Password:'), 'polkit ignores password prompts') + +assert( + polkit.fingerprintFirstFromPamConfig(` +# comment +auth sufficient pam_fprintd.so +auth include system-auth +`), + 'polkit detects fingerprint-first PAM config' +) +assert( + !polkit.fingerprintFirstFromPamConfig(` +account include system-auth +auth include system-auth +auth sufficient pam_fprintd.so +`), + 'polkit detects password-first PAM config' +) +JS diff --git a/test/shell.d/power-test.sh b/test/shell.d/power-test.sh new file mode 100644 index 00000000..ca7b02e7 --- /dev/null +++ b/test/shell.d/power-test.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const power = requireFromRoot('shell/plugins/power/PowerModel.js') +const states = { Charging: 1, Discharging: 2, FullyCharged: 3, PendingCharge: 4 } + +assertEqual(power.selectProfileIndex(0, 1, ['balanced', 'performance']), 1, 'power advances profile selection') +assertEqual(power.selectProfileIndex(1, 1, ['balanced', 'performance']), 1, 'power clamps profile selection') + +assertDeepEqual(power.parseKeyValue('time\t2:00\nenergy\t42\n'), { time: '2:00', energy: '42' }, 'power parses key-value output') +assertDeepEqual( + power.parseProfiles('power-saver\t0\nbalanced\t1\nperformance\t0\n', 5), + { profiles: ['power-saver', 'balanced', 'performance'], activeProfile: 'balanced', profileIndex: 2 }, + 'power parses profile output and clamps selection' +) + +assert(power.profileIcon('performance').length > 0, 'power maps profile icons') +assertEqual(power.batteryFraction({ isPresent: true, percentage: 1.5 }), 1, 'power clamps battery fraction') + +assert(power.chargeThresholdActive({ isPresent: true, percentage: 0.8, state: states.PendingCharge }, false, states), 'power detects threshold by pending charge state') +assert(power.chargeThresholdActive({ isPresent: true, percentage: 0.8, state: states.Charging, changeRate: 0.1, timeToFull: 120 }, false, states), 'power detects threshold by stalled charging') +assert(!power.chargeThresholdActive({ isPresent: true, percentage: 0.8, state: states.Charging, changeRate: 1.0, timeToFull: 120 }, false, states), 'power does not flag active charging as threshold') +assertEqual(power.modeLabel({ isPresent: true, percentage: 1, state: states.FullyCharged }, false, states), 'Fully charged', 'power labels full battery') +assertEqual(power.modeLabel({ isPresent: true, percentage: 0.5, state: states.Discharging }, true, states), 'On battery', 'power labels battery mode') +assert(power.batteryIcon({ isPresent: true, percentage: 0.4, state: states.Charging }, false, states).length > 0, 'power maps battery icons') +JS diff --git a/test/shell.d/reminders-test.sh b/test/shell.d/reminders-test.sh new file mode 100644 index 00000000..4ad3c4e2 --- /dev/null +++ b/test/shell.d/reminders-test.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const reminders = requireFromRoot('shell/plugins/reminders/ReminderFlowModel.js') + +assertEqual(reminders.validMinutes('15'), '15', 'reminders accepts positive integer minutes') +assertEqual(reminders.validMinutes(' 5 '), '5', 'reminders trims minute input') +assertEqual(reminders.validMinutes('0'), '', 'reminders rejects zero minutes') +assertEqual(reminders.validMinutes('-5'), '', 'reminders rejects negative minutes') +assertEqual(reminders.validMinutes('1.5'), '', 'reminders rejects fractional minutes') +assertEqual(reminders.validMinutes('soon'), '', 'reminders rejects non-numeric minutes') + +assertDeepEqual( + reminders.reminderArgs('10', 'Check the oven'), + ['10', 'Check the oven'], + 'reminders builds command args with message' +) + +assertDeepEqual( + reminders.reminderArgs('10', ''), + ['10'], + 'reminders omits empty message arg' +) + +assertDeepEqual( + reminders.reminderArgs('0', 'ignored'), + [], + 'reminders command args are empty for invalid minutes' +) +JS diff --git a/test/shell.d/weather-test.sh b/test/shell.d/weather-test.sh new file mode 100644 index 00000000..bb106afd --- /dev/null +++ b/test/shell.d/weather-test.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const weather = requireFromRoot('shell/plugins/weather/WeatherModel.js') + +assertDeepEqual( + weather.parseWeatherStatus('{"text":"☀","class":"sunny"}'), + { label: '☀', klass: 'sunny' }, + 'weather parses pill status JSON' +) +assertDeepEqual(weather.parseWeatherStatus('{'), { label: '', klass: '' }, 'weather handles invalid pill status JSON') + +assertEqual(weather.roundedTemp('21.6'), '22', 'weather rounds temperatures') +assertEqual(weather.roundedTemp('nope'), '', 'weather ignores invalid temperatures') +assertEqual(weather.formatTemp(72, true), '72°F', 'weather formats imperial temperatures') +assertEqual(weather.formatTemp(22, false), '22°C', 'weather formats metric temperatures') +assertEqual(weather.dayName('2026-05-25'), 'Monday', 'weather derives day names') + +const openMeteo = { + daily: { + time: ['2026-05-25', '2026-05-26', '2026-05-27', '2026-05-28', '2026-05-29'], + temperature_2m_max: [20.1, 21.6, 18.2, 17.9, 22.4], + temperature_2m_min: [12.2, 13.1, 10.8, 9.2, 11.5], + weather_code: [0, 63, 95, 3, 1] + } +} + +assertDeepEqual( + weather.openMeteoForecastDays(openMeteo, '2026-05-25').map(day => ({ + date: day.date, + maxtempC: day.maxtempC, + mintempF: day.mintempF, + code: day.openMeteoWeatherCode + })), + [ + { date: '2026-05-26', maxtempC: '22', mintempF: '56', code: 63 }, + { date: '2026-05-27', maxtempC: '18', mintempF: '51', code: 95 }, + { date: '2026-05-28', maxtempC: '18', mintempF: '49', code: 3 } + ], + 'weather builds future Open-Meteo forecast days' +) + +const wttr = { + weather: [ + { date: '2026-05-25', maxtempC: '20', mintempC: '12' }, + { date: '2026-05-26', maxtempC: '22', mintempC: '13' } + ] +} +assertEqual(weather.buildForecastDays(wttr, {}, '2026-05-25')[0].date, '2026-05-26', 'weather falls back to wttr forecast') +assertEqual(weather.bareTempForDay({ maxtempC: '22', mintempC: '13', maxtempF: '72', mintempF: '55' }, 'max', false), '22°', 'weather formats forecast metric highs') +assertEqual(weather.bareTempForDay({ maxtempC: '22', mintempC: '13', maxtempF: '72', mintempF: '55' }, 'min', true), '55°', 'weather formats forecast imperial lows') + +assert(weather.dayIcon({ openMeteoWeatherCode: 95 }).length > 0, 'weather maps Open-Meteo weather icons') +assertEqual( + weather.dayIcon({ hourly: [{ time: '900', weatherCode: 113 }, { time: '1200', weatherCode: 389 }, { time: '1800', weatherCode: 116 }] }), + weather.iconForCode(389, false), + 'weather picks hourly forecast icon nearest noon' +) +JS