Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24d8bfd641 | ||
|
|
af0ba13573 | ||
|
|
7541a45e77 | ||
|
|
1a91d00f2c | ||
|
|
19063a3e02 | ||
|
|
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 | ||
|
|
c2cd79b547 | ||
|
|
0a18290137 | ||
|
|
efc53435a2 | ||
|
|
67674d297e | ||
|
|
2d4b126f14 | ||
|
|
5671324eb2 |
@@ -13,3 +13,6 @@
|
||||
# Python helper bytecode is local runtime state.
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Generated from the colour scheme; machine state, not configuration.
|
||||
/config/dot/kitty/current-theme.conf
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,11 +17,88 @@
|
||||
-- overrides.lua) and read the notes in that file first.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
local prefs = require("prefs")
|
||||
|
||||
-- Per-output overrides written by Panama Settings, keyed by output name:
|
||||
-- { ["DP-2"] = { mode = "3840x2160@60", scale = 2, transform = 0 } }
|
||||
--
|
||||
-- Only mode, scale, and transform are read. Colour management and bit depth
|
||||
-- stay here, because those are the settings with a documented reason attached
|
||||
-- (see the header) rather than preferences, and a settings page has no way to
|
||||
-- explain the screencopy tradeoff at the moment you would be changing it.
|
||||
local displays = prefs.get("displays", {})
|
||||
if type(displays) ~= "table" then
|
||||
displays = {}
|
||||
end
|
||||
|
||||
local function mode_dimensions(mode)
|
||||
if type(mode) ~= "string" then
|
||||
return nil, nil
|
||||
end
|
||||
local width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+%.%d+)$")
|
||||
if width == nil then
|
||||
width, height, refresh = mode:match("^(%d+)x(%d+)@(%d+)$")
|
||||
end
|
||||
width, height, refresh = tonumber(width), tonumber(height), tonumber(refresh)
|
||||
if width == nil or height == nil or refresh == nil
|
||||
or width <= 0 or height <= 0 or refresh <= 0 then
|
||||
return nil, nil
|
||||
end
|
||||
return width, height
|
||||
end
|
||||
|
||||
local function valid_mode(mode)
|
||||
local width = mode_dimensions(mode)
|
||||
return width ~= nil
|
||||
end
|
||||
|
||||
local function valid_scale(mode, scale)
|
||||
local width, height = mode_dimensions(mode)
|
||||
if width == nil or type(scale) ~= "number" or scale ~= scale
|
||||
or scale <= 0 or scale > 4 then
|
||||
return false
|
||||
end
|
||||
local logical_width = width / scale
|
||||
local logical_height = height / scale
|
||||
return math.abs(logical_width - math.floor(logical_width + 0.5)) < 0.0001
|
||||
and math.abs(logical_height - math.floor(logical_height + 0.5)) < 0.0001
|
||||
end
|
||||
|
||||
local function valid_transform(transform)
|
||||
return type(transform) == "number"
|
||||
and transform == math.floor(transform)
|
||||
and transform >= 0
|
||||
and transform <= 3
|
||||
end
|
||||
|
||||
local function display_entry(output)
|
||||
if type(output) ~= "string" or output == ""
|
||||
or output:match("^[%w_.-]+$") == nil then
|
||||
return nil
|
||||
end
|
||||
local entry = displays[output]
|
||||
if type(entry) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
if not valid_mode(entry.mode)
|
||||
or not valid_scale(entry.mode, entry.scale)
|
||||
or not valid_transform(entry.transform) then
|
||||
return nil
|
||||
end
|
||||
return entry
|
||||
end
|
||||
|
||||
local shipped_mode = "4500x3000@60"
|
||||
local shipped_scale = 1.5
|
||||
local shipped_transform = 0
|
||||
local dp2 = display_entry("DP-2")
|
||||
|
||||
hl.monitor({
|
||||
output = "DP-2",
|
||||
mode = "4500x3000@60",
|
||||
mode = dp2 and dp2.mode or shipped_mode,
|
||||
position = "0x0",
|
||||
scale = 1.5,
|
||||
scale = dp2 and dp2.scale or shipped_scale,
|
||||
transform = dp2 and dp2.transform or shipped_transform,
|
||||
|
||||
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of
|
||||
-- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or
|
||||
@@ -32,6 +109,24 @@ hl.monitor({
|
||||
cm = "auto",
|
||||
})
|
||||
|
||||
-- Other connected outputs use the same validated per-output store. They keep
|
||||
-- automatic placement and the compositor's normal colour policy; DP-2 alone
|
||||
-- carries the panel-specific 10-bit policy documented above.
|
||||
for output, _ in pairs(displays) do
|
||||
if output ~= "DP-2" then
|
||||
local entry = display_entry(output)
|
||||
if entry ~= nil then
|
||||
hl.monitor({
|
||||
output = output,
|
||||
mode = entry.mode,
|
||||
position = "auto",
|
||||
scale = entry.scale,
|
||||
transform = entry.transform,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Any monitor not named above: sane defaults rather than nothing.
|
||||
hl.monitor({
|
||||
output = "",
|
||||
|
||||
@@ -183,7 +183,6 @@ font_size 14.0
|
||||
|
||||
#: Cursor customization {{{
|
||||
|
||||
cursor #c8d3f5
|
||||
|
||||
#: Default cursor color. If set to the special value none the cursor
|
||||
#: will be rendered with a "reverse video" effect. It's color will be
|
||||
@@ -193,7 +192,6 @@ cursor #c8d3f5
|
||||
#: precedence. Also, the cursor colors are modified if the cell
|
||||
#: background and foreground colors have very low contrast.
|
||||
|
||||
cursor_text_color #111325
|
||||
|
||||
#: The color of text under the cursor. If you want it rendered with
|
||||
#: the background color of the cell underneath instead, use the
|
||||
@@ -312,7 +310,6 @@ cursor_shape block
|
||||
#: robustly with the ever-changing sea of bugs that is Cocoa is too
|
||||
#: much effort.
|
||||
|
||||
url_color #4fd6be
|
||||
# url_style curly
|
||||
|
||||
#: The color and style for highlighting URLs on mouse-over. url_style
|
||||
@@ -734,12 +731,10 @@ window_border_width 1pt
|
||||
#: placed centrally. A value of top-left means the padding will be
|
||||
#: only at the bottom and right edges.
|
||||
|
||||
active_border_color #82aaff
|
||||
|
||||
#: The color for the border of the active window. Set this to none to
|
||||
#: not draw borders around the active window.
|
||||
|
||||
inactive_border_color #2f334d
|
||||
|
||||
#: The color for the border of inactive windows.
|
||||
|
||||
@@ -960,16 +955,11 @@ tab_powerline_style round
|
||||
#: Template to use for active tabs. If not specified falls back to
|
||||
#: tab_title_template.
|
||||
|
||||
active_tab_foreground #1e2030
|
||||
active_tab_background #82aaff
|
||||
# active_tab_font_style bold-italic
|
||||
inactive_tab_foreground #c8d3f5
|
||||
inactive_tab_background #42465a
|
||||
# inactive_tab_font_style normal
|
||||
|
||||
#: Tab bar colors and styles.
|
||||
|
||||
tab_bar_background #222436
|
||||
|
||||
#: Background color for the tab bar. Defaults to using the terminal
|
||||
#: background color.
|
||||
@@ -983,8 +973,8 @@ tab_bar_margin_color none
|
||||
|
||||
#: Color scheme {{{
|
||||
|
||||
foreground #c8d3f5
|
||||
background #222436
|
||||
|
||||
include current-theme.conf
|
||||
|
||||
#: The foreground and background colors.
|
||||
|
||||
@@ -1043,8 +1033,6 @@ dim_opacity 1
|
||||
#: How much to dim text that has the DIM/FAINT attribute set. One
|
||||
#: means no dimming and zero means fully dimmed (i.e. invisible).
|
||||
|
||||
selection_foreground #2d3f76
|
||||
selection_background #c8d3f5
|
||||
|
||||
#: The foreground and background colors for text selected with the
|
||||
#: mouse. Setting both of these to none will cause a "reverse video"
|
||||
@@ -1060,47 +1048,29 @@ selection_background #c8d3f5
|
||||
#: dull and bright version, for the first 16 colors. You can set the
|
||||
#: remaining 240 colors as color16 to color255.
|
||||
|
||||
color0 #1b1d2b
|
||||
color8 #444a73
|
||||
|
||||
#: black
|
||||
|
||||
color1 #ff757f
|
||||
color9 #ff757f
|
||||
|
||||
#: red
|
||||
|
||||
color2 #a5e8b5
|
||||
color10 #a5e8b5
|
||||
|
||||
#: green
|
||||
color3 #ffc777
|
||||
color11 #ffc777
|
||||
|
||||
#: yellow
|
||||
|
||||
color4 #82aaff
|
||||
color12 #82aaff
|
||||
|
||||
#: blue
|
||||
|
||||
color5 #c099ff
|
||||
color13 #c099ff
|
||||
|
||||
#: magenta
|
||||
|
||||
color6 #86e1fc
|
||||
color14 #86e1fc
|
||||
|
||||
#: cyan
|
||||
|
||||
color7 #828bb8
|
||||
color15 #c8d3f5
|
||||
|
||||
#: white
|
||||
|
||||
color16 #ff966c
|
||||
color17 #c53b53
|
||||
|
||||
# mark1_foreground black
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Tokyo Night Day — the palette's own light variant.
|
||||
#
|
||||
# The same hues as Moon at a different lightness, so a terminal in light mode
|
||||
# still belongs to this desktop rather than looking like a different machine.
|
||||
# Kept in the same order as tokyonight-moon.conf so the two can be diffed.
|
||||
|
||||
cursor #3760bf
|
||||
cursor_text_color #e1e2e7
|
||||
url_color #118c74
|
||||
active_border_color #2e7de9
|
||||
inactive_border_color #c4c8da
|
||||
active_tab_foreground #e1e2e7
|
||||
active_tab_background #2e7de9
|
||||
inactive_tab_foreground #3760bf
|
||||
inactive_tab_background #c4c8da
|
||||
tab_bar_background #e1e2e7
|
||||
foreground #3760bf
|
||||
background #e1e2e7
|
||||
selection_foreground #e1e2e7
|
||||
selection_background #99a7df
|
||||
color0 #e9e9ed
|
||||
color8 #a1a6c5
|
||||
color1 #f52a65
|
||||
color9 #f52a65
|
||||
color2 #587539
|
||||
color10 #587539
|
||||
color3 #8c6c3e
|
||||
color11 #8c6c3e
|
||||
color4 #2e7de9
|
||||
color12 #2e7de9
|
||||
color5 #9854f1
|
||||
color13 #9854f1
|
||||
color6 #007197
|
||||
color14 #007197
|
||||
color7 #6172b0
|
||||
color15 #3760bf
|
||||
color16 #b15c00
|
||||
color17 #c64343
|
||||
@@ -0,0 +1,42 @@
|
||||
# Tokyo Night Moon — the dark theme Panama ships.
|
||||
#
|
||||
# Extracted from kitty.conf so the two schemes can be swapped. kitty.conf
|
||||
# includes current-theme.conf, which Panama generates from the colour scheme
|
||||
# setting; that generated file is gitignored because it is machine state.
|
||||
#
|
||||
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
|
||||
# remote control. Without it a scheme change would only reach terminals opened
|
||||
# afterwards.
|
||||
|
||||
cursor #c8d3f5
|
||||
cursor_text_color #111325
|
||||
url_color #4fd6be
|
||||
active_border_color #82aaff
|
||||
inactive_border_color #2f334d
|
||||
active_tab_foreground #1e2030
|
||||
active_tab_background #82aaff
|
||||
inactive_tab_foreground #c8d3f5
|
||||
inactive_tab_background #42465a
|
||||
tab_bar_background #222436
|
||||
foreground #c8d3f5
|
||||
background #222436
|
||||
selection_foreground #2d3f76
|
||||
selection_background #c8d3f5
|
||||
color0 #1b1d2b
|
||||
color8 #444a73
|
||||
color1 #ff757f
|
||||
color9 #ff757f
|
||||
color2 #a5e8b5
|
||||
color10 #a5e8b5
|
||||
color3 #ffc777
|
||||
color11 #ffc777
|
||||
color4 #82aaff
|
||||
color12 #82aaff
|
||||
color5 #c099ff
|
||||
color13 #c099ff
|
||||
color6 #86e1fc
|
||||
color14 #86e1fc
|
||||
color7 #828bb8
|
||||
color15 #c8d3f5
|
||||
color16 #ff966c
|
||||
color17 #c53b53
|
||||
@@ -10,3 +10,6 @@ vim.api.nvim_create_autocmd("BufWritePre", {
|
||||
vim.fn.winrestview(view)
|
||||
end,
|
||||
})
|
||||
|
||||
-- Follow the desktop's light/dark setting while running, not only at startup.
|
||||
require("config.panama").watch()
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Panama desktop integration.
|
||||
--
|
||||
-- Neovim is the one application here that neither reads the desktop portal nor
|
||||
-- has a control socket open by default, so it reads the shared settings file
|
||||
-- directly -- the same ~/.config/panama/settings.json that the shell and the
|
||||
-- Hyprland config read.
|
||||
--
|
||||
-- Nothing here may raise. A missing or malformed settings file must cost the
|
||||
-- user their colour scheme preference and nothing else; editing text is more
|
||||
-- important than matching the desktop.
|
||||
|
||||
local M = {}
|
||||
|
||||
local function settings_path()
|
||||
local config_home = os.getenv("XDG_CONFIG_HOME")
|
||||
if config_home == nil or config_home == "" then
|
||||
local home = os.getenv("HOME")
|
||||
if home == nil or home == "" then
|
||||
return nil
|
||||
end
|
||||
config_home = home .. "/.config"
|
||||
end
|
||||
return config_home .. "/panama/settings.json"
|
||||
end
|
||||
|
||||
-- "dark" or "light". Defaults to dark, which is what Panama ships.
|
||||
function M.color_scheme()
|
||||
local path = settings_path()
|
||||
if not path then
|
||||
return "dark"
|
||||
end
|
||||
|
||||
local ok, contents = pcall(function()
|
||||
local file = io.open(path, "r")
|
||||
if not file then
|
||||
return nil
|
||||
end
|
||||
local text = file:read("*a")
|
||||
file:close()
|
||||
return text
|
||||
end)
|
||||
if not ok or not contents or contents == "" then
|
||||
return "dark"
|
||||
end
|
||||
|
||||
local decoded_ok, decoded = pcall(vim.json.decode, contents)
|
||||
if not decoded_ok or type(decoded) ~= "table" then
|
||||
return "dark"
|
||||
end
|
||||
|
||||
return decoded.colorScheme == "light" and "light" or "dark"
|
||||
end
|
||||
|
||||
function M.is_light()
|
||||
return M.color_scheme() == "light"
|
||||
end
|
||||
|
||||
-- tokyonight ships a light variant in the same family, so light mode stays the
|
||||
-- same identity rather than becoming a different editor theme.
|
||||
function M.tokyonight_style()
|
||||
return M.is_light() and "day" or "moon"
|
||||
end
|
||||
|
||||
-- Re-apply the scheme when the window regains focus.
|
||||
--
|
||||
-- Neovim reads the setting once at startup and has no control socket open by
|
||||
-- default, so an editor already running when the desktop scheme flips would
|
||||
-- otherwise stay on the old palette until it was restarted. FocusGained is
|
||||
-- cheap, happens exactly when you would notice the mismatch, and does nothing
|
||||
-- at all unless the scheme actually changed.
|
||||
function M.watch()
|
||||
local applied = M.color_scheme()
|
||||
|
||||
vim.api.nvim_create_autocmd("FocusGained", {
|
||||
group = vim.api.nvim_create_augroup("PanamaColorScheme", { clear = true }),
|
||||
callback = function()
|
||||
local current = M.color_scheme()
|
||||
if current == applied then
|
||||
return
|
||||
end
|
||||
applied = current
|
||||
vim.o.background = current
|
||||
pcall(vim.cmd.colorscheme, current == "light" and "tokyonight-day" or "tokyonight-moon")
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,31 +1,68 @@
|
||||
-- Tokyo Night, following the desktop's colour scheme.
|
||||
--
|
||||
-- Moon when Panama is dark, Day when it is light. Same theme family either way,
|
||||
-- so the editor keeps the identity the rest of the desktop has rather than
|
||||
-- becoming a different-looking application when the scheme flips.
|
||||
--
|
||||
-- The readability fixes below are deliberately dark-only. They were written
|
||||
-- against Moon's palette -- a pale comment colour, a mid-grey gutter -- and
|
||||
-- applying them to Day would put light grey text on a light background, which
|
||||
-- is exactly the legibility problem they exist to solve, inverted.
|
||||
|
||||
local panama = require("config.panama")
|
||||
|
||||
return {
|
||||
{
|
||||
"folke/tokyonight.nvim",
|
||||
opts = {
|
||||
style = "moon",
|
||||
opts = function()
|
||||
local light = panama.is_light()
|
||||
|
||||
return {
|
||||
style = panama.tokyonight_style(),
|
||||
light_style = "day",
|
||||
transparent = true,
|
||||
|
||||
on_colors = function(colors)
|
||||
if light then
|
||||
-- Day's defaults are tuned for a light ground and mostly need no
|
||||
-- help. Comments are the exception: the shipped #848cb5 measures
|
||||
-- 2.54:1 against the #e1e2e7 background, well under the 3:1 floor
|
||||
-- for secondary text. This is 3.25:1 -- readable, and still clearly
|
||||
-- dimmer than Normal's 4.52:1 so it does not compete with code.
|
||||
colors.comment = "#7079a8"
|
||||
return
|
||||
end
|
||||
|
||||
colors.comment = "#a0a7c5"
|
||||
colors.fg_gutter = "#787f93"
|
||||
colors.terminal_black = "#828bb8"
|
||||
end,
|
||||
|
||||
on_highlights = function(highlights, colors)
|
||||
-- Fix inline code visibility in markdown
|
||||
-- Inline code in markdown is invisible at both lightnesses without
|
||||
-- an explicit background, because the theme leaves it unset.
|
||||
highlights["@markup.raw.markdown_inline"] = {
|
||||
bg = colors.terminal_black,
|
||||
bg = light and colors.bg_highlight or colors.terminal_black,
|
||||
fg = colors.fg,
|
||||
}
|
||||
highlights["RenderMarkdownCodeInline"] = {
|
||||
bg = colors.terminal_black,
|
||||
bg = light and colors.bg_highlight or colors.terminal_black,
|
||||
fg = colors.fg,
|
||||
}
|
||||
-- Fix LspReference* readability: DiagnosticUnnecessary dims fg for unused
|
||||
-- imports, making text nearly invisible against LspReferenceText's background
|
||||
highlights["LspReferenceText"] = { bg = colors.fg_gutter, fg = colors.fg }
|
||||
highlights["LspReferenceRead"] = { bg = colors.fg_gutter, fg = colors.fg }
|
||||
highlights["LspReferenceWrite"] = { bg = colors.fg_gutter, fg = colors.fg }
|
||||
|
||||
-- LspReference* readability: DiagnosticUnnecessary dims fg for unused
|
||||
-- imports, making text nearly invisible against LspReferenceText's
|
||||
-- background.
|
||||
local reference = {
|
||||
bg = light and colors.bg_visual or colors.fg_gutter,
|
||||
fg = colors.fg,
|
||||
}
|
||||
highlights["LspReferenceText"] = reference
|
||||
highlights["LspReferenceRead"] = reference
|
||||
highlights["LspReferenceWrite"] = reference
|
||||
end,
|
||||
}
|
||||
end,
|
||||
},
|
||||
},
|
||||
{
|
||||
"LazyVim/LazyVim",
|
||||
|
||||
@@ -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",
|
||||
@@ -508,6 +621,34 @@ Singleton {
|
||||
detail: "Shortcuts you have moved from their shipped chord"
|
||||
},
|
||||
|
||||
// ── Display configuration ───────────────────────────────────────────
|
||||
// { "<output>": { mode, scale, transform } }, applied by
|
||||
// hypr/monitors.lua on top of the shipped values. Colour management and
|
||||
// bit depth are deliberately not here: those carry a documented
|
||||
// screencopy tradeoff that a settings page cannot explain at the moment
|
||||
// you would be changing it.
|
||||
{
|
||||
key: "displays", type: "json", def: ({}), group: "display",
|
||||
internal: true,
|
||||
label: "Display configuration",
|
||||
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,75 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
IpcHandler {
|
||||
target: "displays-test"
|
||||
|
||||
function status(): string {
|
||||
const monitor = Displays.monitors.length > 0 ? Displays.monitors[0] : null;
|
||||
return JSON.stringify({
|
||||
count: Displays.monitors.length,
|
||||
name: monitor ? monitor.name : "",
|
||||
width: monitor ? monitor.width : 0,
|
||||
height: monitor ? monitor.height : 0,
|
||||
refresh: monitor ? monitor.refreshRate : 0,
|
||||
mode: monitor ? monitor.mode : "",
|
||||
scale: monitor ? monitor.scale : 0,
|
||||
transform: monitor ? monitor.transform : -1,
|
||||
modes: monitor ? monitor.modes.length : 0,
|
||||
awaiting: Displays.awaitingConfirmation,
|
||||
canConfirm: Displays.canConfirm,
|
||||
secondsLeft: Displays.secondsLeft,
|
||||
lastError: Displays.lastError,
|
||||
overridden: monitor ? Displays.isOverridden(monitor.name) : false
|
||||
});
|
||||
}
|
||||
|
||||
function applyScale(scale: real): bool {
|
||||
const monitor = Displays.monitors[0];
|
||||
if (!monitor) return false;
|
||||
const mode = monitor.mode;
|
||||
return Displays.apply(monitor.name, mode, scale, monitor.transform);
|
||||
}
|
||||
|
||||
function refreshIdentityFixture(): string {
|
||||
const modes = Displays.normaliseModes([
|
||||
"[email protected]",
|
||||
"[email protected]"
|
||||
]);
|
||||
const monitor = { width: 1920, height: 1080, refreshRate: 59.94 };
|
||||
return JSON.stringify({
|
||||
count: modes.length,
|
||||
modes: modes.map(mode => mode.mode),
|
||||
selected: modes.filter(mode => Displays.modeIsCurrent(monitor, mode)).map(mode => mode.mode)
|
||||
});
|
||||
}
|
||||
|
||||
function applyBad(kind: string): bool {
|
||||
const monitor = Displays.monitors[0];
|
||||
if (!monitor) return false;
|
||||
const mode = monitor.mode;
|
||||
if (kind === "mode") return Displays.apply(monitor.name, "9999x9999@240", monitor.scale, monitor.transform);
|
||||
if (kind === "scale") return Displays.apply(monitor.name, mode, 1.37, monitor.transform);
|
||||
if (kind === "dirtyScale") {
|
||||
const dirty = Displays.scales.find(scale => !Displays.isScaleClean(mode, scale));
|
||||
return dirty === undefined ? false : Displays.apply(monitor.name, mode, dirty, monitor.transform);
|
||||
}
|
||||
if (kind === "transform") return Displays.apply(monitor.name, mode, monitor.scale, 9);
|
||||
if (kind === "output") return Displays.apply("NOPE-1", mode, monitor.scale, monitor.transform);
|
||||
return false;
|
||||
}
|
||||
|
||||
function confirmChange(): bool { return Displays.confirm(); }
|
||||
function revertChange(): void { Displays.revert(); }
|
||||
function forget(): void {
|
||||
const monitor = Displays.monitors[0];
|
||||
if (monitor) Displays.forget(monitor.name);
|
||||
}
|
||||
function refresh(): void { Displays.refresh(); }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
// Backlight slider, via brightnessctl.
|
||||
// Brightness, from whichever source this machine actually has.
|
||||
//
|
||||
// This machine drives an external DisplayPort monitor and has no backlight
|
||||
// class device at all (brightnessctl only reports keyboard/NIC LEDs), so the
|
||||
// row removes itself rather than sitting there as a dead control. Probed once
|
||||
// at startup — backlight devices do not appear and disappear.
|
||||
// Two exist and they are not interchangeable:
|
||||
//
|
||||
// The kernel backlight class, driven by brightnessctl. Laptop panels have it;
|
||||
// this desktop does not -- brightnessctl reports only keyboard and NIC LEDs.
|
||||
//
|
||||
// DDC/CI, the channel the buttons on a monitor's bezel drive. That is the
|
||||
// only brightness an external display has, and it is per-monitor.
|
||||
//
|
||||
// A machine may have neither, either, or both, so this renders a row per source
|
||||
// found and removes itself entirely when there are none, rather than sitting
|
||||
// there as a dead control.
|
||||
//
|
||||
// Connector labels appear only when there is more than one row. A single
|
||||
// slider needs no explanation of which screen it dims.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.widgets
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property bool available: false
|
||||
property real value: 0
|
||||
property bool backlightAvailable: false
|
||||
property real backlightValue: 0
|
||||
|
||||
visible: root.available
|
||||
implicitHeight: root.available ? 32 : 0
|
||||
readonly property int rowCount: (root.backlightAvailable ? 1 : 0) + Brightness.displays.length
|
||||
readonly property bool labelled: root.rowCount > 1
|
||||
|
||||
visible: root.rowCount > 0
|
||||
implicitHeight: rows.implicitHeight
|
||||
|
||||
// Probing I2C takes on the order of a second, so it waits until the panel
|
||||
// is actually on screen rather than running at shell startup. Monitors do
|
||||
// not come and go, so once is enough.
|
||||
onVisibleChanged: if (visible && !Brightness.scanned) Brightness.refresh()
|
||||
Component.onCompleted: if (root.visible && !Brightness.scanned) Brightness.refresh()
|
||||
|
||||
// `-m` is the machine-readable form: name,class,current,percent,max
|
||||
Process {
|
||||
@@ -34,18 +54,85 @@ Item {
|
||||
const fields = line.split(",");
|
||||
if (fields.length < 5 || fields[1] !== "backlight")
|
||||
continue;
|
||||
root.available = true;
|
||||
root.value = parseInt(fields[3]) / 100;
|
||||
root.backlightAvailable = true;
|
||||
root.backlightValue = parseInt(fields[3]) / 100;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function apply(v: real): void {
|
||||
root.value = v;
|
||||
function applyBacklight(v: real): void {
|
||||
root.backlightValue = v;
|
||||
// Never go fully dark: a 0% backlight looks like a broken shell.
|
||||
Quickshell.execDetached(["brightnessctl", "-c", "backlight", "-q", "set", Math.max(1, Math.round(v * 100)) + "%"]);
|
||||
}
|
||||
|
||||
Column {
|
||||
id: rows
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
spacing: 4
|
||||
|
||||
BrightnessRow {
|
||||
width: rows.width
|
||||
visible: root.backlightAvailable
|
||||
label: "Built-in"
|
||||
value: root.backlightValue
|
||||
onMoved: v => root.applyBacklight(v)
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Brightness.displays
|
||||
|
||||
BrightnessRow {
|
||||
required property var modelData
|
||||
width: rows.width
|
||||
// Hyprland already knows what each output is called, so the
|
||||
// name comes from there rather than from a second source that
|
||||
// could disagree with the Displays page. The connector is the
|
||||
// fallback, so a display is never an unlabelled slider.
|
||||
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
|
||||
value: modelData.value / 100
|
||||
onMoved: v => Brightness.set(modelData.bus, Math.round(v * 100))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component BrightnessRow: Item {
|
||||
id: row
|
||||
|
||||
property string label: ""
|
||||
property real value: 0
|
||||
signal moved(real value)
|
||||
|
||||
implicitHeight: caption.height + control.height
|
||||
|
||||
Text {
|
||||
id: caption
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 6
|
||||
anchors.rightMargin: 6
|
||||
anchors.top: parent.top
|
||||
visible: root.labelled
|
||||
height: visible ? implicitHeight + 2 : 0
|
||||
text: row.label
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// The slider and its glyph share one strip so the two stay aligned
|
||||
// whether or not a caption sits above them. Anchoring the glyph to
|
||||
// both a caption and a centre line instead would conflict, and an
|
||||
// anchor set to undefined is not released.
|
||||
Item {
|
||||
id: control
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: caption.bottom
|
||||
height: 32
|
||||
|
||||
// ValueSlider draws its own leading icon, but symbolic icons need
|
||||
// recolouring to be visible — see ThemedIcon.
|
||||
ThemedIcon {
|
||||
@@ -63,7 +150,9 @@ Item {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 32
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
value: root.value
|
||||
onMoved: v => root.apply(v)
|
||||
value: row.value
|
||||
onMoved: v => row.moved(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// A row of choices that wraps, for options that do not fit a segmented control.
|
||||
//
|
||||
// ChoiceRow puts two or three options on one line. Scales and rotations are
|
||||
// more numerous and their labels are wider, so they wrap into a grid rather
|
||||
// than shrinking to illegibility on a narrow, tiled window.
|
||||
//
|
||||
// Unlike ChoiceRow this is not schema-bound: it reports a value and lets the
|
||||
// caller decide what to do with it, because a display change has to go through
|
||||
// an apply-then-confirm cycle rather than straight into the store.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property string detail: ""
|
||||
property var options: []
|
||||
property var current: null
|
||||
property bool enabled: true
|
||||
property bool divider: true
|
||||
|
||||
signal picked(var value)
|
||||
|
||||
spacing: 9
|
||||
bottomPadding: 12
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.label
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.detail !== ""
|
||||
text: root.detail
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
Flow {
|
||||
width: parent.width
|
||||
spacing: 7
|
||||
|
||||
Repeater {
|
||||
model: root.options
|
||||
|
||||
Rectangle {
|
||||
id: option
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool selected: root.current === option.modelData.value
|
||||
|
||||
implicitWidth: Math.max(78, caption.implicitWidth + 26)
|
||||
implicitHeight: 32
|
||||
radius: 9
|
||||
opacity: root.enabled ? 1 : 0.45
|
||||
color: option.selected ? "transparent" : Theme.alpha(Theme.fg, hover.hovered && root.enabled ? 0.11 : 0.06)
|
||||
border.width: option.selected ? 1 : 0
|
||||
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||
|
||||
// The prism marks the selection here as everywhere else.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: parent.radius
|
||||
visible: option.selected
|
||||
border.width: 0
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) }
|
||||
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) }
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: caption
|
||||
anchors.centerIn: parent
|
||||
text: option.modelData.label
|
||||
color: option.selected ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: option.selected ? Font.DemiBold : Font.Normal
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: hover
|
||||
enabled: root.enabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
enabled: root.enabled && !option.selected
|
||||
onTapped: root.picked(option.modelData.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
visible: root.divider
|
||||
color: Theme.alpha(Theme.fg, 0.065)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// The resolution list for one display.
|
||||
//
|
||||
// Grouped by resolution with refresh rates beside it, rather than a flat list
|
||||
// of "[email protected]" strings: this panel reports 35 modes, many of which
|
||||
// differ only in refresh-rate rounding, and a flat list of those is a wall of
|
||||
// near-identical text rather than a choice.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
property var monitor: null
|
||||
property bool enabled: true
|
||||
|
||||
spacing: 0
|
||||
|
||||
readonly property var grouped: {
|
||||
if (!root.monitor)
|
||||
return [];
|
||||
const buckets = {};
|
||||
const order = [];
|
||||
for (const mode of root.monitor.modes) {
|
||||
const key = mode.label;
|
||||
if (!buckets[key]) {
|
||||
buckets[key] = { label: key, width: mode.width, height: mode.height, rates: [] };
|
||||
order.push(key);
|
||||
}
|
||||
buckets[key].rates.push(mode);
|
||||
}
|
||||
return order.map(key => buckets[key]);
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.grouped
|
||||
|
||||
SettingRow {
|
||||
id: resolution
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool isCurrent: root.monitor
|
||||
&& root.monitor.width === resolution.modelData.width
|
||||
&& root.monitor.height === resolution.modelData.height
|
||||
|
||||
label: resolution.modelData.label
|
||||
detail: resolution.isCurrent ? "Current resolution" : ""
|
||||
controlWidth: Math.max(120, resolution.modelData.rates.length * 84)
|
||||
divider: resolution.index < root.grouped.length - 1
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 6
|
||||
|
||||
Repeater {
|
||||
model: resolution.modelData.rates
|
||||
|
||||
Rectangle {
|
||||
id: rate
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool selected: resolution.isCurrent
|
||||
&& Displays.modeIsCurrent(root.monitor, rate.modelData)
|
||||
|
||||
implicitWidth: Math.max(74, rateCaption.implicitWidth + 22)
|
||||
implicitHeight: 30
|
||||
radius: 9
|
||||
opacity: root.enabled ? 1 : 0.45
|
||||
color: rate.selected ? "transparent" : Theme.alpha(Theme.fg, rateHover.hovered && root.enabled ? 0.11 : 0.06)
|
||||
border.width: rate.selected ? 1 : 0
|
||||
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: parent.radius
|
||||
visible: rate.selected
|
||||
border.width: 0
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) }
|
||||
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) }
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: rateCaption
|
||||
anchors.centerIn: parent
|
||||
text: rate.modelData.refreshLabel
|
||||
color: rate.selected ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: rate.selected ? Font.DemiBold : Font.Normal
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: rateHover
|
||||
enabled: root.enabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
enabled: root.enabled && !rate.selected
|
||||
onTapped: Displays.apply(
|
||||
root.monitor.name,
|
||||
rate.modelData.mode,
|
||||
Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale),
|
||||
root.monitor.transform)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +1,278 @@
|
||||
// Displays.
|
||||
//
|
||||
// Resolution, refresh rate, scale, and rotation, plus panel brightness and the
|
||||
// gaming display policy that was already here.
|
||||
//
|
||||
// Every geometry change goes through an apply-then-confirm countdown. This is
|
||||
// the one page where a wrong value can leave the screen unreadable or blank,
|
||||
// and no other control in the app can undo it once that happens. Confirming is
|
||||
// what writes the choice to the settings store; letting the countdown run
|
||||
// leaves nothing behind.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Displays"
|
||||
lede: SystemSettings.monitorDescription || "Reading the active display…"
|
||||
|
||||
SettingsCard {
|
||||
title: SystemSettings.monitorName || "Active display"
|
||||
subtitle: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight} at ${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale · ${SystemSettings.monitorFormat}`
|
||||
TextRow { label: "Color mode"; detail: "Wide-gamut SDR desktop at 10-bit"; value: SystemSettings.colorPreset || "wide" }
|
||||
TextRow { label: "Variable refresh"; detail: SystemSettings.monitorVrrActive ? "Active for current fullscreen content" : "Ready when game or video content requests it"; value: SystemSettings.monitorVrrActive ? "Active" : "Standby"; divider: false }
|
||||
property string selectedOutput: ""
|
||||
readonly property var monitor: Displays.monitorNamed(root.selectedOutput)
|
||||
?? (Displays.monitors.length > 0 ? Displays.monitors[0] : null)
|
||||
readonly property string currentMode: root.monitor
|
||||
? root.monitor.mode
|
||||
: ""
|
||||
|
||||
function syncSelectedOutput(): void {
|
||||
if (!Displays.monitorNamed(root.selectedOutput))
|
||||
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
|
||||
}
|
||||
|
||||
// Probing I2C for DDC-capable monitors takes on the order of a second, so
|
||||
// it runs when this page is opened rather than at shell startup. Monitors
|
||||
// do not appear while you are looking at a settings page, so once is enough.
|
||||
Component.onCompleted: {
|
||||
root.syncSelectedOutput();
|
||||
if (!Brightness.scanned)
|
||||
Brightness.refresh();
|
||||
}
|
||||
Connections {
|
||||
target: Displays
|
||||
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
|
||||
}
|
||||
|
||||
// The confirmation sits above everything, because while it is counting down
|
||||
// it is the only thing that matters on this page.
|
||||
header: Component {
|
||||
Rectangle {
|
||||
visible: Displays.awaitingConfirmation
|
||||
implicitHeight: visible ? confirmRow.implicitHeight + 28 : 0
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.mix(Theme.bgPanel, Theme.warn, 0.12)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.warn, 0.4)
|
||||
|
||||
Row {
|
||||
id: confirmRow
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.margins: 16
|
||||
spacing: 14
|
||||
|
||||
Column {
|
||||
width: parent.width - keepButton.width - revertButton.width - 28
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Keep this display setting?"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Reverting in " + Displays.secondsLeft + (Displays.secondsLeft === 1 ? " second" : " seconds")
|
||||
+ " if you do nothing. If you cannot read this, just wait."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: revertButton
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Revert now"
|
||||
onClicked: Displays.revert()
|
||||
}
|
||||
SettingsButton {
|
||||
id: keepButton
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Keep"
|
||||
enabled: Displays.canConfirm
|
||||
onClicked: Displays.confirm()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Gaming display policy"
|
||||
subtitle: "These values apply immediately and are restored when Panama starts."
|
||||
SettingRow {
|
||||
label: "Game-aware HDR"
|
||||
detail: "Enter HDR only for fullscreen content that requests it"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.autoHdr; onToggled: value => SystemSettings.setAutoHdr(value) }
|
||||
}
|
||||
SettingRow {
|
||||
label: "Content-aware VRR"
|
||||
detail: "Enable variable refresh only for game and video content"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.vrrPolicy === 3; onToggled: value => SystemSettings.setVrrPolicy(value ? 3 : 0) }
|
||||
}
|
||||
SettingRow {
|
||||
label: "Direct scanout for games"
|
||||
detail: "Bypass compositing only for windows classified as games"
|
||||
visible: Displays.monitors.length > 1
|
||||
title: "Connected display"
|
||||
subtitle: "Choose the output whose resolution, scale, and rotation you want to adjust."
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
label: "Display"
|
||||
options: Displays.monitors.map(monitor => ({
|
||||
value: monitor.name,
|
||||
label: monitor.description || monitor.name
|
||||
}))
|
||||
current: root.monitor ? root.monitor.name : ""
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
divider: false
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.directScanoutPolicy === 2; onToggled: value => SystemSettings.setDirectScanoutPolicy(value ? 2 : 0) }
|
||||
onPicked: value => root.selectedOutput = value
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: root.monitor ? root.monitor.name : (SystemSettings.monitorName || "Active display")
|
||||
subtitle: root.monitor
|
||||
? `${root.monitor.description} · ${root.monitor.width} × ${root.monitor.height} at ${Math.round(root.monitor.refreshRate)} Hz · ${root.monitor.scale.toFixed(2)}× scale`
|
||||
: "Reading the active display…"
|
||||
|
||||
TextRow {
|
||||
label: "Color mode"
|
||||
detail: "Wide-gamut SDR at 10-bit. Full-time HDR is left to the Hyprland config: it currently breaks screenshots, OBS, and the lock screen's blurred background."
|
||||
value: root.monitor
|
||||
? `${root.monitor.colorPreset || "standard"} · ${root.monitor.currentFormat || "detecting format"}`
|
||||
: "Detecting"
|
||||
}
|
||||
TextRow {
|
||||
label: "Variable refresh"
|
||||
detail: root.monitor && root.monitor.vrr
|
||||
? "Active on this output for current fullscreen content"
|
||||
: "This output is ready when game or video content requests it"
|
||||
value: root.monitor && root.monitor.vrr ? "Active" : "Standby"
|
||||
divider: Displays.isOverridden(root.monitor ? root.monitor.name : "")
|
||||
}
|
||||
ActionRow {
|
||||
visible: Displays.isOverridden(root.monitor ? root.monitor.name : "")
|
||||
label: "Using a custom display setting"
|
||||
detail: "Forget it to go back to the resolution and scale Panama ships"
|
||||
action: "Forget"
|
||||
divider: false
|
||||
onTriggered: Displays.forget(root.monitor.name)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.monitor !== null
|
||||
title: "Resolution"
|
||||
subtitle: "Applied straight away, then reverted automatically unless you confirm."
|
||||
|
||||
DisplayModePicker {
|
||||
width: parent.width
|
||||
monitor: root.monitor
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.monitor !== null
|
||||
title: "Scale and rotation"
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
label: "Scale"
|
||||
detail: "Fractional scales that do not divide the resolution into whole pixels are rejected by the compositor, so only clean ones are offered."
|
||||
options: Displays.scalesForMode(root.currentMode)
|
||||
.map(scale => ({ value: scale, label: scale.toFixed(2) + "×" }))
|
||||
current: root.monitor ? root.monitor.scale : 1
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
onPicked: value => root.applyWith({ scale: value })
|
||||
}
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
label: "Rotation"
|
||||
options: Displays.transforms
|
||||
current: root.monitor ? root.monitor.transform : 0
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
divider: false
|
||||
onPicked: value => root.applyWith({ transform: value })
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Night Light"
|
||||
SettingRow {
|
||||
label: "Warm display colors"
|
||||
detail: NightLight.automatic ? "Following the evening schedule" : "Manual control"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: NightLight.active; onToggled: NightLight.toggle() }
|
||||
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 }
|
||||
}
|
||||
|
||||
// Panel brightness, over DDC/CI.
|
||||
//
|
||||
// This is hardware state rather than a stored preference: the monitor
|
||||
// remembers it, the bezel buttons change it behind Panama's back, and
|
||||
// writing it into settings.json would mean restoring a value the panel had
|
||||
// already moved on from. So there is no schema key here and no SliderRow --
|
||||
// the rows read and write the display directly.
|
||||
SettingsCard {
|
||||
visible: Brightness.available || Brightness.lastError !== ""
|
||||
title: "Brightness"
|
||||
subtitle: Brightness.available
|
||||
? "Sent to the monitor over DDC/CI, the same channel its buttons use."
|
||||
: Brightness.lastError
|
||||
|
||||
Repeater {
|
||||
model: Brightness.displays
|
||||
|
||||
SettingRow {
|
||||
label: "Color temperature"
|
||||
detail: `${NightLight.temperature} K`
|
||||
divider: false
|
||||
controlWidth: 230
|
||||
id: brightnessRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
|
||||
detail: modelData.connector ? modelData.connector + " · " + modelData.value + "%"
|
||||
: modelData.value + "%"
|
||||
divider: brightnessRow.index < Brightness.displays.length - 1
|
||||
controlWidth: 190
|
||||
|
||||
ValueSlider {
|
||||
anchors.fill: parent
|
||||
value: (6500 - NightLight.temperature) / 4000
|
||||
icon: "weather-clear-night-symbolic"
|
||||
onMoved: value => NightLight.temperature = Math.round((6500 - value * 4000) / 50) * 50
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.right: parent.right
|
||||
width: parent.width
|
||||
value: brightnessRow.modelData.value / 100
|
||||
onMoved: v => Brightness.set(brightnessRow.modelData.bus, Math.round(v * 100))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: warningText.implicitHeight + 30
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.warn, 0.085)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.warn, 0.22)
|
||||
Text {
|
||||
id: warningText
|
||||
anchors.fill: parent
|
||||
anchors.margins: 15
|
||||
text: "Full-time desktop HDR stays unavailable here because the current compositor path can break screenshots, OBS, Sunshine, and lock-screen capture. Game-aware HDR keeps the desktop dependable without giving up HDR games."
|
||||
color: Theme.mix(Theme.fg, Theme.warn, 0.25)
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
SettingsCard {
|
||||
title: "Gaming display policy"
|
||||
subtitle: "Applied immediately and restored when Panama starts."
|
||||
|
||||
ToggleRow { setting: "autoHdr" }
|
||||
ChoiceRow { setting: "vrrPolicy" }
|
||||
ChoiceRow { setting: "directScanoutPolicy"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: Displays.lastError !== ""
|
||||
title: "Display problem"
|
||||
subtitle: Displays.lastError
|
||||
}
|
||||
|
||||
// Applies a change to one field, keeping the others at what is in effect.
|
||||
function applyWith(change: var): void {
|
||||
if (!root.monitor)
|
||||
return;
|
||||
const mode = change.mode ?? root.currentMode;
|
||||
const requestedScale = change.scale ?? root.monitor.scale;
|
||||
Displays.apply(
|
||||
root.monitor.name,
|
||||
mode,
|
||||
Displays.isScaleClean(mode, requestedScale)
|
||||
? requestedScale
|
||||
: Displays.nearestCleanScale(mode, requestedScale),
|
||||
change.transform ?? root.monitor.transform);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,11 +28,31 @@ Item {
|
||||
// scrolls -- the Appearance page pins its live preview here.
|
||||
property Component header: null
|
||||
|
||||
Loader {
|
||||
id: pinnedHeader
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.leftMargin: 34
|
||||
anchors.rightMargin: 34
|
||||
anchors.topMargin: 30
|
||||
active: root.header !== null
|
||||
sourceComponent: root.header
|
||||
z: 1
|
||||
}
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
id: pageScroll
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: pinnedHeader.implicitHeight > 0 ? pinnedHeader.bottom : parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.topMargin: pinnedHeader.implicitHeight > 0 ? 16 : 0
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: layout.implicitHeight + 64
|
||||
contentHeight: layout.implicitHeight + (pinnedHeader.implicitHeight > 0 ? 34 : 64)
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
@@ -40,15 +60,9 @@ Item {
|
||||
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
y: pinnedHeader.implicitHeight > 0 ? 0 : 30
|
||||
spacing: 16
|
||||
|
||||
Loader {
|
||||
width: parent.width
|
||||
active: root.header !== null
|
||||
sourceComponent: root.header
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.title !== ""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -35,3 +35,14 @@ ApplicationsPage 1.0 ApplicationsPage.qml
|
||||
DockPinsEditor 1.0 DockPinsEditor.qml
|
||||
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
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# External monitor brightness over DDC/CI.
|
||||
#
|
||||
# A desktop with no backlight class device has no brightness control at all --
|
||||
# brightnessctl only sees keyboard and NIC LEDs. The panel itself still has a
|
||||
# brightness setting, reachable over the monitor's DDC/CI channel (VCP feature
|
||||
# 0x10), which is what the buttons on the bezel drive.
|
||||
#
|
||||
# Usage:
|
||||
# panama-brightness list -> {"displays":[...],"error":""}
|
||||
# panama-brightness get <bus> -> integer percent
|
||||
# panama-brightness set <bus> <pct> -> applies, prints nothing
|
||||
#
|
||||
# Displays are enumerated from sysfs rather than from `ddcutil detect`. The
|
||||
# kernel publishes the connector-to-I2C-bus mapping directly, as
|
||||
# /sys/class/drm/<card>-<connector>/ddc, along with whether anything is plugged
|
||||
# in. That is better than parsing detect output in three ways: the format is
|
||||
# stable where detect's brief output is undocumented, the connector name comes
|
||||
# out exactly as Hyprland and the Displays page already spell it (DP-2), and
|
||||
# only connectors with a monitor attached get probed -- one bus on this machine
|
||||
# instead of fourteen, which is the difference between a fast scan and a slow
|
||||
# one, since each probe of an empty bus waits for a timeout.
|
||||
#
|
||||
# No model name is reported. Hyprland already knows the human-readable
|
||||
# description of every output, so the UI joins on the connector name rather than
|
||||
# having two sources of truth for what a monitor is called.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
readonly VCP_BRIGHTNESS=0x10
|
||||
|
||||
# Test seams. The contract needs to exercise enumeration and parsing on a
|
||||
# machine whose real monitors it must not touch, so both roots this script
|
||||
# reads are overridable. Nothing sets them in normal use.
|
||||
readonly DRM_ROOT="${PANAMA_BRIGHTNESS_DRM_ROOT:-/sys/class/drm}"
|
||||
readonly DEV_ROOT="${PANAMA_BRIGHTNESS_DEV_ROOT:-/dev}"
|
||||
|
||||
emit_error() {
|
||||
printf '{"displays":[],"error":%s}\n' "$(jq -Rn --arg e "$1" '$e')"
|
||||
exit 0
|
||||
}
|
||||
|
||||
command -v ddcutil >/dev/null 2>&1 || emit_error 'ddcutil is not installed'
|
||||
|
||||
# Reading a VCP value needs read/write access to the monitor's I2C bus. The
|
||||
# udev rule ddcutil ships grants that to the seat user through uaccess, but only
|
||||
# to devices created after the rule was installed -- so a machine that installed
|
||||
# ddcutil without rebooting has the rule in place and no access to show for it.
|
||||
# That is by far the most likely reason for an empty list, and it is fixable in
|
||||
# one command, so say so rather than reporting "no displays".
|
||||
has_accessible_bus() {
|
||||
local dev
|
||||
for dev in "$DEV_ROOT"/i2c-*; do
|
||||
[[ -r "$dev" && -w "$dev" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
has_accessible_bus || emit_error 'no I2C bus is accessible. ddcutil ships a udev rule that grants this, but only to devices created after it was installed. Run: sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm'
|
||||
|
||||
local rows=() connector bus value path
|
||||
for path in "$DRM_ROOT"/card*-*; do
|
||||
[[ -e "$path/ddc" ]] || continue
|
||||
[[ "$(cat "$path/status" 2>/dev/null)" == "connected" ]] || continue
|
||||
|
||||
# card1-DP-2 -> DP-2, the name Hyprland uses.
|
||||
connector="$(basename "$path")"
|
||||
connector="${connector#card*-}"
|
||||
|
||||
bus="$(basename "$(readlink -f "$path/ddc")")"
|
||||
bus="${bus#i2c-}"
|
||||
[[ "$bus" =~ ^[0-9]+$ ]] || continue
|
||||
|
||||
# A monitor that does not implement 0x10 is not an error; it simply
|
||||
# cannot be controlled, and is left out rather than shown as a slider
|
||||
# that does nothing.
|
||||
value="$(cmd_get "$bus")" || continue
|
||||
[[ -n "$value" ]] || continue
|
||||
|
||||
rows+=("$(jq -cn \
|
||||
--argjson bus "$bus" \
|
||||
--arg connector "$connector" \
|
||||
--argjson value "$value" \
|
||||
'{bus: $bus, connector: $connector, value: $value}')")
|
||||
done
|
||||
|
||||
if [[ ${#rows[@]} -eq 0 ]]; then
|
||||
emit_error 'no connected monitor reports DDC/CI brightness. Some panels implement it only when "DDC/CI" is enabled in their on-screen menu.'
|
||||
fi
|
||||
|
||||
printf '{"displays":[%s],"error":""}\n' "$(IFS=,; printf '%s' "${rows[*]}")"
|
||||
}
|
||||
|
||||
# Prints the current brightness as a whole percent, or nothing when the display
|
||||
# cannot report it. `getvcp --brief` is documented as machine readable and
|
||||
# answers "VCP 10 C <current> <max>"; the max is almost always 100 but is not
|
||||
# guaranteed to be, so it is read rather than assumed.
|
||||
cmd_get() {
|
||||
local bus="$1" out current max
|
||||
out="$(timeout 10 ddcutil --bus "$bus" getvcp "$VCP_BRIGHTNESS" --brief 2>/dev/null)" || return 1
|
||||
read -r _ _ _ current max <<<"$out"
|
||||
[[ "$current" =~ ^[0-9]+$ && "$max" =~ ^[0-9]+$ && "$max" -gt 0 ]] || return 1
|
||||
printf '%s' "$(( current * 100 / max ))"
|
||||
}
|
||||
|
||||
cmd_set() {
|
||||
local bus="$1" percent="$2" max out
|
||||
[[ "$percent" =~ ^[0-9]+$ ]] || return 1
|
||||
(( percent > 100 )) && percent=100
|
||||
|
||||
out="$(timeout 10 ddcutil --bus "$bus" getvcp "$VCP_BRIGHTNESS" --brief 2>/dev/null)" || return 1
|
||||
read -r _ _ _ _ max <<<"$out"
|
||||
[[ "$max" =~ ^[0-9]+$ && "$max" -gt 0 ]] || max=100
|
||||
|
||||
timeout 10 ddcutil --bus "$bus" setvcp "$VCP_BRIGHTNESS" "$(( percent * max / 100 ))" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
case "${1:-list}" in
|
||||
list) cmd_list ;;
|
||||
get) cmd_get "${2:?bus required}" ;;
|
||||
set) cmd_set "${2:?bus required}" "${3:?percent required}" ;;
|
||||
*) printf 'usage: panama-brightness [list|get <bus>|set <bus> <percent>]\n' >&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:
|
||||
# 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 if env is None else 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)
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Propagates the colour scheme to applications that do not read the desktop
|
||||
# portal.
|
||||
#
|
||||
# Most modern applications DO follow org.freedesktop.portal.Settings and need
|
||||
# nothing from us: GTK4/libadwaita, Qt6, Chromium and Electron all read
|
||||
# org.freedesktop.appearance color-scheme, which xdg-desktop-portal-gtk serves
|
||||
# from gsettings. Panama sets that, so those follow automatically.
|
||||
#
|
||||
# Terminals are the notable exception -- they predate the standard and carry
|
||||
# their own palettes. kitty is handled here.
|
||||
#
|
||||
# panama-theme-apps dark|light
|
||||
#
|
||||
# kitty gets it twice: the generated include file so terminals opened later
|
||||
# start correct, and a live `set-colors` over its control socket so terminals
|
||||
# already open change now. Without the second, a scheme change appears to do
|
||||
# nothing until you open a new window.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
scheme="${1:-dark}"
|
||||
case "$scheme" in
|
||||
dark|light) ;;
|
||||
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
kitty_dir="${XDG_CONFIG_HOME:-$HOME/.config}/kitty"
|
||||
theme_file="$kitty_dir/themes/tokyonight-moon.conf"
|
||||
[[ "$scheme" == "light" ]] && theme_file="$kitty_dir/themes/tokyonight-day.conf"
|
||||
|
||||
status_kitty="skipped"
|
||||
if [[ -r "$theme_file" ]]; then
|
||||
# Written atomically: kitty may read this while a new window is starting.
|
||||
if cp "$theme_file" "$kitty_dir/current-theme.conf.tmp" 2>/dev/null \
|
||||
&& mv "$kitty_dir/current-theme.conf.tmp" "$kitty_dir/current-theme.conf" 2>/dev/null; then
|
||||
status_kitty="written"
|
||||
fi
|
||||
|
||||
# Live-apply to running terminals. kitty appends its PID to the socket name
|
||||
# from listen_on, so there is one socket per instance -- "unix:@mykitty"
|
||||
# alone reaches nothing, which is exactly how this looked like remote
|
||||
# control being disabled when it was not.
|
||||
if command -v kitty >/dev/null 2>&1 && command -v ss >/dev/null 2>&1; then
|
||||
applied=0
|
||||
while read -r socket; do
|
||||
[[ -n "$socket" ]] || continue
|
||||
if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1; then
|
||||
applied=$((applied + 1))
|
||||
fi
|
||||
done < <(ss -xl 2>/dev/null | grep -oE '@mykitty[^[:space:]]*' | sort -u)
|
||||
(( applied > 0 )) && status_kitty="applied to $applied"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '{"scheme":"%s","kitty":"%s"}\n' "$scheme" "$status_kitty"
|
||||
@@ -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,163 @@
|
||||
pragma Singleton
|
||||
|
||||
// Panel brightness for external monitors, over DDC/CI.
|
||||
//
|
||||
// brightnessctl covers laptop panels through the kernel's backlight class. A
|
||||
// desktop driving a DisplayPort monitor has no such device, so it has no
|
||||
// brightness control at all -- the only way to dim the screen is the buttons on
|
||||
// the bezel. DDC/CI is the channel those buttons drive, and monitors expose it
|
||||
// over the same I2C lines that carry EDID.
|
||||
//
|
||||
// Two things shape everything here:
|
||||
//
|
||||
// Detection is slow. Probing every I2C bus takes on the order of a second,
|
||||
// which is far too slow to sit in front of a settings page opening. It runs
|
||||
// once, on demand, and afterwards each display is addressed by its bus number
|
||||
// directly.
|
||||
//
|
||||
// Writes are slow AND rate-limited by the monitor's firmware. A slider drag
|
||||
// emits values continuously; sending each one produces a queue the panel
|
||||
// works through seconds after the user let go, and some monitors drop or
|
||||
// garble writes that arrive too fast. So `value` updates immediately for the
|
||||
// UI and the hardware write is debounced, with only the latest value sent.
|
||||
//
|
||||
// Displays are keyed by DRM connector name (DP-2) so they line up with what
|
||||
// Hyprland, the Displays page, and the monitor list already call them.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-brightness"
|
||||
|
||||
// [{ bus, connector, model, value }] where value is 0..100.
|
||||
property var displays: []
|
||||
property bool scanning: false
|
||||
|
||||
// Empty when everything is fine. Carries the helper's explanation
|
||||
// otherwise -- most usefully the udev command that grants I2C access,
|
||||
// which is the difference between "brightness is unavailable" and
|
||||
// "brightness is one command away".
|
||||
property string lastError: ""
|
||||
|
||||
readonly property bool available: root.displays.length > 0
|
||||
|
||||
// True once a scan has completed, however it went. Lets the UI tell "not
|
||||
// looked yet" apart from "looked and found nothing", which otherwise render
|
||||
// identically and leave a permanently empty panel with no explanation.
|
||||
property bool scanned: false
|
||||
|
||||
// Pending writes, keyed by bus. A monitor being dragged accumulates exactly
|
||||
// one entry no matter how many values the slider emits.
|
||||
property var pending: ({})
|
||||
|
||||
function refresh(): void {
|
||||
if (root.scanning)
|
||||
return;
|
||||
root.scanning = true;
|
||||
scan.running = true;
|
||||
}
|
||||
|
||||
function displayFor(connector: string): var {
|
||||
return root.displays.find(display => display.connector === connector) ?? null;
|
||||
}
|
||||
|
||||
// Sets brightness for one display. The stored value moves at once so the
|
||||
// slider tracks the pointer; the hardware follows when the drag settles.
|
||||
function set(bus: int, percent: int): void {
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(percent)));
|
||||
|
||||
root.displays = root.displays.map(display =>
|
||||
display.bus === bus ? Object.assign({}, display, { value: clamped }) : display);
|
||||
|
||||
const next = Object.assign({}, root.pending);
|
||||
next[String(bus)] = clamped;
|
||||
root.pending = next;
|
||||
writeDebounce.restart();
|
||||
}
|
||||
|
||||
Process {
|
||||
id: scan
|
||||
command: [root.helperPath, "list"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const parsed = JSON.parse(this.text);
|
||||
root.displays = Array.isArray(parsed.displays) ? parsed.displays : [];
|
||||
root.lastError = String(parsed.error ?? "");
|
||||
} catch (error) {
|
||||
root.displays = [];
|
||||
root.lastError = "Could not read the brightness helper's output.";
|
||||
console.warn("Brightness: could not parse helper output:", error);
|
||||
}
|
||||
root.scanning = false;
|
||||
root.scanned = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Long enough that a drag produces one write rather than dozens, short
|
||||
// enough that a single click still feels immediate.
|
||||
Timer {
|
||||
id: writeDebounce
|
||||
interval: 120
|
||||
onTriggered: root.pump()
|
||||
}
|
||||
|
||||
// Writes run one at a time, and each is read back.
|
||||
//
|
||||
// Serial because DDC/CI is a bus protocol with no arbitration: two ddcutil
|
||||
// processes talking to the same monitor interleave their exchanges and both
|
||||
// can come back with garbage. Read back because a write is not a promise --
|
||||
// panels clamp to their own range, ignore values while waking from standby,
|
||||
// and drop writes that arrive too quickly. Without the read the slider shows
|
||||
// what Panama asked for rather than what the monitor did, which is the same
|
||||
// class of lie as trusting `hyprctl keyword` to have applied something.
|
||||
property int writingBus: -1
|
||||
|
||||
function pump(): void {
|
||||
if (writer.running || reader.running)
|
||||
return;
|
||||
|
||||
for (const bus in root.pending) {
|
||||
const value = root.pending[bus];
|
||||
const remaining = Object.assign({}, root.pending);
|
||||
delete remaining[bus];
|
||||
root.pending = remaining;
|
||||
|
||||
root.writingBus = parseInt(bus);
|
||||
writer.command = [root.helperPath, "set", bus, String(value)];
|
||||
writer.running = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: writer
|
||||
onExited: {
|
||||
reader.command = [root.helperPath, "get", String(root.writingBus)];
|
||||
reader.running = true;
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: reader
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const actual = parseInt(this.text.trim());
|
||||
if (!isNaN(actual)) {
|
||||
root.displays = root.displays.map(display =>
|
||||
display.bus === root.writingBus
|
||||
? Object.assign({}, display, { value: actual })
|
||||
: display);
|
||||
}
|
||||
root.writingBus = -1;
|
||||
// Anything queued while this write was in flight goes now.
|
||||
root.pump();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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 string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
|
||||
|
||||
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}" } } })`]);
|
||||
|
||||
// Applications that predate org.freedesktop.appearance and carry their
|
||||
// own palettes -- terminals, chiefly. Everything that reads the portal
|
||||
// (GTK4, Qt6, Chromium, Electron) is already handled by the gsettings
|
||||
// write above and needs nothing here.
|
||||
commands.push([root.appThemePath, root.dark ? "dark" : "light"]);
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
pragma Singleton
|
||||
|
||||
// Display configuration: resolution, refresh rate, scale, and rotation.
|
||||
//
|
||||
// This is the only page in Panama Settings where a wrong value can leave you
|
||||
// unable to SEE the screen well enough to undo it. A mode the display cannot
|
||||
// show, or a scale that makes everything unreadable, is not recoverable through
|
||||
// the same UI that caused it.
|
||||
//
|
||||
// So a change is never applied irreversibly. It is applied, then reverted
|
||||
// automatically after a countdown unless confirmed -- the same contract every
|
||||
// desktop uses for this one setting, and for the same reason. Confirming is
|
||||
// what writes it to the settings store; letting the countdown run leaves
|
||||
// nothing behind.
|
||||
//
|
||||
// Applied with `hyprctl eval` and hl.monitor{}. As everywhere else in Panama,
|
||||
// success means the value was read back from the compositor and matched, never
|
||||
// that a command exited zero.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// [{ name, description, width, height, refreshRate, scale, transform,
|
||||
// modes: [{ label, mode, width, height, refresh }] }]
|
||||
property var monitors: []
|
||||
property string lastError: ""
|
||||
|
||||
// Set while a change is applied but not yet confirmed.
|
||||
property string pendingOutput: ""
|
||||
property var pendingPrevious: null
|
||||
property var pendingRequested: null
|
||||
property bool pendingVerified: false
|
||||
property bool revertQueued: false
|
||||
property var revertExpected: null
|
||||
property string revertReason: ""
|
||||
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 !== ""
|
||||
readonly property bool canConfirm: root.awaitingConfirmation
|
||||
&& root.pendingVerified
|
||||
&& !root.busy
|
||||
readonly property bool busy: query.running || applyRun.running || revertRun.running
|
||||
|| root.revertExpected !== null
|
||||
|
||||
readonly property int confirmSeconds: 15
|
||||
|
||||
readonly property var transforms: [
|
||||
{ value: 0, label: "Landscape" },
|
||||
{ value: 1, label: "Portrait" },
|
||||
{ value: 2, label: "Landscape (flipped)" },
|
||||
{ value: 3, label: "Portrait (flipped)" }
|
||||
]
|
||||
|
||||
// Scales that divide this desktop's common resolutions into whole pixels.
|
||||
// Hyprland rejects a fractional scale that does not, and the message it
|
||||
// gives is not something to put in front of a user.
|
||||
readonly property var scales: [1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0]
|
||||
|
||||
Process {
|
||||
id: query
|
||||
property int generation: 0
|
||||
command: ["hyprctl", "-j", "monitors"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.parse(this.text, query.generation)
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0)
|
||||
root.lastError = "Could not read the connected displays.";
|
||||
if (root.revertQueued && !applyRun.running && root.awaitingConfirmation)
|
||||
root.performRevert();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: applyRun
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (!root.awaitingConfirmation)
|
||||
return;
|
||||
if (root.revertQueued) {
|
||||
if (!query.running)
|
||||
root.performRevert();
|
||||
return;
|
||||
}
|
||||
if (exitCode !== 0) {
|
||||
root.revertWithMessage("The display rejected that change and Panama restored the previous setting.");
|
||||
return;
|
||||
}
|
||||
verifyTimer.attempts = 0;
|
||||
verifyTimer.ticks = 0;
|
||||
verifyTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: revertRun
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
// Exit status is advisory only. Hyprland's Lua bridge can report
|
||||
// success without applying a value, so exact readback decides.
|
||||
root.revertVerificationActive = true;
|
||||
revertVerifyTimer.attempts = 0;
|
||||
revertVerifyTimer.ticks = 0;
|
||||
revertVerifyTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
|
||||
function refresh(): bool {
|
||||
if (!query.running) {
|
||||
query.generation = root.operationGeneration;
|
||||
query.running = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parse(text: string, generation: int): void {
|
||||
try {
|
||||
const raw = JSON.parse(text);
|
||||
root.monitors = raw.map(monitor => {
|
||||
const modes = root.normaliseModes(monitor.availableModes ?? []);
|
||||
const width = monitor.width ?? 0;
|
||||
const height = monitor.height ?? 0;
|
||||
const refreshRate = monitor.refreshRate ?? 0;
|
||||
const current = modes
|
||||
.filter(mode => mode.width === width && mode.height === height)
|
||||
.sort((left, right) =>
|
||||
Math.abs(left.refresh - refreshRate) - Math.abs(right.refresh - refreshRate))[0];
|
||||
return {
|
||||
name: monitor.name ?? "",
|
||||
description: monitor.description ?? monitor.model ?? "Display",
|
||||
width: width,
|
||||
height: height,
|
||||
refreshRate: refreshRate,
|
||||
mode: current?.mode ?? `${width}x${height}@${refreshRate}`,
|
||||
scale: monitor.scale ?? 1,
|
||||
transform: monitor.transform ?? 0,
|
||||
currentFormat: monitor.currentFormat ?? "",
|
||||
colorPreset: monitor.colorManagementPreset ?? "",
|
||||
vrr: monitor.vrr === true,
|
||||
modes: modes
|
||||
};
|
||||
});
|
||||
if (root.awaitingConfirmation && root.pendingRequested
|
||||
&& root.matchesRequest(root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
|
||||
root.pendingVerified = true;
|
||||
verifyTimer.stop();
|
||||
root.lastError = "";
|
||||
} else if (root.revertVerificationActive
|
||||
&& generation === root.revertGeneration
|
||||
&& root.revertExpected
|
||||
&& root.matchesRequest(root.monitorNamed(root.revertExpected.output), root.revertExpected)) {
|
||||
revertVerifyTimer.stop();
|
||||
root.revertVerificationActive = false;
|
||||
root.revertGeneration = -1;
|
||||
root.revertExpected = null;
|
||||
if (root.revertReason === "")
|
||||
root.lastError = "";
|
||||
else
|
||||
root.lastError = root.revertReason;
|
||||
root.revertReason = "";
|
||||
} else if (!root.awaitingConfirmation && !root.revertExpected && (
|
||||
root.lastError === "Could not read the connected displays."
|
||||
|| root.lastError === "The display list could not be read.")) {
|
||||
root.lastError = "";
|
||||
}
|
||||
} catch (error) {
|
||||
root.lastError = "The display list could not be read.";
|
||||
}
|
||||
}
|
||||
|
||||
// "[email protected]" -> a sortable record. The compositor reports the same
|
||||
// resolution at distinct rates such as 60.00 and 59.94. Those identities
|
||||
// remain separate because confirmation and recovery must read back the
|
||||
// exact mode the user chose, even when their rounded labels look similar.
|
||||
function normaliseModes(raw: var): var {
|
||||
const seen = {};
|
||||
const out = [];
|
||||
for (const entry of raw) {
|
||||
const match = String(entry).match(/^(\d+)x(\d+)@([\d.]+)Hz$/);
|
||||
if (!match)
|
||||
continue;
|
||||
const width = Number(match[1]);
|
||||
const height = Number(match[2]);
|
||||
const refreshText = match[3];
|
||||
const refresh = Number(refreshText);
|
||||
const roundedRefresh = Math.round(refresh);
|
||||
const key = `${width}x${height}@${refreshText}`;
|
||||
if (seen[key])
|
||||
continue;
|
||||
seen[key] = true;
|
||||
out.push({
|
||||
label: `${width} × ${height}`,
|
||||
refreshLabel: Math.abs(refresh - roundedRefresh) < 0.005
|
||||
? `${roundedRefresh} Hz`
|
||||
: `${refresh.toFixed(2)} Hz`,
|
||||
mode: `${width}x${height}@${refreshText}`,
|
||||
width: width,
|
||||
height: height,
|
||||
refresh: refresh
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => (b.width * b.height) - (a.width * a.height) || b.refresh - a.refresh);
|
||||
}
|
||||
|
||||
function monitorNamed(name: string): var {
|
||||
return root.monitors.find(monitor => monitor.name === name) ?? null;
|
||||
}
|
||||
|
||||
function modeParts(mode: string): var {
|
||||
const match = String(mode).match(/^(\d+)x(\d+)@(\d+(?:\.\d+)?)$/);
|
||||
if (!match)
|
||||
return null;
|
||||
return {
|
||||
width: Number(match[1]),
|
||||
height: Number(match[2]),
|
||||
refresh: Number(match[3])
|
||||
};
|
||||
}
|
||||
|
||||
function isScaleClean(mode: string, scale: real): bool {
|
||||
const parts = root.modeParts(mode);
|
||||
if (!parts || root.scales.indexOf(scale) < 0 || !Number.isFinite(scale) || scale <= 0)
|
||||
return false;
|
||||
const logicalWidth = parts.width / scale;
|
||||
const logicalHeight = parts.height / scale;
|
||||
return Math.abs(logicalWidth - Math.round(logicalWidth)) < 0.0001
|
||||
&& Math.abs(logicalHeight - Math.round(logicalHeight)) < 0.0001;
|
||||
}
|
||||
|
||||
function scalesForMode(mode: string): var {
|
||||
return root.scales.filter(scale => root.isScaleClean(mode, scale));
|
||||
}
|
||||
|
||||
function nearestCleanScale(mode: string, preferred: real): real {
|
||||
const choices = root.scalesForMode(mode);
|
||||
if (choices.length === 0)
|
||||
return 1.0;
|
||||
return choices.reduce((best, candidate) =>
|
||||
Math.abs(candidate - preferred) < Math.abs(best - preferred) ? candidate : best,
|
||||
choices[0]);
|
||||
}
|
||||
|
||||
function matchesRequest(monitor: var, requested: var): bool {
|
||||
if (!monitor || !requested || monitor.name !== requested.output)
|
||||
return false;
|
||||
const parts = root.modeParts(requested.mode);
|
||||
return !!parts
|
||||
&& monitor.width === parts.width
|
||||
&& monitor.height === parts.height
|
||||
&& Math.abs(monitor.refreshRate - parts.refresh) < 0.01
|
||||
&& Math.abs(monitor.scale - requested.scale) < 0.001
|
||||
&& monitor.transform === requested.transform;
|
||||
}
|
||||
|
||||
function modeIsCurrent(monitor: var, candidate: var): bool {
|
||||
return !!monitor && !!candidate
|
||||
&& monitor.width === candidate.width
|
||||
&& monitor.height === candidate.height
|
||||
&& Math.abs(monitor.refreshRate - candidate.refresh) < 0.01;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
if (root.awaitingConfirmation) {
|
||||
root.lastError = "Finish the current display change first.";
|
||||
return false;
|
||||
}
|
||||
const monitor = root.monitorNamed(output);
|
||||
if (!monitor) {
|
||||
root.lastError = "That display is not connected.";
|
||||
return false;
|
||||
}
|
||||
if (!monitor.modes.some(candidate => candidate.mode === mode)) {
|
||||
root.lastError = "That display does not offer that mode.";
|
||||
return false;
|
||||
}
|
||||
if (!root.isScaleClean(mode, scale)) {
|
||||
root.lastError = "That scale does not divide this resolution cleanly.";
|
||||
return false;
|
||||
}
|
||||
if (!root.transforms.some(candidate => candidate.value === transform)) {
|
||||
root.lastError = "That rotation is not one Panama offers.";
|
||||
return false;
|
||||
}
|
||||
|
||||
root.pendingPrevious = {
|
||||
output: output,
|
||||
mode: monitor.mode,
|
||||
scale: monitor.scale,
|
||||
transform: monitor.transform
|
||||
};
|
||||
root.operationGeneration++;
|
||||
root.pendingRequested = {
|
||||
output: output,
|
||||
mode: mode,
|
||||
scale: scale,
|
||||
transform: transform
|
||||
};
|
||||
root.pendingOutput = output;
|
||||
root.pendingVerified = false;
|
||||
root.revertQueued = false;
|
||||
root.secondsLeft = root.confirmSeconds;
|
||||
root.lastError = "";
|
||||
countdown.restart();
|
||||
|
||||
root.push(output, mode, scale, transform);
|
||||
return true;
|
||||
}
|
||||
|
||||
function push(output: string, mode: string, scale: real, transform: int): void {
|
||||
// Values are validated above and the output name comes from the
|
||||
// compositor's own list, so nothing user-authored reaches the payload.
|
||||
applyRun.exec(["hyprctl", "eval",
|
||||
`hl.monitor({ output = "${output}", mode = "${mode}", scale = ${scale}, transform = ${transform} })`]);
|
||||
}
|
||||
|
||||
function confirm(): bool {
|
||||
if (!root.canConfirm || !root.matchesRequest(
|
||||
root.monitorNamed(root.pendingOutput), root.pendingRequested)) {
|
||||
if (root.awaitingConfirmation)
|
||||
root.lastError = "Wait for the display to finish applying before keeping it.";
|
||||
return false;
|
||||
}
|
||||
|
||||
const stored = DesktopPreferences.get("displays");
|
||||
const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
|
||||
next[root.pendingOutput] = {
|
||||
mode: root.pendingRequested.mode,
|
||||
scale: root.pendingRequested.scale,
|
||||
transform: root.pendingRequested.transform
|
||||
};
|
||||
if (!DesktopPreferences.set("displays", next)) {
|
||||
root.lastError = "That display setting could not be saved. Revert it and try again.";
|
||||
return false;
|
||||
}
|
||||
|
||||
root.clearPending();
|
||||
root.lastError = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearPending(): void {
|
||||
countdown.stop();
|
||||
verifyTimer.stop();
|
||||
root.pendingOutput = "";
|
||||
root.pendingPrevious = null;
|
||||
root.pendingRequested = null;
|
||||
root.pendingVerified = false;
|
||||
root.revertQueued = false;
|
||||
root.secondsLeft = 0;
|
||||
}
|
||||
|
||||
function revert(): void {
|
||||
root.revertWithMessage("");
|
||||
}
|
||||
|
||||
function revertWithMessage(message: string): void {
|
||||
if (!root.awaitingConfirmation)
|
||||
return;
|
||||
countdown.stop();
|
||||
verifyTimer.stop();
|
||||
root.pendingVerified = false;
|
||||
root.revertReason = message;
|
||||
if (message !== "")
|
||||
root.lastError = message;
|
||||
if (applyRun.running || query.running) {
|
||||
root.revertQueued = true;
|
||||
return;
|
||||
}
|
||||
root.performRevert();
|
||||
}
|
||||
|
||||
function performRevert(): void {
|
||||
const previous = root.pendingPrevious;
|
||||
root.operationGeneration++;
|
||||
root.revertGeneration = root.operationGeneration;
|
||||
root.revertExpected = previous;
|
||||
root.revertVerificationActive = false;
|
||||
root.clearPending();
|
||||
if (previous) {
|
||||
revertRun.exec(["hyprctl", "eval",
|
||||
`hl.monitor({ output = "${previous.output}", mode = "${previous.mode}", scale = ${previous.scale}, transform = ${previous.transform} })`]);
|
||||
}
|
||||
}
|
||||
|
||||
// Clears any stored override for an output so it returns to the value
|
||||
// shipped in hypr/monitors.lua on the next start.
|
||||
function forget(output: string): void {
|
||||
const stored = DesktopPreferences.get("displays");
|
||||
if (!stored || typeof stored !== "object" || stored[output] === undefined)
|
||||
return;
|
||||
const next = Object.assign({}, stored);
|
||||
delete next[output];
|
||||
DesktopPreferences.set("displays", next);
|
||||
}
|
||||
|
||||
function isOverridden(output: string): bool {
|
||||
const stored = DesktopPreferences.get("displays");
|
||||
return !!(stored && typeof stored === "object" && stored[output] !== undefined);
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: verifyTimer
|
||||
property int attempts: 0
|
||||
property int ticks: 0
|
||||
interval: 120
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
ticks++;
|
||||
if (ticks > 50) {
|
||||
root.revertWithMessage("The display did not apply that setting, so Panama restored the previous one.");
|
||||
return;
|
||||
}
|
||||
if (root.refresh())
|
||||
attempts++;
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: revertVerifyTimer
|
||||
property int attempts: 0
|
||||
property int ticks: 0
|
||||
interval: 120
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
ticks++;
|
||||
if (ticks > 50) {
|
||||
stop();
|
||||
root.revertVerificationActive = false;
|
||||
root.revertGeneration = -1;
|
||||
root.revertExpected = null;
|
||||
root.revertReason = "";
|
||||
root.lastError = "The previous display setting could not be verified. Open Displays and restore it manually.";
|
||||
return;
|
||||
}
|
||||
if (root.refresh())
|
||||
attempts++;
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: countdown
|
||||
interval: 1000
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
root.secondsLeft -= 1;
|
||||
if (root.secondsLeft <= 0)
|
||||
root.revert();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -109,11 +222,10 @@ 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.
|
||||
@@ -0,0 +1,566 @@
|
||||
# Panama Health & Recovery Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a quiet, trustworthy System Health surface that diagnoses Panama-owned desktop functionality, exposes redacted reports, and offers only narrow allow-listed repairs.
|
||||
|
||||
**Architecture:** An executable Python helper, `panama-doctor`, is the only operating-system boundary and emits one deterministic JSON schema. A `Health.qml` singleton owns accepted snapshots, scan generations, repair state, and report copying; Settings, the bar, IPC, and Vicinae consume that typed state without constructing commands.
|
||||
|
||||
**Tech Stack:** Python 3 standard library, Bash contract tests, Quickshell/QML, QtQuick, Hyprland IPC, Vicinae script commands, Prism design tokens.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-18-panama-health-recovery-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Healthy background scans are silent: no notifications, Signal Glass events, animations, or permanent bar ornament.
|
||||
- Allowed statuses are exactly `ok`, `warning`, `error`, and `unconfigured`; overall status is `healthy`, `warning`, or `error`.
|
||||
- Optional integrations that have never been configured are `unconfigured`, never warnings.
|
||||
- The helper never reads or reports secret values, clipboard contents, notification bodies, calendar event data, SSIDs, addresses, or arbitrary command output.
|
||||
- The helper never installs packages, invokes `sudo`, deletes user data, rewrites arbitrary configuration, or repairs services Panama does not own.
|
||||
- Probe-derived values may populate observations only; check IDs, groups, titles, actions, commands, and arguments are authored constants.
|
||||
- All process launches use argument arrays. UI text and report content never become commands.
|
||||
- Preserve the last valid snapshot on helper failure or malformed JSON.
|
||||
- Repairs are judged by a fresh observed scan, not by process exit status alone.
|
||||
- Do not run a state-changing live repair without a genuinely degraded disposable target or explicit user approval.
|
||||
|
||||
## File map and stable interfaces
|
||||
|
||||
- `config/dot/quickshell/scripts/panama-doctor`: Python CLI and sole diagnostic/repair OS boundary.
|
||||
- `config/dot/quickshell/services/Health.qml`: snapshot state machine, scan/repair processes, report copy, and fixture seams.
|
||||
- `config/dot/quickshell/health-harness.qml`: deterministic IPC harness for generations, malformed data, coalescing, and repairs.
|
||||
- `config/dot/quickshell/modules/settings/HealthPage.qml`: System Health page composition.
|
||||
- `config/dot/quickshell/modules/settings/HealthSummary.qml`: stable-height summary hero and primary controls.
|
||||
- `config/dot/quickshell/modules/settings/HealthCheckRow.qml`: one accessible check row with one action.
|
||||
- `config/dot/quickshell/modules/bar/HealthIndicator.qml`: degraded-only bar entry point.
|
||||
- `config/local/share/vicinae/scripts/check-system-health.sh`: searchable launcher command.
|
||||
- `tests/quickshell/fixtures/doctor/`: isolated command, config, state, and runtime fixtures containing no real workstation data.
|
||||
- `tests/quickshell/panama-doctor-contract.sh`: schema, status, redaction, timeout, ordering, and repair allow-list contract.
|
||||
- `tests/quickshell/health-service-contract.sh`: QML state-machine contract.
|
||||
- `tests/quickshell/health-ui-contract.sh`: Settings, footer, report, indicator, IPC, and Vicinae integration contract.
|
||||
|
||||
The helper's authored check order is:
|
||||
|
||||
```text
|
||||
desktop.hyprland
|
||||
desktop.quickshell
|
||||
desktop.notifications
|
||||
desktop.portals
|
||||
desktop.hyprpaper
|
||||
desktop.hypridle
|
||||
desktop.vicinae
|
||||
input.pipewire
|
||||
input.clipboard
|
||||
input.wallpaper
|
||||
input.capture
|
||||
input.ocr
|
||||
input.brightness
|
||||
integration.nextcloud
|
||||
integration.rustdesk
|
||||
integration.kdeconnect
|
||||
integration.bluebubbles
|
||||
integration.home-assistant
|
||||
integration.calendar
|
||||
panama.runtime-links
|
||||
panama.vicinae-commands
|
||||
panama.selected-terminal
|
||||
panama.selected-launcher
|
||||
panama.processes
|
||||
panama.caffeine
|
||||
```
|
||||
|
||||
Only these repair IDs are executable in release one:
|
||||
|
||||
```text
|
||||
desktop.hyprpaper -> systemctl --user restart hyprpaper.service
|
||||
desktop.hypridle -> systemctl --user restart hypridle.service
|
||||
desktop.vicinae -> systemctl --user restart vicinae.service
|
||||
desktop.quickshell -> panama-action restart-shell (confirmation required)
|
||||
panama.runtime-links -> recreate only known Panama-owned broken symlinks
|
||||
panama.vicinae-commands -> setup/scripts/link-vicinae-scripts
|
||||
panama.caffeine -> release duplicate Panama/Caffeine inhibitor PIDs only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Prism health mocks and visual approval
|
||||
|
||||
**Files:**
|
||||
- Create outside tracked source: `.superpowers/mocks/system-health/index.html`
|
||||
- Create outside tracked source: `.superpowers/mocks/system-health/panama.css`
|
||||
- Create outside tracked source: `.superpowers/mocks/system-health/mock.js`
|
||||
- Create outside tracked source: `.superpowers/mocks/system-health/a-ledger.html`
|
||||
- Create outside tracked source: `.superpowers/mocks/system-health/b-focus.html`
|
||||
- Create outside tracked source: `.superpowers/mocks/system-health/c-compact.html`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the existing 272 px Settings sidebar, 48 px titlebar, Tokyo Night Moon Prism tokens, and the approved information architecture.
|
||||
- Produces: one approved visual composition for healthy, warning, checking, and error states plus the degraded-only bar indicator.
|
||||
|
||||
- [ ] **Step 1: Build three static compositions from real copy**
|
||||
|
||||
Use the same warning fixture in all three: `Vicinae` is stopped with action `Restart Vicinae`; `External monitor brightness` needs permission with action `View setup instructions`; `BlueBubbles` is `Not set up`. Keep every variant inside the real Settings geometry and include the footer and bar indicator.
|
||||
|
||||
```text
|
||||
A — Diagnostic ledger: one restrained amber issue rail beside calm grouped rows.
|
||||
B — Focus card: issues receive the visual focus; healthy groups collapse into quieter ledgers below.
|
||||
C — Compact matrix: dense two-column group cards with the same issue-first ordering.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Serve and visually inspect the mocks**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m http.server 52780 --directory .superpowers/mocks/system-health
|
||||
```
|
||||
|
||||
Expected: all three variants render at `http://localhost:52780`, keyboard focus is visible, no element overflows at 1360x900, and reduced-motion mode has no continuous animation.
|
||||
|
||||
- [ ] **Step 3: Capture the approved direction in the plan**
|
||||
|
||||
Add a short `Approved visual: <variant>` note beneath this task after user selection. Production UI work in Task 4 must reproduce that composition using existing QML tokens rather than copying browser-only effects.
|
||||
|
||||
### Task 2: Deterministic read-only doctor
|
||||
|
||||
**Files:**
|
||||
- Create: `config/dot/quickshell/scripts/panama-doctor`
|
||||
- Create: `tests/quickshell/panama-doctor-contract.sh`
|
||||
- Create: `tests/quickshell/fixtures/doctor/bin/systemctl`
|
||||
- Create: `tests/quickshell/fixtures/doctor/bin/pgrep`
|
||||
- Create: `tests/quickshell/fixtures/doctor/bin/busctl`
|
||||
- Create: `tests/quickshell/fixtures/doctor/bin/qs`
|
||||
- Create: `tests/quickshell/fixtures/doctor/bin/vicinae`
|
||||
- Create: `tests/quickshell/fixtures/doctor/bin/systemd-inhibit`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `PANAMA_DOCTOR_ROOT`, `PANAMA_DOCTOR_HOME`, `PANAMA_DOCTOR_CONFIG_HOME`, `PANAMA_DOCTOR_STATE_HOME`, `PANAMA_DOCTOR_RUNTIME_DIR`, `PANAMA_DOCTOR_PATH`, and `PANAMA_DOCTOR_TIMEOUT` test seams; production defaults resolve from the real process environment.
|
||||
- Produces: `panama-doctor --json`, `panama-doctor --summary`, and a versioned schema with `summary` plus the 25 ordered check objects listed above.
|
||||
|
||||
- [ ] **Step 1: Write the failing schema and redaction contract**
|
||||
|
||||
The contract must create an isolated home, tracked source tree, runtime tree, and fake command directory, then assert:
|
||||
|
||||
```bash
|
||||
snapshot="$($doctor --json)"
|
||||
jq -e '.schemaVersion == 1
|
||||
and (.generatedAt | type == "string")
|
||||
and (.summary.status | IN("healthy", "warning", "error"))
|
||||
and (.context.session | IN("hyprland", "other"))
|
||||
and (.context.versions | type == "array")
|
||||
and ([.checks[].id] | length == 25)
|
||||
and ([.checks[].id] | unique | length == 25)
|
||||
and ([.checks[].status] | all(IN("ok", "warning", "error", "unconfigured")))' <<<"$snapshot"
|
||||
[[ "$(jq -r '.checks[].id' <<<"$snapshot")" == "$expected_order" ]]
|
||||
! grep -Fq 'fixture-secret-token' <<<"$snapshot"
|
||||
! grep -Fq 'fixture clipboard body' <<<"$snapshot"
|
||||
! grep -Fq 'AA:BB:CC:DD:EE:FF' <<<"$snapshot"
|
||||
```
|
||||
|
||||
Cover a healthy required service, a missing required executable, an unconfigured optional integration, a configured-but-stopped integration, an inaccessible DDC bus, a timed-out probe, duplicate Caffeine inhibitors, malformed probe output, and concise `--summary` output.
|
||||
|
||||
- [ ] **Step 2: Run the contract and verify the helper is absent**
|
||||
|
||||
Run: `tests/quickshell/panama-doctor-contract.sh`
|
||||
|
||||
Expected: FAIL because `config/dot/quickshell/scripts/panama-doctor` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement authored checks and concurrent bounded probes**
|
||||
|
||||
Use Python standard-library types and deterministic assembly:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class Action:
|
||||
kind: Literal["repair", "open", "instructions"]
|
||||
label: str
|
||||
confirm: bool = False
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Check:
|
||||
id: str
|
||||
group: Literal["desktop-foundation", "input-media", "integrations", "panama-tools"]
|
||||
title: str
|
||||
status: Literal["ok", "warning", "error", "unconfigured"]
|
||||
detail: str
|
||||
action: Action | None = None
|
||||
```
|
||||
|
||||
Run independent probes through `ThreadPoolExecutor(max_workers=8)`. Every subprocess call must use a constant argument tuple, `capture_output=True`, `text=True`, and the configured timeout. Convert timeout, non-zero status, and parse failure into a check result. Assemble checks by the authored ID tuple after futures settle; never emit completion order.
|
||||
|
||||
Configuration checks may inspect existence and file type only. Home Assistant is configured when both expected variable names are present, but their values are never retained. Calendar is configured from enabled EDS source count only; event commands are never called. BlueBubbles is configured from the Flatpak installation check. DDC uses only `panama-brightness list` and retains display count plus its authored error classification, never connector names.
|
||||
|
||||
The top-level `context` contains only an authored session class and an ordered array of parsed Hyprland, Quickshell, Fedora, and Panama revision versions. `panama.processes` counts only exact authored process names and flags duplicate Quickshell, Vicinae, Hyprpaper, or Hypridle instances without exposing command lines. Integration actions are authored too: Nextcloud, RustDesk, KDE Connect, and BlueBubbles may offer their exact Open action; Home Assistant and calendar failures route to `home-phone` and `datetime`; never derive an application or page name from probe output.
|
||||
|
||||
- [ ] **Step 4: Run the doctor contract**
|
||||
|
||||
Run: `tests/quickshell/panama-doctor-contract.sh`
|
||||
|
||||
Expected: `panama doctor contract: PASS`.
|
||||
|
||||
- [ ] **Step 5: Commit the read-only engine**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/scripts/panama-doctor tests/quickshell/panama-doctor-contract.sh tests/quickshell/fixtures/doctor
|
||||
git commit -m "Add Panama system health diagnostics"
|
||||
```
|
||||
|
||||
### Task 3: Health singleton and typed IPC state machine
|
||||
|
||||
**Files:**
|
||||
- Create: `config/dot/quickshell/services/Health.qml`
|
||||
- Create: `config/dot/quickshell/health-harness.qml`
|
||||
- Create: `tests/quickshell/health-service-contract.sh`
|
||||
- Modify: `config/dot/quickshell/shell.qml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `panama-doctor --json` and `panama-doctor --repair CHECK_ID --json`.
|
||||
- Produces: `Health.snapshot`, `Health.checks`, `Health.summary`, `Health.status`, `Health.actionable`, `Health.busy`, `Health.diagnosticUnavailable`, `Health.lastError`, `Health.repairingId`, `Health.lastRepair`, `Health.lastCopyResult`, `Health.refresh()`, `Health.repair(id, external)`, `Health.copyReport()`, and IPC target `health` with `refresh`, `status`, `open`, and `repair(id)`.
|
||||
|
||||
- [ ] **Step 1: Write the failing QML state contract**
|
||||
|
||||
The harness exposes fixture methods that call the real singleton's pure consumption seams:
|
||||
|
||||
```qml
|
||||
function accept(text: string, generation: int): bool { return Health.consumeSnapshot(text, generation); }
|
||||
function queue(): void { Health.refresh(); Health.refresh(); }
|
||||
function status(): string { return JSON.stringify(Health.diagnostics()); }
|
||||
```
|
||||
|
||||
Assert that a valid warning snapshot is accepted, an older generation is ignored, malformed JSON preserves the prior checks and marks the engine unavailable, two refreshes while running schedule exactly one follow-up, a valid repair triggers one rescan, and unknown/non-repairable IDs start no process.
|
||||
|
||||
- [ ] **Step 2: Run the contract and verify it fails**
|
||||
|
||||
Run: `tests/quickshell/health-service-contract.sh`
|
||||
|
||||
Expected: FAIL because `Health.qml` and the harness do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the singleton state machine**
|
||||
|
||||
Define the stable state shape:
|
||||
|
||||
```qml
|
||||
property var snapshot: ({})
|
||||
property var checks: []
|
||||
property var summary: ({ status: "healthy", healthy: 0, warnings: 0, errors: 0, unconfigured: 0 })
|
||||
property string status: "healthy"
|
||||
property bool diagnosticUnavailable: false
|
||||
property bool queuedRefresh: false
|
||||
property int generation: 0
|
||||
property int acceptedGeneration: 0
|
||||
property string repairingId: ""
|
||||
property var lastRepair: ({})
|
||||
property string lastCopyResult: ""
|
||||
readonly property bool actionable: status === "warning" || status === "error"
|
||||
readonly property bool busy: scanProcess.running || repairProcess.running
|
||||
```
|
||||
|
||||
Use `Process.exec([root.helperPath, "--json"])`; attach the current generation to the collector before launch. `consumeSnapshot(text, generation)` validates schema version, summary keys, context shape, unique IDs, groups, statuses, titles, details, and action shapes before replacing state. A 2200 ms one-shot startup timer requests the initial scan. A running scan sets `queuedRefresh`; exit consumes at most one queued follow-up. `copyReport()` sends only `JSON.stringify(root.snapshot, null, 2)` to `wl-copy` through a `Process` stdin buffer and writes success or failure to `lastCopyResult` without touching the clipboard service's history model.
|
||||
|
||||
The `health` IPC `status()` returns only the already-redacted summary, busy flags, generation, and check IDs/statuses. `open()` calls `ShellState.openSettings("services")` then refreshes. IPC `repair(id)` calls `Health.repair(id, true)` and returns a Boolean acceptance result; Settings calls `Health.repair(id, false)`. A failed externally-originated repair uses an argument-array `notify-send` process with the existing `Panama action failed` title, while Settings failures remain inline.
|
||||
|
||||
- [ ] **Step 4: Run service and IPC contracts**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tests/quickshell/health-service-contract.sh
|
||||
tests/quickshell/settings-window-contract.sh
|
||||
```
|
||||
|
||||
Expected: both PASS.
|
||||
|
||||
- [ ] **Step 5: Commit the service layer**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/services/Health.qml config/dot/quickshell/health-harness.qml tests/quickshell/health-service-contract.sh config/dot/quickshell/shell.qml
|
||||
git commit -m "Add Panama health state service"
|
||||
```
|
||||
|
||||
### Task 4: Approved System Health Settings page
|
||||
|
||||
**Files:**
|
||||
- Create: `config/dot/quickshell/modules/settings/HealthPage.qml`
|
||||
- Create: `config/dot/quickshell/modules/settings/HealthSummary.qml`
|
||||
- Create: `config/dot/quickshell/modules/settings/HealthCheckRow.qml`
|
||||
- Modify: `config/dot/quickshell/modules/settings/SettingsShell.qml`
|
||||
- Modify: `config/dot/quickshell/modules/settings/SettingsSidebar.qml`
|
||||
- Modify: `config/dot/quickshell/services/SettingsSearch.qml`
|
||||
- Delete: `config/dot/quickshell/modules/settings/ServicesPage.qml`
|
||||
- Create: `tests/quickshell/health-ui-contract.sh`
|
||||
- Modify: `tests/quickshell/settings-pages-contract.sh`
|
||||
- Modify: `tests/quickshell/settings-search-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: all read-only state and methods from `Health.qml`; route remains the stable internal name `services`.
|
||||
- Produces: System Health summary, issues-first cards, four grouped ledgers, live clickable sidebar footer, Copy Report feedback, and confirmation requests for disruptive repairs.
|
||||
|
||||
- [ ] **Step 1: Write failing Settings and accessibility assertions**
|
||||
|
||||
Assert static structure and fixture-rendered state:
|
||||
|
||||
```bash
|
||||
rg -Fq 'label: "System Health"' config/dot/quickshell/modules/settings/SettingsSidebar.qml
|
||||
rg -Fq 'onClicked: Health.copyReport()' config/dot/quickshell/modules/settings/HealthSummary.qml
|
||||
rg -Fq 'onTapped: root.pageRequested("services")' config/dot/quickshell/modules/settings/SettingsSidebar.qml
|
||||
rg -Fq 'text: "Checking…"' config/dot/quickshell/modules/settings/HealthSummary.qml
|
||||
rg -Fq 'Health.refresh()' config/dot/quickshell/modules/settings/HealthPage.qml
|
||||
```
|
||||
|
||||
The runtime harness must prove warning rows appear before healthy groups, unconfigured is visible as `Not set up`, every status has text in addition to color, refresh preserves row geometry, keyboard focus reaches both hero actions and row actions, and a Quickshell-restart repair opens a confirmation sheet.
|
||||
|
||||
- [ ] **Step 2: Run UI contracts and verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tests/quickshell/health-ui-contract.sh
|
||||
tests/quickshell/settings-pages-contract.sh
|
||||
tests/quickshell/settings-search-contract.sh
|
||||
```
|
||||
|
||||
Expected: FAIL because the approved Health components are absent.
|
||||
|
||||
- [ ] **Step 3: Implement the approved composition**
|
||||
|
||||
Use `SettingsPage`, `SettingsCard`, `SettingsButton`, `Theme`, and `PrismEdge`. Keep the summary hero stable at 126 px and each check row at least 62 px. Derive labels exactly:
|
||||
|
||||
```qml
|
||||
function statusLabel(status: string): string {
|
||||
if (status === "ok") return "Healthy";
|
||||
if (status === "warning") return "Needs attention";
|
||||
if (status === "error") return "Action required";
|
||||
return "Not set up";
|
||||
}
|
||||
```
|
||||
|
||||
`HealthPage.Component.onCompleted` calls `Health.refresh()`. Issues are checks with `warning` or `error`. Group cards preserve helper order. Each row exposes at most one action. `open` actions route to exact Settings pages; `instructions` actions reveal authored inline instructions; `repair` actions call `Health.repair(id, false)` after confirmation only when `action.confirm === true`.
|
||||
|
||||
The sidebar footer is a 54 px `TapHandler` target with status text derived from Health, not a hardcoded string. It opens `services`; when no scan has completed it says `Checking Panama desktop`. A malformed or failed doctor run retains the last rows, changes only the hero to `Health check unavailable`, and exposes one bounded `Retry` action. The page ends with the approved boundary note and an `Open GNOME Settings` action for networking, printers, users, and other Fedora-owned areas.
|
||||
|
||||
- [ ] **Step 4: Run UI contracts and inspect the rendered fixture**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tests/quickshell/health-ui-contract.sh
|
||||
tests/quickshell/settings-pages-contract.sh
|
||||
tests/quickshell/settings-search-contract.sh
|
||||
```
|
||||
|
||||
Expected: all PASS with zero QML warnings.
|
||||
|
||||
- [ ] **Step 5: Commit the Settings experience**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/modules/settings config/dot/quickshell/services/SettingsSearch.qml tests/quickshell/health-ui-contract.sh tests/quickshell/settings-pages-contract.sh tests/quickshell/settings-search-contract.sh
|
||||
git commit -m "Build the System Health settings page"
|
||||
```
|
||||
|
||||
### Task 5: Quiet bar indicator and launcher entry point
|
||||
|
||||
**Files:**
|
||||
- Create: `config/dot/quickshell/modules/bar/HealthIndicator.qml`
|
||||
- Modify: `config/dot/quickshell/modules/bar/Bar.qml`
|
||||
- Create: `config/local/share/vicinae/scripts/check-system-health.sh`
|
||||
- Modify: `config/dot/quickshell/scripts/panama-action`
|
||||
- Modify: `tests/quickshell/health-ui-contract.sh`
|
||||
- Modify: `tests/quickshell/panama-action-contract.sh`
|
||||
- Modify: `tests/quickshell/panama-commands-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Health.actionable`, `Health.status`, and `Health.summary`; existing `panama-action` dispatcher and Settings IPC.
|
||||
- Produces: one degraded-only bar affordance and Vicinae command `Panama: Check System Health`.
|
||||
|
||||
- [ ] **Step 1: Extend contracts before production files**
|
||||
|
||||
Assert the indicator is absent for healthy/unconfigured-only fixtures, visible amber for warnings, visible red for errors, includes a textual accessible label, and opens `services`. Extend command fixtures so:
|
||||
|
||||
```text
|
||||
panama-action health -> qs ipc call health open
|
||||
check-system-health.sh title -> Panama: Check System Health
|
||||
check-system-health.sh exec -> $HOME/.config/quickshell/scripts/panama-action health
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run focused tests and verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tests/quickshell/health-ui-contract.sh
|
||||
tests/quickshell/panama-action-contract.sh
|
||||
tests/quickshell/panama-commands-contract.sh
|
||||
```
|
||||
|
||||
Expected: FAIL on the missing indicator and command.
|
||||
|
||||
- [ ] **Step 3: Implement the quiet entry points**
|
||||
|
||||
Place `HealthIndicator` in the right-side bar row before `ActivityIndicator`. It has no reserved width while hidden, no animation, and one compact shield/wrench glyph with an issue-count tooltip or accessible description. Use `Theme.warn` only for warnings and `Theme.danger` only for errors. Clicking calls `ShellState.openSettings("services")` and `Health.refresh()`.
|
||||
|
||||
Add this dispatcher case and usage token:
|
||||
|
||||
```bash
|
||||
health) qs ipc call health open ;;
|
||||
```
|
||||
|
||||
Create a Vicinae script with schema version 1, silent mode, Panama Settings icon, keywords `health`, `doctor`, `repair`, `services`, and the stable `panama-action health` execution path.
|
||||
|
||||
- [ ] **Step 4: Run focused tests**
|
||||
|
||||
Run the three commands from Step 2.
|
||||
|
||||
Expected: all PASS; command count increases from 17 to 18.
|
||||
|
||||
- [ ] **Step 5: Commit the entry points**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/modules/bar config/local/share/vicinae/scripts/check-system-health.sh config/dot/quickshell/scripts/panama-action tests/quickshell
|
||||
git commit -m "Add quiet System Health entry points"
|
||||
```
|
||||
|
||||
### Task 6: Allow-listed repairs and observed recovery
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/dot/quickshell/scripts/panama-doctor`
|
||||
- Modify: `tests/quickshell/panama-doctor-contract.sh`
|
||||
- Modify: `config/dot/quickshell/services/Health.qml`
|
||||
- Modify: `tests/quickshell/health-service-contract.sh`
|
||||
- Modify: `config/dot/quickshell/modules/settings/HealthCheckRow.qml`
|
||||
- Modify: `tests/quickshell/health-ui-contract.sh`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the fixed repair matrix in this plan and current accepted checks from `Health.qml`.
|
||||
- Produces: `panama-doctor --repair CHECK_ID --json` result `{schemaVersion, checkId, accepted, exitCode, message}`, inline repair state, and one post-repair scan.
|
||||
|
||||
- [ ] **Step 1: Add exact repair-command tests**
|
||||
|
||||
For every repair ID, use fake commands and isolated paths to assert the exact argv. Assert all of these are rejected before any process or filesystem write:
|
||||
|
||||
```text
|
||||
unknown.check
|
||||
integration.home-assistant
|
||||
input.brightness
|
||||
desktop.notifications
|
||||
../../escape
|
||||
desktop.vicinae;touch injected
|
||||
```
|
||||
|
||||
For runtime links, fixtures must prove only these link names are eligible: `hypr`, `quickshell`, `uwsm`, and `vicinae`; a regular user-owned directory is reported but never replaced. For Caffeine, only duplicate rows with application `Panama`, current UID, reason `Caffeine`, and mode `block` may yield numeric PIDs; leave one valid inhibitor alive and release extras.
|
||||
|
||||
- [ ] **Step 2: Run repair contracts and verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tests/quickshell/panama-doctor-contract.sh
|
||||
tests/quickshell/health-service-contract.sh
|
||||
```
|
||||
|
||||
Expected: FAIL because `--repair` is not implemented.
|
||||
|
||||
- [ ] **Step 3: Implement the authored repair registry**
|
||||
|
||||
Represent commands as immutable constant tuples or dedicated functions:
|
||||
|
||||
```python
|
||||
REPAIR_COMMANDS = {
|
||||
"desktop.hyprpaper": ("systemctl", "--user", "restart", "hyprpaper.service"),
|
||||
"desktop.hypridle": ("systemctl", "--user", "restart", "hypridle.service"),
|
||||
"desktop.vicinae": ("systemctl", "--user", "restart", "vicinae.service"),
|
||||
"desktop.quickshell": ("panama-action", "restart-shell"),
|
||||
}
|
||||
```
|
||||
|
||||
Handle runtime links, Vicinae command linking, and duplicate inhibitors in dedicated functions that accept no caller-controlled path or command. Return JSON on every known failure. Unknown IDs exit 2 with `accepted: false` and do not invoke any runner.
|
||||
|
||||
`Health.repair(id, external)` requires the ID to exist in the current snapshot with `action.kind === "repair"`, records `repairingId`, runs the helper with an argument array, parses the result, clears the busy row, and requests exactly one fresh scan. Keep the row degraded until that scan reports recovery.
|
||||
|
||||
- [ ] **Step 4: Run repair and UI contracts**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tests/quickshell/panama-doctor-contract.sh
|
||||
tests/quickshell/health-service-contract.sh
|
||||
tests/quickshell/health-ui-contract.sh
|
||||
```
|
||||
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 5: Commit repairs**
|
||||
|
||||
```bash
|
||||
git add config/dot/quickshell/scripts/panama-doctor config/dot/quickshell/services/Health.qml config/dot/quickshell/modules/settings/HealthCheckRow.qml tests/quickshell
|
||||
git commit -m "Add bounded Panama recovery actions"
|
||||
```
|
||||
|
||||
### Task 7: Full verification, live read-only audit, and documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/dot/hypr/DESKTOP-PARITY.md`
|
||||
- Modify: `config/dot/quickshell/modules/settings/README.md`
|
||||
- Modify: `docs/superpowers/plans/2026-08-18-panama-health-recovery.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the complete feature and existing regression suite.
|
||||
- Produces: current user documentation, a redacted live health snapshot, and final verification evidence.
|
||||
|
||||
- [ ] **Step 1: Document boundaries and entry points**
|
||||
|
||||
Document `Panama: Check System Health`, Settings → System Health, the degraded-only bar indicator, `panama-doctor --summary`, the no-`sudo`/no-package-install boundary, and the fact that GNOME/Fedora tools remain responsible for generic system configuration.
|
||||
|
||||
- [ ] **Step 2: Run syntax, focused, and full contracts**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m py_compile config/dot/quickshell/scripts/panama-doctor
|
||||
bash -n config/dot/quickshell/scripts/panama-action
|
||||
tests/quickshell/panama-doctor-contract.sh
|
||||
tests/quickshell/health-service-contract.sh
|
||||
tests/quickshell/health-ui-contract.sh
|
||||
for test in tests/quickshell/*contract.sh; do "$test"; done
|
||||
for test in tests/hypr/*contract.sh; do "$test"; done
|
||||
```
|
||||
|
||||
Expected: every command exits 0; Quickshell tests report 58 contracts after the three new contracts land.
|
||||
|
||||
- [ ] **Step 3: Run a redacted live read-only comparison**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
config/dot/quickshell/scripts/panama-doctor --json >"$(mktemp)"
|
||||
config/dot/quickshell/scripts/panama-doctor --summary
|
||||
systemctl --user is-active hyprpaper.service hypridle.service vicinae.service pipewire.service
|
||||
qs ipc call health refresh
|
||||
qs ipc call health status | jq '{status, busy, checks: [.checks[] | {id, status}]}'
|
||||
```
|
||||
|
||||
Expected: helper and direct service states agree. Do not print details from integrations; copied and IPC reports contain only redacted authored observations.
|
||||
|
||||
- [ ] **Step 4: Reload and inspect the live shell**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
qs reload
|
||||
sleep 4
|
||||
journalctl --user --since '-2 minutes' --no-pager | rg -i 'quickshell|qml|panama' | tail -200
|
||||
```
|
||||
|
||||
Expected: the shell returns, System Health opens, the healthy state is silent, and there are no new QML errors or binding-loop warnings. Do not invoke a repair during this step.
|
||||
|
||||
- [ ] **Step 5: Final diff and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
git status --short
|
||||
git diff --stat origin/main...HEAD
|
||||
git add config/dot/hypr/DESKTOP-PARITY.md config/dot/quickshell/modules/settings/README.md docs/superpowers/plans/2026-08-18-panama-health-recovery.md
|
||||
git commit -m "Document Panama health and recovery"
|
||||
```
|
||||
|
||||
Expected: only intentional Health & Recovery files are present and no workstation-specific values appear in the diff.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user