Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e6dc9e533 | ||
|
|
ca64d4b9b6 | ||
|
|
b28edcd01f | ||
|
|
9c574f9eb7 | ||
|
|
d1b2cd2083 | ||
|
|
8c4dee0ca8 | ||
|
|
71ee18048e | ||
|
|
668983e16d | ||
|
|
17e23a6cf4 | ||
|
|
d18fe51553 | ||
|
|
9f9515ffe0 | ||
|
|
94353aa0bd | ||
|
|
3a2295ff53 | ||
|
|
de1b8a7673 | ||
|
|
39a26adcc2 | ||
|
|
eff0bc44ff | ||
|
|
c038c57278 | ||
|
|
420b7aa1a6 | ||
|
|
f22e405b50 | ||
|
|
3cfa592db9 | ||
|
|
81096e2d95 | ||
|
|
c415c4f176 | ||
|
|
86742da63d | ||
|
|
3c521cf5fa | ||
|
|
c087998710 | ||
|
|
b9800bfbab | ||
|
|
7b8270fdd6 | ||
|
|
6d3f888784 |
@@ -30,6 +30,9 @@ local sysmonitor = "flatpak run io.missioncenter.MissionCenter"
|
||||
-- Vicinae is the Raycast-style launcher. `vicinae toggle` shows/hides the
|
||||
-- window against the already-running server (started in autostart.lua).
|
||||
local launcher = "vicinae toggle"
|
||||
local osd = function(action)
|
||||
return "$HOME/.config/quickshell/scripts/panama-osd " .. action
|
||||
end
|
||||
|
||||
-- Quickshell IPC targets. See quickshell/shell.qml for the handlers.
|
||||
local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end
|
||||
@@ -252,23 +255,23 @@ bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { descri
|
||||
-- ── Media and volume ────────────────────────────────────────────────────────
|
||||
-- locked = true keeps these working on the lock screen, as they do in GNOME.
|
||||
-- 6% steps match the GNOME volume-step setting.
|
||||
bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 6%+"), { locked = true, repeating = true , description = "Volume up" })
|
||||
bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 6%-"), { locked = true, repeating = true , description = "Volume down" })
|
||||
bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true , description = "Mute" })
|
||||
bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true , description = "Mute microphone" })
|
||||
bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 6")), { locked = true, repeating = true , description = "Volume up" })
|
||||
bind("XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 6")), { locked = true, repeating = true , description = "Volume down" })
|
||||
bind("XF86AudioMute", hl.dsp.exec_cmd(osd("volume toggle")), { locked = true , description = "Mute" })
|
||||
bind("XF86AudioMicMute", hl.dsp.exec_cmd(osd("microphone toggle")), { locked = true , description = "Mute microphone" })
|
||||
|
||||
-- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
|
||||
bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 1%+"), { locked = true, repeating = true , description = "Volume up (fine)" })
|
||||
bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%-"), { locked = true, repeating = true , description = "Volume down (fine)" })
|
||||
bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 1")), { locked = true, repeating = true , description = "Volume up (fine)" })
|
||||
bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 1")), { locked = true, repeating = true , description = "Volume down (fine)" })
|
||||
|
||||
bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true , description = "Play or pause" })
|
||||
bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true , description = "Play or pause" })
|
||||
bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true , description = "Next track" })
|
||||
bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true , description = "Previous track" })
|
||||
bind("XF86AudioStop", hl.dsp.exec_cmd("playerctl stop"), { locked = true , description = "Stop playback" })
|
||||
bind("XF86AudioPlay", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
|
||||
bind("XF86AudioPause", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
|
||||
bind("XF86AudioNext", hl.dsp.exec_cmd(osd("media next")), { locked = true , description = "Next track" })
|
||||
bind("XF86AudioPrev", hl.dsp.exec_cmd(osd("media previous")), { locked = true , description = "Previous track" })
|
||||
bind("XF86AudioStop", hl.dsp.exec_cmd(osd("media stop")), { locked = true , description = "Stop playback" })
|
||||
|
||||
bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true , description = "Brightness up" })
|
||||
bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true , description = "Brightness down" })
|
||||
bind("XF86MonBrightnessUp", hl.dsp.exec_cmd(osd("brightness up 5")), { locked = true, repeating = true , description = "Brightness up" })
|
||||
bind("XF86MonBrightnessDown", hl.dsp.exec_cmd(osd("brightness down 5")), { locked = true, repeating = true , description = "Brightness down" })
|
||||
|
||||
-- Hardware keys GNOME mapped that have obvious equivalents.
|
||||
bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
|
||||
|
||||
@@ -29,7 +29,11 @@ hl.config({
|
||||
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
|
||||
-- Unfocused windows get no colour at all. The gradient only means
|
||||
-- something if exactly one window on screen is wearing it.
|
||||
inactive_border = "rgba(3b426199)",
|
||||
-- Follows the colour scheme: a dark neutral is invisible against a
|
||||
-- light desktop. services/ColorScheme.qml applies changes live;
|
||||
-- this is the value a fresh session starts from.
|
||||
inactive_border = prefs.get("colorScheme", "dark") == "light"
|
||||
and "rgba(a8aecb99)" or "rgba(3b426199)",
|
||||
},
|
||||
|
||||
resize_on_border = true,
|
||||
@@ -115,7 +119,11 @@ hl.config({
|
||||
disable_hyprland_logo = true,
|
||||
disable_splash_rendering = true,
|
||||
|
||||
font_family = "Adwaita Sans",
|
||||
-- Same setting as Theme.fontFamily in the shell. If only the QML side
|
||||
-- followed the preference, the compositor and the shell would disagree
|
||||
-- about the interface font and nobody would notice until a tooltip
|
||||
-- rendered in a different typeface.
|
||||
font_family = prefs.get("interfaceFont", "Adwaita Sans"),
|
||||
|
||||
-- Variable refresh rate. 3 = enable only for fullscreen windows whose
|
||||
-- content type is "video" or "game" -- the tag rules.lua applies to
|
||||
|
||||
@@ -369,6 +369,73 @@ Singleton {
|
||||
detail: "Requires your password when the machine wakes"
|
||||
},
|
||||
|
||||
// ── Night light schedule ────────────────────────────────────────────
|
||||
// Hours as decimals, so 17.5 is half past five. Wrapping past midnight
|
||||
// is normal here and is what the shipped values do: on at 17:00, off at
|
||||
// 10:00 the following morning.
|
||||
{
|
||||
key: "nightLightFrom", type: "real", def: 17.0, min: 0, max: 23.5, step: 0.5,
|
||||
group: "nightLight",
|
||||
label: "Turns on at",
|
||||
detail: "Only used when Night Light follows a schedule"
|
||||
},
|
||||
{
|
||||
key: "nightLightTo", type: "real", def: 10.0, min: 0, max: 23.5, step: 0.5,
|
||||
group: "nightLight",
|
||||
label: "Turns off at",
|
||||
detail: "A time earlier than the start simply means the next morning"
|
||||
},
|
||||
|
||||
// ── Colour scheme ───────────────────────────────────────────────────
|
||||
// Light is Tokyo Night Day, the official light variant, rather than a
|
||||
// palette invented to merely not be dark. Both share the same hues at
|
||||
// different lightness, which is what keeps the Prism identity intact
|
||||
// across the switch.
|
||||
//
|
||||
// services/ColorScheme.qml pushes the choice to GTK and to the
|
||||
// compositor's border colours, because an application toolbar or a
|
||||
// window border still wearing the other scheme is more jarring than
|
||||
// either scheme on its own.
|
||||
{
|
||||
key: "colorScheme", type: "enum", def: "dark", group: "appearance",
|
||||
label: "Appearance",
|
||||
detail: "Light and dark share one identity, not two themes",
|
||||
options: [
|
||||
{ value: "dark", label: "Dark" },
|
||||
{ value: "light", label: "Light" }
|
||||
]
|
||||
},
|
||||
|
||||
// ── Typography ──────────────────────────────────────────────────────
|
||||
// The single largest thing in this desktop that used to be changeable
|
||||
// only by editing Theme.qml.
|
||||
//
|
||||
// interfaceFont is every piece of text a person reads. iconFont is used
|
||||
// ONLY to draw glyphs -- workspace pills, the status cluster, search
|
||||
// icons -- so it must carry Nerd Font glyphs; a plain monospace family
|
||||
// there replaces every icon in the shell with tofu, which is why the
|
||||
// picker offers them as a separate, filtered list.
|
||||
{
|
||||
key: "interfaceFont", type: "string", def: "Adwaita Sans", group: "typography",
|
||||
// A family name, matched by fontconfig. Constrained because it also
|
||||
// reaches hl.config as a string in hypr/looks.lua.
|
||||
pattern: "^[A-Za-z0-9 ._-]{1,64}$",
|
||||
label: "Interface font",
|
||||
detail: "Used for every piece of text in the shell"
|
||||
},
|
||||
{
|
||||
key: "iconFont", type: "string", def: "VictorMono Nerd Font", group: "typography",
|
||||
pattern: "^[A-Za-z0-9 ._-]{1,64}$",
|
||||
label: "Icon font",
|
||||
detail: "Draws the shell's glyphs, so it must be a Nerd Font"
|
||||
},
|
||||
{
|
||||
key: "interfaceFontSize", type: "int", def: 13, min: 10, max: 18, step: 1,
|
||||
unit: "px", group: "typography",
|
||||
label: "Interface text size",
|
||||
detail: "The base size the rest of the shell's type scales from"
|
||||
},
|
||||
|
||||
// ── Accessibility ───────────────────────────────────────────────────
|
||||
// Backed by gsettings so GTK applications agree with the shell, and
|
||||
// pushed to the compositor as well where it has its own notion.
|
||||
@@ -402,6 +469,47 @@ Singleton {
|
||||
detail: "How often Panama updates the current conditions"
|
||||
},
|
||||
|
||||
// ── Which GPU the vitals readout tracks ─────────────────────────────
|
||||
// A sysfs path rather than a card number, because the number is neither
|
||||
// stable across machines nor meaningful. Constrained to the one shape
|
||||
// that can be read for utilisation; VitalsWidget hides itself when the
|
||||
// path is unreadable, so a stale value degrades to no readout rather
|
||||
// than a wrong one.
|
||||
{
|
||||
key: "gpuBusyPath", type: "string",
|
||||
def: "/sys/class/drm/card1/device/gpu_busy_percent",
|
||||
group: "vitals", internal: true,
|
||||
pattern: "^/sys/class/drm/card[0-9]+/device/gpu_busy_percent$",
|
||||
label: "Graphics device",
|
||||
detail: "Which GPU the graphics readout in the bar measures"
|
||||
},
|
||||
|
||||
// ── Weather location ────────────────────────────────────────────────
|
||||
// Coordinates rather than a place name, because that is what Open-Meteo
|
||||
// takes and it needs no API key. weatherLocation is only the label shown
|
||||
// in the UI; it is never sent anywhere, so it can say whatever makes the
|
||||
// reading recognisable.
|
||||
{
|
||||
key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001,
|
||||
group: "weather", internal: true,
|
||||
label: "Latitude",
|
||||
detail: "Set by choosing a location"
|
||||
},
|
||||
{
|
||||
key: "weatherLongitude", type: "real", def: -82.6861, min: -180, max: 180, step: 0.0001,
|
||||
group: "weather", internal: true,
|
||||
label: "Longitude",
|
||||
detail: "Set by choosing a location"
|
||||
},
|
||||
{
|
||||
key: "weatherLocation", type: "string", def: "Local weather", group: "weather",
|
||||
internal: true,
|
||||
// Display only -- never sent to the weather service.
|
||||
pattern: "^[^\\n]{1,64}$",
|
||||
label: "Weather location",
|
||||
detail: "The place the weather reading is for"
|
||||
},
|
||||
|
||||
// ── Vitals refresh ──────────────────────────────────────────────────
|
||||
{
|
||||
key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500,
|
||||
@@ -476,13 +584,18 @@ Singleton {
|
||||
// A "json" value: the ordered list of desktop entry ids pinned to the
|
||||
// dock. Kept in the shared store so that reordering the dock is covered
|
||||
// by Restore defaults like everything else, rather than living in its
|
||||
// own file. The shipped order is the GNOME dash it replaced.
|
||||
// own file. The shipped order is the GNOME dash it replaced, with one
|
||||
// deliberate substitution: Panama Settings takes the first slot rather
|
||||
// than GNOME Settings. Panama now covers what GNOME Settings did for
|
||||
// this desktop and delegates the remainder to it by name, so pinning
|
||||
// the thing it delegates TO put the fallback in front of the real one.
|
||||
// GNOME Settings stays installed and searchable in the launcher.
|
||||
{
|
||||
key: "dockPinned", type: "json", group: "dock",
|
||||
label: "Pinned applications",
|
||||
detail: "Applications that stay in the Dock whether or not they are running",
|
||||
def: [
|
||||
"org.gnome.Settings", "kitty", "org.gnome.Nautilus",
|
||||
"panama-settings", "kitty", "org.gnome.Nautilus",
|
||||
"com.bitwarden.desktop", "org.gnome.Software", "helium",
|
||||
"org.mozilla.thunderbird_esr", "com.slack.Slack",
|
||||
"app.bluebubbles.BlueBubbles", "rustdesk",
|
||||
@@ -521,6 +634,21 @@ Singleton {
|
||||
detail: "Resolution, scale, and rotation per connected display"
|
||||
},
|
||||
|
||||
// ── Per-application notification rules ──────────────────────────────
|
||||
// { "<appId>": { enabled, showOnLockScreen, showContentOnLockScreen } }
|
||||
//
|
||||
// Absent means "no rule", which is not the same as a rule that allows
|
||||
// everything: a new application must be able to notify without needing
|
||||
// an entry written for it first. services/Notifs.qml treats a missing
|
||||
// entry as permissive and Do Not Disturb remains an override on top,
|
||||
// rather than being duplicated per application.
|
||||
{
|
||||
key: "notificationAppRules", type: "json", def: ({}), group: "notifications",
|
||||
internal: true,
|
||||
label: "Application notification rules",
|
||||
detail: "Per-application notification and lock-screen visibility preferences"
|
||||
},
|
||||
|
||||
// ── Internal ────────────────────────────────────────────────────────
|
||||
{
|
||||
key: "lastPage", type: "string", def: "home", group: "internal",
|
||||
|
||||
@@ -22,12 +22,12 @@ Singleton {
|
||||
// ── Weather ─────────────────────────────────────────────────────────────
|
||||
// Coordinates taken from the GNOME night-light setting, which had already
|
||||
// resolved the location. Uses Open-Meteo, which needs no API key.
|
||||
readonly property real latitude: 27.7375
|
||||
readonly property real longitude: -82.6861
|
||||
readonly property real latitude: DesktopPreferences.get("weatherLatitude")
|
||||
readonly property real longitude: DesktopPreferences.get("weatherLongitude")
|
||||
// Open-Meteo returns coordinates but no friendly place name. Keep the
|
||||
// label deliberately general rather than exposing precise coordinates in
|
||||
// the UI or guessing at a city from them.
|
||||
readonly property string weatherLocation: "Local weather"
|
||||
readonly property string weatherLocation: DesktopPreferences.get("weatherLocation")
|
||||
readonly property string temperatureUnit: DesktopPreferences.get("temperatureUnit")
|
||||
readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes")
|
||||
|
||||
@@ -41,13 +41,13 @@ Singleton {
|
||||
|
||||
// amdgpu exposes utilisation here. Verified present on this machine; the
|
||||
// widget hides itself if the path is missing rather than showing zeros.
|
||||
readonly property string gpuBusyPath: "/sys/class/drm/card1/device/gpu_busy_percent"
|
||||
readonly property string gpuBusyPath: DesktopPreferences.get("gpuBusyPath")
|
||||
|
||||
// ── Night light ─────────────────────────────────────────────────────────
|
||||
// Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00.
|
||||
readonly property int nightLightTemperature: DesktopPreferences.get("nightLightTemperature")
|
||||
readonly property real nightLightFrom: 17.0
|
||||
readonly property real nightLightTo: 10.0
|
||||
readonly property real nightLightFrom: DesktopPreferences.get("nightLightFrom")
|
||||
readonly property real nightLightTo: DesktopPreferences.get("nightLightTo")
|
||||
readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled")
|
||||
|
||||
// ── Notifications ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -18,36 +18,47 @@ import QtQuick
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// ── Colour scheme ───────────────────────────────────────────────────────
|
||||
// Tokyo Night ships an official light variant (Day), so light mode is that
|
||||
// rather than a palette invented to merely not be dark. The two share the
|
||||
// same hues at different lightness, which is what lets the Prism identity
|
||||
// survive the switch: blue still leads into orchid, it is simply a darker
|
||||
// blue on a lighter ground.
|
||||
//
|
||||
// Every token below is a binding on this, so flipping it repaints the whole
|
||||
// shell without anything needing to know it happened.
|
||||
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
|
||||
|
||||
// ── Palette ─────────────────────────────────────────────────────────────
|
||||
// Canonical Tokyo Night Moon. `accent` matches the GNOME accent exactly.
|
||||
readonly property color bg: "#222436"
|
||||
readonly property color bgDark: "#1e2030"
|
||||
readonly property color bgHighlight: "#2f334d"
|
||||
readonly property color bgPanel: "#2e2f3d" // dock glass surface
|
||||
readonly property color bgPopover: "#21212f" // Openbar submenu background
|
||||
readonly property color bg: root.dark ? "#222436" : "#e1e2e7"
|
||||
readonly property color bgDark: root.dark ? "#1e2030" : "#d3d5de"
|
||||
readonly property color bgHighlight: root.dark ? "#2f334d" : "#c4c8da"
|
||||
readonly property color bgPanel: root.dark ? "#2e2f3d" : "#d9dae3" // dock glass surface
|
||||
readonly property color bgPopover: root.dark ? "#21212f" : "#eaeaee" // Openbar submenu background
|
||||
|
||||
readonly property color fg: "#c8d3f5"
|
||||
readonly property color fgDim: "#828bb8"
|
||||
readonly property color fgMuted: "#636da6"
|
||||
readonly property color gutter: "#3b4261"
|
||||
readonly property color fg: root.dark ? "#c8d3f5" : "#3760bf"
|
||||
readonly property color fgDim: root.dark ? "#828bb8" : "#6172b0"
|
||||
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
|
||||
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
|
||||
|
||||
// The pair. `accent` is the primary and carries every state meaning
|
||||
// (focused, active, on). `accentSecondary` is the orchid from the tmux
|
||||
// theme — it never appears alone, only as the far end of a gradient. That
|
||||
// restraint is the whole point: the two colours meeting is the signature,
|
||||
// so the pink stops being special the moment it's used as a flat fill.
|
||||
readonly property color accent: "#82aaff" // blue
|
||||
readonly property color accentSecondary: "#b172b0" // orchid, from tmux
|
||||
readonly property color accentAlt: "#65bcff" // blue1, a lighter blue
|
||||
readonly property color cyan: "#86e1fc"
|
||||
readonly property color teal: "#4fd6be"
|
||||
readonly property color green: "#c3e88d"
|
||||
readonly property color yellow: "#ffc777"
|
||||
readonly property color orange: "#ff966c"
|
||||
readonly property color red: "#ff757f"
|
||||
readonly property color redDeep: "#c53b53"
|
||||
readonly property color magenta: "#c099ff"
|
||||
readonly property color pink: "#fca7ea"
|
||||
readonly property color accent: root.dark ? "#82aaff" : "#2e7de9" // blue
|
||||
readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux
|
||||
readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
|
||||
readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
|
||||
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
|
||||
readonly property color green: root.dark ? "#c3e88d" : "#587539"
|
||||
readonly property color yellow: root.dark ? "#ffc777" : "#8c6c3e"
|
||||
readonly property color orange: root.dark ? "#ff966c" : "#b15c00"
|
||||
readonly property color red: root.dark ? "#ff757f" : "#f52a65"
|
||||
readonly property color redDeep: root.dark ? "#c53b53" : "#c64343"
|
||||
readonly property color magenta: root.dark ? "#c099ff" : "#9854f1"
|
||||
readonly property color pink: root.dark ? "#fca7ea" : "#d20065"
|
||||
|
||||
// Semantic aliases — prefer these in widgets so intent survives a repaint.
|
||||
readonly property color ok: green
|
||||
@@ -59,11 +70,14 @@ Singleton {
|
||||
// The dock sits on compositor blur, so its fill is deliberately very
|
||||
// translucent. Popovers are near-opaque because they carry text that must
|
||||
// stay legible. The bar itself has no surface or alpha token.
|
||||
readonly property real dockAlpha: 0.34
|
||||
readonly property real popoverAlpha: 0.92
|
||||
readonly property real overlayAlpha: 0.55
|
||||
readonly property real hoverAlpha: 0.14
|
||||
readonly property real activeAlpha: 0.24
|
||||
// Light surfaces need more opacity for the same sense of a solid panel: the
|
||||
// same 0.34 that reads as glass over a dark desktop reads as haze over a
|
||||
// light one, and text on it stops being legible.
|
||||
readonly property real dockAlpha: root.dark ? 0.34 : 0.62
|
||||
readonly property real popoverAlpha: root.dark ? 0.92 : 0.97
|
||||
readonly property real overlayAlpha: root.dark ? 0.55 : 0.40
|
||||
readonly property real hoverAlpha: root.dark ? 0.14 : 0.10
|
||||
readonly property real activeAlpha: root.dark ? 0.24 : 0.18
|
||||
|
||||
// ── Geometry ────────────────────────────────────────────────────────────
|
||||
readonly property int barHeight: 36
|
||||
@@ -90,12 +104,12 @@ Singleton {
|
||||
// Adwaita Sans for everything the user reads as text. No exceptions: a
|
||||
// monospaced clock or percentage reads as a terminal readout pasted into a
|
||||
// UI, which is the opposite of the intent here.
|
||||
readonly property string fontFamily: "Adwaita Sans"
|
||||
readonly property string fontFamily: DesktopPreferences.get("interfaceFont")
|
||||
|
||||
// Nerd Font, used ONLY to draw icon glyphs — never for text. It is the
|
||||
// pragmatic alternative to freedesktop symbolic icons, which ship with a
|
||||
// hardcoded dark fill Qt will not recolour (see widgets/ThemedIcon.qml).
|
||||
readonly property string fontMono: "VictorMono Nerd Font"
|
||||
readonly property string fontMono: DesktopPreferences.get("iconFont")
|
||||
|
||||
// Apply to any text whose digits change in place — clocks, percentages,
|
||||
// elapsed timers, dimension readouts. Tabular figures all share one
|
||||
@@ -107,10 +121,13 @@ Singleton {
|
||||
readonly property var tabularFigures: ({
|
||||
"tnum": 1
|
||||
})
|
||||
readonly property int fontSize: 13
|
||||
readonly property int fontSizeSmall: 11
|
||||
readonly property int fontSizeLarge: 16
|
||||
readonly property int fontSizeTitle: 20
|
||||
// The four sizes are a scale, not four independent numbers: they move
|
||||
// together so the relationship between body, caption, heading and title
|
||||
// survives a change to the base rather than drifting apart.
|
||||
readonly property int fontSize: DesktopPreferences.get("interfaceFontSize")
|
||||
readonly property int fontSizeSmall: Math.max(9, root.fontSize - 2)
|
||||
readonly property int fontSizeLarge: root.fontSize + 3
|
||||
readonly property int fontSizeTitle: root.fontSize + 7
|
||||
|
||||
// ── Motion ──────────────────────────────────────────────────────────────
|
||||
// Event-driven only. Nothing in this shell animates while idle — no pulse,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
IpcHandler {
|
||||
target: "connectivity-test"
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
wifiDevice: Connectivity.wifiDevice ? Connectivity.wifiDevice.name : "",
|
||||
wiredDevice: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : "",
|
||||
wiredConnected: !!(Connectivity.wiredDevice && Connectivity.wiredDevice.connected),
|
||||
networks: Connectivity.networks.length,
|
||||
activeSsid: Connectivity.activeNetwork ? Connectivity.activeNetwork.name : "",
|
||||
activeStrength: Connectivity.activeNetwork ? Connectivity.activeNetwork.signalStrength : -1,
|
||||
activeLabel: Connectivity.activeNetwork ? Connectivity.signalLabel(Connectivity.activeNetwork.signalStrength) : "",
|
||||
adapter: Connectivity.adapter ? true : false,
|
||||
btDevices: Connectivity.bluetoothDevices.length
|
||||
});
|
||||
}
|
||||
|
||||
function labelFor(strength: real): string { return Connectivity.signalLabel(strength); }
|
||||
function setActive(on: bool): void { Connectivity.active = on; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
ShellRoot {
|
||||
// DesktopEntries populates asynchronously. Reading `applications.values`
|
||||
// here rather than calling byId() blind is the documented way to wait for
|
||||
// it -- and is exactly the trap noted in modules/settings/README.md.
|
||||
readonly property var ids: {
|
||||
const out = {};
|
||||
for (const entry of DesktopEntries.applications.values)
|
||||
out[entry.id] = entry;
|
||||
return out;
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "dock-pin-test"
|
||||
|
||||
function count(): int { return DesktopEntries.applications.values.length; }
|
||||
|
||||
function resolve(): string {
|
||||
const pinned = DesktopPreferences.get("dockPinned");
|
||||
const missing = [];
|
||||
let first = null;
|
||||
for (const id of pinned) {
|
||||
const entry = DesktopEntries.byId(id);
|
||||
if (!entry) missing.push(id);
|
||||
if (first === null)
|
||||
first = { id: id, found: !!entry, name: entry ? entry.name : "", icon: entry ? entry.icon : "" };
|
||||
}
|
||||
return JSON.stringify({ total: pinned.length, first: first, missing: missing });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// Compact, click-through system feedback. Repeated hardware-key presses update
|
||||
// this one surface in place, avoiding a stack of notification cards.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
property var modelData: null
|
||||
readonly property var presentation: OsdState.state
|
||||
readonly property bool belongsHere: !OsdState.monitorName || !root.modelData
|
||||
|| root.modelData.name === OsdState.monitorName
|
||||
readonly property bool requestedVisible: OsdState.active && root.belongsHere
|
||||
readonly property int horizontalPadding: 18
|
||||
readonly property int contentGap: 14
|
||||
readonly property int progressWidth: 156
|
||||
readonly property int progressLabelWidth: 48
|
||||
readonly property int messageWidth: Math.min(300, Math.max(52, Math.ceil(labelMetrics.advanceWidth)))
|
||||
readonly property int desiredWidth: root.horizontalPadding * 2 + 24 + root.contentGap
|
||||
+ (root.presentation.progress
|
||||
? root.progressWidth + root.contentGap + root.progressLabelWidth
|
||||
: root.messageWidth)
|
||||
|
||||
screen: root.modelData
|
||||
anchors.bottom: true
|
||||
margins.bottom: Theme.dockIconSize + Theme.dockPadding * 2 + Theme.barGap * 3
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
implicitWidth: root.desiredWidth
|
||||
implicitHeight: 64
|
||||
color: "transparent"
|
||||
mask: Region {}
|
||||
|
||||
WlrLayershell.namespace: "qs-popover-osd"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
property bool mapped: false
|
||||
visible: root.mapped
|
||||
|
||||
onRequestedVisibleChanged: {
|
||||
if (root.requestedVisible) {
|
||||
unmapTimer.stop();
|
||||
root.mapped = true;
|
||||
entrance.restart();
|
||||
} else if (root.mapped) {
|
||||
unmapTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (root.requestedVisible)
|
||||
root.mapped = true;
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: unmapTimer
|
||||
interval: Theme.durNormal
|
||||
onTriggered: root.mapped = false
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: surface
|
||||
anchors.fill: parent
|
||||
radius: Theme.popoverRadius
|
||||
color: Theme.alpha(Theme.bgPopover, 0.9)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.1)
|
||||
opacity: root.requestedVisible ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
transform: Translate {
|
||||
id: slide
|
||||
y: root.requestedVisible ? 0 : 8
|
||||
Behavior on y {
|
||||
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 1
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: parent.radius
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: root.contentGap
|
||||
|
||||
ThemedIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 24
|
||||
icon: root.presentation.icon
|
||||
iconFallback: "dialog-information-symbolic"
|
||||
tint: Theme.fg
|
||||
}
|
||||
|
||||
Item {
|
||||
visible: root.presentation.progress
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: visible ? root.progressWidth : 0
|
||||
height: 8
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: height / 2
|
||||
color: Theme.alpha(Theme.fg, 0.14)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width * root.presentation.ratio
|
||||
radius: height / 2
|
||||
color: Theme.accent
|
||||
|
||||
Behavior on width {
|
||||
NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: root.presentation.progress ? root.progressLabelWidth : root.messageWidth
|
||||
text: root.presentation.label
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 1
|
||||
horizontalAlignment: root.presentation.progress ? Text.AlignRight : Text.AlignLeft
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextMetrics {
|
||||
id: labelMetrics
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
text: root.presentation.label
|
||||
}
|
||||
|
||||
ParallelAnimation {
|
||||
id: entrance
|
||||
NumberAnimation {
|
||||
target: surface
|
||||
property: "opacity"
|
||||
from: 0
|
||||
to: 1
|
||||
duration: Theme.durNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
NumberAnimation {
|
||||
target: slide
|
||||
property: "y"
|
||||
from: 8
|
||||
to: 0
|
||||
duration: Theme.durNormal
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
function clamp(value, minimum, maximum) {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
|
||||
function finiteNumber(value, fallback) {
|
||||
var parsed = Number(value);
|
||||
return isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function iconFor(kind, ratio) {
|
||||
var name = String(kind || "").toLowerCase();
|
||||
if (name === "volume-muted")
|
||||
return "audio-volume-muted-symbolic";
|
||||
if (name === "volume") {
|
||||
if (ratio <= 0)
|
||||
return "audio-volume-muted-symbolic";
|
||||
if (ratio < 0.34)
|
||||
return "audio-volume-low-symbolic";
|
||||
if (ratio < 0.67)
|
||||
return "audio-volume-medium-symbolic";
|
||||
return "audio-volume-high-symbolic";
|
||||
}
|
||||
if (name === "microphone-muted")
|
||||
return "microphone-sensitivity-muted-symbolic";
|
||||
if (name === "microphone")
|
||||
return "audio-input-microphone-symbolic";
|
||||
if (name === "brightness")
|
||||
return "display-brightness-symbolic";
|
||||
if (name === "media-play" || name === "media-playing")
|
||||
return "media-playback-start-symbolic";
|
||||
if (name === "media-pause" || name === "media-paused")
|
||||
return "media-playback-pause-symbolic";
|
||||
if (name === "media-next")
|
||||
return "media-skip-forward-symbolic";
|
||||
if (name === "media-previous")
|
||||
return "media-skip-backward-symbolic";
|
||||
if (name === "media-stop")
|
||||
return "media-playback-stop-symbolic";
|
||||
return name || "dialog-information-symbolic";
|
||||
}
|
||||
|
||||
function normalizedDuration(value) {
|
||||
var parsed = finiteNumber(value, 1400);
|
||||
return Math.max(0, Math.round(parsed));
|
||||
}
|
||||
|
||||
function progressState(kind, rawValue, rawMaximum, rawLabel, rawDuration) {
|
||||
var maximum = Math.max(1, finiteNumber(rawMaximum, 100));
|
||||
var value = clamp(finiteNumber(rawValue, 0), 0, maximum);
|
||||
var ratio = value / maximum;
|
||||
var label = String(rawLabel || "");
|
||||
if (!label)
|
||||
label = Math.round(ratio * 100) + "%";
|
||||
|
||||
return {
|
||||
kind: String(kind || ""),
|
||||
value: value,
|
||||
maximum: maximum,
|
||||
ratio: ratio,
|
||||
label: label,
|
||||
icon: iconFor(kind, ratio),
|
||||
duration: normalizedDuration(rawDuration),
|
||||
progress: true
|
||||
};
|
||||
}
|
||||
|
||||
function messageState(kind, rawLabel, rawDuration) {
|
||||
return {
|
||||
kind: String(kind || ""),
|
||||
value: 0,
|
||||
maximum: 100,
|
||||
ratio: 0,
|
||||
label: String(rawLabel || ""),
|
||||
icon: iconFor(kind, 0),
|
||||
duration: normalizedDuration(rawDuration),
|
||||
progress: false
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
clamp: clamp,
|
||||
iconFor: iconFor,
|
||||
progressState: progressState,
|
||||
messageState: messageState
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
module qs.modules.osd
|
||||
Osd 1.0 Osd.qml
|
||||
@@ -6,6 +6,7 @@ import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
@@ -16,23 +17,12 @@ Item {
|
||||
|
||||
implicitHeight: list.implicitHeight
|
||||
|
||||
readonly property var nodes: {
|
||||
return Pipewire.nodes.values.filter(n => {
|
||||
if (n.isStream)
|
||||
return false;
|
||||
// Sources have to be filtered on the type flags: !isSink also
|
||||
// matches video nodes (webcams show up here otherwise).
|
||||
return root.output ? n.isSink : (n.type & PwNodeType.AudioSource) === PwNodeType.AudioSource;
|
||||
});
|
||||
}
|
||||
readonly property var nodes: root.output ? AudioDevices.outputs : AudioDevices.inputs
|
||||
|
||||
readonly property var current: root.output ? Pipewire.defaultAudioSink : Pipewire.defaultAudioSource
|
||||
readonly property var current: AudioDevices.current(root.output)
|
||||
|
||||
function select(node): void {
|
||||
if (root.output)
|
||||
Pipewire.preferredDefaultAudioSink = node;
|
||||
else
|
||||
Pipewire.preferredDefaultAudioSource = node;
|
||||
AudioDevices.select(root.output, node)
|
||||
}
|
||||
|
||||
ScrollColumn {
|
||||
@@ -52,7 +42,7 @@ Item {
|
||||
implicitHeight: 38
|
||||
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
|
||||
iconFallback: "audio-card-symbolic"
|
||||
label: nodeRow.modelData.description || nodeRow.modelData.nickname || nodeRow.modelData.name
|
||||
label: AudioDevices.label(nodeRow.modelData)
|
||||
selected: nodeRow.modelData === root.current
|
||||
onClicked: root.select(nodeRow.modelData)
|
||||
}
|
||||
|
||||
@@ -49,9 +49,18 @@ SettingsPage {
|
||||
: "Applied to every display. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
||||
|
||||
WallpaperPicker {
|
||||
id: wallpapers
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
visible: wallpapers.hidden > 0 || wallpapers.expanded
|
||||
label: wallpapers.expanded ? "Showing every image" : wallpapers.hidden + " more available"
|
||||
detail: "The grid is kept short so the rest of this page stays reachable"
|
||||
action: wallpapers.expanded ? "Show fewer" : "Show all"
|
||||
onTriggered: wallpapers.expanded = !wallpapers.expanded
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Look for new images"
|
||||
detail: Wallpaper.scanning
|
||||
@@ -64,6 +73,56 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Colour scheme"
|
||||
subtitle: ColorScheme.lastError !== ""
|
||||
? ColorScheme.lastError
|
||||
: "Light is Tokyo Night Day, the official light variant — the same hues at a different lightness, so the blue-into-orchid signature survives the switch. Applications and window borders follow."
|
||||
|
||||
ChoiceRow { setting: "colorScheme"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Typography"
|
||||
subtitle: Fonts.lastError !== ""
|
||||
? Fonts.lastError
|
||||
: "Every piece of text in the shell. Samples are drawn in the font they name."
|
||||
|
||||
SliderRow { setting: "interfaceFontSize" }
|
||||
|
||||
TextRow {
|
||||
label: "Interface font"
|
||||
detail: Fonts.interfaceMissing
|
||||
? "Not installed on this machine — fontconfig is substituting something else"
|
||||
: "Used for all shell text"
|
||||
value: Fonts.interfaceFont
|
||||
}
|
||||
|
||||
FontPicker {
|
||||
width: parent.width
|
||||
families: Fonts.interfaceFonts
|
||||
current: Fonts.interfaceFont
|
||||
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No interface fonts found"
|
||||
onPicked: family => Fonts.setInterface(family)
|
||||
}
|
||||
|
||||
TextRow {
|
||||
label: "Icon font"
|
||||
detail: Fonts.iconMissing
|
||||
? "Not installed — the shell's glyphs will not draw correctly"
|
||||
: "Draws the shell's glyphs, so only Nerd Fonts are offered"
|
||||
value: Fonts.iconFont
|
||||
}
|
||||
|
||||
FontPicker {
|
||||
width: parent.width
|
||||
families: Fonts.iconFonts
|
||||
current: Fonts.iconFont
|
||||
emptyText: "No Nerd Fonts installed"
|
||||
onPicked: family => Fonts.setIcon(family)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Windows"
|
||||
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
|
||||
@@ -103,7 +162,24 @@ SettingsPage {
|
||||
|
||||
ToggleRow { setting: "showCpu" }
|
||||
ToggleRow { setting: "showMemory" }
|
||||
ToggleRow { setting: "showGpu"; divider: false }
|
||||
ToggleRow { setting: "showGpu"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
|
||||
|
||||
// Only worth asking when there is a choice to make.
|
||||
ChoiceGrid {
|
||||
visible: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing
|
||||
width: parent.width
|
||||
label: "Graphics device"
|
||||
detail: GraphicsDevices.selectionMissing
|
||||
? "The stored device is not present on this machine, so the graphics readout is hidden. Choose one below."
|
||||
: "Which GPU the graphics readout measures."
|
||||
options: GraphicsDevices.devices.map(device => ({
|
||||
value: device.path,
|
||||
label: GraphicsDevices.shortName(device.name)
|
||||
}))
|
||||
current: GraphicsDevices.selectedPath
|
||||
divider: false
|
||||
onPicked: value => GraphicsDevices.select(value)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.config
|
||||
import qs.widgets
|
||||
|
||||
SettingRow {
|
||||
id: root
|
||||
|
||||
property var node: null
|
||||
|
||||
label: "Balance"
|
||||
detail: "Adjust the left and right channels"
|
||||
controlWidth: 270
|
||||
visible: root.available
|
||||
divider: false
|
||||
|
||||
PwObjectTracker {
|
||||
objects: root.node ? [root.node] : []
|
||||
}
|
||||
|
||||
function channelIndex(channel): int {
|
||||
if (!root.node?.audio)
|
||||
return -1;
|
||||
const channels = root.node.audio.channels;
|
||||
for (let index = 0; index < channels.length; index++) {
|
||||
if (channels[index] === channel)
|
||||
return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
readonly property int leftIndex: root.channelIndex(PwAudioChannel.FrontLeft)
|
||||
readonly property int rightIndex: root.channelIndex(PwAudioChannel.FrontRight)
|
||||
readonly property bool available: root.node?.audio
|
||||
&& root.leftIndex >= 0 && root.rightIndex >= 0
|
||||
&& root.node.audio.volumes.length > Math.max(root.leftIndex, root.rightIndex)
|
||||
|
||||
readonly property real position: {
|
||||
if (!root.available)
|
||||
return 0.5;
|
||||
const left = root.node.audio.volumes[root.leftIndex];
|
||||
const right = root.node.audio.volumes[root.rightIndex];
|
||||
const level = Math.max(left, right);
|
||||
if (level <= 0.001)
|
||||
return 0.5;
|
||||
return right >= left ? 0.5 + (1 - left / level) * 0.5
|
||||
: 0.5 - (1 - right / level) * 0.5;
|
||||
}
|
||||
|
||||
function setBalance(value: real): void {
|
||||
if (!root.available)
|
||||
return;
|
||||
const next = Array.from(root.node.audio.volumes);
|
||||
const level = Math.max(next[root.leftIndex], next[root.rightIndex], 0.001);
|
||||
if (value < 0.5) {
|
||||
next[root.leftIndex] = level;
|
||||
next[root.rightIndex] = level * value * 2;
|
||||
} else {
|
||||
next[root.leftIndex] = level * (1 - value) * 2;
|
||||
next[root.rightIndex] = level;
|
||||
}
|
||||
root.node.audio.volumes = next;
|
||||
}
|
||||
|
||||
ValueSlider {
|
||||
anchors.fill: parent
|
||||
value: root.position
|
||||
icon: "audio-speakers-symbolic"
|
||||
onMoved: value => root.setBalance(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Bluetooth, at page size.
|
||||
//
|
||||
// Paired devices first, because reconnecting to something you already own is
|
||||
// what you are here for nine times out of ten; discovered devices follow.
|
||||
// Battery is shown where BlueZ reports it, which is the one thing people
|
||||
// routinely open a terminal for.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Bluetooth
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
spacing: 0
|
||||
|
||||
function primaryAction(device: var): void {
|
||||
if (device.connected) {
|
||||
device.disconnect();
|
||||
return;
|
||||
}
|
||||
if (device.paired) {
|
||||
device.connect();
|
||||
return;
|
||||
}
|
||||
device.pair();
|
||||
}
|
||||
|
||||
function stateLabel(device: var): string {
|
||||
if (device.pairing)
|
||||
return "Pairing…";
|
||||
if (device.connected)
|
||||
return device.batteryAvailable
|
||||
? `Connected · ${Math.round(device.battery * 100)}% battery`
|
||||
: "Connected";
|
||||
if (device.paired)
|
||||
return "Paired";
|
||||
return device.address || "Not paired";
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Connectivity.bluetoothDevices
|
||||
|
||||
SettingRow {
|
||||
id: entry
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: parent.width
|
||||
label: entry.modelData.name || entry.modelData.address || "Unknown device"
|
||||
detail: root.stateLabel(entry.modelData)
|
||||
divider: entry.index < Connectivity.bluetoothDevices.length - 1
|
||||
controlWidth: 200
|
||||
activatable: !entry.modelData.pairing
|
||||
onActivated: root.primaryAction(entry.modelData)
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
enabled: !entry.modelData.pairing
|
||||
text: entry.modelData.connected
|
||||
? "Disconnect"
|
||||
: (entry.modelData.paired ? "Connect" : "Pair")
|
||||
onClicked: root.primaryAction(entry.modelData)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: entry.modelData.paired
|
||||
text: "Forget"
|
||||
onClicked: entry.modelData.forget()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: Connectivity.bluetoothDevices.length === 0
|
||||
label: !Connectivity.adapter
|
||||
? "No Bluetooth adapter"
|
||||
: (Connectivity.adapter.enabled ? "Looking for devices…" : "Bluetooth is off")
|
||||
detail: Connectivity.adapter && !Connectivity.adapter.enabled
|
||||
? "Turn it on above to discover devices"
|
||||
: "Put the device into pairing mode to make it appear"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
@@ -1,92 +1,124 @@
|
||||
// Network & Devices.
|
||||
//
|
||||
// Wi-Fi and Bluetooth are handled here rather than delegated. Everything goes
|
||||
// through Quickshell.Networking and Quickshell.Bluetooth -- NetworkManager and
|
||||
// BlueZ over DBus -- and nothing shells out to nmcli or bluetoothctl. That was
|
||||
// the founding requirement for this desktop: never having to drop to a terminal
|
||||
// to join a network.
|
||||
//
|
||||
// Scanning follows this page being on screen. Wi-Fi scanning and especially
|
||||
// Bluetooth discovery hold the radio, and doing either for a list nobody is
|
||||
// looking at is battery and airtime spent on nothing.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Networking
|
||||
import Quickshell.Bluetooth
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Network & Devices"
|
||||
lede: "Connect graphically—no terminal workflow required."
|
||||
lede: Connectivity.activeNetwork
|
||||
? "Connected to " + Connectivity.activeNetwork.name
|
||||
: "Wi-Fi, Bluetooth, and the things Fedora owns."
|
||||
|
||||
readonly property var wifiDevice: {
|
||||
for (const device of Networking.devices.values) {
|
||||
if (device.type === DeviceType.Wifi)
|
||||
return device;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
readonly property var bluetoothAdapter: Bluetooth.defaultAdapter
|
||||
// Drive the scanners only while this page is the one being shown.
|
||||
Component.onCompleted: Connectivity.active = true
|
||||
Component.onDestruction: Connectivity.active = false
|
||||
|
||||
SettingsCard {
|
||||
title: "Wi‑Fi"
|
||||
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off"
|
||||
title: "Wired"
|
||||
visible: Connectivity.wiredDevice !== null
|
||||
|
||||
TextRow {
|
||||
label: "Ethernet"
|
||||
detail: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : ""
|
||||
value: Connectivity.wiredDevice && Connectivity.wiredDevice.connected ? "Connected" : "Not connected"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Wi-Fi"
|
||||
// A Wi-Fi switch reading "On" above the words "No Wi-Fi adapter" is a
|
||||
// contradiction; with no radio the card simply does not belong.
|
||||
visible: Connectivity.wifiDevice !== null
|
||||
subtitle: "Networks are re-scanned while this page is open."
|
||||
|
||||
SettingRow {
|
||||
label: "Wi‑Fi"
|
||||
detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found"
|
||||
label: "Wi-Fi"
|
||||
detail: Connectivity.wifiEnabled ? "On" : "Off"
|
||||
controlWidth: 48
|
||||
divider: Connectivity.wifiEnabled
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Networking.wifiEnabled
|
||||
enabled: Networking.wifiHardwareEnabled
|
||||
checked: Connectivity.wifiEnabled
|
||||
enabled: Connectivity.wifiAvailable
|
||||
onToggled: value => Networking.wifiEnabled = value
|
||||
}
|
||||
}
|
||||
|
||||
WifiList {
|
||||
WifiPanel {
|
||||
width: parent.width
|
||||
device: root.wifiDevice
|
||||
active: true
|
||||
maxHeight: 240
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Advanced network settings"
|
||||
detail: "VPN, wired profiles, DNS, and connection details"
|
||||
divider: false
|
||||
action: "Open panel"
|
||||
onTriggered: SystemSettings.openGnomePanel("network")
|
||||
visible: Connectivity.wifiEnabled
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Bluetooth"
|
||||
subtitle: root.bluetoothAdapter?.enabled ? "Nearby and paired devices" : "Bluetooth is off"
|
||||
visible: Connectivity.adapter !== null
|
||||
subtitle: "Discovery runs while this page is open."
|
||||
|
||||
SettingRow {
|
||||
label: "Bluetooth"
|
||||
detail: root.bluetoothAdapter ? "Pair and reconnect without leaving Settings" : "No Bluetooth adapter found"
|
||||
detail: Connectivity.adapter
|
||||
? (Connectivity.adapter.enabled ? "On" : "Off")
|
||||
: "Unavailable"
|
||||
controlWidth: 48
|
||||
divider: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: root.bluetoothAdapter?.enabled ?? false
|
||||
enabled: root.bluetoothAdapter !== null
|
||||
checked: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||
enabled: Connectivity.adapter !== null
|
||||
onToggled: value => {
|
||||
if (root.bluetoothAdapter)
|
||||
root.bluetoothAdapter.enabled = value;
|
||||
if (Connectivity.adapter)
|
||||
Connectivity.adapter.enabled = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BluetoothList {
|
||||
BluetoothPanel {
|
||||
width: parent.width
|
||||
active: true
|
||||
maxHeight: 220
|
||||
visible: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Owned by Fedora"
|
||||
subtitle: "VPNs, per-connection routing, printers, and online accounts are configured by GNOME's panels, which are installed and searchable."
|
||||
|
||||
ActionRow {
|
||||
label: "Advanced Bluetooth settings"
|
||||
detail: "Device details and system-level options"
|
||||
label: "Network connections"
|
||||
detail: "VPN, proxies, and per-connection settings"
|
||||
action: "Open"
|
||||
onTriggered: SystemSettings.openGnomePanel("network")
|
||||
}
|
||||
ActionRow {
|
||||
label: "Printers"
|
||||
action: "Open"
|
||||
onTriggered: SystemSettings.openGnomePanel("printers")
|
||||
}
|
||||
ActionRow {
|
||||
label: "Online accounts"
|
||||
action: "Open"
|
||||
divider: false
|
||||
action: "Open panel"
|
||||
onTriggered: SystemSettings.openGnomePanel("bluetooth")
|
||||
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,10 +50,13 @@ Rectangle {
|
||||
|
||||
// Stands in for the wallpaper. Static: an animated gradient here would
|
||||
// repaint forever behind a settings page.
|
||||
// Follows the colour scheme. A preview that stays dark while the shell
|
||||
// around it is light does not read as "your desktop" -- it reads as a
|
||||
// screenshot of someone else's.
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Vertical
|
||||
GradientStop { position: 0.0; color: "#2b3050" }
|
||||
GradientStop { position: 1.0; color: "#241f33" }
|
||||
GradientStop { position: 0.0; color: Theme.dark ? "#2b3050" : "#b9c0dd" }
|
||||
GradientStop { position: 1.0; color: Theme.dark ? "#241f33" : "#cbbcd4" }
|
||||
}
|
||||
|
||||
// The bar, so the preview reads as this desktop and not a generic one.
|
||||
@@ -203,7 +206,7 @@ Rectangle {
|
||||
shadowEnabled: true
|
||||
shadowColor: win.focused && root.glowOn
|
||||
? Theme.alpha(Theme.accent, 0.5)
|
||||
: Theme.alpha("#15161e", 0.85)
|
||||
: Theme.alpha(Theme.dark ? "#15161e" : "#6172b0", Theme.dark ? 0.85 : 0.45)
|
||||
shadowBlur: win.focused && root.glowOn
|
||||
? Math.min(1.0, root.px(root.glowRange) / 12)
|
||||
: Math.min(1.0, root.px(root.shadowRange) / 24)
|
||||
|
||||
@@ -187,6 +187,19 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Night Light"
|
||||
subtitle: NightLight.active
|
||||
? "On now, warming the display to reduce blue light."
|
||||
: "Warms the display in the evening to reduce blue light."
|
||||
|
||||
ToggleRow { setting: "nightLightEnabled" }
|
||||
ToggleRow { setting: "nightLightAutomatic" }
|
||||
TimeOfDayRow { setting: "nightLightFrom" }
|
||||
TimeOfDayRow { setting: "nightLightTo" }
|
||||
SliderRow { setting: "nightLightTemperature"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Gaming display policy"
|
||||
subtitle: "Applied immediately and restored when Panama starts."
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Choosing a font family.
|
||||
//
|
||||
// Each candidate is rendered IN the font it names. A list of family names set
|
||||
// in the current font tells you nothing about what you are choosing, and the
|
||||
// whole point of picking a typeface is seeing it.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.modules.clipboard
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
spacing: 0
|
||||
|
||||
property var families: []
|
||||
property string current: ""
|
||||
property string emptyText: "No fonts found"
|
||||
|
||||
signal picked(string family)
|
||||
|
||||
// Filtered rather than listing all 139: a scrolling wall of family names
|
||||
// inside a page that is already scrolling is worse than a search box.
|
||||
readonly property var matches: {
|
||||
const needle = filter.text.trim().toLowerCase();
|
||||
const list = root.families.filter(family =>
|
||||
needle === "" ? true : family.toLowerCase().indexOf(needle) >= 0);
|
||||
// Always show what is currently selected, even when it does not match.
|
||||
if (needle === "" && list.indexOf(root.current) >= 0)
|
||||
return [root.current].concat(list.filter(f => f !== root.current)).slice(0, 12);
|
||||
return list.slice(0, 12);
|
||||
}
|
||||
|
||||
SearchField {
|
||||
id: filter
|
||||
width: parent.width
|
||||
placeholder: "Search installed fonts"
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.matches
|
||||
|
||||
SettingRow {
|
||||
id: candidate
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool selected: candidate.modelData === root.current
|
||||
|
||||
label: candidate.modelData
|
||||
detail: candidate.selected ? "Currently in use" : ""
|
||||
controlWidth: 210
|
||||
divider: candidate.index < root.matches.length - 1
|
||||
activatable: !candidate.selected
|
||||
onActivated: root.picked(candidate.modelData)
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 200
|
||||
horizontalAlignment: Text.AlignRight
|
||||
elide: Text.ElideRight
|
||||
// The sample is drawn in the candidate family, which is the
|
||||
// entire reason this is a list rather than a text field.
|
||||
text: "Handgloves 0123"
|
||||
font.family: candidate.modelData
|
||||
font.pixelSize: Theme.fontSize + 1
|
||||
color: candidate.selected ? Theme.accent : Theme.fgDim
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: root.matches.length === 0
|
||||
label: root.emptyText
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
@@ -9,93 +9,61 @@ Rectangle {
|
||||
required property string sourceName
|
||||
required property int index
|
||||
required property bool featured
|
||||
required property bool canMoveEarlier
|
||||
required property bool canMoveLater
|
||||
|
||||
signal aliasCommitted(string id, string alias)
|
||||
signal removeRequested(string id)
|
||||
signal moveRequested(string id, int targetIndex)
|
||||
|
||||
readonly property bool dragging: dragHandler.active
|
||||
|
||||
implicitHeight: 108
|
||||
radius: Theme.cardRadius
|
||||
color: root.dragging
|
||||
? Theme.mix(Theme.bgDark, Theme.accent, 0.09)
|
||||
: Theme.alpha(Theme.bgDark, 0.7)
|
||||
border.width: root.dragging ? 2 : 1
|
||||
border.color: root.dragging
|
||||
? Theme.alpha(Theme.accent, 0.82)
|
||||
: Theme.alpha(Theme.fg, 0.07)
|
||||
z: root.dragging ? 10 : 0
|
||||
|
||||
transform: Translate {
|
||||
x: root.dragging ? dragHandler.translation.x : 0
|
||||
y: root.dragging ? dragHandler.translation.y : 0
|
||||
}
|
||||
color: Theme.alpha(Theme.bgDark, 0.7)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.07)
|
||||
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: root.radius
|
||||
opacity: root.dragging ? 0.82 : 0.2
|
||||
opacity: 0.2
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: dragHandle
|
||||
Column {
|
||||
id: reorderControls
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 11
|
||||
anchors.leftMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 30
|
||||
height: 42
|
||||
radius: 9
|
||||
activeFocusOnTab: true
|
||||
color: root.dragging || activeFocus
|
||||
? Theme.alpha(Theme.accent, 0.14)
|
||||
: (handleMouse.containsMouse ? Theme.alpha(Theme.fg, 0.09) : Theme.alpha(Theme.fg, 0.045))
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.06)
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "⠿"
|
||||
color: root.dragging ? Theme.accent : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 16
|
||||
SettingsButton {
|
||||
width: 30
|
||||
height: 29
|
||||
text: "↑"
|
||||
enabled: root.canMoveEarlier
|
||||
activeFocusOnTab: enabled
|
||||
onClicked: root.moveRequested(root.favorite.id, root.index - 1)
|
||||
Keys.onReturnPressed: if (enabled) root.moveRequested(root.favorite.id, root.index - 1)
|
||||
Keys.onSpacePressed: if (enabled) root.moveRequested(root.favorite.id, root.index - 1)
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: handleMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
cursorShape: Qt.SizeAllCursor
|
||||
}
|
||||
|
||||
DragHandler {
|
||||
id: dragHandler
|
||||
target: null
|
||||
onActiveChanged: {
|
||||
if (!active)
|
||||
root.commitDrag();
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_Left || event.key === Qt.Key_Up) {
|
||||
root.moveRequested(root.favorite.id, Math.max(0, root.index - 1));
|
||||
event.accepted = true;
|
||||
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_Down) {
|
||||
const grid = root.GridView.view;
|
||||
const lastIndex = grid ? grid.count - 1 : root.index;
|
||||
root.moveRequested(root.favorite.id, Math.min(lastIndex, root.index + 1));
|
||||
event.accepted = true;
|
||||
}
|
||||
SettingsButton {
|
||||
width: 30
|
||||
height: 29
|
||||
text: "↓"
|
||||
enabled: root.canMoveLater
|
||||
activeFocusOnTab: enabled
|
||||
onClicked: root.moveRequested(root.favorite.id, root.index + 1)
|
||||
Keys.onReturnPressed: if (enabled) root.moveRequested(root.favorite.id, root.index + 1)
|
||||
Keys.onSpacePressed: if (enabled) root.moveRequested(root.favorite.id, root.index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: aliasFrame
|
||||
anchors.left: dragHandle.right
|
||||
anchors.left: reorderControls.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: removeButton.left
|
||||
anchors.rightMargin: 12
|
||||
@@ -188,16 +156,4 @@ Rectangle {
|
||||
Keys.onReturnPressed: root.removeRequested(root.favorite.id)
|
||||
Keys.onSpacePressed: root.removeRequested(root.favorite.id)
|
||||
}
|
||||
|
||||
function commitDrag(): void {
|
||||
const grid = root.GridView.view;
|
||||
if (!grid || grid.count <= 0)
|
||||
return;
|
||||
const centerX = root.x + dragHandler.translation.x + root.width / 2;
|
||||
const centerY = root.y + dragHandler.translation.y + root.height / 2;
|
||||
const modelCount = grid.count;
|
||||
const column = Math.max(0, Math.min(1, Math.floor(centerX / grid.cellWidth)));
|
||||
const row = Math.max(0, Math.floor(centerY / grid.cellHeight));
|
||||
root.moveRequested(root.favorite.id, Math.min(modelCount - 1, row * 2 + column));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,16 @@ SettingsPage {
|
||||
SettingsCard {
|
||||
title: "Weather"
|
||||
subtitle: "Local conditions in the date menu"
|
||||
TextRow {
|
||||
label: "Location"
|
||||
detail: "Only the search term is sent; the name below is a label kept on this machine"
|
||||
value: Settings.weatherLocation
|
||||
}
|
||||
|
||||
LocationPicker {
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
ChoiceRow { setting: "temperatureUnit" }
|
||||
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
||||
}
|
||||
|
||||
@@ -43,10 +43,200 @@ SettingsPage {
|
||||
return "Home Assistant is unavailable";
|
||||
}
|
||||
|
||||
function saveHomeAssistantConfig(): void {
|
||||
HomeAssistantConfig.save(
|
||||
homeUrlInput.text,
|
||||
homeEntitiesInput.text,
|
||||
homeTokenInput.text
|
||||
);
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: HomeAssistantConfig
|
||||
|
||||
function onConfigurationSaved(): void {
|
||||
homeTokenInput.clear();
|
||||
homeUrlInput.text = HomeAssistantConfig.url;
|
||||
homeEntitiesInput.text = HomeAssistantConfig.entities.join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Home Assistant"
|
||||
subtitle: root.homeStatus()
|
||||
|
||||
SettingRow {
|
||||
label: "Connection"
|
||||
detail: HomeAssistantConfig.tokenConfigured
|
||||
? "A long-lived access token is stored privately"
|
||||
: "Paste a long-lived access token to connect"
|
||||
value: HomeAssistantConfig.configured ? "Configured" : "Not configured"
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Server URL"
|
||||
detail: "The local or remote address of Home Assistant"
|
||||
controlWidth: 330
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Theme.pillRadius
|
||||
color: Theme.alpha(Theme.fg, 0.07)
|
||||
border.width: homeUrlInput.activeFocus ? 2 : 1
|
||||
border.color: homeUrlInput.activeFocus
|
||||
? Theme.alpha(Theme.accent, 0.55) : "transparent"
|
||||
|
||||
TextInput {
|
||||
id: homeUrlInput
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
activeFocusOnTab: true
|
||||
text: HomeAssistantConfig.url
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||
selectedTextColor: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
clip: true
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
visible: homeUrlInput.text === ""
|
||||
text: "https://homeassistant.local:8123"
|
||||
color: Theme.fgMuted
|
||||
font: homeUrlInput.font
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Access token"
|
||||
detail: HomeAssistantConfig.tokenConfigured
|
||||
? "Stored · leave blank to keep it"
|
||||
: "Create one in your Home Assistant profile"
|
||||
controlWidth: 330
|
||||
|
||||
PasswordField {
|
||||
id: homeTokenInput
|
||||
anchors.fill: parent
|
||||
placeholder: HomeAssistantConfig.tokenConfigured
|
||||
? "Stored token" : "Long-lived access token"
|
||||
onAccepted: root.saveHomeAssistantConfig()
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 7
|
||||
topPadding: 10
|
||||
bottomPadding: 12
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Light entities"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Comma-separated entity IDs. These define the discoverable light catalog."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 72
|
||||
radius: 10
|
||||
color: Theme.alpha(Theme.fg, 0.055)
|
||||
border.width: homeEntitiesInput.activeFocus ? 2 : 1
|
||||
border.color: homeEntitiesInput.activeFocus
|
||||
? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.06)
|
||||
|
||||
TextEdit {
|
||||
id: homeEntitiesInput
|
||||
anchors.fill: parent
|
||||
anchors.margins: 10
|
||||
activeFocusOnTab: true
|
||||
text: HomeAssistantConfig.entities.join(", ")
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||
selectedTextColor: Theme.fg
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: TextEdit.Wrap
|
||||
clip: true
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
visible: homeEntitiesInput.text === ""
|
||||
text: "light.living_room, light.kitchen"
|
||||
color: Theme.fgMuted
|
||||
font: homeEntitiesInput.font
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: HomeAssistantConfig.lastError !== ""
|
||||
text: HomeAssistantConfig.lastError
|
||||
color: Theme.danger
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
bottomPadding: 9
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Private configuration"
|
||||
detail: "Saved with owner-only permissions in Panama's private environment file"
|
||||
controlWidth: 216
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
id: clearTokenButton
|
||||
text: "Clear token"
|
||||
enabled: HomeAssistantConfig.tokenConfigured && !HomeAssistantConfig.busy
|
||||
activeFocusOnTab: enabled
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: HomeAssistantConfig.clearToken()
|
||||
Keys.onReturnPressed: if (enabled) HomeAssistantConfig.clearToken()
|
||||
Keys.onSpacePressed: if (enabled) HomeAssistantConfig.clearToken()
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: saveHomeConfigButton
|
||||
text: HomeAssistantConfig.busy ? "Saving…" : "Save"
|
||||
tone: "accent"
|
||||
enabled: !HomeAssistantConfig.busy
|
||||
activeFocusOnTab: enabled
|
||||
border.width: activeFocus ? 2 : 0
|
||||
border.color: activeFocus ? Theme.fg : "transparent"
|
||||
onClicked: root.saveHomeAssistantConfig()
|
||||
Keys.onReturnPressed: if (enabled) root.saveHomeAssistantConfig()
|
||||
Keys.onSpacePressed: if (enabled) root.saveHomeAssistantConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Light catalog"
|
||||
detail: "Panama reads light state through the Home Assistant helper."
|
||||
@@ -122,6 +312,8 @@ SettingsPage {
|
||||
})
|
||||
sourceName: modelData.sourceName
|
||||
featured: index < 4
|
||||
canMoveEarlier: index > 0
|
||||
canMoveLater: index < favoritesGrid.count - 1
|
||||
onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias)
|
||||
onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex)
|
||||
onRemoveRequested: id => HomePreferences.remove(id)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Choosing where the weather reading is for.
|
||||
//
|
||||
// A search box rather than latitude and longitude fields: nobody knows their
|
||||
// own coordinates, and a control that demands them is one nobody ever uses. The
|
||||
// coordinates are what actually get stored -- the name is only a label.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.clipboard
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
spacing: 0
|
||||
|
||||
SearchField {
|
||||
id: query
|
||||
width: parent.width
|
||||
placeholder: "Search for a town or city"
|
||||
onTextChanged: Geocoding.search(query.text)
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Geocoding.results
|
||||
|
||||
SettingRow {
|
||||
id: place
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: place.modelData.name
|
||||
detail: [place.modelData.admin, place.modelData.country].filter(part => !!part).join(", ")
|
||||
value: place.modelData.latitude.toFixed(2) + ", " + place.modelData.longitude.toFixed(2)
|
||||
controlWidth: 150
|
||||
divider: place.index < Geocoding.results.length - 1
|
||||
activatable: true
|
||||
onActivated: {
|
||||
if (Geocoding.choose(place.modelData))
|
||||
query.text = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: Geocoding.searching || Geocoding.lastError !== ""
|
||||
label: Geocoding.searching ? "Searching…" : "No result"
|
||||
detail: Geocoding.searching ? "" : Geocoding.lastError
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
@@ -50,10 +50,89 @@ SettingsPage {
|
||||
SliderRow { setting: "maxVisibleToasts"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Application rules"
|
||||
subtitle: "Apps appear here after they send a notification."
|
||||
|
||||
TextRow {
|
||||
visible: Notifs.applications.length === 0
|
||||
label: "No applications remembered yet"
|
||||
detail: "Application controls will appear after the first notification arrives."
|
||||
divider: false
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Notifs.applications
|
||||
|
||||
Column {
|
||||
required property var modelData
|
||||
|
||||
readonly property var app: modelData
|
||||
|
||||
width: parent.width
|
||||
|
||||
SettingRow {
|
||||
label: app.name
|
||||
detail: app.id
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Notifs.appRule(app.id).enabled
|
||||
onToggled: value => Notifs.setAppRule(app.id, { enabled: value })
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Show on lock screen"
|
||||
detail: "Allow this app's notifications on the lock screen"
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Notifs.appRule(app.id).showOnLockScreen
|
||||
onToggled: value => Notifs.setAppRule(app.id, { showOnLockScreen: value })
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Show content on lock screen"
|
||||
detail: "Show message details when this app is visible there"
|
||||
divider: false
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Notifs.appRule(app.id).showContentOnLockScreen
|
||||
onToggled: value => Notifs.setAppRule(app.id, { showContentOnLockScreen: value })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Focus sessions"
|
||||
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
|
||||
|
||||
SettingRow {
|
||||
label: "Keep the screen awake"
|
||||
detail: Caffeine.enabled
|
||||
? "The display will not blank or lock while this is on"
|
||||
: "Idle timings on Power & Lock apply normally"
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Caffeine.enabled
|
||||
onToggled: value => Caffeine.enabled = value
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
id: durationRow
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// A password entry with a reveal toggle.
|
||||
//
|
||||
// Deliberately not the clipboard popover's SearchField with different text: a
|
||||
// Wi-Fi key typed into a field that echoes it is readable by anyone behind you,
|
||||
// and a search glyph in front of a password prompt is simply wrong. Masked by
|
||||
// default, revealable while held, because the reason people want to see it is
|
||||
// to check a character they just typed.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property alias text: input.text
|
||||
property string placeholder: "Password"
|
||||
property bool revealed: false
|
||||
|
||||
signal accepted
|
||||
|
||||
implicitHeight: 32
|
||||
radius: Theme.pillRadius
|
||||
color: Theme.alpha(Theme.fg, 0.07)
|
||||
// Not left at 0 so the focus ring has something to animate. See the note in
|
||||
// modules/clipboard/SearchField.qml.
|
||||
border.width: 1
|
||||
border.color: input.activeFocus ? Theme.alpha(Theme.accent, 0.55) : "transparent"
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation { duration: Theme.durFast }
|
||||
}
|
||||
|
||||
function grab(): void {
|
||||
input.forceActiveFocus();
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
input.text = "";
|
||||
root.revealed = false;
|
||||
}
|
||||
|
||||
TextInput {
|
||||
id: input
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 13
|
||||
anchors.right: revealButton.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
activeFocusOnTab: true
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||
selectedTextColor: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
echoMode: root.revealed ? TextInput.Normal : TextInput.Password
|
||||
passwordCharacter: "•"
|
||||
clip: true
|
||||
|
||||
onAccepted: root.accepted()
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
visible: input.text === ""
|
||||
text: root.placeholder
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: revealButton
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 5
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 26
|
||||
height: 24
|
||||
radius: 7
|
||||
color: revealHover.hovered ? Theme.alpha(Theme.fg, 0.12) : "transparent"
|
||||
border.width: 0
|
||||
visible: input.text !== ""
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
// Nerd Font eye / eye-slash. fontMono is used for icon glyphs only.
|
||||
text: root.revealed ? "\u{F070}" : "\u{F06E}"
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 12
|
||||
color: root.revealed ? Theme.accent : Theme.fgMuted
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: revealHover
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: root.revealed = !root.revealed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,11 @@ SettingsPage {
|
||||
SettingsCard {
|
||||
title: "Keyboard"
|
||||
|
||||
TextRow {
|
||||
label: "Keyboard layout"
|
||||
detail: "XKB layout name. Changing it needs a compositor reload, so it is shown here rather than offered as a control that appears to apply instantly."
|
||||
value: Settings ? DesktopPreferences.get("keyboardLayout") : "us"
|
||||
}
|
||||
SliderRow { setting: "keyRepeatDelay" }
|
||||
SliderRow { setting: "keyRepeatRate" }
|
||||
ToggleRow { setting: "numlockByDefault"; divider: false }
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import QtQuick
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
property bool output: true
|
||||
readonly property var nodes: AudioDevices.nodes(root.output)
|
||||
readonly property var current: AudioDevices.current(root.output)
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
model: root.nodes
|
||||
|
||||
SoundDeviceRow {
|
||||
required property var modelData
|
||||
|
||||
width: root.width
|
||||
node: modelData
|
||||
output: root.output
|
||||
selected: modelData === root.current
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.nodes.length === 0
|
||||
text: Pipewire.ready ? "No audio devices found" : "Discovering audio devices…"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
topPadding: 18
|
||||
bottomPadding: 18
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.config
|
||||
import qs.widgets
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
required property var node
|
||||
property bool output: true
|
||||
property bool selected: false
|
||||
|
||||
implicitHeight: root.output || !root.selected ? 88 : 105
|
||||
radius: Theme.cardRadius
|
||||
color: root.selected ? Theme.alpha(Theme.accent, 0.09) : Theme.alpha(Theme.fg, 0.025)
|
||||
border.width: 1
|
||||
border.color: root.selected ? Theme.alpha(Theme.accent, 0.34) : Theme.alpha(Theme.fg, 0.07)
|
||||
|
||||
PwObjectTracker {
|
||||
objects: root.node ? [root.node] : []
|
||||
}
|
||||
|
||||
PwNodePeakMonitor {
|
||||
id: inputPeak
|
||||
node: root.node
|
||||
enabled: !root.output && root.selected
|
||||
}
|
||||
|
||||
readonly property real volume: root.node?.audio?.volume ?? 0
|
||||
readonly property bool muted: root.node?.audio?.muted ?? false
|
||||
|
||||
function iconName(): string {
|
||||
if (!root.output)
|
||||
return root.muted ? "microphone-sensitivity-muted-symbolic" : "audio-input-microphone-symbolic";
|
||||
if (root.muted || root.volume <= 0.001)
|
||||
return "audio-volume-muted-symbolic";
|
||||
if (root.volume < 0.34)
|
||||
return "audio-volume-low-symbolic";
|
||||
if (root.volume < 0.67)
|
||||
return "audio-volume-medium-symbolic";
|
||||
return "audio-volume-high-symbolic";
|
||||
}
|
||||
|
||||
Row {
|
||||
id: heading
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 10
|
||||
anchors.topMargin: 8
|
||||
height: 28
|
||||
spacing: 10
|
||||
|
||||
ThemedIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 20
|
||||
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
|
||||
iconFallback: "audio-card-symbolic"
|
||||
tint: root.selected ? Theme.accent : Theme.fg
|
||||
}
|
||||
|
||||
Column {
|
||||
width: Math.max(0, parent.width - 20 - useButton.width - parent.spacing * 2)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 1
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: AudioDevices.label(root.node)
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: root.selected ? Font.DemiBold : Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.node.nickname && root.node.nickname !== AudioDevices.label(root.node)
|
||||
text: root.node.nickname ?? ""
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: useButton
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: root.selected ? 78 : 64
|
||||
text: root.selected ? "Default" : "Use"
|
||||
enabled: !root.selected
|
||||
onClicked: AudioDevices.select(root.output, root.node)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: muteButton
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 8
|
||||
anchors.top: heading.bottom
|
||||
anchors.topMargin: 7
|
||||
size: 30
|
||||
iconSize: 17
|
||||
icon: root.iconName()
|
||||
iconFallback: root.output ? "audio-volume-high-symbolic" : "audio-input-microphone-symbolic"
|
||||
onClicked: {
|
||||
if (root.node?.audio)
|
||||
root.node.audio.muted = !root.node.audio.muted;
|
||||
}
|
||||
}
|
||||
|
||||
ValueSlider {
|
||||
id: volumeSlider
|
||||
anchors.left: muteButton.right
|
||||
anchors.leftMargin: 7
|
||||
anchors.right: volumeText.left
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: muteButton.verticalCenter
|
||||
value: root.muted ? 0 : root.volume
|
||||
onMoved: value => {
|
||||
if (!root.node?.audio)
|
||||
return;
|
||||
root.node.audio.muted = false;
|
||||
root.node.audio.volume = value;
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: volumeText
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: muteButton.verticalCenter
|
||||
width: 38
|
||||
text: Math.round(root.volume * 100) + "%"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
horizontalAlignment: Text.AlignRight
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: volumeSlider.left
|
||||
anchors.right: volumeText.right
|
||||
anchors.top: muteButton.bottom
|
||||
anchors.topMargin: 5
|
||||
height: 5
|
||||
radius: height / 2
|
||||
visible: !root.output && root.selected
|
||||
color: Theme.alpha(Theme.fg, 0.10)
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width * Math.max(0, Math.min(1, inputPeak.peak))
|
||||
radius: parent.radius
|
||||
color: inputPeak.peak > 0.88 ? Theme.danger : Theme.accentSecondary
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import QtQuick
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
|
||||
SettingsPage {
|
||||
title: "Sound"
|
||||
@@ -10,43 +8,58 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
title: "Output"
|
||||
subtitle: Pipewire.defaultAudioSink?.description ?? "No output device"
|
||||
subtitle: AudioDevices.current(true)?.description ?? "No output device"
|
||||
|
||||
AudioSlider {
|
||||
width: parent.width
|
||||
node: Pipewire.defaultAudioSink
|
||||
output: true
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.alpha(Theme.fg, 0.06)
|
||||
}
|
||||
AudioDeviceList {
|
||||
SoundDeviceList {
|
||||
width: parent.width
|
||||
output: true
|
||||
maxHeight: 190
|
||||
}
|
||||
|
||||
AudioBalance {
|
||||
width: parent.width
|
||||
node: AudioDevices.current(true)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Input"
|
||||
subtitle: Pipewire.defaultAudioSource?.description ?? "No input device"
|
||||
subtitle: AudioDevices.current(false)?.description ?? "No input device"
|
||||
|
||||
AudioSlider {
|
||||
width: parent.width
|
||||
node: Pipewire.defaultAudioSource
|
||||
output: false
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.alpha(Theme.fg, 0.06)
|
||||
}
|
||||
AudioDeviceList {
|
||||
SoundDeviceList {
|
||||
width: parent.width
|
||||
output: false
|
||||
maxHeight: 160
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Sound feedback"
|
||||
subtitle: "Use the same event preferences as GTK and GNOME applications."
|
||||
|
||||
SettingRow {
|
||||
label: "Event sounds"
|
||||
detail: "Play alerts and interface event sounds"
|
||||
controlWidth: 42
|
||||
|
||||
SettingsToggle {
|
||||
anchors.fill: parent
|
||||
checked: SoundFeedback.eventSounds
|
||||
enabled: !SoundFeedback.busy
|
||||
onToggled: checked => SoundFeedback.setEventSounds(checked)
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Input feedback"
|
||||
detail: "Play sounds for supported typing and input events"
|
||||
controlWidth: 42
|
||||
divider: false
|
||||
|
||||
SettingsToggle {
|
||||
anchors.fill: parent
|
||||
checked: SoundFeedback.inputFeedback
|
||||
enabled: !SoundFeedback.busy
|
||||
onToggled: checked => SoundFeedback.setInputFeedback(checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// A time of day, stored as a decimal hour.
|
||||
//
|
||||
// SliderRow would render 17.5 as "17.50", which is not how anyone reads a
|
||||
// clock. This is the same control with the readout formatted as a time, so the
|
||||
// night light schedule says "5:30 PM" rather than a number you have to convert.
|
||||
//
|
||||
// Honours the 24-hour clock preference, because a user who has asked for 18:30
|
||||
// everywhere else should not be shown 6:30 PM here.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SliderRow {
|
||||
id: root
|
||||
|
||||
// SliderRow renders `unit` after the number; a time needs the whole readout
|
||||
// replaced, so the formatting is done here instead.
|
||||
function display(value: real): string {
|
||||
const hour = Math.floor(value);
|
||||
const minute = Math.round((value - hour) * 60);
|
||||
const padded = String(minute).padStart(2, "0");
|
||||
|
||||
if (Settings.use24Hour)
|
||||
return `${String(hour).padStart(2, "0")}:${padded}`;
|
||||
|
||||
const suffix = hour < 12 ? "AM" : "PM";
|
||||
const twelve = hour % 12 === 0 ? 12 : hour % 12;
|
||||
return `${twelve}:${padded} ${suffix}`;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,19 @@ Item {
|
||||
|
||||
implicitHeight: grid.implicitHeight
|
||||
|
||||
// Sixty tiles is two screens of wallpaper on a page that also holds
|
||||
// typography, window geometry and effects -- everything below it becomes
|
||||
// unreachable without a long scroll past pictures. Two rows by default,
|
||||
// all of them on request.
|
||||
property bool expanded: false
|
||||
readonly property int collapsedRows: 2
|
||||
|
||||
readonly property var shown: root.expanded
|
||||
? Wallpaper.available
|
||||
: Wallpaper.available.slice(0, root.columns * root.collapsedRows)
|
||||
|
||||
readonly property int hidden: Wallpaper.available.length - root.shown.length
|
||||
|
||||
readonly property int columns: Math.max(2, Math.floor(width / 190))
|
||||
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
|
||||
|
||||
@@ -30,7 +43,7 @@ Item {
|
||||
spacing: 10
|
||||
|
||||
Repeater {
|
||||
model: Wallpaper.available
|
||||
model: root.shown
|
||||
|
||||
Rectangle {
|
||||
id: tile
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Wi-Fi, at page size.
|
||||
//
|
||||
// The quick settings version is a popover: a compact list you glance at. This
|
||||
// is the one you sit in front of when a network is not behaving, so each row
|
||||
// carries what you would otherwise open a terminal to find out — signal,
|
||||
// security, and whether it is a network this machine already knows.
|
||||
//
|
||||
// Joining a secured network reveals an inline password field rather than
|
||||
// failing silently, which is the one interaction the popover already got right
|
||||
// and is worth keeping identical.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Networking
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
spacing: 0
|
||||
|
||||
// SSID whose password field is open, and the last failure.
|
||||
property string passwordFor: ""
|
||||
property string failedSsid: ""
|
||||
property string failedText: ""
|
||||
|
||||
function activate(network: var): void {
|
||||
root.failedSsid = "";
|
||||
if (network.connected)
|
||||
return;
|
||||
if (network.known || !Connectivity.isSecured(network)) {
|
||||
root.passwordFor = "";
|
||||
network.connect();
|
||||
return;
|
||||
}
|
||||
root.passwordFor = root.passwordFor === network.name ? "" : network.name;
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Connectivity.networks
|
||||
|
||||
Column {
|
||||
id: entry
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: parent.width
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
|
||||
label: entry.modelData.name || "Hidden network"
|
||||
detail: {
|
||||
const bits = [];
|
||||
if (entry.modelData.connected)
|
||||
bits.push("Connected");
|
||||
else if (entry.modelData.known)
|
||||
bits.push("Saved");
|
||||
bits.push(Connectivity.signalLabel(entry.modelData.signalStrength));
|
||||
bits.push(Connectivity.securityLabel(entry.modelData));
|
||||
return bits.join(" · ");
|
||||
}
|
||||
divider: entry.index < Connectivity.networks.length - 1 || root.passwordFor === entry.modelData.name
|
||||
controlWidth: 190
|
||||
activatable: !entry.modelData.connected
|
||||
onActivated: root.activate(entry.modelData)
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: entry.modelData.connected
|
||||
text: "Connected"
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: entry.modelData.connected
|
||||
text: "Disconnect"
|
||||
onClicked: entry.modelData.disconnect()
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: !entry.modelData.connected
|
||||
text: entry.modelData.known ? "Connect" : "Join"
|
||||
onClicked: root.activate(entry.modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The password field for this network, when it is the one being
|
||||
// joined. Inline rather than a dialog: a dialog over a tiled window
|
||||
// is a worse place to type than the row you just clicked.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: root.passwordFor === entry.modelData.name ? 54 : 0
|
||||
visible: height > 0
|
||||
clip: true
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible)
|
||||
password.grab();
|
||||
else
|
||||
password.clear();
|
||||
}
|
||||
|
||||
PasswordField {
|
||||
id: password
|
||||
anchors.left: parent.left
|
||||
anchors.right: joinButton.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
placeholder: "Password for " + (entry.modelData.name || "network")
|
||||
onAccepted: joinButton.join()
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: joinButton
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Join"
|
||||
|
||||
function join(): void {
|
||||
entry.modelData.connect(password.text);
|
||||
root.passwordFor = "";
|
||||
password.text = "";
|
||||
}
|
||||
|
||||
onClicked: joinButton.join()
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.failedSsid === entry.modelData.name
|
||||
leftPadding: 2
|
||||
bottomPadding: 8
|
||||
text: root.failedText
|
||||
color: Theme.warn
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: entry.modelData
|
||||
function onConnectionFailed(reason): void {
|
||||
root.failedSsid = entry.modelData.name;
|
||||
root.failedText = Connectivity.connectionFailureText(reason);
|
||||
root.passwordFor = entry.modelData.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: Connectivity.networks.length === 0
|
||||
label: !Connectivity.wifiDevice
|
||||
? "No Wi-Fi adapter"
|
||||
: (Connectivity.wifiEnabled ? "Looking for networks…" : "Wi-Fi is off")
|
||||
detail: Connectivity.wifiDevice && !Connectivity.wifiEnabled
|
||||
? "Turn it on above to see what is nearby"
|
||||
: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
@@ -37,3 +37,12 @@ DockAppPicker 1.0 DockAppPicker.qml
|
||||
ShortcutCapture 1.0 ShortcutCapture.qml
|
||||
ChoiceGrid 1.0 ChoiceGrid.qml
|
||||
DisplayModePicker 1.0 DisplayModePicker.qml
|
||||
WifiPanel 1.0 WifiPanel.qml
|
||||
BluetoothPanel 1.0 BluetoothPanel.qml
|
||||
PasswordField 1.0 PasswordField.qml
|
||||
AudioBalance 1.0 AudioBalance.qml
|
||||
SoundDeviceList 1.0 SoundDeviceList.qml
|
||||
SoundDeviceRow 1.0 SoundDeviceRow.qml
|
||||
TimeOfDayRow 1.0 TimeOfDayRow.qml
|
||||
LocationPicker 1.0 LocationPicker.qml
|
||||
FontPicker 1.0 FontPicker.qml
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
// A living reference for Panama's shared visual language. This deliberately
|
||||
// renders the production widgets instead of lookalike mock components, so a
|
||||
// change to a control or Theme token is visible here immediately.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.config
|
||||
import qs.widgets
|
||||
|
||||
ShellRoot {
|
||||
FloatingWindow {
|
||||
id: galleryWindow
|
||||
|
||||
title: "Panama Prism Gallery"
|
||||
visible: true
|
||||
implicitWidth: 1120
|
||||
implicitHeight: 760
|
||||
minimumSize: Qt.size(920, 640)
|
||||
color: Theme.bg
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Theme.bg
|
||||
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: 0
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 34
|
||||
spacing: 34
|
||||
|
||||
// Quiet editorial rail: enough context to make screenshots
|
||||
// self-explanatory without competing with the components.
|
||||
ColumnLayout {
|
||||
Layout.preferredWidth: 222
|
||||
Layout.fillHeight: true
|
||||
spacing: 0
|
||||
|
||||
Rectangle {
|
||||
Layout.preferredWidth: 38
|
||||
Layout.preferredHeight: 38
|
||||
radius: 12
|
||||
color: Theme.alpha(Theme.fg, 0.07)
|
||||
|
||||
ThemedIcon {
|
||||
anchors.centerIn: parent
|
||||
size: 20
|
||||
icon: "applications-graphics-symbolic"
|
||||
tint: Theme.accent
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.topMargin: 20
|
||||
text: "PRISM"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 26
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 1.4
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.topMargin: 5
|
||||
Layout.maximumWidth: 210
|
||||
text: "Panama's living interface reference. Calm by default, vivid with purpose."
|
||||
color: Theme.fgDim
|
||||
wrapMode: Text.WordWrap
|
||||
lineHeight: 1.25
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
Item { Layout.preferredHeight: 36 }
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{ label: "Tokyo Night Moon", color: Theme.bgHighlight },
|
||||
{ label: "Signal blue", color: Theme.accent },
|
||||
{ label: "Prism orchid", color: Theme.accentSecondary }
|
||||
]
|
||||
|
||||
RowLayout {
|
||||
required property var modelData
|
||||
Layout.topMargin: 10
|
||||
spacing: 10
|
||||
|
||||
Rectangle {
|
||||
Layout.preferredWidth: 12
|
||||
Layout.preferredHeight: 12
|
||||
radius: 6
|
||||
color: modelData.color
|
||||
}
|
||||
|
||||
Text {
|
||||
text: modelData.label
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillHeight: true }
|
||||
|
||||
Text {
|
||||
text: "REAL COMPONENTS · LIVE TOKENS"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 10
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 1.1
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
radius: Theme.popoverRadius
|
||||
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
PrismEdge {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
inset: parent.radius
|
||||
}
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 30
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
ColumnLayout {
|
||||
id: content
|
||||
width: parent.width
|
||||
spacing: 28
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
text: "Interface inventory"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeTitle
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Production controls in their canonical states"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Pill {
|
||||
interactive: false
|
||||
horizontalPadding: 12
|
||||
|
||||
ThemedIcon {
|
||||
size: 14
|
||||
icon: "emblem-ok-symbolic"
|
||||
tint: Theme.green
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "TOKYO NIGHT MOON"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 10
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 0.8
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
text: "QUICK CONTROLS"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 10
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 1.1
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
Toggle {
|
||||
id: wifiToggle
|
||||
Layout.fillWidth: true
|
||||
icon: "network-wireless-signal-excellent-symbolic"
|
||||
label: "Wi-Fi"
|
||||
sublabel: active ? "Connected" : "Off"
|
||||
active: true
|
||||
onToggled: active = !active
|
||||
}
|
||||
|
||||
Toggle {
|
||||
id: bluetoothToggle
|
||||
Layout.fillWidth: true
|
||||
icon: "bluetooth-active-symbolic"
|
||||
label: "Bluetooth"
|
||||
sublabel: active ? "2 devices" : "Off"
|
||||
active: false
|
||||
onToggled: active = !active
|
||||
}
|
||||
|
||||
Toggle {
|
||||
id: focusToggle
|
||||
Layout.fillWidth: true
|
||||
icon: "weather-clear-night-symbolic"
|
||||
label: "Focus"
|
||||
sublabel: active ? "Until 5:30" : "Available"
|
||||
active: false
|
||||
onToggled: active = !active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
text: "CONTINUOUS VALUES"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 10
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 1.1
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 126
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.fg, 0.045)
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 18
|
||||
spacing: 12
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
Text {
|
||||
text: "System audio"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Text {
|
||||
text: Math.round(volumeSlider.value * 100) + "%"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
ValueSlider {
|
||||
id: volumeSlider
|
||||
Layout.fillWidth: true
|
||||
value: 0.68
|
||||
icon: "audio-volume-high-symbolic"
|
||||
onMoved: newValue => volumeSlider.value = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
text: "BAR MATERIAL"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 10
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 1.1
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 74
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.bg, 0.62)
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
|
||||
Pill {
|
||||
ThemedIcon {
|
||||
size: 15
|
||||
icon: "view-grid-symbolic"
|
||||
tint: Theme.accent
|
||||
}
|
||||
Text {
|
||||
text: "1 2 3"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
Pill {
|
||||
ThemedIcon {
|
||||
size: 15
|
||||
icon: "edit-copy-symbolic"
|
||||
tint: Theme.fgDim
|
||||
}
|
||||
Text {
|
||||
text: "Clipboard"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
Pill {
|
||||
ThemedIcon {
|
||||
size: 15
|
||||
icon: "appointment-soon-symbolic"
|
||||
tint: Theme.fgDim
|
||||
}
|
||||
Text {
|
||||
text: "Tue Aug 18 · 4:46 AM"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+154
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# One stable command boundary for launcher actions. Vicinae scripts stay tiny,
|
||||
# while the actual mapping to Quickshell IPC remains testable in one place.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
action="${1:-}"
|
||||
helper_dir="${PANAMA_ACTION_HELPER_DIR:-$script_dir}"
|
||||
confirm_attempts="${PANAMA_ACTION_CONFIRM_ATTEMPTS:-20}"
|
||||
confirm_delay="${PANAMA_ACTION_CONFIRM_DELAY:-0.1}"
|
||||
kill_command="${PANAMA_ACTION_KILL_COMMAND:-/usr/bin/kill}"
|
||||
|
||||
osd_command=("$helper_dir/panama-osd")
|
||||
gallery_command=("$helper_dir/panama-prism-gallery")
|
||||
|
||||
report_failure() {
|
||||
local status=$?
|
||||
trap - EXIT
|
||||
if (( status != 0 )) && command -v notify-send >/dev/null 2>&1; then
|
||||
notify-send -a Panama -i dialog-error-symbolic \
|
||||
"Panama action failed" \
|
||||
"The “${action//-/ }” action could not be completed." || true
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
trap report_failure EXIT
|
||||
|
||||
show_state() {
|
||||
local state="$1" on_icon="$2" off_icon="$3" label="$4"
|
||||
case "$state" in
|
||||
true) PANAMA_OSD_STRICT=1 "${osd_command[@]}" message "$on_icon" "$label On" ;;
|
||||
false) PANAMA_OSD_STRICT=1 "${osd_command[@]}" message "$off_icon" "$label Off" ;;
|
||||
*)
|
||||
printf 'Panama action returned an invalid state: %s\n' "$state" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
state_is_active() {
|
||||
local target="$1"
|
||||
case "$target" in
|
||||
caffeine)
|
||||
systemd-inhibit --list --no-pager --no-legend 2>/dev/null \
|
||||
| awk '$1 == "Panama" && $(NF - 1) == "Caffeine" && $NF == "block" { found = 1 } END { exit !found }'
|
||||
;;
|
||||
night-light)
|
||||
pgrep -u "$(id -u)" -x hyprsunset >/dev/null 2>&1
|
||||
;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
release_caffeine_inhibitors() {
|
||||
local inhibitors pid uid
|
||||
uid="$(id -u)"
|
||||
inhibitors="$(systemd-inhibit --list --no-pager --no-legend 2>/dev/null \
|
||||
| awk -v uid="$uid" '$1 == "Panama" && $2 == uid && $(NF - 1) == "Caffeine" && $NF == "block" { print $4 }')"
|
||||
while IFS= read -r pid; do
|
||||
[[ $pid =~ ^[0-9]+$ ]] || continue
|
||||
"$kill_command" "$pid" 2>/dev/null || true
|
||||
done <<<"$inhibitors"
|
||||
}
|
||||
|
||||
wait_for_confirmed_state() {
|
||||
local target="$1" expected="$2" attempt
|
||||
for ((attempt = 0; attempt < confirm_attempts; attempt++)); do
|
||||
if [[ $expected == true ]] && state_is_active "$target"; then
|
||||
return 0
|
||||
fi
|
||||
if [[ $expected == false ]] && ! state_is_active "$target"; then
|
||||
return 0
|
||||
fi
|
||||
sleep "$confirm_delay"
|
||||
done
|
||||
printf 'Panama action could not confirm %s reached state %s\n' "$target" "$expected" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
toggle_state() {
|
||||
local target="$1" on_icon="$2" off_icon="$3" label="$4" state
|
||||
state="$(qs ipc call "$target" toggle)"
|
||||
case "$state" in true|false) ;; *) show_state "$state" "$on_icon" "$off_icon" "$label"; return ;; esac
|
||||
if [[ $target == caffeine && $state == false ]]; then
|
||||
release_caffeine_inhibitors
|
||||
fi
|
||||
wait_for_confirmed_state "$target" "$state"
|
||||
show_state "$state" "$on_icon" "$off_icon" "$label"
|
||||
}
|
||||
|
||||
wait_for_shell() {
|
||||
local attempt
|
||||
for ((attempt = 0; attempt < confirm_attempts; attempt++)); do
|
||||
if qs ipc show >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep "$confirm_delay"
|
||||
done
|
||||
printf 'Panama shell did not become ready after restart\n' >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
case "$action" in
|
||||
control-center) qs ipc call quicksettings open ;;
|
||||
notifications) qs ipc call notifications open ;;
|
||||
calendar) qs ipc call calendar-agenda open ;;
|
||||
clipboard) qs ipc call clipboard open ;;
|
||||
overview) qs ipc call overview open ;;
|
||||
settings) qs ipc call settings open ;;
|
||||
|
||||
dnd)
|
||||
state="$(qs ipc call notifications dnd)"
|
||||
show_state "$state" notifications-disabled-symbolic notifications-symbolic "Do Not Disturb"
|
||||
;;
|
||||
caffeine)
|
||||
toggle_state caffeine weather-clear-symbolic weather-clear-night-symbolic Caffeine
|
||||
;;
|
||||
night-light)
|
||||
toggle_state night-light night-light-symbolic night-light-disabled-symbolic "Night Light"
|
||||
;;
|
||||
focus-start)
|
||||
state="$(qs ipc call focus start)"
|
||||
[[ $state == true ]]
|
||||
PANAMA_OSD_STRICT=1 "${osd_command[@]}" message preferences-system-time-symbolic "Focus Session Started"
|
||||
;;
|
||||
focus-end)
|
||||
state="$(qs ipc call focus end)"
|
||||
if [[ $state == true ]]; then
|
||||
PANAMA_OSD_STRICT=1 "${osd_command[@]}" message media-playback-stop-symbolic "Focus Session Ended"
|
||||
else
|
||||
PANAMA_OSD_STRICT=1 "${osd_command[@]}" message dialog-information-symbolic "No Focus Session Running"
|
||||
fi
|
||||
;;
|
||||
|
||||
capture) qs ipc call capture open ;;
|
||||
intelligence) qs ipc call screen-intelligence open ;;
|
||||
screenshot) qs ipc call capture screenNow ;;
|
||||
microphone) PANAMA_OSD_STRICT=1 "${osd_command[@]}" microphone toggle ;;
|
||||
|
||||
gallery) "${gallery_command[@]}" ;;
|
||||
restart-shell)
|
||||
qs kill >/dev/null 2>&1 || true
|
||||
quickshell --daemonize
|
||||
wait_for_shell
|
||||
PANAMA_OSD_STRICT=1 "${osd_command[@]}" message view-refresh-symbolic "Panama Restarted"
|
||||
;;
|
||||
*)
|
||||
printf 'Usage: panama-action {%s}\n' \
|
||||
'control-center|notifications|calendar|clipboard|overview|settings|dnd|caffeine|night-light|focus-start|focus-end|capture|intelligence|screenshot|microphone|gallery|restart-shell' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Lists installed font families, split by what they are usable for.
|
||||
#
|
||||
# Two separate questions get asked of fonts here, and they have different
|
||||
# answers:
|
||||
#
|
||||
# interface proportional families suitable for reading UI text
|
||||
# monospace fixed-pitch families
|
||||
# icons families carrying Nerd Font glyphs
|
||||
#
|
||||
# The icon distinction matters more than it looks. Theme.fontMono is used ONLY
|
||||
# to draw glyphs -- the workspace pills, the status cluster, the search icon --
|
||||
# and picking a monospace family without those glyphs replaces every icon in the
|
||||
# shell with tofu. So they are reported separately rather than lumped in with
|
||||
# monospace, and the picker can say so.
|
||||
#
|
||||
# fc-list reports one line per style, so families are deduplicated here. The
|
||||
# first comma-separated alias is the canonical name Qt will match.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
emit() {
|
||||
# $1: fontconfig pattern, $2: json key
|
||||
fc-list "$1" family 2>/dev/null \
|
||||
| sed 's/,.*//' \
|
||||
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
|
||||
| grep -v '^$' \
|
||||
| sort -u
|
||||
}
|
||||
|
||||
json_array() {
|
||||
local first=true
|
||||
printf '['
|
||||
while IFS= read -r line; do
|
||||
[[ -n "$line" ]] || continue
|
||||
[[ "$first" == true ]] || printf ','
|
||||
first=false
|
||||
printf '%s' "$(printf '%s' "$line" | jq -Rs 'rtrimstr("\n")')"
|
||||
done
|
||||
printf ']'
|
||||
}
|
||||
|
||||
# spacing=100 is fontconfig's mono flag. Proportional is everything else.
|
||||
mono="$(emit ':spacing=100')"
|
||||
all="$(emit ':')"
|
||||
interface="$(comm -23 <(printf '%s\n' "$all") <(printf '%s\n' "$mono"))"
|
||||
icons="$(printf '%s\n' "$all" | grep -i 'nerd font' || true)"
|
||||
|
||||
printf '{"interface":'
|
||||
printf '%s\n' "$interface" | json_array
|
||||
printf ',"monospace":'
|
||||
printf '%s\n' "$mono" | json_array
|
||||
printf ',"icons":'
|
||||
printf '%s\n' "$icons" | json_array
|
||||
printf '}\n'
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Enumerates GPUs that can report utilisation, with a readable name for each.
|
||||
#
|
||||
# Panama's vitals readout needs one specific sysfs file, and the card numbering
|
||||
# is neither stable across machines nor meaningful to a person: this box has
|
||||
# card1 and card2, both amdgpu, one discrete and one integrated. Picking a
|
||||
# number blindly shows whichever the kernel happened to enumerate first.
|
||||
#
|
||||
# Names come from lspci where available, because the sysfs device directory
|
||||
# exposes only numeric vendor/device ids.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
first=true
|
||||
printf '['
|
||||
|
||||
for busy in /sys/class/drm/card*/device/gpu_busy_percent; do
|
||||
[[ -r "$busy" ]] || continue
|
||||
|
||||
device_dir="$(dirname "$busy")"
|
||||
card="$(basename "$(dirname "$device_dir")")"
|
||||
|
||||
# The device directory is a symlink into the PCI tree; its target's basename
|
||||
# is the PCI address lspci wants.
|
||||
pci="$(basename "$(readlink -f "$device_dir")" 2>/dev/null || true)"
|
||||
name=""
|
||||
if [[ -n "$pci" ]] && command -v lspci >/dev/null 2>&1; then
|
||||
# Strip the leading domain: lspci -s wants 00:02.0, sysfs gives 0000:00:02.0
|
||||
short="${pci#*:}"
|
||||
name="$(lspci -s "$short" 2>/dev/null | sed -E 's/^[^ ]+ [^:]+: //' | head -1)"
|
||||
fi
|
||||
if [[ -z "$name" ]]; then
|
||||
driver="$(sed -n 's/^DRIVER=//p' "$device_dir/uevent" 2>/dev/null | head -1)"
|
||||
name="${driver:-Graphics} ($card)"
|
||||
fi
|
||||
|
||||
reading="$(cat "$busy" 2>/dev/null || printf '')"
|
||||
[[ "$reading" =~ ^[0-9]+$ ]] || reading=-1
|
||||
|
||||
[[ "$first" == true ]] || printf ','
|
||||
first=false
|
||||
printf '{"card":"%s","path":"%s","name":%s,"busy":%s}' \
|
||||
"$card" "$busy" "$(printf '%s' "$name" | jq -Rs .)" "$reading"
|
||||
done
|
||||
|
||||
printf ']\n'
|
||||
@@ -192,8 +192,17 @@ def resolve_config(
|
||||
env: Mapping[str, str] | None = None,
|
||||
legacy: Callable[[], Config] = load_legacy_config,
|
||||
) -> Config:
|
||||
private_env = read_panama_env()
|
||||
private_env.update(dict(os.environ if env is None else env))
|
||||
# An explicitly supplied environment is the WHOLE environment. Reading the
|
||||
# user's private env file underneath it makes callers -- tests especially --
|
||||
# depend on whatever happens to be in that file: adding a real
|
||||
# PANAMA_HOME_ASSISTANT_ENTITIES to it silently overrode a fixture that was
|
||||
# asserting the legacy fallback. Production passes env=None and still gets
|
||||
# the file.
|
||||
if env is None:
|
||||
private_env = read_panama_env()
|
||||
private_env.update(dict(os.environ))
|
||||
else:
|
||||
private_env = dict(env)
|
||||
|
||||
url_value = private_env.get("PANAMA_HOME_ASSISTANT_URL", "").strip()
|
||||
token_value = private_env.get("PANAMA_HOME_ASSISTANT_TOKEN", "").strip()
|
||||
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Read and atomically update Panama's private Home Assistant settings.
|
||||
|
||||
Secret values are accepted only as a single JSON object on stdin and are never
|
||||
returned. The command line therefore remains safe to inspect with ps(1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
|
||||
KEY_URL = "PANAMA_HOME_ASSISTANT_URL"
|
||||
KEY_TOKEN = "PANAMA_HOME_ASSISTANT_TOKEN"
|
||||
KEY_ENTITIES = "PANAMA_HOME_ASSISTANT_ENTITIES"
|
||||
TARGET_KEYS = (KEY_URL, KEY_TOKEN, KEY_ENTITIES)
|
||||
ASSIGNMENT = re.compile(
|
||||
r"^(?P<prefix>\s*(?:export\s+)?)(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=(?P<value>.*)$"
|
||||
)
|
||||
ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$")
|
||||
DEFAULT_ENV = pathlib.Path(__file__).resolve().parents[3] / "bash/env"
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""An error code safe to display without including submitted values."""
|
||||
|
||||
|
||||
def env_path() -> pathlib.Path:
|
||||
override = os.environ.get("PANAMA_HOME_ASSISTANT_ENV_FILE", "")
|
||||
return pathlib.Path(override) if override else DEFAULT_ENV
|
||||
|
||||
|
||||
def parse_assignment(raw: str) -> str | None:
|
||||
try:
|
||||
parsed = shlex.split(raw, comments=True, posix=True)
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed[0] if len(parsed) == 1 else None
|
||||
|
||||
|
||||
def read_values(path: pathlib.Path) -> dict[str, str]:
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except OSError as error:
|
||||
raise ConfigError("read-failed") from error
|
||||
|
||||
values: dict[str, str] = {}
|
||||
for line in lines:
|
||||
match = ASSIGNMENT.match(line)
|
||||
if not match or match.group("key") not in TARGET_KEYS:
|
||||
continue
|
||||
value = parse_assignment(match.group("value"))
|
||||
if value is not None:
|
||||
values[match.group("key")] = value
|
||||
return values
|
||||
|
||||
|
||||
def normalize_url(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ConfigError("invalid-url")
|
||||
normalized = value.strip().rstrip("/")
|
||||
if not normalized:
|
||||
return ""
|
||||
parsed = urllib.parse.urlsplit(normalized)
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or not parsed.hostname
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
):
|
||||
raise ConfigError("invalid-url")
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_token(value: Any) -> str:
|
||||
if not isinstance(value, str) or "\x00" in value or "\n" in value or "\r" in value:
|
||||
raise ConfigError("invalid-token")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def normalize_entities(value: Any) -> tuple[str, ...]:
|
||||
if isinstance(value, str):
|
||||
candidates: Sequence[Any] = re.split(r"[,\n]", value)
|
||||
elif isinstance(value, list):
|
||||
candidates = value
|
||||
else:
|
||||
raise ConfigError("invalid-entities")
|
||||
|
||||
entities: list[str] = []
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, str):
|
||||
raise ConfigError("invalid-entities")
|
||||
entity_id = candidate.strip()
|
||||
if not entity_id:
|
||||
continue
|
||||
if not ENTITY_ID.fullmatch(entity_id):
|
||||
raise ConfigError("invalid-entities")
|
||||
if entity_id not in entities:
|
||||
entities.append(entity_id)
|
||||
return tuple(entities)
|
||||
|
||||
|
||||
def public_state(values: Mapping[str, str]) -> dict[str, object]:
|
||||
try:
|
||||
url = normalize_url(values.get(KEY_URL, ""))
|
||||
entities = list(normalize_entities(values.get(KEY_ENTITIES, "")))
|
||||
error = ""
|
||||
except ConfigError as config_error:
|
||||
url = ""
|
||||
entities = []
|
||||
error = str(config_error)
|
||||
token_configured = bool(values.get(KEY_TOKEN, "").strip())
|
||||
return {
|
||||
"ok": error == "",
|
||||
"configured": bool(url and token_configured),
|
||||
"tokenConfigured": token_configured,
|
||||
"url": url,
|
||||
"entities": entities,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def render_updated(original: str, updates: Mapping[str, str]) -> str:
|
||||
lines = original.splitlines(keepends=True)
|
||||
rendered: list[str] = []
|
||||
replaced: set[str] = set()
|
||||
|
||||
for line in lines:
|
||||
content = line.rstrip("\r\n")
|
||||
ending = line[len(content) :]
|
||||
match = ASSIGNMENT.match(content)
|
||||
key = match.group("key") if match else ""
|
||||
if key not in updates:
|
||||
rendered.append(line)
|
||||
continue
|
||||
if key in replaced:
|
||||
continue
|
||||
rendered.append(f"export {key}={shlex.quote(updates[key])}{ending or os.linesep}")
|
||||
replaced.add(key)
|
||||
|
||||
if rendered and not rendered[-1].endswith(("\n", "\r")):
|
||||
rendered[-1] += os.linesep
|
||||
for key in TARGET_KEYS:
|
||||
if key in updates and key not in replaced:
|
||||
rendered.append(f"export {key}={shlex.quote(updates[key])}{os.linesep}")
|
||||
return "".join(rendered)
|
||||
|
||||
|
||||
def atomic_write(path: pathlib.Path, text: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(prefix=".env.", dir=path.parent)
|
||||
temporary = pathlib.Path(temporary_name)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
||||
stream.write(text)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, 0o600)
|
||||
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
finally:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def read_payload() -> dict[str, Any]:
|
||||
line = sys.stdin.buffer.readline(1_048_577)
|
||||
if not line or len(line) > 1_048_576:
|
||||
raise ConfigError("invalid-payload")
|
||||
try:
|
||||
payload = json.loads(line)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise ConfigError("invalid-payload") from error
|
||||
if not isinstance(payload, dict):
|
||||
raise ConfigError("invalid-payload")
|
||||
return payload
|
||||
|
||||
|
||||
def write_payload(path: pathlib.Path, payload: Mapping[str, Any]) -> dict[str, object]:
|
||||
allowed = {"url", "token", "entities"}
|
||||
if not set(payload).issubset(allowed) or not payload:
|
||||
raise ConfigError("invalid-payload")
|
||||
|
||||
updates: dict[str, str] = {}
|
||||
if "url" in payload:
|
||||
updates[KEY_URL] = normalize_url(payload["url"])
|
||||
if "token" in payload:
|
||||
updates[KEY_TOKEN] = normalize_token(payload["token"])
|
||||
if "entities" in payload:
|
||||
updates[KEY_ENTITIES] = ",".join(normalize_entities(payload["entities"]))
|
||||
|
||||
lock_path = path.with_name("." + path.name + ".lock")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
||||
try:
|
||||
os.fchmod(lock_fd, 0o600)
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
try:
|
||||
original = path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
original = ""
|
||||
except OSError as error:
|
||||
raise ConfigError("read-failed") from error
|
||||
atomic_write(path, render_updated(original, updates))
|
||||
finally:
|
||||
os.close(lock_fd)
|
||||
|
||||
result = public_state(read_values(path))
|
||||
result["ok"] = True
|
||||
result["error"] = ""
|
||||
return result
|
||||
|
||||
|
||||
def compact_json(value: Mapping[str, object]) -> str:
|
||||
return json.dumps(value, separators=(",", ":"))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
command = sys.argv[1] if len(sys.argv) > 1 else "status"
|
||||
if len(sys.argv) > 2 or command not in {"status", "write"}:
|
||||
raise ConfigError("usage")
|
||||
path = env_path()
|
||||
result = public_state(read_values(path)) if command == "status" else write_payload(path, read_payload())
|
||||
print(compact_json(result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except ConfigError as error:
|
||||
print(compact_json({"ok": False, "error": str(error)}))
|
||||
raise SystemExit(1) from error
|
||||
except OSError:
|
||||
print(compact_json({"ok": False, "error": "write-failed"}))
|
||||
raise SystemExit(1)
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -u
|
||||
|
||||
strict_delivery() {
|
||||
[[ ${PANAMA_OSD_STRICT:-false} == true || ${PANAMA_OSD_STRICT:-false} == 1 ]]
|
||||
}
|
||||
|
||||
show_progress() {
|
||||
if ! qs ipc call osd progress "$1" "$2" 100 "$3" >/dev/null 2>&1; then
|
||||
strict_delivery && return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
show_message() {
|
||||
if ! qs ipc call osd message "$1" "$2" >/dev/null 2>&1; then
|
||||
strict_delivery && return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
volume_state() {
|
||||
local target="$1" output level percent muted=false
|
||||
output="$(wpctl get-volume "$target" 2>/dev/null)" || return 1
|
||||
if [[ $output =~ Volume:[[:space:]]*([0-9]+([.][0-9]+)?) ]]; then
|
||||
level="${BASH_REMATCH[1]}"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
[[ $output == *"[MUTED]"* ]] && muted=true
|
||||
percent="$(awk -v value="$level" 'BEGIN { printf "%d", value * 100 + 0.5 }')"
|
||||
printf '%s %s\n' "$percent" "$muted"
|
||||
}
|
||||
|
||||
show_volume() {
|
||||
local target="$1" kind="$2" state percent muted label
|
||||
state="$(volume_state "$target")" || return 0
|
||||
read -r percent muted <<<"$state"
|
||||
if [[ $muted == true ]]; then
|
||||
show_progress "${kind}-muted" "$percent" "Muted"
|
||||
else
|
||||
label="${percent}%"
|
||||
show_progress "$kind" "$percent" "$label"
|
||||
fi
|
||||
}
|
||||
|
||||
adjust_volume() {
|
||||
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SINK@"
|
||||
case "$action" in
|
||||
up) wpctl set-volume -l 1 "$target" "${step}%+" || return ;;
|
||||
down) wpctl set-volume "$target" "${step}%-" || return ;;
|
||||
toggle) wpctl set-mute "$target" toggle || return ;;
|
||||
*) printf 'Usage: panama-osd volume up|down|toggle [step]\n' >&2; return 2 ;;
|
||||
esac
|
||||
show_volume "$target" volume
|
||||
}
|
||||
|
||||
adjust_microphone() {
|
||||
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SOURCE@"
|
||||
case "$action" in
|
||||
up) wpctl set-volume -l 1 "$target" "${step}%+" || return ;;
|
||||
down) wpctl set-volume "$target" "${step}%-" || return ;;
|
||||
toggle) wpctl set-mute "$target" toggle || return ;;
|
||||
*) printf 'Usage: panama-osd microphone up|down|toggle [step]\n' >&2; return 2 ;;
|
||||
esac
|
||||
show_volume "$target" microphone
|
||||
}
|
||||
|
||||
adjust_brightness() {
|
||||
local action="${1:-}" step="${2:-5}" output percent
|
||||
case "$action" in
|
||||
up) brightnessctl -e4 -n2 set "${step}%+" >/dev/null || return ;;
|
||||
down) brightnessctl -e4 -n2 set "${step}%-" >/dev/null || return ;;
|
||||
*) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;;
|
||||
esac
|
||||
|
||||
output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0
|
||||
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
|
||||
[[ $percent =~ ^[0-9]+$ ]] || return 0
|
||||
show_progress brightness "$percent" "${percent}%"
|
||||
}
|
||||
|
||||
media_action() {
|
||||
local action="${1:-}" kind label fallback
|
||||
case "$action" in
|
||||
play-pause)
|
||||
playerctl play-pause || return
|
||||
if [[ $(playerctl status 2>/dev/null) == "Playing" ]]; then
|
||||
kind="media-play"
|
||||
fallback="Playing"
|
||||
else
|
||||
kind="media-pause"
|
||||
fallback="Paused"
|
||||
fi
|
||||
;;
|
||||
next) playerctl next || return; kind="media-next"; fallback="Next track" ;;
|
||||
previous) playerctl previous || return; kind="media-previous"; fallback="Previous track" ;;
|
||||
stop) playerctl stop || return; kind="media-stop"; fallback="Stopped" ;;
|
||||
*) printf 'Usage: panama-osd media play-pause|next|previous|stop\n' >&2; return 2 ;;
|
||||
esac
|
||||
|
||||
label="$(playerctl metadata --format '{{ title }} — {{ artist }}' 2>/dev/null)"
|
||||
[[ -n $label ]] || label="$fallback"
|
||||
show_message "$kind" "$label"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
volume) shift; adjust_volume "$@" ;;
|
||||
microphone) shift; adjust_microphone "$@" ;;
|
||||
brightness) shift; adjust_brightness "$@" ;;
|
||||
media) shift; media_action "$@" ;;
|
||||
message)
|
||||
shift
|
||||
kind="${1:-}"
|
||||
[[ -n $kind && $# -ge 2 ]] || {
|
||||
printf 'Usage: panama-osd message ICON LABEL\n' >&2
|
||||
exit 2
|
||||
}
|
||||
shift
|
||||
show_message "$kind" "$*"
|
||||
;;
|
||||
*)
|
||||
printf 'Usage: panama-osd volume|microphone|brightness|media|message ACTION [value]\n' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
gallery="${XDG_CONFIG_HOME:-$HOME/.config}/quickshell/prism-gallery.qml"
|
||||
|
||||
if qs -p "$gallery" ipc show >/dev/null 2>&1; then
|
||||
qs -p "$gallery" kill
|
||||
else
|
||||
qs -p "$gallery" --daemonize
|
||||
fi
|
||||
@@ -572,6 +572,28 @@ def command_restore(arguments: list[str]) -> None:
|
||||
home_present = False
|
||||
home_data = None
|
||||
|
||||
# Display geometry is never restored from a snapshot. Applying it requires
|
||||
# the visible confirmation/recovery flow in Displays.qml; a settings-file
|
||||
# restore followed by `hyprctl reload` must not bypass that safety boundary.
|
||||
# Preserve the currently confirmed generation when it is readable, and
|
||||
# otherwise remove the snapshot's geometry so startup uses shipped policy.
|
||||
try:
|
||||
current_desktop = read_json(SETTINGS, "The current settings file") \
|
||||
if is_present(SETTINGS) else {}
|
||||
except BackupError:
|
||||
current_desktop = {}
|
||||
|
||||
if isinstance(current_desktop, dict) and "displays" in current_desktop:
|
||||
# Even a Home-only snapshot must retain the confirmed monitor layout.
|
||||
# In that case the restored desktop file contains only the protected
|
||||
# geometry; every ordinary desktop preference remains absent/default.
|
||||
desktop_present = True
|
||||
desktop_data = dict(desktop_data) if desktop_data is not None else {}
|
||||
desktop_data["displays"] = current_desktop["displays"]
|
||||
elif desktop_present and desktop_data is not None:
|
||||
desktop_data = dict(desktop_data)
|
||||
desktop_data.pop("displays", None)
|
||||
|
||||
# Restoring remains undoable, but a corrupt current file must not prevent a
|
||||
# known-good snapshot from recovering the desktop.
|
||||
save_snapshot(require_any=False, validate=False)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
pragma Singleton
|
||||
|
||||
// Shared PipeWire device discovery and default selection. Quick Settings and
|
||||
// Panama Settings intentionally use this same boundary so they cannot disagree
|
||||
// about what counts as an input or which node should become the default.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var outputs: Pipewire.nodes.values.filter(node =>
|
||||
!node.isStream && node.isSink)
|
||||
|
||||
readonly property var inputs: Pipewire.nodes.values.filter(node =>
|
||||
!node.isStream
|
||||
&& (node.type & PwNodeType.AudioSource) === PwNodeType.AudioSource)
|
||||
|
||||
function nodes(output: bool): var {
|
||||
return output ? root.outputs : root.inputs;
|
||||
}
|
||||
|
||||
function current(output: bool): var {
|
||||
return output ? Pipewire.defaultAudioSink : Pipewire.defaultAudioSource;
|
||||
}
|
||||
|
||||
function select(output: bool, node: var): void {
|
||||
if (!node)
|
||||
return;
|
||||
if (output)
|
||||
Pipewire.preferredDefaultAudioSink = node;
|
||||
else
|
||||
Pipewire.preferredDefaultAudioSource = node;
|
||||
}
|
||||
|
||||
function label(node: var): string {
|
||||
if (!node)
|
||||
return "Unknown device";
|
||||
return node.description || node.nickname || node.name || "Unknown device";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
pragma Singleton
|
||||
|
||||
// Keeps everything that draws a window agreeing about light or dark.
|
||||
//
|
||||
// The shell repaints itself from Theme.qml the moment the preference changes,
|
||||
// because every token there is a binding. Two other things draw on this desktop
|
||||
// and do not read Panama's store:
|
||||
//
|
||||
// GTK applications read gsettings colour-scheme and gtk-theme
|
||||
// the compositor draws window borders and shadows
|
||||
//
|
||||
// A toolbar or a window border still wearing the other scheme is more jarring
|
||||
// than either scheme on its own, which is why this exists rather than the
|
||||
// setting simply being a Theme property.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
|
||||
property string lastError: ""
|
||||
|
||||
// Applied one command at a time: Process runs a single command, and several
|
||||
// of these are separate programs.
|
||||
property var pending: []
|
||||
|
||||
Process {
|
||||
id: runner
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0)
|
||||
root.lastError = "The colour scheme could not be applied everywhere.";
|
||||
root.drain();
|
||||
}
|
||||
}
|
||||
|
||||
function drain(): void {
|
||||
if (runner.running || root.pending.length === 0)
|
||||
return;
|
||||
const next = root.pending[0];
|
||||
root.pending = root.pending.slice(1);
|
||||
runner.exec(next);
|
||||
}
|
||||
|
||||
function enqueue(commands: var): void {
|
||||
root.pending = root.pending.concat(commands);
|
||||
root.drain();
|
||||
}
|
||||
|
||||
// Pushed once at startup as well as on change: gsettings and the compositor
|
||||
// do not read Panama's store, so a scheme chosen in a previous session is
|
||||
// only in effect for the shell until this runs.
|
||||
Component.onCompleted: settle.restart()
|
||||
|
||||
Timer {
|
||||
id: settle
|
||||
interval: 1200
|
||||
onTriggered: root.apply()
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: DesktopPreferences
|
||||
function onRevisionChanged(): void { coalesce.restart(); }
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: coalesce
|
||||
interval: 250
|
||||
onTriggered: root.apply()
|
||||
}
|
||||
|
||||
function apply(): void {
|
||||
root.lastError = "";
|
||||
|
||||
const scheme = root.dark ? "prefer-dark" : "prefer-light";
|
||||
// Adwaita's light and dark are the same theme; only the preference and
|
||||
// the -dark suffix differ, so applications that honour either agree.
|
||||
const gtkTheme = root.dark ? "Adwaita-dark" : "Adwaita";
|
||||
|
||||
const commands = [
|
||||
["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme],
|
||||
["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
|
||||
];
|
||||
|
||||
// Unfocused window borders. The focused border is the prism gradient and
|
||||
// is already scheme-independent; the inactive one is a flat neutral that
|
||||
// would be invisible against the opposite background.
|
||||
const inactive = root.dark ? "rgba(3b426199)" : "rgba(a8aecb99)";
|
||||
commands.push(["hyprctl", "eval",
|
||||
`hl.config({ general = { col = { inactive_border = "${inactive}" } } })`]);
|
||||
|
||||
root.enqueue(commands);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
pragma Singleton
|
||||
|
||||
// Network and Bluetooth state for the settings page.
|
||||
//
|
||||
// The hard parts -- scanning, joining, pairing -- already work in the quick
|
||||
// settings panel through Quickshell.Networking and Quickshell.Bluetooth, which
|
||||
// speak to NetworkManager and BlueZ over DBus. Nothing here shells out to nmcli
|
||||
// or bluetoothctl, and nothing should: the founding requirement for this
|
||||
// desktop was never having to drop to a terminal to join a network.
|
||||
//
|
||||
// This exists so the page does not have to reach into those modules for the
|
||||
// same derived values the panel already computes, and so scanning is driven by
|
||||
// whether the page is actually on screen. Scanning while nobody is looking is
|
||||
// radio time and battery spent on a list that is not being read.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Networking
|
||||
import Quickshell.Bluetooth
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Set by the page while it is visible; drives both scanners.
|
||||
property bool active: false
|
||||
|
||||
readonly property var wifiDevice: {
|
||||
for (const device of Networking.devices.values) {
|
||||
if (device.type === DeviceType.Wifi)
|
||||
return device;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
readonly property var wiredDevice: {
|
||||
for (const device of Networking.devices.values) {
|
||||
if (device.type === DeviceType.Wired)
|
||||
return device;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
readonly property var adapter: Bluetooth.defaultAdapter
|
||||
|
||||
readonly property bool wifiEnabled: Networking.wifiEnabled
|
||||
readonly property bool wifiAvailable: Networking.wifiHardwareEnabled
|
||||
|
||||
// Current network, then saved, then by signal -- the order GNOME uses,
|
||||
// which is the order you actually look for things in.
|
||||
readonly property var networks: {
|
||||
if (!root.wifiDevice || !root.wifiDevice.networks)
|
||||
return [];
|
||||
const list = root.wifiDevice.networks.values.slice();
|
||||
list.sort((a, b) => {
|
||||
if (a.connected !== b.connected)
|
||||
return a.connected ? -1 : 1;
|
||||
if (a.known !== b.known)
|
||||
return a.known ? -1 : 1;
|
||||
return b.signalStrength - a.signalStrength;
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
readonly property var savedNetworks: root.networks.filter(network => network.known)
|
||||
|
||||
readonly property var bluetoothDevices: {
|
||||
if (!Bluetooth.devices)
|
||||
return [];
|
||||
const list = Bluetooth.devices.values.slice();
|
||||
list.sort((a, b) => {
|
||||
if (a.connected !== b.connected)
|
||||
return a.connected ? -1 : 1;
|
||||
if (a.paired !== b.paired)
|
||||
return a.paired ? -1 : 1;
|
||||
return String(a.name || "").localeCompare(String(b.name || ""));
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
readonly property var activeNetwork: root.networks.find(network => network.connected) ?? null
|
||||
|
||||
function isSecured(network: var): bool {
|
||||
return network.security !== WifiSecurityType.Open
|
||||
&& network.security !== WifiSecurityType.Owe
|
||||
&& network.security !== WifiSecurityType.Unknown;
|
||||
}
|
||||
|
||||
function securityLabel(network: var): string {
|
||||
if (!root.isSecured(network))
|
||||
return "Open";
|
||||
switch (network.security) {
|
||||
case WifiSecurityType.Wep: return "WEP";
|
||||
case WifiSecurityType.Wpa: return "WPA";
|
||||
case WifiSecurityType.Wpa2: return "WPA2";
|
||||
case WifiSecurityType.Wpa3: return "WPA3";
|
||||
case WifiSecurityType.Enterprise: return "Enterprise";
|
||||
}
|
||||
return "Secured";
|
||||
}
|
||||
|
||||
// Four bars is what people read signal as, so bucket rather than showing a
|
||||
// percentage that changes every scan and means nothing to anyone.
|
||||
//
|
||||
// signalStrength is 0.0-1.0, NOT a percentage. Treating it as 0-100 puts
|
||||
// every network including the connected one in the bottom bucket, which is
|
||||
// exactly as useless as showing nothing. Thresholds match the icon buckets
|
||||
// in modules/quicksettings/WifiList.qml so the two never disagree.
|
||||
function signalLabel(strength: real): string {
|
||||
if (strength >= 0.8) return "Excellent";
|
||||
if (strength >= 0.55) return "Good";
|
||||
if (strength >= 0.3) return "Fair";
|
||||
if (strength > 0.05) return "Weak";
|
||||
return "No signal";
|
||||
}
|
||||
|
||||
function connectionFailureText(reason: var): string {
|
||||
switch (reason) {
|
||||
case ConnectionFailReason.WifiAuthTimeout:
|
||||
case ConnectionFailReason.Authentication:
|
||||
return "Wrong password";
|
||||
case ConnectionFailReason.WifiNetworkLost:
|
||||
return "Network out of range";
|
||||
}
|
||||
return "Could not connect";
|
||||
}
|
||||
|
||||
// Scanning follows visibility. NetworkManager keeps scanning as long as it
|
||||
// is asked to, and Bluetooth discovery is worse -- it holds the radio.
|
||||
function syncScanners(): void {
|
||||
if (root.wifiDevice)
|
||||
root.wifiDevice.scannerEnabled = root.active && root.wifiEnabled;
|
||||
|
||||
if (root.adapter && root.adapter.enabled) {
|
||||
const shouldDiscover = root.active;
|
||||
if (root.adapter.discovering !== shouldDiscover)
|
||||
root.adapter.discovering = shouldDiscover;
|
||||
}
|
||||
}
|
||||
|
||||
onActiveChanged: root.syncScanners()
|
||||
onWifiDeviceChanged: root.syncScanners()
|
||||
onWifiEnabledChanged: root.syncScanners()
|
||||
onAdapterChanged: root.syncScanners()
|
||||
}
|
||||
@@ -41,6 +41,7 @@ Singleton {
|
||||
property bool revertVerificationActive: false
|
||||
property int operationGeneration: 0
|
||||
property int revertGeneration: -1
|
||||
property bool externalChangeBlocked: false
|
||||
property int secondsLeft: 0
|
||||
|
||||
readonly property bool awaitingConfirmation: root.pendingOutput !== ""
|
||||
@@ -271,6 +272,10 @@ Singleton {
|
||||
// Applies immediately and starts the countdown. Nothing is stored yet: the
|
||||
// settings file is only written by confirm().
|
||||
function apply(output: string, mode: string, scale: real, transform: int): bool {
|
||||
if (root.externalChangeBlocked) {
|
||||
root.lastError = "Wait for Settings to finish restoring before changing a display.";
|
||||
return false;
|
||||
}
|
||||
if (root.busy) {
|
||||
root.lastError = "Wait for the current display operation to finish.";
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
pragma Singleton
|
||||
|
||||
// Installed fonts, split by what they can actually be used for.
|
||||
//
|
||||
// Two different questions with different answers: which families are readable
|
||||
// as interface text, and which carry the Nerd Font glyphs the shell draws its
|
||||
// icons with. Offering one list for both is how you end up with a desktop full
|
||||
// of tofu, so they stay separate.
|
||||
//
|
||||
// Enumerated once on demand. Fonts do not appear while you are looking at a
|
||||
// settings page, and fc-list over a few hundred families is not free.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-fonts"
|
||||
|
||||
property var interfaceFonts: []
|
||||
property var monospaceFonts: []
|
||||
property var iconFonts: []
|
||||
property bool scanning: false
|
||||
property string lastError: ""
|
||||
|
||||
readonly property string interfaceFont: DesktopPreferences.get("interfaceFont")
|
||||
readonly property string iconFont: DesktopPreferences.get("iconFont")
|
||||
|
||||
readonly property bool loaded: root.interfaceFonts.length > 0
|
||||
|
||||
// True when the stored family is not installed. fontconfig silently
|
||||
// substitutes something else, so the shell keeps working and quietly stops
|
||||
// looking the way it was configured to -- worth saying out loud.
|
||||
readonly property bool interfaceMissing: root.loaded
|
||||
&& root.interfaceFonts.indexOf(root.interfaceFont) < 0
|
||||
readonly property bool iconMissing: root.iconFonts.length > 0
|
||||
&& root.iconFonts.indexOf(root.iconFont) < 0
|
||||
|
||||
Process {
|
||||
id: scan
|
||||
command: [root.helperPath]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const parsed = JSON.parse(this.text);
|
||||
root.interfaceFonts = parsed.interface ?? [];
|
||||
root.monospaceFonts = parsed.monospace ?? [];
|
||||
root.iconFonts = parsed.icons ?? [];
|
||||
root.lastError = "";
|
||||
} catch (error) {
|
||||
root.lastError = "The installed fonts could not be read.";
|
||||
}
|
||||
root.scanning = false;
|
||||
}
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.scanning = false;
|
||||
if (exitCode !== 0)
|
||||
root.lastError = "The installed fonts could not be read.";
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
|
||||
function refresh(): void {
|
||||
if (scan.running)
|
||||
return;
|
||||
root.scanning = true;
|
||||
scan.running = true;
|
||||
}
|
||||
|
||||
// Only a family this machine reported is accepted, so a hand-edited
|
||||
// settings file cannot put arbitrary text where a font name belongs -- it
|
||||
// reaches hl.config as a string on the compositor side.
|
||||
function setInterface(family: string): bool {
|
||||
if (root.interfaceFonts.indexOf(family) < 0) {
|
||||
root.lastError = "That font is not installed.";
|
||||
return false;
|
||||
}
|
||||
return root.store("interfaceFont", family);
|
||||
}
|
||||
|
||||
function setIcon(family: string): bool {
|
||||
if (root.iconFonts.indexOf(family) < 0) {
|
||||
root.lastError = "That font does not carry icon glyphs.";
|
||||
return false;
|
||||
}
|
||||
return root.store("iconFont", family);
|
||||
}
|
||||
|
||||
function store(key: string, family: string): bool {
|
||||
if (!DesktopPreferences.set(key, family)) {
|
||||
root.lastError = "That font name could not be saved.";
|
||||
return false;
|
||||
}
|
||||
root.lastError = "";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
pragma Singleton
|
||||
|
||||
// Turning a place name into coordinates.
|
||||
//
|
||||
// The weather card needs latitude and longitude, but nobody knows their own
|
||||
// coordinates, and a settings page that demands them is a settings page nobody
|
||||
// changes. Open-Meteo publishes a geocoding endpoint that needs no API key and
|
||||
// no account, which is the same reason the forecast itself uses them.
|
||||
//
|
||||
// Fetched with curl rather than XMLHttpRequest for the same reason as
|
||||
// services/Weather.qml: curl is guaranteed present, and a search that fails
|
||||
// must leave the page usable rather than producing an error popup.
|
||||
//
|
||||
// Only the query is sent. The stored location label never leaves the machine.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// [{ name, admin, country, latitude, longitude, label }]
|
||||
property var results: []
|
||||
property bool searching: false
|
||||
property string lastError: ""
|
||||
property string lastQuery: ""
|
||||
|
||||
readonly property string endpoint: "https://geocoding-api.open-meteo.com/v1/search"
|
||||
|
||||
Process {
|
||||
id: fetch
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.parse(this.text)
|
||||
}
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.searching = false;
|
||||
if (exitCode !== 0)
|
||||
root.lastError = "Could not reach the location service.";
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced: typing "Denver" should not fire six searches.
|
||||
Timer {
|
||||
id: debounce
|
||||
interval: 350
|
||||
onTriggered: root.run()
|
||||
}
|
||||
|
||||
property string pending: ""
|
||||
|
||||
function search(query: string): void {
|
||||
const trimmed = String(query).trim();
|
||||
root.pending = trimmed;
|
||||
if (trimmed.length < 2) {
|
||||
root.results = [];
|
||||
root.lastError = "";
|
||||
debounce.stop();
|
||||
return;
|
||||
}
|
||||
debounce.restart();
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
if (fetch.running || root.pending.length < 2)
|
||||
return;
|
||||
root.searching = true;
|
||||
root.lastError = "";
|
||||
root.lastQuery = root.pending;
|
||||
|
||||
// --get with --data-urlencode makes curl do the escaping, so a place
|
||||
// name with spaces or an ampersand cannot alter the request.
|
||||
fetch.exec(["curl", "-s", "--max-time", "10", "--get",
|
||||
"--data-urlencode", `name=${root.pending}`,
|
||||
"--data-urlencode", "count=8",
|
||||
"--data-urlencode", "format=json",
|
||||
root.endpoint]);
|
||||
}
|
||||
|
||||
function parse(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
const out = [];
|
||||
for (const item of (parsed.results ?? [])) {
|
||||
if (typeof item.latitude !== "number" || typeof item.longitude !== "number")
|
||||
continue;
|
||||
const admin = item.admin1 ?? "";
|
||||
const country = item.country ?? "";
|
||||
out.push({
|
||||
name: item.name ?? "",
|
||||
admin: admin,
|
||||
country: country,
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
// What the user will see stored as their location label.
|
||||
label: [item.name, admin, country].filter(part => !!part).join(", ")
|
||||
});
|
||||
}
|
||||
root.results = out;
|
||||
root.lastError = out.length === 0 ? "No places match that name." : "";
|
||||
} catch (error) {
|
||||
root.results = [];
|
||||
root.lastError = "The location service returned something unreadable.";
|
||||
}
|
||||
}
|
||||
|
||||
// Stores a chosen place. Coordinates are rounded to four decimals -- roughly
|
||||
// ten metres, far finer than a weather reading resolves, and it keeps a
|
||||
// precise home location out of the settings file.
|
||||
function choose(place: var): bool {
|
||||
const latitude = Math.round(place.latitude * 10000) / 10000;
|
||||
const longitude = Math.round(place.longitude * 10000) / 10000;
|
||||
const label = String(place.label).slice(0, 64);
|
||||
|
||||
const ok = DesktopPreferences.set("weatherLatitude", latitude)
|
||||
&& DesktopPreferences.set("weatherLongitude", longitude)
|
||||
&& DesktopPreferences.set("weatherLocation", label);
|
||||
if (!ok) {
|
||||
root.lastError = "That location could not be saved.";
|
||||
return false;
|
||||
}
|
||||
root.results = [];
|
||||
root.lastError = "";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
pragma Singleton
|
||||
|
||||
// The GPUs that can report utilisation.
|
||||
//
|
||||
// The vitals readout needs one specific sysfs file, and card numbering is
|
||||
// neither stable across machines nor meaningful to a person -- this machine has
|
||||
// two amdgpu cards, one discrete and one integrated, and picking a number
|
||||
// blindly measures whichever the kernel enumerated first.
|
||||
//
|
||||
// Enumerated on demand rather than polled: hardware does not appear while you
|
||||
// are looking at a settings page.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-gpus"
|
||||
|
||||
// [{ card, path, name, busy }]
|
||||
property var devices: []
|
||||
property bool scanning: false
|
||||
property string lastError: ""
|
||||
|
||||
readonly property string selectedPath: DesktopPreferences.get("gpuBusyPath")
|
||||
|
||||
readonly property var selected: root.devices.find(device => device.path === root.selectedPath) ?? null
|
||||
|
||||
// True when a GPU is stored that this machine does not have -- after moving
|
||||
// the settings file between machines, say.
|
||||
readonly property bool selectionMissing: root.devices.length > 0 && root.selected === null
|
||||
|
||||
Process {
|
||||
id: scan
|
||||
command: [root.helperPath]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const parsed = JSON.parse(this.text);
|
||||
root.devices = Array.isArray(parsed) ? parsed : [];
|
||||
root.lastError = "";
|
||||
} catch (error) {
|
||||
root.devices = [];
|
||||
root.lastError = "The graphics devices could not be read.";
|
||||
}
|
||||
root.scanning = false;
|
||||
}
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.scanning = false;
|
||||
if (exitCode !== 0)
|
||||
root.lastError = "The graphics devices could not be read.";
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
|
||||
function refresh(): void {
|
||||
if (scan.running)
|
||||
return;
|
||||
root.scanning = true;
|
||||
scan.running = true;
|
||||
}
|
||||
|
||||
// Only a path this machine actually reported is accepted, so a hand-edited
|
||||
// settings file cannot point the readout at an arbitrary file.
|
||||
function select(path: string): bool {
|
||||
if (!root.devices.some(device => device.path === path)) {
|
||||
root.lastError = "That graphics device is not present.";
|
||||
return false;
|
||||
}
|
||||
if (!DesktopPreferences.set("gpuBusyPath", path)) {
|
||||
root.lastError = "That graphics device could not be saved.";
|
||||
return false;
|
||||
}
|
||||
root.lastError = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
// "AMD ... [Radeon RX 7700 XT / 7800 XT] (rev c8)" is what lspci gives; the
|
||||
// bracketed marketing name is the part anyone recognises.
|
||||
function shortName(name: string): string {
|
||||
const bracketed = String(name).match(/\[([^\]]+)\]\s*(?:\(rev[^)]*\))?\s*$/);
|
||||
return bracketed ? bracketed[1] : String(name).replace(/\s*\(rev[^)]*\)\s*$/, "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
pragma Singleton
|
||||
|
||||
// Redacted Home Assistant configuration state. The helper is the only object
|
||||
// that touches the private env file; QML never receives the stored token.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-home-assistant-config"
|
||||
|
||||
property string url: ""
|
||||
property var entities: []
|
||||
property bool tokenConfigured: false
|
||||
property bool configured: false
|
||||
property string lastError: ""
|
||||
property string pendingPayload: ""
|
||||
property var refreshHomeAssistant: function() { HomeAssistant.refresh(); }
|
||||
readonly property bool busy: statusProc.running || writeProc.running
|
||||
|
||||
signal configurationSaved
|
||||
|
||||
function errorMessage(code: string): string {
|
||||
switch (code) {
|
||||
case "invalid-url": return "Enter an HTTP or HTTPS Home Assistant URL.";
|
||||
case "invalid-token": return "The access token contains unsupported characters.";
|
||||
case "invalid-entities": return "Entity IDs must look like light.living_room.";
|
||||
case "read-failed": return "The private configuration file could not be read.";
|
||||
case "write-failed": return "The private configuration file could not be saved.";
|
||||
case "invalid-payload": return "The configuration could not be validated.";
|
||||
default: return code ? "Home Assistant configuration is unavailable." : "";
|
||||
}
|
||||
}
|
||||
|
||||
function applyResult(text: string, saved: bool): void {
|
||||
let result = null;
|
||||
try {
|
||||
result = JSON.parse(text);
|
||||
} catch (error) {
|
||||
result = { ok: false, error: "invalid-response" };
|
||||
}
|
||||
if (result.ok !== true) {
|
||||
root.lastError = root.errorMessage(String(result.error || "invalid-response"));
|
||||
return;
|
||||
}
|
||||
root.url = String(result.url || "");
|
||||
root.entities = Array.isArray(result.entities) ? result.entities : [];
|
||||
root.tokenConfigured = result.tokenConfigured === true;
|
||||
root.configured = result.configured === true;
|
||||
root.lastError = "";
|
||||
if (saved) {
|
||||
root.configurationSaved();
|
||||
root.refreshHomeAssistant();
|
||||
}
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (!statusProc.running && !writeProc.running)
|
||||
statusProc.running = true;
|
||||
}
|
||||
|
||||
// An empty token means "keep the stored token". Clearing is an explicit
|
||||
// separate action so editing the URL can never erase a secret by accident.
|
||||
function save(url: string, entitiesText: string, token: string): bool {
|
||||
if (root.busy)
|
||||
return false;
|
||||
const payload = { url: url, entities: entitiesText };
|
||||
if (token.trim() !== "")
|
||||
payload.token = token;
|
||||
return root.startWrite(payload);
|
||||
}
|
||||
|
||||
function clearToken(): bool {
|
||||
if (root.busy || !root.tokenConfigured)
|
||||
return false;
|
||||
return root.startWrite({ token: "" });
|
||||
}
|
||||
|
||||
function startWrite(payload: var): bool {
|
||||
root.lastError = "";
|
||||
root.pendingPayload = JSON.stringify(payload);
|
||||
writeProc.running = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: statusProc
|
||||
command: [root.helperPath, "status"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.applyResult(this.text, false)
|
||||
}
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0 && root.lastError === "")
|
||||
root.lastError = "Home Assistant configuration could not be loaded.";
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: writeProc
|
||||
command: [root.helperPath, "write"]
|
||||
stdinEnabled: true
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.applyResult(this.text, true)
|
||||
}
|
||||
onStarted: {
|
||||
writeProc.write(root.pendingPayload + "\n");
|
||||
root.pendingPayload = "";
|
||||
}
|
||||
onExited: (code, status) => {
|
||||
root.pendingPayload = "";
|
||||
if (code !== 0 && root.lastError === "")
|
||||
root.lastError = "Home Assistant configuration could not be saved.";
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
}
|
||||
@@ -26,6 +26,7 @@ Singleton {
|
||||
id: root
|
||||
|
||||
property bool initialized: false
|
||||
property bool suppressStatusEvents: false
|
||||
|
||||
// The manual switch. Ignored while `automatic` is on.
|
||||
property bool enabled: Settings.nightLightEnabledByDefault
|
||||
@@ -53,6 +54,22 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
// Launcher actions already provide immediate Prism OSD feedback. Keep the
|
||||
// ambient Signal Glass channel quiet for that path so one choice produces
|
||||
// one confirmation instead of two competing transients.
|
||||
function toggleQuietly(): void {
|
||||
root.suppressStatusEvents = true;
|
||||
root.toggle();
|
||||
root.suppressStatusEvents = false;
|
||||
}
|
||||
|
||||
function restore(manualEnabled: bool, automaticEnabled: bool): void {
|
||||
root.suppressStatusEvents = true;
|
||||
root.enabled = manualEnabled;
|
||||
root.automatic = automaticEnabled;
|
||||
root.suppressStatusEvents = false;
|
||||
}
|
||||
|
||||
// The window wraps midnight (17:00 → 10:00), so the comparison flips when
|
||||
// `from` is later in the day than `to`.
|
||||
function inWindow(hour: real): bool {
|
||||
@@ -89,8 +106,36 @@ Singleton {
|
||||
onEnabledChanged: DesktopPreferences.set("nightLightEnabled", root.enabled)
|
||||
onAutomaticChanged: DesktopPreferences.set("nightLightAutomatic", root.automatic)
|
||||
|
||||
// `enabled`, `temperature`, and `automatic` are declared as bindings on the
|
||||
// store, but a binding is destroyed the moment anything assigns to the
|
||||
// property -- which toggle() does. Without this, the service would write to
|
||||
// the store and never read from it again: Quick Settings would keep working
|
||||
// while the same settings on the Displays page silently did nothing, which
|
||||
// is worse than not offering them there at all.
|
||||
//
|
||||
// No loop: set() is a no-op when the value is unchanged, and assigning a
|
||||
// property its current value emits nothing, so this converges immediately.
|
||||
Connections {
|
||||
target: DesktopPreferences
|
||||
function onRevisionChanged(): void { root.syncFromStore(); }
|
||||
}
|
||||
|
||||
function syncFromStore(): void {
|
||||
const storedEnabled = DesktopPreferences.get("nightLightEnabled") === true;
|
||||
if (root.enabled !== storedEnabled)
|
||||
root.enabled = storedEnabled;
|
||||
|
||||
const storedAutomatic = DesktopPreferences.get("nightLightAutomatic") === true;
|
||||
if (root.automatic !== storedAutomatic)
|
||||
root.automatic = storedAutomatic;
|
||||
|
||||
const storedTemperature = DesktopPreferences.get("nightLightTemperature");
|
||||
if (typeof storedTemperature === "number" && root.temperature !== storedTemperature)
|
||||
root.temperature = storedTemperature;
|
||||
}
|
||||
|
||||
onActiveChanged: {
|
||||
if (!root.initialized)
|
||||
if (!root.initialized || root.suppressStatusEvents)
|
||||
return;
|
||||
StatusEvents.publish({
|
||||
key: "display-night-light",
|
||||
|
||||
@@ -37,6 +37,39 @@ Singleton {
|
||||
// Cleared when the notification centre is opened. The bar binds to this.
|
||||
property int unreadCount: 0
|
||||
|
||||
// Kept separate from the persisted map so this version can safely run
|
||||
// before the matching schema entry lands. A later accepted write folds the
|
||||
// complete map into DesktopPreferences and clears this fallback.
|
||||
property var fallbackAppRules: ({})
|
||||
|
||||
// Display metadata is intentionally session-only. The durable shape stays
|
||||
// just the per-application rule map, while a fresh notification gives the
|
||||
// settings page a human-readable name straight away.
|
||||
property var rememberedApplications: ({})
|
||||
|
||||
readonly property var persistedAppRules: {
|
||||
const stored = DesktopPreferences.get("notificationAppRules");
|
||||
return stored && typeof stored === "object" && !Array.isArray(stored) ? stored : {};
|
||||
}
|
||||
|
||||
// The schema change is the persistence boundary. This branch keeps a
|
||||
// session fallback only so it remains usable while that companion change
|
||||
// is being integrated; it intentionally makes no restart guarantee then.
|
||||
readonly property bool appRulesSchemaAvailable: PreferenceSchema.has("notificationAppRules")
|
||||
|
||||
readonly property var appRules: Object.assign({}, root.persistedAppRules, root.fallbackAppRules)
|
||||
|
||||
readonly property var applications: {
|
||||
// byId()/heuristicLookup() do not make a binding by themselves. This
|
||||
// read updates persisted app labels once DesktopEntries finishes scan.
|
||||
const entries = DesktopEntries.applications.values;
|
||||
const remembered = root.rememberedApplications;
|
||||
return Object.keys(root.appRules).map(appId => ({
|
||||
id: appId,
|
||||
name: root.applicationLabel(appId, entries, remembered)
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// Arrival times, keyed by notification id — the protocol carries no
|
||||
// timestamp. Deliberately formatted once at arrival rather than shown as
|
||||
// "5 minutes ago", which would need a clock ticking behind every card.
|
||||
@@ -49,6 +82,79 @@ Singleton {
|
||||
|
||||
readonly property bool hasNotifications: root.history.length > 0
|
||||
|
||||
function notificationAppId(notification: var): string {
|
||||
const desktopEntry = String(notification.desktopEntry ?? "").trim();
|
||||
return desktopEntry || String(notification.appName ?? "").trim() || "Notifications";
|
||||
}
|
||||
|
||||
function applicationLabel(appId: string, entries: var, remembered: var): string {
|
||||
const desktopId = appId.endsWith(".desktop") ? appId.slice(0, -8) : appId;
|
||||
const entry = DesktopEntries.byId(appId)
|
||||
|| DesktopEntries.byId(desktopId)
|
||||
|| DesktopEntries.heuristicLookup(appId)
|
||||
|| DesktopEntries.heuristicLookup(desktopId);
|
||||
return entry?.name || remembered[appId]?.name || appId;
|
||||
}
|
||||
|
||||
function normalizedAppRule(rule: var): var {
|
||||
const source = rule && typeof rule === "object" && !Array.isArray(rule) ? rule : {};
|
||||
return {
|
||||
enabled: source.enabled !== false,
|
||||
showOnLockScreen: source.showOnLockScreen !== false,
|
||||
showContentOnLockScreen: source.showContentOnLockScreen !== false
|
||||
};
|
||||
}
|
||||
|
||||
function appRule(appId: string): var {
|
||||
return root.normalizedAppRule(root.appRules[appId]);
|
||||
}
|
||||
|
||||
function setAppRule(appId: string, patch: var): bool {
|
||||
if (!appId)
|
||||
return false;
|
||||
|
||||
const current = root.appRule(appId);
|
||||
const next = {};
|
||||
for (const knownAppId of Object.keys(root.appRules))
|
||||
next[knownAppId] = root.appRule(knownAppId);
|
||||
next[appId] = {
|
||||
enabled: patch.enabled === undefined ? current.enabled : patch.enabled === true,
|
||||
showOnLockScreen: patch.showOnLockScreen === undefined ? current.showOnLockScreen : patch.showOnLockScreen === true,
|
||||
showContentOnLockScreen: patch.showContentOnLockScreen === undefined ? current.showContentOnLockScreen : patch.showContentOnLockScreen === true
|
||||
};
|
||||
|
||||
if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next))
|
||||
root.fallbackAppRules = {};
|
||||
else
|
||||
root.fallbackAppRules = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
function rememberApplication(notification: var): string {
|
||||
const appId = root.notificationAppId(notification);
|
||||
const next = Object.assign({}, root.rememberedApplications);
|
||||
next[appId] = {
|
||||
name: String(notification.appName ?? "").trim() || appId
|
||||
};
|
||||
root.rememberedApplications = next;
|
||||
|
||||
if (root.appRules[appId] === undefined)
|
||||
root.setAppRule(appId, {});
|
||||
return appId;
|
||||
}
|
||||
|
||||
// These policy getters deliberately accept Notification objects, so a lock
|
||||
// screen can use the same source of truth without duplicating app matching.
|
||||
function shouldShowOnLockScreen(notification: var): bool {
|
||||
const rule = root.appRule(root.notificationAppId(notification));
|
||||
return rule.enabled && rule.showOnLockScreen;
|
||||
}
|
||||
|
||||
function shouldShowContentOnLockScreen(notification: var): bool {
|
||||
const rule = root.appRule(root.notificationAppId(notification));
|
||||
return rule.enabled && rule.showOnLockScreen && rule.showContentOnLockScreen;
|
||||
}
|
||||
|
||||
// history grouped by app, in most-recent-app-first order — the shape
|
||||
// NotificationCenter.qml renders directly.
|
||||
readonly property var groups: {
|
||||
@@ -85,13 +191,20 @@ Singleton {
|
||||
actionIconsSupported: true
|
||||
inlineReplySupported: true
|
||||
|
||||
onNotification: notification => {
|
||||
onNotification: notification => root.handleNotification(notification)
|
||||
}
|
||||
|
||||
function handleNotification(notification: var): void {
|
||||
// Replayed from before a shell reload. Letting these through would
|
||||
// re-toast and re-list everything on every edit, so they are left
|
||||
// untracked and allowed to die.
|
||||
if (notification.lastGeneration)
|
||||
return;
|
||||
|
||||
const appId = root.rememberApplication(notification);
|
||||
if (!root.appRule(appId).enabled)
|
||||
return;
|
||||
|
||||
// Without this the object is destroyed the instant this returns.
|
||||
notification.tracked = true;
|
||||
root.arrivals[notification.id] = new Date();
|
||||
@@ -108,12 +221,11 @@ Singleton {
|
||||
|
||||
if (!root.doNotDisturb)
|
||||
root.popups = [notification].concat(root.popups);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mutation ────────────────────────────────────────────────────────────
|
||||
|
||||
function pushHistory(n: Notification): void {
|
||||
function pushHistory(n: var): void {
|
||||
const next = [n].concat(root.history);
|
||||
|
||||
// Anything past the cap is released, otherwise it stays tracked
|
||||
@@ -166,7 +278,7 @@ Singleton {
|
||||
|
||||
// Called from the `closed` signal — the object is on its way out, so this
|
||||
// only ever removes references, never touches the notification.
|
||||
function forget(n: Notification): void {
|
||||
function forget(n: var): void {
|
||||
delete root.arrivals[n.id];
|
||||
if (root.history.indexOf(n) !== -1)
|
||||
root.history = root.history.filter(x => x !== n);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
pragma Singleton
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import QtQuick
|
||||
import "../modules/osd/OsdModel.js" as OsdModel
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property bool active: false
|
||||
property string monitorName: ""
|
||||
property var state: OsdModel.messageState("dialog-information-symbolic", "", 1400)
|
||||
|
||||
function present(next: var): void {
|
||||
root.state = next;
|
||||
root.monitorName = Hyprland.focusedMonitor?.name ?? "";
|
||||
root.active = true;
|
||||
if (next.duration > 0) {
|
||||
hideTimer.interval = next.duration;
|
||||
hideTimer.restart();
|
||||
} else {
|
||||
hideTimer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
function progress(kind: string, value: int, maximum: int, label: string): void {
|
||||
root.present(OsdModel.progressState(kind, value, maximum, label, 1400));
|
||||
}
|
||||
|
||||
function message(kind: string, label: string): void {
|
||||
root.present(OsdModel.messageState(kind, label, 1400));
|
||||
}
|
||||
|
||||
function hide(): void {
|
||||
hideTimer.stop();
|
||||
root.active = false;
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: hideTimer
|
||||
interval: 1400
|
||||
onTriggered: root.active = false
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ Singleton {
|
||||
property var initializeHome: function(ids) { HomePreferences.initialize(ids); }
|
||||
property var aliasHome: function(id, alias) { HomePreferences.setAlias(id, alias); }
|
||||
property var reloadDesktop: function() { DesktopPreferences.reload(); }
|
||||
property var readDisplays: function() { return DesktopPreferences.get("displays"); }
|
||||
property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); }
|
||||
property var displayBusy: function() { return Displays.busy || Displays.awaitingConfirmation; }
|
||||
property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; }
|
||||
property var applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); }
|
||||
property var reloadKeybinds: function() { Keybinds.applyReload(); }
|
||||
property var keybindsReloading: function() { return Keybinds.reloading; }
|
||||
@@ -47,6 +51,7 @@ Singleton {
|
||||
}
|
||||
property var applyWallpaper: function(path) { Wallpaper.set(path); }
|
||||
property var reloadShell: function() { Quickshell.reload(false); }
|
||||
property var protectedDisplays: ({})
|
||||
|
||||
readonly property bool busy: listQuery.running || actionRun.running
|
||||
|| applyRestoredState.running || settleReload.running
|
||||
@@ -81,6 +86,10 @@ Singleton {
|
||||
root.lastError = actionRun.restoring
|
||||
? "That snapshot could not be restored."
|
||||
: "The settings could not be backed up.";
|
||||
if (actionRun.restoring) {
|
||||
root.setDisplayBlocked(false);
|
||||
root.protectedDisplays = ({});
|
||||
}
|
||||
return;
|
||||
}
|
||||
root.lastAction = actionRun.restoring ? "restored" : "saved";
|
||||
@@ -89,6 +98,10 @@ Singleton {
|
||||
root.lastError = homeReloaded
|
||||
? ""
|
||||
: "Desktop settings were restored, but Home favourites could not be reloaded.";
|
||||
if (!homeReloaded) {
|
||||
root.setDisplayBlocked(false);
|
||||
root.protectedDisplays = ({});
|
||||
}
|
||||
} else
|
||||
root.lastError = "";
|
||||
root.refresh();
|
||||
@@ -124,6 +137,8 @@ Singleton {
|
||||
// from leaving restored Home state stale indefinitely.
|
||||
if ((!root.keybindsReloading() && !root.systemBusy()) || attempts >= 30) {
|
||||
stop();
|
||||
root.setDisplayBlocked(false);
|
||||
root.protectedDisplays = ({});
|
||||
root.reloadShell();
|
||||
}
|
||||
}
|
||||
@@ -162,6 +177,8 @@ Singleton {
|
||||
if (!root.reloadHomeState(text))
|
||||
return false;
|
||||
root.reloadDesktop();
|
||||
if (!root.protectDisplays(root.protectedDisplays))
|
||||
return false;
|
||||
applyRestoredState.restart();
|
||||
return true;
|
||||
}
|
||||
@@ -222,10 +239,18 @@ Singleton {
|
||||
function restore(name: string): bool {
|
||||
if (actionRun.running)
|
||||
return false;
|
||||
if (root.displayBusy()) {
|
||||
root.lastError = "Finish the current display change before restoring settings.";
|
||||
return false;
|
||||
}
|
||||
if (!root.snapshots.some(snapshot => snapshot.name === name)) {
|
||||
root.lastError = "That snapshot is not in the list.";
|
||||
return false;
|
||||
}
|
||||
const currentDisplays = root.readDisplays();
|
||||
root.protectedDisplays = JSON.parse(JSON.stringify(
|
||||
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
|
||||
root.setDisplayBlocked(true);
|
||||
actionRun.restoring = true;
|
||||
actionRun.exec([root.helperPath, "restore", name]);
|
||||
return true;
|
||||
|
||||
@@ -29,6 +29,7 @@ Singleton {
|
||||
"dock": "desktop",
|
||||
"focus": "desktop",
|
||||
"display": "displays",
|
||||
"nightLight": "displays",
|
||||
"idle": "power",
|
||||
"accessibility": "accessibility",
|
||||
"input": "shortcuts",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
pragma Singleton
|
||||
|
||||
// GNOME and GTK applications already honour these desktop sound preferences.
|
||||
// Panama controls the same durable keys so moving between sessions does not
|
||||
// create two competing notions of whether event feedback is enabled.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property bool eventSounds: true
|
||||
property bool inputFeedback: false
|
||||
property string lastError: ""
|
||||
readonly property bool busy: eventRead.running || inputRead.running
|
||||
|| eventWrite.running || inputWrite.running
|
||||
|
||||
function parsedBoolean(text: string, fallback: bool): bool {
|
||||
const value = text.trim();
|
||||
if (value === "true")
|
||||
return true;
|
||||
if (value === "false")
|
||||
return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (!eventRead.running)
|
||||
eventRead.running = true;
|
||||
if (!inputRead.running)
|
||||
inputRead.running = true;
|
||||
}
|
||||
|
||||
function setEventSounds(enabled: bool): void {
|
||||
root.eventSounds = enabled;
|
||||
eventWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "event-sounds", String(enabled)];
|
||||
eventWrite.running = true;
|
||||
}
|
||||
|
||||
function setInputFeedback(enabled: bool): void {
|
||||
root.inputFeedback = enabled;
|
||||
inputWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "input-feedback-sounds", String(enabled)];
|
||||
inputWrite.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: eventRead
|
||||
command: ["gsettings", "get", "org.gnome.desktop.sound", "event-sounds"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.eventSounds = root.parsedBoolean(this.text, root.eventSounds)
|
||||
}
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0)
|
||||
root.lastError = "Event sound preferences could not be read.";
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: inputRead
|
||||
command: ["gsettings", "get", "org.gnome.desktop.sound", "input-feedback-sounds"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.inputFeedback = root.parsedBoolean(this.text, root.inputFeedback)
|
||||
}
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0)
|
||||
root.lastError = "Input feedback preferences could not be read.";
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: eventWrite
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0) {
|
||||
root.lastError = "Event sound preferences could not be changed.";
|
||||
root.refresh();
|
||||
} else {
|
||||
root.lastError = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: inputWrite
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0) {
|
||||
root.lastError = "Input feedback preferences could not be changed.";
|
||||
root.refresh();
|
||||
} else {
|
||||
root.lastError = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
}
|
||||
@@ -33,6 +33,16 @@ Singleton {
|
||||
property string quickshellVersion: "0.3.0"
|
||||
property string lastError: ""
|
||||
|
||||
// Explicit seams keep reset sequencing testable without changing the live
|
||||
// keymap, wallpaper, or display from an isolated contract harness.
|
||||
property var displayBusy: function() { return Displays.busy || Displays.awaitingConfirmation; }
|
||||
property var readDisplays: function() { return DesktopPreferences.get("displays"); }
|
||||
property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); }
|
||||
property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; }
|
||||
property var reloadKeybinds: function() { Keybinds.applyReload(); }
|
||||
property var keybindsReloading: function() { return Keybinds.reloading; }
|
||||
property var applyWallpaper: function(path) { Wallpaper.set(path); }
|
||||
|
||||
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|
||||
|| configWrite.running || configVerify.running || bluebubblesQuery.running
|
||||
|
||||
@@ -196,6 +206,11 @@ Singleton {
|
||||
}
|
||||
|
||||
// ── Applying options ────────────────────────────────────────────────────
|
||||
// Test harnesses may replace the external compositor boundary while still
|
||||
// exercising validation, commit routing, persistence, and reset replay.
|
||||
// Production leaves this unset and always uses the verified Hyprland path.
|
||||
property var compositorApplyOverride: null
|
||||
|
||||
// `values` maps schema keys to values, e.g. { vrrPolicy: 3, gapsOut: 12 }.
|
||||
// The whole batch is validated before anything is sent, so one bad value
|
||||
// rejects the batch rather than half-applying it.
|
||||
@@ -217,6 +232,9 @@ Singleton {
|
||||
if (Object.keys(requested).length === 0)
|
||||
return false;
|
||||
|
||||
if (root.compositorApplyOverride !== null)
|
||||
return root.compositorApplyOverride(requested);
|
||||
|
||||
// A write in flight is queued rather than refused. Options are applied
|
||||
// and verified one batch at a time, but the callers are a settings UI
|
||||
// and a startup replay of every compositor-backed preference -- they
|
||||
@@ -389,8 +407,22 @@ Singleton {
|
||||
//
|
||||
// Compositor-backed values are re-applied afterwards, since resetting the
|
||||
// stored value does not by itself tell Hyprland anything.
|
||||
function restoreDefaults(): void {
|
||||
function restoreDefaults(): bool {
|
||||
if (root.displayBusy()) {
|
||||
root.lastError = "Finish the current display change before restoring defaults.";
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentDisplays = root.readDisplays();
|
||||
const protectedDisplays = JSON.parse(JSON.stringify(
|
||||
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
|
||||
root.setDisplayBlocked(true);
|
||||
DesktopPreferences.resetDesktopDefaults();
|
||||
if (!root.protectDisplays(protectedDisplays)) {
|
||||
root.setDisplayBlocked(false);
|
||||
root.lastError = "The current display setting could not be protected during reset.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Home accessories keep their own store (panama-home.json), so a reset
|
||||
// that only cleared the schema store would silently leave a customised
|
||||
@@ -401,12 +433,33 @@ Singleton {
|
||||
HomePreferences.resetHomeDefaults();
|
||||
|
||||
resettleTimer.restart();
|
||||
return true;
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: resettleTimer
|
||||
interval: 60
|
||||
onTriggered: root.applyPersistedDisplayPolicy()
|
||||
onTriggered: {
|
||||
root.applyPersistedDisplayPolicy();
|
||||
root.reloadKeybinds();
|
||||
root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));
|
||||
resetRelease.attempts = 0;
|
||||
resetRelease.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: resetRelease
|
||||
property int attempts: 0
|
||||
interval: 100
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
attempts++;
|
||||
if ((!root.keybindsReloading() && !root.busy) || attempts >= 50) {
|
||||
stop();
|
||||
root.setDisplayBlocked(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setAutoHdr(enabled: bool): void {
|
||||
|
||||
@@ -33,6 +33,7 @@ Singleton {
|
||||
property bool scanning: false
|
||||
|
||||
readonly property string configured: DesktopPreferences.get("wallpaperPath")
|
||||
readonly property string shippedPath: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg`
|
||||
|
||||
// Directories searched for wallpapers, in order. Screenshots are
|
||||
// deliberately excluded: a folder of 300 screenshots is not a wallpaper
|
||||
@@ -85,6 +86,7 @@ Singleton {
|
||||
id: apply
|
||||
|
||||
property string requested: ""
|
||||
property string storedValue: ""
|
||||
property var remaining: []
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
@@ -100,7 +102,7 @@ Singleton {
|
||||
return;
|
||||
}
|
||||
root.lastError = "";
|
||||
DesktopPreferences.set("wallpaperPath", apply.requested);
|
||||
DesktopPreferences.set("wallpaperPath", apply.storedValue);
|
||||
root.refreshActive();
|
||||
}
|
||||
}
|
||||
@@ -140,19 +142,16 @@ Singleton {
|
||||
// Applies to every connected output. Returns false when the path is not one
|
||||
// the schema will accept, so a caller can report the refusal.
|
||||
function set(path: string): bool {
|
||||
if (PreferenceSchema.coerce("wallpaperPath", path) === undefined) {
|
||||
const effectivePath = path === "" ? root.shippedPath : path;
|
||||
if (PreferenceSchema.coerce("wallpaperPath", effectivePath) === undefined) {
|
||||
root.lastError = "That file path cannot be used as a wallpaper.";
|
||||
return false;
|
||||
}
|
||||
if (apply.running)
|
||||
return false;
|
||||
|
||||
apply.requested = path;
|
||||
// "" clears the preference without touching what is on screen.
|
||||
if (path === "") {
|
||||
DesktopPreferences.set("wallpaperPath", "");
|
||||
return true;
|
||||
}
|
||||
apply.requested = effectivePath;
|
||||
apply.storedValue = path;
|
||||
|
||||
const outputs = Quickshell.screens.map(screen => screen.name).filter(name => !!name);
|
||||
if (outputs.length === 0) {
|
||||
@@ -161,7 +160,7 @@ Singleton {
|
||||
}
|
||||
|
||||
apply.remaining = outputs.slice(1);
|
||||
apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${outputs[0]},${path}`]);
|
||||
apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${outputs[0]},${effectivePath}`]);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ ShellRoot {
|
||||
property var calls: []
|
||||
property bool homeInitialized: false
|
||||
property var homeFavorites: []
|
||||
property bool displayOperationBusy: false
|
||||
property bool displayBlocked: false
|
||||
property var displayGeneration: ({ "DP-2": { mode: "4500x3000@60", scale: 1.5, transform: 0 } })
|
||||
|
||||
function record(name: string): void {
|
||||
const next = root.calls.slice();
|
||||
@@ -43,6 +46,17 @@ ShellRoot {
|
||||
favorite.id === id ? { id: id, alias: alias } : favorite);
|
||||
};
|
||||
SettingsBackup.reloadDesktop = function() { root.record("desktop.reload"); };
|
||||
SettingsBackup.readDisplays = function() { return root.displayGeneration; };
|
||||
SettingsBackup.protectDisplays = function(value) {
|
||||
root.record("display.protect:" + JSON.stringify(value));
|
||||
root.displayGeneration = value;
|
||||
return true;
|
||||
};
|
||||
SettingsBackup.displayBusy = function() { return root.displayOperationBusy; };
|
||||
SettingsBackup.setDisplayBlocked = function(blocked) {
|
||||
root.record("display.block:" + blocked);
|
||||
root.displayBlocked = blocked;
|
||||
};
|
||||
SettingsBackup.applyCompositor = function() { root.record("system.apply"); };
|
||||
SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); };
|
||||
SettingsBackup.keybindsReloading = function() { return false; };
|
||||
@@ -59,17 +73,29 @@ ShellRoot {
|
||||
root.calls = [];
|
||||
root.homeInitialized = false;
|
||||
root.homeFavorites = [];
|
||||
root.displayOperationBusy = false;
|
||||
root.displayBlocked = false;
|
||||
SettingsBackup.protectedDisplays = root.displayGeneration;
|
||||
}
|
||||
|
||||
function apply(output: string): bool {
|
||||
return SettingsBackup.handleRestoreOutput(output);
|
||||
}
|
||||
|
||||
function restoreWhileDisplayBusy(): bool {
|
||||
root.displayOperationBusy = true;
|
||||
SettingsBackup.snapshots = [{ name: "settings-20260818-010203004.json" }];
|
||||
return SettingsBackup.restore("settings-20260818-010203004.json");
|
||||
}
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
calls: root.calls,
|
||||
initialized: root.homeInitialized,
|
||||
favorites: root.homeFavorites
|
||||
favorites: root.homeFavorites,
|
||||
displayBlocked: root.displayBlocked,
|
||||
displays: root.displayGeneration,
|
||||
lastError: SettingsBackup.lastError
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,48 @@ import qs.config
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
property var resetCalls: []
|
||||
property var appliedBatches: []
|
||||
property bool displayBlocked: false
|
||||
|
||||
function recordReset(name: string): void {
|
||||
const next = root.resetCalls.slice();
|
||||
next.push(name);
|
||||
root.resetCalls = next;
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
// Keep compositor verification entirely inside the isolated harness.
|
||||
// Production applyOptions is covered separately by the Hyprland write
|
||||
// contract; this seam proves commit/reset routing without changing the
|
||||
// desktop that is running the test.
|
||||
if (Quickshell.env("PANAMA_SETTINGS_TEST_ISOLATE_COMPOSITOR") === "1") {
|
||||
SystemSettings.compositorApplyOverride = function(requested) {
|
||||
const batches = root.appliedBatches.slice();
|
||||
batches.push(requested);
|
||||
root.appliedBatches = batches;
|
||||
for (const key in requested)
|
||||
DesktopPreferences.set(key, requested[key]);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
SystemSettings.displayBusy = function() { return false; };
|
||||
SystemSettings.readDisplays = function() { return DesktopPreferences.get("displays"); };
|
||||
SystemSettings.protectDisplays = function(value) {
|
||||
root.recordReset("display.protect");
|
||||
return DesktopPreferences.set("displays", value);
|
||||
};
|
||||
SystemSettings.setDisplayBlocked = function(blocked) {
|
||||
root.recordReset("display.block:" + blocked);
|
||||
root.displayBlocked = blocked;
|
||||
};
|
||||
SystemSettings.reloadKeybinds = function() { root.recordReset("keybinds.reload"); };
|
||||
SystemSettings.keybindsReloading = function() { return false; };
|
||||
SystemSettings.applyWallpaper = function(path) { root.recordReset("wallpaper.set:" + path); };
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "settings-system-test"
|
||||
|
||||
@@ -52,7 +94,22 @@ ShellRoot {
|
||||
});
|
||||
}
|
||||
|
||||
function restoreDefaults(): void { SystemSettings.restoreDefaults(); }
|
||||
function restoreDefaults(): bool {
|
||||
root.resetCalls = [];
|
||||
return SystemSettings.restoreDefaults();
|
||||
}
|
||||
|
||||
function resetState(): string {
|
||||
return JSON.stringify({
|
||||
calls: root.resetCalls,
|
||||
displayBlocked: root.displayBlocked,
|
||||
appliedBatches: root.appliedBatches
|
||||
});
|
||||
}
|
||||
|
||||
function applyState(): string {
|
||||
return JSON.stringify(root.appliedBatches);
|
||||
}
|
||||
|
||||
function panelAllowed(panel: string): bool {
|
||||
return SystemSettings.isGnomePanelAllowed(panel);
|
||||
|
||||
@@ -38,6 +38,7 @@ import qs.modules.datemenu
|
||||
import qs.modules.focus
|
||||
import qs.modules.signals
|
||||
import qs.modules.settings
|
||||
import qs.modules.osd
|
||||
|
||||
// Clipboard and datemenu ship explicit qmldir manifests because they were
|
||||
// added while this daily-driver shell was already running; that also keeps
|
||||
@@ -72,6 +73,11 @@ ShellRoot {
|
||||
SignalGlass {}
|
||||
}
|
||||
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
Osd {}
|
||||
}
|
||||
|
||||
// ── Single-instance overlays ────────────────────────────────────────────
|
||||
// These are always constructed but only *visible* when ShellState says so.
|
||||
// They're cheap while hidden, and keeping them alive means opening the
|
||||
@@ -108,7 +114,10 @@ ShellRoot {
|
||||
IpcHandler {
|
||||
target: "focus"
|
||||
|
||||
function start(): void { FocusSession.startDefault(); }
|
||||
function start(): bool {
|
||||
FocusSession.startDefault();
|
||||
return FocusSession.active;
|
||||
}
|
||||
function reveal(): void { FocusSession.reveal(); }
|
||||
function dismiss(): void { FocusSession.dismiss(); }
|
||||
function pause(): void { FocusSession.pauseOrResume(); }
|
||||
@@ -117,7 +126,12 @@ ShellRoot {
|
||||
ShellState.openOverview(FocusSession.workspaceId);
|
||||
FocusSession.dismiss();
|
||||
}
|
||||
function end(): void { FocusSession.end(false); }
|
||||
function end(): bool {
|
||||
if (!FocusSession.active)
|
||||
return false;
|
||||
FocusSession.end(false);
|
||||
return !FocusSession.active;
|
||||
}
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
active: FocusSession.active,
|
||||
@@ -194,6 +208,25 @@ ShellRoot {
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "osd"
|
||||
|
||||
function progress(kind: string, value: int, maximum: int, label: string): void {
|
||||
OsdState.progress(kind, value, maximum, label);
|
||||
}
|
||||
function message(kind: string, label: string): void {
|
||||
OsdState.message(kind, label);
|
||||
}
|
||||
function hide(): void { OsdState.hide(); }
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
active: OsdState.active,
|
||||
monitorName: OsdState.monitorName,
|
||||
state: OsdState.state
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "activity"
|
||||
|
||||
@@ -232,6 +265,38 @@ ShellRoot {
|
||||
}
|
||||
}
|
||||
|
||||
// Small stateful controls used by launcher commands. Returning the state
|
||||
// after mutation lets callers give accurate OSD feedback without keeping a
|
||||
// second copy of service state in a shell script.
|
||||
IpcHandler {
|
||||
target: "caffeine"
|
||||
function toggle(): bool {
|
||||
Caffeine.toggle();
|
||||
return Caffeine.enabled;
|
||||
}
|
||||
function status(): bool { return Caffeine.enabled; }
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "night-light"
|
||||
function toggle(): bool {
|
||||
NightLight.toggleQuietly();
|
||||
return NightLight.active;
|
||||
}
|
||||
function status(): bool { return NightLight.active; }
|
||||
function settings(): string {
|
||||
return JSON.stringify({
|
||||
enabled: NightLight.enabled,
|
||||
automatic: NightLight.automatic,
|
||||
active: NightLight.active
|
||||
});
|
||||
}
|
||||
function restore(enabled: bool, automatic: bool): bool {
|
||||
NightLight.restore(enabled, automatic);
|
||||
return NightLight.active;
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "kdeconnect"
|
||||
function fixture(name: string): void { KdeConnect.applyFixture(name); }
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Read-only contract harness for the Sound page. It instantiates every device
|
||||
// row against the real PipeWire graph but exposes no mutating IPC methods.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pipewire
|
||||
import QtQuick
|
||||
import qs.services
|
||||
import qs.modules.settings
|
||||
|
||||
ShellRoot {
|
||||
SoundPage {
|
||||
width: 760
|
||||
height: 900
|
||||
}
|
||||
|
||||
PwObjectTracker {
|
||||
objects: AudioDevices.outputs.concat(AudioDevices.inputs)
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "sound-page-test"
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
ready: Pipewire.ready,
|
||||
outputs: AudioDevices.outputs.length,
|
||||
inputs: AudioDevices.inputs.length,
|
||||
defaultOutput: AudioDevices.label(AudioDevices.current(true)),
|
||||
defaultInput: AudioDevices.label(AudioDevices.current(false))
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
IpcHandler {
|
||||
target: "weather-gpu-test"
|
||||
|
||||
function gpuStatus(): string {
|
||||
return JSON.stringify({
|
||||
count: GraphicsDevices.devices.length,
|
||||
names: GraphicsDevices.devices.map(d => GraphicsDevices.shortName(d.name)),
|
||||
paths: GraphicsDevices.devices.map(d => d.path),
|
||||
selected: GraphicsDevices.selectedPath,
|
||||
resolved: GraphicsDevices.selected !== null,
|
||||
missing: GraphicsDevices.selectionMissing,
|
||||
error: GraphicsDevices.lastError
|
||||
});
|
||||
}
|
||||
|
||||
function selectGpu(path: string): bool { return GraphicsDevices.select(path); }
|
||||
|
||||
function geoSearch(query: string): void { Geocoding.search(query); }
|
||||
|
||||
function geoStatus(): string {
|
||||
return JSON.stringify({
|
||||
searching: Geocoding.searching,
|
||||
count: Geocoding.results.length,
|
||||
top: Geocoding.results.length > 0 ? Geocoding.results[0].label : "",
|
||||
error: Geocoding.lastError
|
||||
});
|
||||
}
|
||||
|
||||
function geoChooseTop(): bool {
|
||||
if (Geocoding.results.length === 0) return false;
|
||||
return Geocoding.choose(Geocoding.results[0]);
|
||||
}
|
||||
|
||||
function storedLocation(): string {
|
||||
return JSON.stringify({
|
||||
label: DesktopPreferences.get("weatherLocation"),
|
||||
lat: DesktopPreferences.get("weatherLatitude"),
|
||||
lon: DesktopPreferences.get("weatherLongitude")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ Name=Panama Settings
|
||||
GenericName=System Settings
|
||||
Comment=Configure the Panama Hyprland desktop
|
||||
Exec=qs ipc call settings open
|
||||
Icon=preferences-system-symbolic
|
||||
Icon=panama-settings
|
||||
Terminal=false
|
||||
StartupNotify=false
|
||||
Categories=Settings;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Panama Settings.
|
||||
|
||||
A gear, because a settings icon has to be recognisable at 48px in a dock
|
||||
before it is anything else - but rendered in the Prism identity rather than
|
||||
the flat monochrome symbolic icon this replaces. Blue leads into orchid, the
|
||||
same pair that marks the focused window, the active workspace, and the
|
||||
hairline along every glass surface.
|
||||
|
||||
The gear is a real toothed outline. An earlier version drew a ring with radial
|
||||
strokes; at dock size the strokes merged into the ring and it read as an X.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
||||
<defs>
|
||||
<linearGradient id="prism" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#82aaff"/>
|
||||
<stop offset="55%" stop-color="#9b8ed8"/>
|
||||
<stop offset="100%" stop-color="#b172b0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#2b2e45"/>
|
||||
<stop offset="100%" stop-color="#1e2030"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#82aaff" stop-opacity="0"/>
|
||||
<stop offset="22%" stop-color="#82aaff" stop-opacity="0.9"/>
|
||||
<stop offset="78%" stop-color="#b172b0" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="#b172b0" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect x="4" y="4" width="120" height="120" rx="28" fill="url(#tile)"/>
|
||||
<rect x="4.5" y="4.5" width="119" height="119" rx="27.5" fill="none"
|
||||
stroke="#c8d3f5" stroke-opacity="0.10" stroke-width="1"/>
|
||||
<path d="M34 6.5 H94" stroke="url(#edge)" stroke-width="1.6" stroke-linecap="round"/>
|
||||
|
||||
<path d="M 52.53 29.88 L 56.45 18.62 L 71.55 18.62 L 75.47 29.88 L 80.02 31.76 L 90.75 26.57 L 101.43 37.25 L 96.24 47.98 L 98.12 52.53 L 109.38 56.45 L 109.38 71.55 L 98.12 75.47 L 96.24 80.02 L 101.43 90.75 L 90.75 101.43 L 80.02 96.24 L 75.47 98.12 L 71.55 109.38 L 56.45 109.38 L 52.53 98.12 L 47.98 96.24 L 37.25 101.43 L 26.57 90.75 L 31.76 80.02 L 29.88 75.47 L 18.62 71.55 L 18.62 56.45 L 29.88 52.53 L 31.76 47.98 L 26.57 37.25 L 37.25 26.57 L 47.98 31.76 Z" fill="url(#prism)" stroke="url(#prism)" stroke-width="5"
|
||||
stroke-linejoin="round"/>
|
||||
<circle cx="64" cy="64" r="15" fill="#1e2030"/>
|
||||
<circle cx="64" cy="64" r="15" fill="none" stroke="#c8d3f5" stroke-opacity="0.18" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Panama Settings.
|
||||
|
||||
A gear, because a settings icon has to be recognisable at 48px in a dock
|
||||
before it is clever — but rendered in the Prism identity rather than the flat
|
||||
monochrome of the symbolic icon this replaces. Blue leads into orchid across
|
||||
the gradient, the same pair that marks the focused window, the active
|
||||
workspace and the hairline on every glass surface.
|
||||
|
||||
Drawn on the dark tile (#222436) so it reads as an application icon among
|
||||
full-colour ones, rather than as a UI glyph that wandered out of a toolbar.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
||||
<defs>
|
||||
<linearGradient id="prism" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#82aaff"/>
|
||||
<stop offset="55%" stop-color="#9b8ed8"/>
|
||||
<stop offset="100%" stop-color="#b172b0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#2b2e45"/>
|
||||
<stop offset="100%" stop-color="#1e2030"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#82aaff" stop-opacity="0"/>
|
||||
<stop offset="22%" stop-color="#82aaff" stop-opacity="0.85"/>
|
||||
<stop offset="78%" stop-color="#b172b0" stop-opacity="0.85"/>
|
||||
<stop offset="100%" stop-color="#b172b0" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect x="4" y="4" width="120" height="120" rx="28" fill="url(#tile)"/>
|
||||
<rect x="4.5" y="4.5" width="119" height="119" rx="27.5" fill="none"
|
||||
stroke="#c8d3f5" stroke-opacity="0.10" stroke-width="1"/>
|
||||
|
||||
<!-- The prism hairline, as it appears along the top edge of every glass
|
||||
surface in the shell. -->
|
||||
<path d="M32 6.5 H96" stroke="url(#edge)" stroke-width="1.5" stroke-linecap="round"/>
|
||||
|
||||
<!-- Gear: eight teeth, drawn as a ring with radial spokes so it stays legible
|
||||
when the icon is scaled down to a dock tile. -->
|
||||
<g transform="translate(64 66)">
|
||||
<g fill="none" stroke="url(#prism)" stroke-width="9" stroke-linecap="round">
|
||||
<circle r="24"/>
|
||||
<g>
|
||||
<path d="M0 -38 V-30"/>
|
||||
<path d="M0 38 V30"/>
|
||||
<path d="M-38 0 H-30"/>
|
||||
<path d="M38 0 H30"/>
|
||||
<path d="M-26.9 -26.9 L-21.2 -21.2"/>
|
||||
<path d="M26.9 26.9 L21.2 21.2"/>
|
||||
<path d="M-26.9 26.9 L-21.2 21.2"/>
|
||||
<path d="M26.9 -26.9 L21.2 -21.2"/>
|
||||
</g>
|
||||
</g>
|
||||
<circle r="7.5" fill="#c8d3f5" fill-opacity="0.92"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Panama: Screenshot and Recording
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Open the polished screenshot and screen-recording picker.
|
||||
# @vicinae.keywords ["capture", "record", "selection", "window", "screen"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" capture
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Panama: End Focus Session
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description End the current focus timer and restore its previous states.
|
||||
# @vicinae.keywords ["stop timer", "deep work", "pomodoro", "restore"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" focus-end
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Panama: Open Calendar
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Open the calendar, synchronized agenda, and ongoing activity.
|
||||
# @vicinae.keywords ["agenda", "events", "appointments", "date", "nextcloud"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" calendar
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Panama: Open Clipboard
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Open Panama's compact clipboard history surface.
|
||||
# @vicinae.keywords ["copy", "paste", "history", "text"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" clipboard
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Panama: Open Control Center
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Open Wi-Fi, Bluetooth, audio, phone, and Home controls.
|
||||
# @vicinae.keywords ["quick settings", "wifi", "bluetooth", "audio", "home", "phone"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" control-center
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Panama: Open Mission Control
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description See every workspace, tiled window, and scratchpad window.
|
||||
# @vicinae.keywords ["overview", "workspaces", "windows", "expose", "scratchpad"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" overview
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Panama: Open Notifications
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Open notification history and the unified date center.
|
||||
# @vicinae.keywords ["alerts", "messages", "history", "date center"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" notifications
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Panama: Open Prism Gallery
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Open the living gallery of Panama's production components.
|
||||
# @vicinae.keywords ["design system", "theme", "components", "tokyo night", "style"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" gallery
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Panama: Open Settings
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Configure the Panama desktop from its native Settings app.
|
||||
# @vicinae.keywords ["preferences", "configure", "desktop", "system"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" settings
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Panama: Capture Entire Screen
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Save an immediate screenshot of the complete desktop.
|
||||
# @vicinae.keywords ["quick screenshot", "full screen", "capture", "grim"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" screenshot
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Panama: Restart Desktop Shell
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Restart Quickshell if a desktop surface becomes unresponsive.
|
||||
# @vicinae.keywords ["reload", "repair", "quickshell", "restart", "recover"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" restart-shell
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Panama: Screen Intelligence
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Select part of the screen for OCR, QR, or code recognition.
|
||||
# @vicinae.keywords ["ocr", "text", "qr", "barcode", "recognize", "scan"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" intelligence
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Panama: Start Focus Session
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Start the configured focus timer with DND and Caffeine.
|
||||
# @vicinae.keywords ["timer", "deep work", "pomodoro", "concentrate", "workspace"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" focus-start
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Panama: Toggle Caffeine
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Keep the display awake and block automatic sleep.
|
||||
# @vicinae.keywords ["awake", "sleep", "inhibit", "presentation", "idle"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" caffeine
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Panama: Toggle Do Not Disturb
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Pause or resume notification banners, with OSD confirmation.
|
||||
# @vicinae.keywords ["dnd", "notifications", "quiet", "silence", "alerts"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" dnd
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Panama: Toggle Microphone Mute
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Mute or unmute the default microphone with OSD feedback.
|
||||
# @vicinae.keywords ["mic", "audio input", "privacy", "mute", "unmute"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" microphone
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Panama: Toggle Night Light
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Toggle the warm display color temperature.
|
||||
# @vicinae.keywords ["warm", "temperature", "blue light", "display", "hyprsunset"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" night-light
|
||||
@@ -0,0 +1,26 @@
|
||||
# Upstream inspiration
|
||||
|
||||
Panama is its own desktop experience, but it should learn from strong open-source work without obscuring where an idea came from. This ledger records upstream projects that materially influence its architecture or interaction design.
|
||||
|
||||
## Omarchy
|
||||
|
||||
- Project: [basecamp/omarchy](https://github.com/basecamp/omarchy)
|
||||
- Reviewed revision: `f32ebbdb730c4e8fe11e4046cef4267e466264ea`
|
||||
- License: MIT, copyright David Heinemeier Hansson
|
||||
- Local reference at review time: `/home/gib/Documents/Code/Projects/omarchy`
|
||||
|
||||
### Adopted ideas, rebuilt for Panama
|
||||
|
||||
- `shell/plugins/osd/Osd.qml` and `OsdModel.js`: inspired the separation between a pure presentation model and a single, click-through per-monitor OSD surface. Panama rebuilt the behavior around freedesktop symbolic icons, Tokyo Night Moon tokens, its Prism glass material, Hyprland's focused monitor, and the existing `wpctl`/`brightnessctl`/`playerctl` keybindings.
|
||||
- `test/shell.d/osd-test.sh` and Omarchy's broader shell contract suite: reinforced Panama's use of fast model and integration contracts alongside live compositor verification.
|
||||
- Omarchy's small shared visual primitives: reinforced the decision to maintain `prism-gallery.qml`, which renders Panama's production widgets and Theme tokens instead of duplicating mock-only controls.
|
||||
- `shell/plugins/menu/Menu.qml`: inspired a searchable desktop-action inventory. Panama keeps Vicinae as its one launcher and exposes native script commands backed by a single `panama-action` dispatcher, rather than adding Omarchy's separate shell menu or plugin host.
|
||||
|
||||
No Omarchy user-facing layout or styling was transplanted. Panama keeps its GNOME-derived information architecture, Prism material, Tokyo Night Moon palette, app model, and established interaction choices.
|
||||
|
||||
### Candidates still under evaluation
|
||||
|
||||
- A plugin boundary for optional bar widgets and services once Panama has enough third-party modules to justify one.
|
||||
- A small first-run health check for missing desktop dependencies and broken service integrations.
|
||||
|
||||
These are research directions, not commitments. They should only land when they improve the daily-driver experience without adding visible complexity or parallel configuration systems.
|
||||
@@ -21,8 +21,9 @@ fi
|
||||
gsettings set org.gnome.desktop.screensaver lock-enabled false
|
||||
gsettings set org.gnome.desktop.session idle-delay 0
|
||||
|
||||
# Run scripts
|
||||
for script in ~/.local/share/Panama/setup/scripts/*; do source $script; done
|
||||
# Run each setup stage in its own process. This keeps strict-shell options and
|
||||
# helper variables local to the script that owns them.
|
||||
for script in ~/.local/share/Panama/setup/scripts/*; do "$script"; done
|
||||
|
||||
# Revert to normal idle settings
|
||||
gsettings set org.gnome.desktop.screensaver lock-enabled true
|
||||
|
||||
@@ -91,6 +91,25 @@ fi
|
||||
# Panama-native applications live in the user data directory so launchers can
|
||||
# discover them alongside system desktop entries. Keep each authored file in
|
||||
# the repository and expose it with a narrow per-file symlink.
|
||||
# Panama ships its own application icons. Without these the desktop entries fall
|
||||
# back to a generic symbolic glyph, which is drawn for 16px toolbar use and
|
||||
# looks wrong beside full-colour application icons in the dock.
|
||||
PANAMA_ICON_DIR="$PANAMA_PATH/config/local/share/icons/hicolor/scalable/apps"
|
||||
USER_ICON_DIR="$HOME/.local/share/icons/hicolor/scalable/apps"
|
||||
mkdir -p "$USER_ICON_DIR"
|
||||
for icon_file in "$PANAMA_ICON_DIR"/*.svg; do
|
||||
[[ -e "$icon_file" ]] || continue
|
||||
icon_name="$(basename "$icon_file")"
|
||||
icon_target="$USER_ICON_DIR/$icon_name"
|
||||
if [[ -L "$icon_target" ]]; then
|
||||
rm "$icon_target"
|
||||
elif [[ -e "$icon_target" ]]; then
|
||||
mv "$icon_target" "$icon_target.bak"
|
||||
fi
|
||||
ln -s "$icon_file" "$icon_target"
|
||||
done
|
||||
gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
|
||||
|
||||
PANAMA_APPLICATION_DIR="$PANAMA_PATH/config/local/share/applications"
|
||||
USER_APPLICATION_DIR="$HOME/.local/share/applications"
|
||||
mkdir -p "$USER_APPLICATION_DIR"
|
||||
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Install Panama's searchable launcher actions as one narrow directory link.
|
||||
# Keeping the runtime path stable lets Vicinae watch it while every command
|
||||
# remains authored and reviewed in the Panama repository.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
panama_path="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||
vicinae_data_dir="${VICINAE_DATA_DIR:-$HOME/.local/share/vicinae}"
|
||||
source_dir="$panama_path/config/local/share/vicinae/scripts"
|
||||
scripts_dir="$vicinae_data_dir/scripts"
|
||||
target_dir="$scripts_dir/panama"
|
||||
backup_root="$vicinae_data_dir/backups"
|
||||
backup_dir="$backup_root/panama.pre-panama"
|
||||
legacy_backup="$scripts_dir/panama.pre-panama"
|
||||
|
||||
[[ -d "$source_dir" ]] || {
|
||||
printf 'Panama command source is missing: %s\n' "$source_dir" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mkdir -p "$scripts_dir" "$backup_root"
|
||||
|
||||
# Older Panama builds preserved this directory alongside searchable commands.
|
||||
# Move it out before reloading so those scripts cannot appear twice.
|
||||
if [[ -e "$legacy_backup" ]]; then
|
||||
if [[ -e "$backup_dir" ]]; then
|
||||
printf 'Refusing to move %s; backup already exists at %s\n' \
|
||||
"$legacy_backup" "$backup_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv "$legacy_backup" "$backup_dir"
|
||||
fi
|
||||
|
||||
if [[ -L "$target_dir" ]]; then
|
||||
rm "$target_dir"
|
||||
elif [[ -e "$target_dir" ]]; then
|
||||
if [[ -e "$backup_dir" ]]; then
|
||||
printf 'Refusing to replace %s; backup already exists at %s\n' \
|
||||
"$target_dir" "$backup_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv "$target_dir" "$backup_dir"
|
||||
fi
|
||||
|
||||
ln -s "$source_dir" "$target_dir"
|
||||
|
||||
# The server also rescans periodically, but an explicit reload makes a setup
|
||||
# run deterministic. If Vicinae is not active yet, its startup scan is enough.
|
||||
if command -v vicinae >/dev/null 2>&1 && vicinae ping >/dev/null 2>&1; then
|
||||
vicinae cmd launch core:reload-scripts >/dev/null 2>&1 || true
|
||||
fi
|
||||
@@ -0,0 +1,56 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
property int refreshCalls: 0
|
||||
|
||||
FileView {
|
||||
id: tokenFile
|
||||
path: Quickshell.env("PANAMA_TEST_TOKEN_FILE")
|
||||
blockLoading: true
|
||||
printErrors: false
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
HomeAssistant.fixtureMode = true;
|
||||
HomeAssistantConfig.refreshHomeAssistant = function() { root.refreshCalls++; };
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "home-assistant-config-test"
|
||||
|
||||
function save(url: string, entities: string): bool {
|
||||
return HomeAssistantConfig.save(url, entities, "");
|
||||
}
|
||||
|
||||
function saveWithToken(url: string, entities: string): bool {
|
||||
return HomeAssistantConfig.save(url, entities, tokenFile.text());
|
||||
}
|
||||
|
||||
function clearToken(): bool {
|
||||
return HomeAssistantConfig.clearToken();
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
HomeAssistantConfig.refresh();
|
||||
}
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
url: HomeAssistantConfig.url,
|
||||
entities: HomeAssistantConfig.entities,
|
||||
tokenConfigured: HomeAssistantConfig.tokenConfigured,
|
||||
configured: HomeAssistantConfig.configured,
|
||||
busy: HomeAssistantConfig.busy,
|
||||
lastError: HomeAssistantConfig.lastError,
|
||||
pendingPayloadEmpty: HomeAssistantConfig.pendingPayload === "",
|
||||
refreshCalls: root.refreshCalls
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
function notification(idValue: int, desktopEntryValue: string, appNameValue: string): var {
|
||||
const closeHandlers = [];
|
||||
return {
|
||||
id: idValue,
|
||||
desktopEntry: desktopEntryValue,
|
||||
appName: appNameValue,
|
||||
appIcon: "",
|
||||
transient: false,
|
||||
lastGeneration: false,
|
||||
tracked: false,
|
||||
dismissed: false,
|
||||
closed: {
|
||||
connect: callback => closeHandlers.push(callback)
|
||||
},
|
||||
dismiss: function() {
|
||||
this.dismissed = true;
|
||||
for (const callback of closeHandlers)
|
||||
callback();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function resetNotifications(): void {
|
||||
Notifs.history = [];
|
||||
Notifs.popups = [];
|
||||
Notifs.unreadCount = 0;
|
||||
Notifs.doNotDisturb = false;
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
root.resetNotifications();
|
||||
Notifs.fallbackAppRules = {};
|
||||
Notifs.rememberedApplications = {};
|
||||
DesktopPreferences.set("notificationAppRules", {});
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "notification-app-rules-test"
|
||||
|
||||
function exercise(): string {
|
||||
root.reset();
|
||||
|
||||
const signal = root.notification(1, "org.signal.Signal.desktop", "Signal");
|
||||
Notifs.handleNotification(signal);
|
||||
const appId = Notifs.notificationAppId(signal);
|
||||
const initialRules = DesktopPreferences.get("notificationAppRules");
|
||||
|
||||
const fallback = root.notification(6, "", "Fallback Terminal");
|
||||
Notifs.handleNotification(fallback);
|
||||
const fallbackId = Notifs.notificationAppId(fallback);
|
||||
const fallbackApplication = Notifs.applications.find(app => app.id === fallbackId);
|
||||
|
||||
root.resetNotifications();
|
||||
Notifs.setAppRule(appId, { enabled: false });
|
||||
const muted = root.notification(2, "org.signal.Signal.desktop", "Signal");
|
||||
Notifs.handleNotification(muted);
|
||||
const mutedResult = {
|
||||
tracked: muted.tracked,
|
||||
history: Notifs.history.length,
|
||||
popups: Notifs.popups.length,
|
||||
unread: Notifs.unreadCount
|
||||
};
|
||||
|
||||
root.reset();
|
||||
Notifs.doNotDisturb = true;
|
||||
const dnd = root.notification(3, "org.signal.Signal.desktop", "Signal");
|
||||
Notifs.handleNotification(dnd);
|
||||
|
||||
Notifs.setAppRule("org.privacy.App.desktop", {
|
||||
showOnLockScreen: false,
|
||||
showContentOnLockScreen: false
|
||||
});
|
||||
const privateNotification = root.notification(4, "org.privacy.App.desktop", "Private");
|
||||
|
||||
return JSON.stringify({
|
||||
appId: appId,
|
||||
initialRules: initialRules,
|
||||
fallback: {
|
||||
id: fallbackId,
|
||||
application: fallbackApplication
|
||||
},
|
||||
muted: mutedResult,
|
||||
dnd: {
|
||||
tracked: dnd.tracked,
|
||||
history: Notifs.history.length,
|
||||
popups: Notifs.popups.length,
|
||||
unread: Notifs.unreadCount
|
||||
},
|
||||
privacy: {
|
||||
visible: Notifs.shouldShowOnLockScreen(privateNotification),
|
||||
content: Notifs.shouldShowContentOnLockScreen(privateNotification)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function persist(): string {
|
||||
root.reset();
|
||||
const notification = root.notification(5, "org.persist.App.desktop", "Persist");
|
||||
Notifs.handleNotification(notification);
|
||||
Notifs.setAppRule("org.persist.App.desktop", {
|
||||
enabled: false,
|
||||
showOnLockScreen: true,
|
||||
showContentOnLockScreen: false
|
||||
});
|
||||
return JSON.stringify(DesktopPreferences.get("notificationAppRules"));
|
||||
}
|
||||
|
||||
function restored(): string {
|
||||
return JSON.stringify(DesktopPreferences.get("notificationAppRules"));
|
||||
}
|
||||
|
||||
function applications(): string {
|
||||
return JSON.stringify(Notifs.applications);
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Network & Devices reads real NetworkManager and BlueZ state.
|
||||
#
|
||||
# Both of the bugs this contract exists to prevent were silent. Neither logged
|
||||
# anything; both produced a page that looked fine and told the user something
|
||||
# false:
|
||||
#
|
||||
# * the device lookups used enum names that do not exist
|
||||
# (NetworkDeviceType.Wifi rather than DeviceType.Wifi), so they returned
|
||||
# null and the page reported "No Wi-Fi adapter" on a machine whose Wi-Fi was
|
||||
# connected;
|
||||
# * signalStrength is 0.0-1.0, not a percentage, so thresholds written for
|
||||
# 0-100 put every network including the connected one in the bottom bucket.
|
||||
#
|
||||
# So this compares what the service resolves against what NetworkManager itself
|
||||
# reports, rather than merely checking the service does not crash.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
harness="$repo_dir/config/dot/quickshell/connectivity-harness.qml"
|
||||
|
||||
fail() {
|
||||
printf 'connectivity contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
command -v nmcli >/dev/null || fail 'nmcli is needed to check the service against reality'
|
||||
|
||||
run() { qs -p "$harness" "$@"; }
|
||||
harness_pid=""
|
||||
|
||||
cleanup() {
|
||||
run ipc call connectivity-test setActive false >/dev/null 2>&1 || true
|
||||
# By PID: never `pkill -f connectivity-harness`, which also matches the
|
||||
# shell running this script.
|
||||
[[ -n "$harness_pid" ]] && kill "$harness_pid" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
qs -p "$harness" --daemonize >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
run ipc show 2>/dev/null | rg -q '^target connectivity-test$' && break
|
||||
sleep 0.1
|
||||
done
|
||||
run ipc show 2>/dev/null | rg -q '^target connectivity-test$' || fail 'test IPC target did not start'
|
||||
harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')"
|
||||
|
||||
# Scanning only runs while the page says it is visible.
|
||||
run ipc call connectivity-test setActive true >/dev/null
|
||||
sleep 3
|
||||
|
||||
state="$(run ipc call connectivity-test status)"
|
||||
|
||||
# ── Devices the service finds must match the ones NetworkManager reports ─────
|
||||
nm_wifi="$(nmcli -t -f DEVICE,TYPE device | awk -F: '$2 == "wifi" { print $1; exit }')"
|
||||
nm_wired="$(nmcli -t -f DEVICE,TYPE,STATE device | awk -F: '$2 == "ethernet" && $3 == "connected" { print $1; exit }')"
|
||||
|
||||
if [[ -n "$nm_wifi" ]]; then
|
||||
[[ "$(jq -r .wifiDevice <<<"$state")" == "$nm_wifi" ]] \
|
||||
|| fail "NetworkManager reports Wi-Fi device '$nm_wifi' but the service found '$(jq -r .wifiDevice <<<"$state")'"
|
||||
fi
|
||||
if [[ -n "$nm_wired" ]]; then
|
||||
[[ "$(jq -r .wiredConnected <<<"$state")" == "true" ]] \
|
||||
|| fail "NetworkManager reports '$nm_wired' connected but the service says it is not"
|
||||
fi
|
||||
|
||||
# ── Signal strength is a ratio, and the labels must reflect that ─────────────
|
||||
while IFS='|' read -r value expect; do
|
||||
got="$(run ipc call connectivity-test labelFor "$value")"
|
||||
[[ "$got" == "$expect" ]] || fail "signal $value labelled '$got', expected '$expect'"
|
||||
done <<'CASES'
|
||||
1.0|Excellent
|
||||
0.85|Excellent
|
||||
0.6|Good
|
||||
0.4|Fair
|
||||
0.1|Weak
|
||||
0.0|No signal
|
||||
CASES
|
||||
|
||||
# If a network is connected, it must not be described as the weakest possible
|
||||
# thing -- that was the visible symptom of reading the ratio as a percentage.
|
||||
active_ssid="$(jq -r .activeSsid <<<"$state")"
|
||||
if [[ -n "$active_ssid" ]]; then
|
||||
strength="$(jq -r .activeStrength <<<"$state")"
|
||||
awk -v s="$strength" 'BEGIN { exit !(s >= 0 && s <= 1) }' \
|
||||
|| fail "signalStrength $strength is outside 0.0-1.0; the label buckets assume a ratio"
|
||||
fi
|
||||
|
||||
# ── Bluetooth ────────────────────────────────────────────────────────────────
|
||||
if [[ "$(bluetoothctl list 2>/dev/null | wc -l)" -gt 0 ]]; then
|
||||
[[ "$(jq -r .adapter <<<"$state")" == "true" ]] \
|
||||
|| fail 'an adapter is present but the service did not find it'
|
||||
fi
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'connectivity contract: PASS\n'
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Every pinned application must resolve to an installed desktop entry.
|
||||
#
|
||||
# DockBody drops a pin it cannot resolve rather than drawing a broken icon,
|
||||
# which is the right behaviour at runtime and a terrible one to debug: a typo in
|
||||
# the shipped list, or an application that changed its desktop id, simply
|
||||
# removes an icon from the dock with nothing logged.
|
||||
#
|
||||
# Also asserts the Settings entry resolves and carries Panama's own icon, since
|
||||
# it is the first pin and the one most likely to be wrong after a rename.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
harness="$repo_dir/config/dot/quickshell/dock-pin-harness.qml"
|
||||
config_home="$(mktemp -d /tmp/panama-dockpins.XXXXXX)"
|
||||
|
||||
fail() {
|
||||
printf 'dock pins contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
run() { XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"; }
|
||||
harness_pid=""
|
||||
|
||||
cleanup() {
|
||||
# By PID, never `pkill -f dock-pin-harness`: that pattern also matches the
|
||||
# shell running this script.
|
||||
[[ -n "$harness_pid" ]] && kill "$harness_pid" >/dev/null 2>&1 || true
|
||||
rm -rf "$config_home"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
run ipc show 2>/dev/null | rg -q '^target dock-pin-test$' && break
|
||||
sleep 0.1
|
||||
done
|
||||
run ipc show 2>/dev/null | rg -q '^target dock-pin-test$' || fail 'test IPC target did not start'
|
||||
harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')"
|
||||
|
||||
# DesktopEntries populates asynchronously; asking before it has finished reports
|
||||
# every pin as missing, which is a false failure rather than a real one.
|
||||
loaded=0
|
||||
for _ in $(seq 1 60); do
|
||||
loaded="$(run ipc call dock-pin-test count 2>/dev/null || printf 0)"
|
||||
[[ "$loaded" =~ ^[0-9]+$ ]] && (( loaded > 0 )) && break
|
||||
sleep 0.25
|
||||
done
|
||||
(( loaded > 0 )) || fail 'no desktop entries were discovered at all'
|
||||
|
||||
state="$(run ipc call dock-pin-test resolve)"
|
||||
|
||||
missing="$(jq -r '.missing | join(", ")' <<<"$state")"
|
||||
[[ -z "$missing" ]] || fail "these pinned applications do not resolve and would vanish from the dock: $missing"
|
||||
|
||||
jq -e '.first.id == "panama-settings" and .first.found == true' <<<"$state" >/dev/null \
|
||||
|| fail "Panama Settings is not the first pin, or does not resolve: $(jq -c .first <<<"$state")"
|
||||
jq -e '.first.icon == "panama-settings"' <<<"$state" >/dev/null \
|
||||
|| fail "Settings is not using Panama's own icon: $(jq -r .first.icon <<<"$state")"
|
||||
|
||||
icon="$HOME/.local/share/icons/hicolor/scalable/apps/panama-settings.svg"
|
||||
[[ -r "$icon" ]] || fail "the icon it names is not installed at $icon"
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'dock pins contract: PASS (%s pins, all resolve)\n' "$(jq -r .total <<<"$state")"
|
||||
@@ -0,0 +1,5 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Persisted Fixture App
|
||||
Exec=/usr/bin/true
|
||||
Icon=applications-system
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-home-assistant-config"
|
||||
service="$repo_dir/config/dot/quickshell/services/HomeAssistantConfig.qml"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/HomePhonePage.qml"
|
||||
password_field="$repo_dir/config/dot/quickshell/modules/settings/PasswordField.qml"
|
||||
harness_fixture="$repo_dir/tests/quickshell/HomeAssistantConfigHarness.qml"
|
||||
work="$(mktemp -d /tmp/panama-ha-config.XXXXXX)"
|
||||
env_file="$work/env"
|
||||
|
||||
fail() {
|
||||
printf 'Home Assistant config contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if declare -F qs_for_test >/dev/null; then
|
||||
qs_for_test kill >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf "$work"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
[[ -x "$helper" ]] || fail 'credential helper is missing or not executable'
|
||||
[[ -f "$service" ]] || fail 'credential service is missing'
|
||||
[[ -f "$harness_fixture" ]] || fail 'credential runtime harness is missing'
|
||||
|
||||
rg -Fq 'stdinEnabled: true' "$service" || fail 'credential writes do not use process stdin'
|
||||
rg -Fq 'writeProc.write(root.pendingPayload + "\n")' "$service" || fail 'credential payload is not written over stdin'
|
||||
rg -Fq 'root.pendingPayload = ""' "$service" || fail 'credential payload remains in service memory after write'
|
||||
if rg -q 'command:.*(token|pendingPayload)' "$service"; then
|
||||
fail 'credential data can reach a process command line'
|
||||
fi
|
||||
rg -Fq 'PasswordField {' "$page" || fail 'Home Assistant token is not entered through the masked field'
|
||||
rg -Fq 'activeFocusOnTab: true' "$password_field" || fail 'masked credential field is not keyboard reachable'
|
||||
rg -Fq 'HomeAssistantConfig.save(' "$page" || fail 'Home Assistant configuration cannot be saved from Settings'
|
||||
rg -Fq 'HomeAssistantConfig.clearToken()' "$page" || fail 'stored Home Assistant token cannot be cleared'
|
||||
rg -Fq 'id: clearTokenButton' "$page" || fail 'clear-token action has no keyboard control identity'
|
||||
rg -Fq 'id: saveHomeConfigButton' "$page" || fail 'save action has no keyboard control identity'
|
||||
rg -Fq 'activeFocusOnTab: enabled' "$page" || fail 'credential actions are not in tab order'
|
||||
rg -Fq 'Keys.onReturnPressed:' "$page" || fail 'credential actions have no keyboard activation'
|
||||
|
||||
cat >"$env_file" <<'EOF'
|
||||
# Existing private shell settings must survive byte-for-byte.
|
||||
export KEEP_ME='untouched value'
|
||||
export JIRA_CREDENTIALS='unrelated-secret'
|
||||
export PANAMA_HOME_ASSISTANT_URL='https://old.example.test'
|
||||
export PANAMA_HOME_ASSISTANT_TOKEN='old-token'
|
||||
export PANAMA_HOME_ASSISTANT_ENTITIES='light.old'
|
||||
EOF
|
||||
chmod 0644 "$env_file"
|
||||
|
||||
run_helper() {
|
||||
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" "$helper" "$@"
|
||||
}
|
||||
|
||||
status="$(run_helper status)" || fail 'status failed for a valid private env file'
|
||||
jq -e '.configured == true and .tokenConfigured == true
|
||||
and .url == "https://old.example.test"
|
||||
and .entities == ["light.old"] and (has("token") | not)' \
|
||||
<<<"$status" >/dev/null || fail "status exposed or misread credentials: $status"
|
||||
if rg -q 'old-token|unrelated-secret' <<<"$status"; then
|
||||
fail 'status output leaked a secret'
|
||||
fi
|
||||
|
||||
secret='ha-secret-must-never-appear-in-ps-or-output'
|
||||
payload="$work/payload.json"
|
||||
jq -cn --arg token "$secret" '{
|
||||
url: "https://home.example.test/",
|
||||
token: $token,
|
||||
entities: ["light.kitchen", "light.desk", "light.kitchen"]
|
||||
}' >"$payload"
|
||||
|
||||
# Keep stdin open long enough to prove the token is absent from the helper's
|
||||
# process arguments. The secret lives only in the private payload file/stdin.
|
||||
fifo="$work/input.fifo"
|
||||
mkfifo "$fifo"
|
||||
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" "$helper" write <"$fifo" >"$work/write.out" 2>"$work/write.err" &
|
||||
helper_pid=$!
|
||||
for _ in $(seq 1 30); do
|
||||
kill -0 "$helper_pid" 2>/dev/null && break
|
||||
sleep 0.05
|
||||
done
|
||||
if ps -o args= -p "$helper_pid" | rg -Fq "$secret"; then
|
||||
fail 'token appeared in the credential helper process arguments'
|
||||
fi
|
||||
cp "$payload" "$fifo"
|
||||
wait "$helper_pid" || fail 'stdin credential write failed'
|
||||
|
||||
write_result="$(cat "$work/write.out")"
|
||||
jq -e '.ok == true and .configured == true and .tokenConfigured == true
|
||||
and .url == "https://home.example.test"
|
||||
and .entities == ["light.kitchen", "light.desk"] and (has("token") | not)' \
|
||||
<<<"$write_result" >/dev/null || fail "write returned unsafe or incorrect state: $write_result"
|
||||
if rg -q "$secret|old-token|unrelated-secret" "$work/write.out" "$work/write.err"; then
|
||||
fail 'credential helper output leaked a secret'
|
||||
fi
|
||||
|
||||
[[ "$(stat -c '%a' "$env_file")" == "600" ]] || fail 'private env file is not mode 0600'
|
||||
rg -Fxq "export KEEP_ME='untouched value'" "$env_file" || fail 'unrelated env content changed'
|
||||
rg -Fxq "export JIRA_CREDENTIALS='unrelated-secret'" "$env_file" || fail 'unrelated secret changed'
|
||||
rg -Fq "$secret" "$env_file" || fail 'new token was not stored'
|
||||
|
||||
# Omitting token preserves it; an explicit empty token clears it.
|
||||
printf '%s\n' '{"url":"https://new.example.test","entities":"light.office, light.hall"}' \
|
||||
| run_helper write >/dev/null || fail 'non-secret update failed'
|
||||
rg -Fq "$secret" "$env_file" || fail 'blank token field unexpectedly erased the stored token'
|
||||
|
||||
printf '%s\n' '{"token":""}' | run_helper write >/dev/null || fail 'token clear failed'
|
||||
cleared="$(run_helper status)"
|
||||
jq -e '.configured == false and .tokenConfigured == false
|
||||
and .url == "https://new.example.test"
|
||||
and .entities == ["light.office", "light.hall"]' \
|
||||
<<<"$cleared" >/dev/null || fail "cleared state is wrong: $cleared"
|
||||
|
||||
before_hash="$(sha256sum "$env_file" | cut -d' ' -f1)"
|
||||
printf '%s\n' '{"url":"file:///etc/passwd"}' | run_helper write >/dev/null 2>&1 \
|
||||
&& fail 'invalid URL was accepted'
|
||||
after_hash="$(sha256sum "$env_file" | cut -d' ' -f1)"
|
||||
[[ "$before_hash" == "$after_hash" ]] || fail 'rejected input still modified the private env file'
|
||||
|
||||
# Exercise the actual QML Process.write() boundary with a pre-existing token.
|
||||
# The IPC carries only non-secret fields; the helper must preserve the token.
|
||||
config_path="$work/quickshell"
|
||||
harness="$config_path/home-assistant-config-harness.qml"
|
||||
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
|
||||
cp "$harness_fixture" "$harness"
|
||||
printf '%s\n' \
|
||||
"export PANAMA_HOME_ASSISTANT_URL='https://qml-old.example.test'" \
|
||||
"export PANAMA_HOME_ASSISTANT_TOKEN=''" \
|
||||
"export PANAMA_HOME_ASSISTANT_ENTITIES='light.old'" >"$env_file"
|
||||
chmod 0600 "$env_file"
|
||||
qml_token_file="$work/qml-token"
|
||||
printf '%s' 'qml-private-token' >"$qml_token_file"
|
||||
chmod 0600 "$qml_token_file"
|
||||
|
||||
qs_for_test() {
|
||||
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" \
|
||||
PANAMA_TEST_TOKEN_FILE="$qml_token_file" \
|
||||
XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" \
|
||||
qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
stop_harness() {
|
||||
qs_for_test kill >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
qs_for_test --daemonize >/dev/null
|
||||
for _ in $(seq 1 60); do
|
||||
qs_for_test ipc show 2>/dev/null | rg -q '^target home-assistant-config-test$' && break
|
||||
sleep 0.1
|
||||
done
|
||||
qs_for_test ipc show 2>/dev/null | rg -q '^target home-assistant-config-test$' \
|
||||
|| fail 'credential QML harness did not start'
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
|
||||
jq -e '.busy == false and .url == "https://qml-old.example.test"' <<<"$qml_status" >/dev/null && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.configured == false and .tokenConfigured == false and .pendingPayloadEmpty == true' \
|
||||
<<<"$qml_status" >/dev/null || fail "QML service did not load redacted state: $qml_status"
|
||||
|
||||
qs_for_test ipc call home-assistant-config-test saveWithToken \
|
||||
https://qml-new.example.test 'light.office,light.hall' >/dev/null \
|
||||
|| fail 'QML service refused a private token-file update'
|
||||
for _ in $(seq 1 60); do
|
||||
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
|
||||
jq -e '.busy == false and .configured == true and .tokenConfigured == true
|
||||
and .refreshCalls > 0' <<<"$qml_status" >/dev/null && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.configured == true and .tokenConfigured == true
|
||||
and .url == "https://qml-new.example.test"
|
||||
and .entities == ["light.office", "light.hall"]
|
||||
and .pendingPayloadEmpty == true and .lastError == "" and .refreshCalls > 0' \
|
||||
<<<"$qml_status" >/dev/null || fail "QML secret stdin save did not settle safely: $qml_status"
|
||||
rg -Fq 'qml-private-token' "$env_file" || fail 'QML secret stdin save did not store the token'
|
||||
if ps -o args= -p "$(qs_for_test list | awk '/Process ID:/ {print $3; exit}')" | rg -Fq 'qml-private-token'; then
|
||||
fail 'QML token appeared in the shell process arguments'
|
||||
fi
|
||||
|
||||
qs_for_test ipc call home-assistant-config-test save \
|
||||
https://qml-final.example.test 'light.bedroom,light.hall' >/dev/null \
|
||||
|| fail 'QML service refused a non-secret update'
|
||||
for _ in $(seq 1 60); do
|
||||
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
|
||||
jq -e '.busy == false and .url == "https://qml-final.example.test"
|
||||
and .entities == ["light.bedroom", "light.hall"]' <<<"$qml_status" >/dev/null && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.configured == true and .tokenConfigured == true
|
||||
and .pendingPayloadEmpty == true and .lastError == ""' \
|
||||
<<<"$qml_status" >/dev/null || fail "QML stdin save did not settle safely: $qml_status"
|
||||
rg -Fq 'qml-private-token' "$env_file" || fail 'QML non-secret save erased the stored token'
|
||||
|
||||
qs_for_test ipc call home-assistant-config-test clearToken >/dev/null \
|
||||
|| fail 'QML service refused token clear'
|
||||
for _ in $(seq 1 60); do
|
||||
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
|
||||
jq -e '.busy == false and .tokenConfigured == false' <<<"$qml_status" >/dev/null && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.configured == false and .tokenConfigured == false
|
||||
and .pendingPayloadEmpty == true and .lastError == ""' \
|
||||
<<<"$qml_status" >/dev/null || fail "QML token clear did not settle safely: $qml_status"
|
||||
if rg -Fq 'qml-private-token' "$env_file"; then
|
||||
fail 'QML token clear left the old token in the private env file'
|
||||
fi
|
||||
stop_harness
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'Home Assistant config contract: PASS\n'
|
||||
@@ -13,6 +13,20 @@ helper="$project_root/config/dot/quickshell/scripts/panama-home-assistant"
|
||||
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
|
||||
|
||||
catalog="$($helper catalog)"
|
||||
|
||||
# This asserts a LIVE, authenticated Home Assistant. An absent credential is not
|
||||
# a defect in Panama, so it skips rather than fails -- otherwise the suite is red
|
||||
# on any machine that has not been given a token, and a red suite that is
|
||||
# expected to be red stops being read.
|
||||
#
|
||||
# A configured-but-broken bridge still fails, which is the case worth catching.
|
||||
if [[ "$(jq -r '.configured' <<<"$catalog")" != "true" ]]; then
|
||||
printf 'Home Assistant helper contract: SKIP (no token configured)\n'
|
||||
printf ' Set PANAMA_HOME_ASSISTANT_TOKEN in config/bash/env to exercise this.\n'
|
||||
printf ' Reason reported by the helper: %s\n' "$(jq -r '.error // "unknown"' <<<"$catalog")"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq -e '
|
||||
.ok == true and .configured == true and .error == "" and
|
||||
(.entities | type == "array" and length > 0) and
|
||||
|
||||
@@ -105,13 +105,21 @@ assert_contains 'Opens BlueBubbles' "$home_page"
|
||||
if rg -Fq 'index: model.index' "$home_page"; then
|
||||
fail 'HomePhonePage.qml reads an undefined model.index instead of the delegate index'
|
||||
fi
|
||||
if rg -qi 'token|bearer|api/states' "$home_page"; then
|
||||
fail 'HomePhonePage.qml crosses the credential or REST privacy boundary'
|
||||
if rg -qi 'bearer|api/states' "$home_page"; then
|
||||
fail 'HomePhonePage.qml crosses the Home Assistant REST boundary'
|
||||
fi
|
||||
assert_contains 'signal aliasCommitted(string id, string alias)' "$favorite_card"
|
||||
assert_contains 'signal removeRequested(string id)' "$favorite_card"
|
||||
assert_contains 'signal moveRequested(string id, int targetIndex)' "$favorite_card"
|
||||
assert_contains 'DragHandler {' "$favorite_card"
|
||||
assert_contains 'text: "↑"' "$favorite_card"
|
||||
assert_contains 'text: "↓"' "$favorite_card"
|
||||
assert_contains 'enabled: root.canMoveEarlier' "$favorite_card"
|
||||
assert_contains 'enabled: root.canMoveLater' "$favorite_card"
|
||||
if rg -Fq 'DragHandler {' "$favorite_card"; then
|
||||
fail 'Home light cards still expose the broken drag affordance'
|
||||
fi
|
||||
assert_contains 'canMoveEarlier: index > 0' "$home_page"
|
||||
assert_contains 'canMoveLater: index < favoritesGrid.count - 1' "$home_page"
|
||||
assert_contains 'onEditingFinished:' "$favorite_card"
|
||||
assert_contains 'text: "Control Center"' "$favorite_card"
|
||||
assert_contains 'activeFocusOnTab: true' "$favorite_card"
|
||||
|
||||
@@ -96,6 +96,8 @@ assert_reset_persists_without_debounce() {
|
||||
initial_ids=' ["light.kitchen","light.hall","light.desk"]'
|
||||
expected='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}],"saveError":""}'
|
||||
expected_file='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}]}'
|
||||
reordered_expected='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"},{"id":"light.hall","alias":""}],"saveError":""}'
|
||||
reordered_file='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"},{"id":"light.hall","alias":""}]}'
|
||||
empty_expected='{"initialized":true,"favorites":[],"saveError":""}'
|
||||
empty_file='{"initialized":true,"favorites":[]}'
|
||||
reset_expected='{"initialized":false,"favorites":[],"saveError":""}'
|
||||
@@ -106,6 +108,12 @@ start_harness
|
||||
qs_for_harness ipc call home-pref-test initialize "$initial_ids" >/dev/null
|
||||
qs_for_harness ipc call home-pref-test alias light.kitchen ' Island ' >/dev/null
|
||||
qs_for_harness ipc call home-pref-test move light.desk 0 >/dev/null
|
||||
wait_for_status "$reordered_expected"
|
||||
wait_for_file_content "$reordered_file"
|
||||
stop_harness
|
||||
start_harness
|
||||
wait_for_status "$reordered_expected"
|
||||
|
||||
qs_for_harness ipc call home-pref-test remove light.hall >/dev/null
|
||||
wait_for_status "$expected"
|
||||
wait_for_file_content "$expected_file"
|
||||
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
service="$repo_dir/config/dot/quickshell/services/Notifs.qml"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml"
|
||||
harness_fixture="$repo_dir/tests/quickshell/NotificationAppRulesHarness.qml"
|
||||
desktop_entry_fixture="$repo_dir/tests/quickshell/fixtures/org.persist.App.desktop"
|
||||
|
||||
fail() {
|
||||
printf 'notification application rules contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -f "$service" ]] || fail 'notification service is missing'
|
||||
[[ -f "$page" ]] || fail 'notification settings page is missing'
|
||||
[[ -f "$harness_fixture" ]] || fail 'runtime harness fixture is missing'
|
||||
[[ -f "$desktop_entry_fixture" ]] || fail 'runtime desktop entry fixture is missing'
|
||||
|
||||
SERVICE_PATH="$service" PAGE_PATH="$page" bun -e '
|
||||
const source = await Bun.file(process.env.SERVICE_PATH).text();
|
||||
const page = await Bun.file(process.env.PAGE_PATH).text();
|
||||
|
||||
function fail(message) {
|
||||
console.error(`notification application rules contract: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function functionBody(name) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
if (start === -1)
|
||||
fail(`missing ${name}()`);
|
||||
const open = source.indexOf("{", start);
|
||||
let depth = 0;
|
||||
for (let index = open; index < source.length; index++) {
|
||||
if (source[index] === "{") depth++;
|
||||
if (source[index] === "}" && --depth === 0)
|
||||
return source.slice(open + 1, index);
|
||||
}
|
||||
fail(`${name}() is unterminated`);
|
||||
}
|
||||
|
||||
const notificationAppId = Function("notification", functionBody("notificationAppId"));
|
||||
const normalizedAppRule = Function("rule", functionBody("normalizedAppRule"));
|
||||
|
||||
const identityFixtures = [
|
||||
{ notification: { desktopEntry: "org.signal.Signal.desktop", appName: "Signal" }, expected: "org.signal.Signal.desktop" },
|
||||
{ notification: { desktopEntry: "", appName: "Terminal" }, expected: "Terminal" },
|
||||
{ notification: { desktopEntry: "", appName: "" }, expected: "Notifications" }
|
||||
];
|
||||
for (const fixture of identityFixtures) {
|
||||
const actual = notificationAppId(fixture.notification);
|
||||
if (actual !== fixture.expected)
|
||||
fail(`stable app identity expected ${fixture.expected}, got ${actual}`);
|
||||
}
|
||||
|
||||
const defaultRule = normalizedAppRule({});
|
||||
if (JSON.stringify(defaultRule) !== JSON.stringify({ enabled: true, showOnLockScreen: true, showContentOnLockScreen: true }))
|
||||
fail(`missing rule fields did not default safely: ${JSON.stringify(defaultRule)}`);
|
||||
|
||||
const explicitRule = normalizedAppRule({ enabled: false, showOnLockScreen: false, showContentOnLockScreen: false });
|
||||
if (JSON.stringify(explicitRule) !== JSON.stringify({ enabled: false, showOnLockScreen: false, showContentOnLockScreen: false }))
|
||||
fail(`explicit rule was not preserved: ${JSON.stringify(explicitRule)}`);
|
||||
|
||||
for (const required of ["rememberApplication", "appRule", "setAppRule", "handleNotification", "shouldShowOnLockScreen", "shouldShowContentOnLockScreen"]) {
|
||||
functionBody(required);
|
||||
}
|
||||
|
||||
const handler = source.indexOf("function handleNotification(notification: var)");
|
||||
const tracked = source.indexOf("notification.tracked = true", handler);
|
||||
const muted = source.indexOf("!root.appRule(appId).enabled", handler);
|
||||
if (handler === -1 || tracked === -1 || muted === -1 || muted > tracked)
|
||||
fail("muted applications are not rejected before tracking/history/unread/toast work");
|
||||
if (!source.includes("root.handleNotification(notification)"))
|
||||
fail("NotificationServer does not delegate delivery to the callable handler");
|
||||
if (!source.includes("PreferenceSchema.has(\"notificationAppRules\")"))
|
||||
fail("the schema dependency is not explicit");
|
||||
if (!source.includes("DesktopPreferences.get(\"notificationAppRules\")"))
|
||||
fail("rules are not read through DesktopPreferences");
|
||||
if (!source.includes("DesktopPreferences.set(\"notificationAppRules\", next)"))
|
||||
fail("rules are not written through DesktopPreferences");
|
||||
if (!source.includes("next[knownAppId] = root.appRule(knownAppId)"))
|
||||
fail("persisted rules are not normalized to the required three-field shape");
|
||||
if (!source.includes("root.fallbackAppRules = next"))
|
||||
fail("missing-schema preference writes do not retain an in-memory fallback");
|
||||
if (!source.includes("if (!root.doNotDisturb)"))
|
||||
fail("global DND popup override was removed");
|
||||
for (const required of [
|
||||
"DesktopEntries.applications.values",
|
||||
"DesktopEntries.byId(appId)",
|
||||
"DesktopEntries.heuristicLookup(appId)"
|
||||
]) {
|
||||
if (!source.includes(required))
|
||||
fail(`persisted desktop entry ids are not reactively resolved through ${required}`);
|
||||
}
|
||||
|
||||
for (const required of [
|
||||
"Notifs.applications",
|
||||
"Notifs.appRule(app.id).enabled",
|
||||
"showOnLockScreen",
|
||||
"showContentOnLockScreen",
|
||||
"Notifs.setAppRule"
|
||||
]) {
|
||||
if (!page.includes(required))
|
||||
fail(`settings page is missing ${required}`);
|
||||
}
|
||||
|
||||
console.log("notification application rules contract: PASS");
|
||||
'
|
||||
|
||||
state_home="$(mktemp -d /tmp/panama-notification-rules-state.XXXXXX)"
|
||||
config_home="$(mktemp -d /tmp/panama-notification-rules-config.XXXXXX)"
|
||||
data_home="$(mktemp -d /tmp/panama-notification-rules-data.XXXXXX)"
|
||||
config_path="$state_home/quickshell"
|
||||
harness="$config_path/notification-app-rules-harness.qml"
|
||||
shell_log="$state_home/notification-app-rules.log"
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${bus_pid:-}" ]]; then
|
||||
kill "$bus_pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf "$state_home" "$config_home" "$data_home"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
|
||||
cp "$harness_fixture" "$harness"
|
||||
mkdir -p "$data_home/applications"
|
||||
cp "$desktop_entry_fixture" "$data_home/applications/org.persist.App.desktop"
|
||||
|
||||
# This is intentionally a copy-local integration dependency. The production
|
||||
# schema is Claude's change; the runtime contract proves persistence only once
|
||||
# that key exists and never stages a schema edit from this branch.
|
||||
perl -0pi -e 's@(\n // ── Capture)@\n {\n key: "notificationAppRules", type: "json", def: {}, group: "notifications", internal: true\n },$1@' \
|
||||
"$config_path/config/PreferenceSchema.qml"
|
||||
rg -q 'key: "notificationAppRules", type: "json"' "$config_path/config/PreferenceSchema.qml" \
|
||||
|| fail 'temporary schema integration key was not installed'
|
||||
|
||||
mapfile -t dbus_info < <(dbus-daemon --session --fork --print-address=1 --print-pid=1)
|
||||
bus_address="${dbus_info[0]:-}"
|
||||
bus_pid="${dbus_info[1]:-}"
|
||||
[[ -n "$bus_address" && "$bus_pid" =~ ^[0-9]+$ ]] || fail 'private D-Bus session did not start'
|
||||
|
||||
qs_for_test() {
|
||||
DBUS_SESSION_BUS_ADDRESS="$bus_address" \
|
||||
XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" \
|
||||
XDG_DATA_HOME="$data_home" XDG_DATA_DIRS="$data_home" \
|
||||
qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
stop_harness() {
|
||||
qs_for_test kill >/dev/null 2>&1 || true
|
||||
for _ in $(seq 1 40); do
|
||||
! qs_for_test ipc show >/dev/null 2>&1 && return
|
||||
sleep 0.1
|
||||
done
|
||||
fail 'isolated notification harness did not stop cleanly'
|
||||
}
|
||||
|
||||
start_harness() {
|
||||
qs_for_test --daemonize >"$shell_log" 2>&1
|
||||
for _ in $(seq 1 40); do
|
||||
qs_for_test ipc show 2>/dev/null | rg -q '^target notification-app-rules-test$' && return
|
||||
sleep 0.1
|
||||
done
|
||||
sed -n '1,240p' "$shell_log" >&2
|
||||
fail 'isolated notification harness did not start'
|
||||
}
|
||||
|
||||
start_harness
|
||||
exercise="$(qs_for_test ipc call notification-app-rules-test exercise)"
|
||||
jq -e '
|
||||
.appId == "org.signal.Signal.desktop" and
|
||||
.initialRules == {
|
||||
"org.signal.Signal.desktop": {
|
||||
enabled: true,
|
||||
showOnLockScreen: true,
|
||||
showContentOnLockScreen: true
|
||||
}
|
||||
} and
|
||||
.muted == { tracked: false, history: 0, popups: 0, unread: 0 } and
|
||||
.dnd == { tracked: true, history: 1, popups: 0, unread: 1 } and
|
||||
.privacy == { visible: false, content: false } and
|
||||
.fallback == {
|
||||
id: "Fallback Terminal",
|
||||
application: { id: "Fallback Terminal", name: "Fallback Terminal" }
|
||||
}
|
||||
' <<<"$exercise" >/dev/null || fail "runtime notification policy fixture failed: $exercise"
|
||||
|
||||
persisted="$(qs_for_test ipc call notification-app-rules-test persist)"
|
||||
jq -e '. == {
|
||||
"org.persist.App.desktop": {
|
||||
enabled: false,
|
||||
showOnLockScreen: true,
|
||||
showContentOnLockScreen: false
|
||||
}
|
||||
}' <<<"$persisted" >/dev/null || fail "runtime persistence fixture wrote the wrong shape: $persisted"
|
||||
|
||||
settings_file="$config_home/panama/settings.json"
|
||||
for _ in $(seq 1 40); do
|
||||
[[ -f "$settings_file" ]] && jq -e '.notificationAppRules["org.persist.App.desktop"].enabled == false' "$settings_file" >/dev/null && break
|
||||
sleep 0.1
|
||||
done
|
||||
[[ -f "$settings_file" ]] || fail 'runtime persistence fixture did not write settings.json'
|
||||
|
||||
wait_for_persisted_application() {
|
||||
local expected="$1"
|
||||
local applications=""
|
||||
for _ in $(seq 1 80); do
|
||||
applications="$(qs_for_test ipc call notification-app-rules-test applications)"
|
||||
if jq -e '.[] | select(.id == "org.persist.App.desktop" and .name == "Persisted Fixture App")' \
|
||||
<<<"$applications" >/dev/null; then
|
||||
[[ "$applications" == *"$expected"* ]] && printf '%s' "$applications" && return
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
fail "persisted desktop entry did not resolve to a friendly name: $applications"
|
||||
}
|
||||
|
||||
before_restart_applications="$(wait_for_persisted_application 'Persisted Fixture App')"
|
||||
|
||||
stop_harness
|
||||
start_harness
|
||||
restored="$(qs_for_test ipc call notification-app-rules-test restored)"
|
||||
[[ "$restored" == "$persisted" ]] || fail "notification rules did not survive isolated restart: $restored"
|
||||
after_restart_applications="$(wait_for_persisted_application 'Persisted Fixture App')"
|
||||
[[ "$before_restart_applications" == *'"id":"org.persist.App.desktop","name":"Persisted Fixture App"'* ]] \
|
||||
|| fail "persisted application name was wrong before restart: $before_restart_applications"
|
||||
[[ "$after_restart_applications" == *'"id":"org.persist.App.desktop","name":"Persisted Fixture App"'* ]] \
|
||||
|| fail "persisted application name was wrong after restart: $after_restart_applications"
|
||||
stop_harness
|
||||
|
||||
printf 'notification application rules runtime contract: PASS\n'
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-osd"
|
||||
scratch="$(mktemp -d)"
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
|
||||
mkdir -p "$scratch/bin"
|
||||
log="$scratch/calls"
|
||||
|
||||
cat >"$scratch/bin/wpctl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'wpctl' >>"$OSD_TEST_LOG"
|
||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
||||
printf '\n' >>"$OSD_TEST_LOG"
|
||||
if [[ $1 == "get-volume" ]]; then
|
||||
printf '%s\n' "${WPCTL_OUTPUT:-Volume: 0.58}"
|
||||
fi
|
||||
SH
|
||||
|
||||
cat >"$scratch/bin/brightnessctl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'brightnessctl' >>"$OSD_TEST_LOG"
|
||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
||||
printf '\n' >>"$OSD_TEST_LOG"
|
||||
if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then
|
||||
printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,1000,50%}"
|
||||
fi
|
||||
SH
|
||||
|
||||
cat >"$scratch/bin/playerctl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'playerctl' >>"$OSD_TEST_LOG"
|
||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
||||
printf '\n' >>"$OSD_TEST_LOG"
|
||||
if [[ $1 == "metadata" ]]; then
|
||||
printf '%s\n' "${PLAYER_OUTPUT:-Horizon — Tycho}"
|
||||
elif [[ $1 == "status" ]]; then
|
||||
printf '%s\n' "${PLAYER_STATUS:-Playing}"
|
||||
fi
|
||||
SH
|
||||
|
||||
cat >"$scratch/bin/qs" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'qs' >>"$OSD_TEST_LOG"
|
||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
||||
printf '\n' >>"$OSD_TEST_LOG"
|
||||
[[ ${OSD_TEST_FAIL_QS:-false} != true ]]
|
||||
SH
|
||||
|
||||
chmod +x "$scratch/bin/"*
|
||||
|
||||
run_helper() {
|
||||
PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" \
|
||||
OSD_TEST_FAIL_QS="${OSD_TEST_FAIL_QS:-false}" \
|
||||
PANAMA_OSD_STRICT="${PANAMA_OSD_STRICT:-false}" \
|
||||
"$helper" "$@"
|
||||
}
|
||||
|
||||
assert_line() {
|
||||
local expected="$1"
|
||||
grep -Fqx -- "$expected" "$log" || {
|
||||
printf 'osd helper contract: missing call\n%s\nactual:\n' "$expected" >&2
|
||||
cat "$log" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
: >"$log"
|
||||
run_helper volume up 6
|
||||
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>'
|
||||
assert_line 'wpctl <get-volume> <@DEFAULT_AUDIO_SINK@>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <volume> <58> <100> <58%>'
|
||||
|
||||
: >"$log"
|
||||
WPCTL_OUTPUT='Volume: 0.58 [MUTED]' run_helper volume toggle
|
||||
assert_line 'wpctl <set-mute> <@DEFAULT_AUDIO_SINK@> <toggle>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <volume-muted> <58> <100> <Muted>'
|
||||
|
||||
: >"$log"
|
||||
WPCTL_OUTPUT='Volume: 0.72 [MUTED]' run_helper microphone toggle
|
||||
assert_line 'wpctl <set-mute> <@DEFAULT_AUDIO_SOURCE@> <toggle>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <microphone-muted> <72> <100> <Muted>'
|
||||
|
||||
: >"$log"
|
||||
run_helper brightness up 5
|
||||
assert_line 'brightnessctl <-e4> <-n2> <set> <5%+>'
|
||||
assert_line 'brightnessctl <-m> <-c> <backlight>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <50> <100> <50%>'
|
||||
|
||||
: >"$log"
|
||||
run_helper media next
|
||||
assert_line 'playerctl <next>'
|
||||
assert_line 'qs <ipc> <call> <osd> <message> <media-next> <Horizon — Tycho>'
|
||||
|
||||
: >"$log"
|
||||
run_helper message notifications-disabled-symbolic 'Do Not Disturb On'
|
||||
assert_line 'qs <ipc> <call> <osd> <message> <notifications-disabled-symbolic> <Do Not Disturb On>'
|
||||
|
||||
: >"$log"
|
||||
if OSD_TEST_FAIL_QS=true PANAMA_OSD_STRICT=1 run_helper message dialog-error-symbolic 'Strict failure'; then
|
||||
printf 'osd helper contract: strict delivery swallowed an IPC failure\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
: >"$log"
|
||||
OSD_TEST_FAIL_QS=true run_helper message dialog-information-symbolic 'Best effort'
|
||||
|
||||
printf 'osd helper contract: PASS\n'
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
model="$repo_dir/config/dot/quickshell/modules/osd/OsdModel.js"
|
||||
|
||||
node - "$model" <<'JS'
|
||||
const assert = require('node:assert/strict')
|
||||
const model = require(process.argv[2])
|
||||
|
||||
assert.equal(model.iconFor('volume', 0), 'audio-volume-muted-symbolic')
|
||||
assert.equal(model.iconFor('volume', 0.2), 'audio-volume-low-symbolic')
|
||||
assert.equal(model.iconFor('volume', 0.5), 'audio-volume-medium-symbolic')
|
||||
assert.equal(model.iconFor('volume', 0.9), 'audio-volume-high-symbolic')
|
||||
assert.equal(model.iconFor('microphone-muted', 0.7), 'microphone-sensitivity-muted-symbolic')
|
||||
assert.equal(model.iconFor('brightness', 0.4), 'display-brightness-symbolic')
|
||||
|
||||
assert.deepEqual(
|
||||
model.progressState('volume', 140, 100, '', 900),
|
||||
{
|
||||
kind: 'volume',
|
||||
value: 100,
|
||||
maximum: 100,
|
||||
ratio: 1,
|
||||
label: '100%',
|
||||
icon: 'audio-volume-high-symbolic',
|
||||
duration: 900,
|
||||
progress: true
|
||||
}
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
model.progressState('volume-muted', 43, 100, 'Muted', -50),
|
||||
{
|
||||
kind: 'volume-muted',
|
||||
value: 43,
|
||||
maximum: 100,
|
||||
ratio: 0.43,
|
||||
label: 'Muted',
|
||||
icon: 'audio-volume-muted-symbolic',
|
||||
duration: 0,
|
||||
progress: true
|
||||
}
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
model.messageState('media-next', 'Glass Beams', 'invalid'),
|
||||
{
|
||||
kind: 'media-next',
|
||||
value: 0,
|
||||
maximum: 100,
|
||||
ratio: 0,
|
||||
label: 'Glass Beams',
|
||||
icon: 'media-skip-forward-symbolic',
|
||||
duration: 1400,
|
||||
progress: false
|
||||
}
|
||||
)
|
||||
|
||||
console.log('osd model contract: PASS')
|
||||
JS
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
qs_dir="$repo_dir/config/dot/quickshell"
|
||||
osd="$qs_dir/modules/osd/Osd.qml"
|
||||
state="$qs_dir/services/OsdState.qml"
|
||||
shell_file="$qs_dir/shell.qml"
|
||||
keybinds="$repo_dir/config/dot/hypr/keybinds.lua"
|
||||
|
||||
fail() {
|
||||
printf 'osd ui contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -f "$osd" ]] || fail 'Prism OSD surface is missing'
|
||||
[[ -f "$state" ]] || fail 'OSD presentation state is missing'
|
||||
[[ -f "$qs_dir/modules/osd/qmldir" ]] || fail 'OSD module manifest is missing'
|
||||
rg -q '^import Quickshell$' "$state" \
|
||||
|| fail 'OSD singleton does not import the Quickshell Singleton type'
|
||||
|
||||
rg -Fq 'import qs.modules.osd' "$shell_file" || fail 'shell does not import the OSD module'
|
||||
[[ "$(rg -c '^[[:space:]]*Osd \{\}' "$shell_file")" -eq 1 ]] \
|
||||
|| fail 'shell does not create exactly one OSD per screen'
|
||||
rg -Fq 'target: "osd"' "$shell_file" || fail 'OSD IPC target is missing'
|
||||
rg -Fq 'OsdState.progress(kind, value, maximum, label)' "$shell_file" \
|
||||
|| fail 'progress IPC is not wired to OSD state'
|
||||
rg -Fq 'OsdState.message(kind, label)' "$shell_file" \
|
||||
|| fail 'message IPC is not wired to OSD state'
|
||||
|
||||
rg -Fq 'WlrLayershell.namespace: "qs-popover-osd"' "$osd" \
|
||||
|| fail 'OSD does not use the existing Prism blur namespace'
|
||||
rg -Fq 'WlrLayershell.keyboardFocus: WlrKeyboardFocus.None' "$osd" \
|
||||
|| fail 'OSD may steal keyboard focus'
|
||||
rg -Fq 'mask: Region {}' "$osd" || fail 'OSD may intercept pointer input'
|
||||
rg -Fq 'PrismEdge {' "$osd" || fail 'OSD is missing the Prism signature edge'
|
||||
rg -Fq 'font.features: Theme.tabularFigures' "$osd" \
|
||||
|| fail 'changing percentages do not use tabular figures'
|
||||
|
||||
rg -Fq '$HOME/.config/quickshell/scripts/panama-osd' "$keybinds" \
|
||||
|| fail 'keybinds do not use the deployed Panama OSD helper'
|
||||
for action in \
|
||||
'volume up 6' \
|
||||
'volume down 6' \
|
||||
'volume toggle' \
|
||||
'microphone toggle' \
|
||||
'volume up 1' \
|
||||
'volume down 1' \
|
||||
'media play-pause' \
|
||||
'media next' \
|
||||
'media previous' \
|
||||
'media stop' \
|
||||
'brightness up 5' \
|
||||
'brightness down 5'; do
|
||||
rg -Fq "osd(\"$action\")" "$keybinds" \
|
||||
|| fail "keybind is not routed through panama-osd $action"
|
||||
done
|
||||
|
||||
printf 'osd ui contract: PASS\n'
|
||||
Executable
+228
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
dispatcher="$repo_dir/config/dot/quickshell/scripts/panama-action"
|
||||
work="$(mktemp -d /tmp/panama-action.XXXXXX)"
|
||||
fake_bin="$work/bin"
|
||||
command_log="$work/commands.log"
|
||||
|
||||
fail() {
|
||||
printf 'Panama action contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$work"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
[[ -x "$dispatcher" ]] || fail 'dispatcher is missing or not executable'
|
||||
|
||||
mkdir -p "$fake_bin" "$work/home/.config/quickshell/scripts"
|
||||
|
||||
make_recorder() {
|
||||
local command_name="$1"
|
||||
cp /dev/stdin "$fake_bin/$command_name"
|
||||
chmod +x "$fake_bin/$command_name"
|
||||
}
|
||||
|
||||
make_recorder qs <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
printf 'qs' >>"$PANAMA_ACTION_TEST_LOG"
|
||||
printf ' <%s>' "$@" >>"$PANAMA_ACTION_TEST_LOG"
|
||||
printf '\n' >>"$PANAMA_ACTION_TEST_LOG"
|
||||
case "$*" in
|
||||
'ipc call notifications dnd'|'ipc call caffeine toggle'|'ipc call night-light toggle')
|
||||
printf '%s\n' "${PANAMA_ACTION_TEST_STATE:-true}"
|
||||
;;
|
||||
'ipc call focus start'|'ipc call focus end')
|
||||
printf '%s\n' "${PANAMA_ACTION_TEST_FOCUS_STATE:-true}"
|
||||
;;
|
||||
esac
|
||||
if [[ ${PANAMA_ACTION_TEST_FAIL_KILL:-false} == true && ${1:-} == kill ]]; then
|
||||
exit 1
|
||||
fi
|
||||
[[ ${PANAMA_ACTION_TEST_FAIL_QS:-false} != true ]]
|
||||
EOF
|
||||
|
||||
make_recorder quickshell <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
printf 'quickshell' >>"$PANAMA_ACTION_TEST_LOG"
|
||||
printf ' <%s>' "$@" >>"$PANAMA_ACTION_TEST_LOG"
|
||||
printf '\n' >>"$PANAMA_ACTION_TEST_LOG"
|
||||
EOF
|
||||
|
||||
make_recorder panama-osd <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
printf 'panama-osd' >>"$PANAMA_ACTION_TEST_LOG"
|
||||
printf ' <%s>' "$@" >>"$PANAMA_ACTION_TEST_LOG"
|
||||
printf '\n' >>"$PANAMA_ACTION_TEST_LOG"
|
||||
[[ ${PANAMA_ACTION_TEST_FAIL_OSD:-false} != true ]]
|
||||
EOF
|
||||
|
||||
make_recorder panama-prism-gallery <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
printf 'panama-prism-gallery' >>"$PANAMA_ACTION_TEST_LOG"
|
||||
if (( $# > 0 )); then
|
||||
printf ' <%s>' "$@" >>"$PANAMA_ACTION_TEST_LOG"
|
||||
fi
|
||||
printf '\n' >>"$PANAMA_ACTION_TEST_LOG"
|
||||
EOF
|
||||
|
||||
make_recorder notify-send <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
printf 'notify-send' >>"$PANAMA_ACTION_TEST_LOG"
|
||||
printf ' <%s>' "$@" >>"$PANAMA_ACTION_TEST_LOG"
|
||||
printf '\n' >>"$PANAMA_ACTION_TEST_LOG"
|
||||
EOF
|
||||
|
||||
make_recorder systemd-inhibit <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
if [[ -n ${PANAMA_ACTION_TEST_INHIBITOR_FILE:-} && -s $PANAMA_ACTION_TEST_INHIBITOR_FILE ]]; then
|
||||
while IFS= read -r pid; do
|
||||
printf 'Panama 1000 gib %s systemd-inhibit sleep:idle Caffeine block\n' "$pid"
|
||||
done <"$PANAMA_ACTION_TEST_INHIBITOR_FILE"
|
||||
exit 0
|
||||
fi
|
||||
if [[ ${PANAMA_ACTION_TEST_CONFIRMED_STATE:-${PANAMA_ACTION_TEST_STATE:-true}} == true ]]; then
|
||||
printf 'Panama 1000 gib 42 systemd-inhibit sleep:idle Caffeine block\n'
|
||||
fi
|
||||
EOF
|
||||
|
||||
make_recorder kill <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
printf 'kill' >>"$PANAMA_ACTION_TEST_LOG"
|
||||
printf ' <%s>' "$@" >>"$PANAMA_ACTION_TEST_LOG"
|
||||
printf '\n' >>"$PANAMA_ACTION_TEST_LOG"
|
||||
if [[ -n ${PANAMA_ACTION_TEST_INHIBITOR_FILE:-} ]]; then
|
||||
: >"$PANAMA_ACTION_TEST_INHIBITOR_FILE"
|
||||
fi
|
||||
EOF
|
||||
|
||||
make_recorder pgrep <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
[[ ${PANAMA_ACTION_TEST_CONFIRMED_STATE:-${PANAMA_ACTION_TEST_STATE:-true}} == true ]]
|
||||
EOF
|
||||
|
||||
make_recorder id <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
printf '1000\n'
|
||||
EOF
|
||||
|
||||
run_action() {
|
||||
: >"$command_log"
|
||||
HOME="$work/home" PATH="$fake_bin:$PATH" \
|
||||
PANAMA_ACTION_HELPER_DIR="$fake_bin" \
|
||||
PANAMA_ACTION_KILL_COMMAND="$fake_bin/kill" \
|
||||
PANAMA_ACTION_CONFIRM_ATTEMPTS=1 \
|
||||
PANAMA_ACTION_CONFIRM_DELAY=0 \
|
||||
PANAMA_ACTION_TEST_LOG="$command_log" \
|
||||
"$dispatcher" "$1"
|
||||
}
|
||||
|
||||
assert_commands() {
|
||||
local expected="$1"
|
||||
local actual
|
||||
actual="$(cat "$command_log")"
|
||||
[[ "$actual" == "$expected" ]] \
|
||||
|| fail "wrong command sequence; expected [$expected], got [$actual]"
|
||||
}
|
||||
|
||||
run_action control-center
|
||||
assert_commands 'qs <ipc> <call> <quicksettings> <open>'
|
||||
|
||||
run_action notifications
|
||||
assert_commands 'qs <ipc> <call> <notifications> <open>'
|
||||
|
||||
run_action calendar
|
||||
assert_commands 'qs <ipc> <call> <calendar-agenda> <open>'
|
||||
|
||||
run_action clipboard
|
||||
assert_commands 'qs <ipc> <call> <clipboard> <open>'
|
||||
|
||||
run_action overview
|
||||
assert_commands 'qs <ipc> <call> <overview> <open>'
|
||||
|
||||
run_action settings
|
||||
assert_commands 'qs <ipc> <call> <settings> <open>'
|
||||
|
||||
run_action dnd
|
||||
assert_commands $'qs <ipc> <call> <notifications> <dnd>\npanama-osd <message> <notifications-disabled-symbolic> <Do Not Disturb On>'
|
||||
|
||||
PANAMA_ACTION_TEST_STATE=false run_action caffeine
|
||||
assert_commands $'qs <ipc> <call> <caffeine> <toggle>\npanama-osd <message> <weather-clear-night-symbolic> <Caffeine Off>'
|
||||
|
||||
printf '4242\n' >"$work/inhibitors"
|
||||
PANAMA_ACTION_TEST_STATE=false PANAMA_ACTION_TEST_INHIBITOR_FILE="$work/inhibitors" run_action caffeine
|
||||
assert_commands $'qs <ipc> <call> <caffeine> <toggle>\nkill <4242>\npanama-osd <message> <weather-clear-night-symbolic> <Caffeine Off>'
|
||||
|
||||
run_action night-light
|
||||
assert_commands $'qs <ipc> <call> <night-light> <toggle>\npanama-osd <message> <night-light-symbolic> <Night Light On>'
|
||||
|
||||
run_action focus-start
|
||||
assert_commands $'qs <ipc> <call> <focus> <start>\npanama-osd <message> <preferences-system-time-symbolic> <Focus Session Started>'
|
||||
|
||||
run_action focus-end
|
||||
assert_commands $'qs <ipc> <call> <focus> <end>\npanama-osd <message> <media-playback-stop-symbolic> <Focus Session Ended>'
|
||||
|
||||
PANAMA_ACTION_TEST_FOCUS_STATE=false run_action focus-end
|
||||
assert_commands $'qs <ipc> <call> <focus> <end>\npanama-osd <message> <dialog-information-symbolic> <No Focus Session Running>'
|
||||
|
||||
run_action capture
|
||||
assert_commands 'qs <ipc> <call> <capture> <open>'
|
||||
|
||||
run_action intelligence
|
||||
assert_commands 'qs <ipc> <call> <screen-intelligence> <open>'
|
||||
|
||||
run_action screenshot
|
||||
assert_commands 'qs <ipc> <call> <capture> <screenNow>'
|
||||
|
||||
run_action microphone
|
||||
assert_commands 'panama-osd <microphone> <toggle>'
|
||||
|
||||
run_action gallery
|
||||
assert_commands 'panama-prism-gallery'
|
||||
|
||||
run_action restart-shell
|
||||
assert_commands $'qs <kill>\nquickshell <--daemonize>\nqs <ipc> <show>\npanama-osd <message> <view-refresh-symbolic> <Panama Restarted>'
|
||||
|
||||
PANAMA_ACTION_TEST_FAIL_KILL=true run_action restart-shell
|
||||
assert_commands $'qs <kill>\nquickshell <--daemonize>\nqs <ipc> <show>\npanama-osd <message> <view-refresh-symbolic> <Panama Restarted>'
|
||||
|
||||
if HOME="$work/home" PATH="$fake_bin:$PATH" PANAMA_ACTION_HELPER_DIR="$fake_bin" \
|
||||
PANAMA_ACTION_CONFIRM_ATTEMPTS=1 PANAMA_ACTION_CONFIRM_DELAY=0 PANAMA_ACTION_TEST_LOG="$command_log" \
|
||||
"$dispatcher" definitely-not-an-action >/dev/null 2>&1; then
|
||||
fail 'unknown action was accepted'
|
||||
fi
|
||||
|
||||
: >"$command_log"
|
||||
if PANAMA_ACTION_TEST_FAIL_QS=true HOME="$work/home" PATH="$fake_bin:$PATH" \
|
||||
PANAMA_ACTION_HELPER_DIR="$fake_bin" PANAMA_ACTION_CONFIRM_ATTEMPTS=1 \
|
||||
PANAMA_ACTION_CONFIRM_DELAY=0 PANAMA_ACTION_TEST_LOG="$command_log" \
|
||||
"$dispatcher" control-center >/dev/null 2>&1; then
|
||||
fail 'failed shell action returned success'
|
||||
fi
|
||||
assert_commands $'qs <ipc> <call> <quicksettings> <open>\nnotify-send <-a> <Panama> <-i> <dialog-error-symbolic> <Panama action failed> <The “control center” action could not be completed.>'
|
||||
|
||||
: >"$command_log"
|
||||
if PANAMA_ACTION_TEST_FAIL_OSD=true HOME="$work/home" PATH="$fake_bin:$PATH" \
|
||||
PANAMA_ACTION_HELPER_DIR="$fake_bin" PANAMA_ACTION_CONFIRM_ATTEMPTS=1 \
|
||||
PANAMA_ACTION_CONFIRM_DELAY=0 PANAMA_ACTION_TEST_LOG="$command_log" \
|
||||
"$dispatcher" microphone >/dev/null 2>&1; then
|
||||
fail 'failed Prism OSD delivery returned success'
|
||||
fi
|
||||
assert_commands $'panama-osd <microphone> <toggle>\nnotify-send <-a> <Panama> <-i> <dialog-error-symbolic> <Panama action failed> <The “microphone” action could not be completed.>'
|
||||
|
||||
: >"$command_log"
|
||||
if PANAMA_ACTION_TEST_CONFIRMED_STATE=false HOME="$work/home" PATH="$fake_bin:$PATH" \
|
||||
PANAMA_ACTION_HELPER_DIR="$fake_bin" PANAMA_ACTION_CONFIRM_ATTEMPTS=1 \
|
||||
PANAMA_ACTION_CONFIRM_DELAY=0 PANAMA_ACTION_TEST_LOG="$command_log" \
|
||||
"$dispatcher" caffeine >/dev/null 2>&1; then
|
||||
fail 'unconfirmed Caffeine state returned success'
|
||||
fi
|
||||
grep -Fqx 'notify-send <-a> <Panama> <-i> <dialog-error-symbolic> <Panama action failed> <The “caffeine” action could not be completed.>' "$command_log" \
|
||||
|| fail 'unconfirmed state did not send a failure notification'
|
||||
|
||||
printf 'Panama action contract: PASS\n'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user