Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1f9891849 | ||
|
|
6026bf308b | ||
|
|
14529c011f | ||
|
|
7378c18ab9 | ||
|
|
9ce7040b91 | ||
|
|
09e5f1ad6b | ||
|
|
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.
|
# Python helper bytecode is local runtime state.
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.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
|
-- Vicinae is the Raycast-style launcher. `vicinae toggle` shows/hides the
|
||||||
-- window against the already-running server (started in autostart.lua).
|
-- window against the already-running server (started in autostart.lua).
|
||||||
local launcher = "vicinae toggle"
|
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.
|
-- Quickshell IPC targets. See quickshell/shell.qml for the handlers.
|
||||||
local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end
|
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 ────────────────────────────────────────────────────────
|
-- ── Media and volume ────────────────────────────────────────────────────────
|
||||||
-- locked = true keeps these working on the lock screen, as they do in GNOME.
|
-- locked = true keeps these working on the lock screen, as they do in GNOME.
|
||||||
-- 6% steps match the GNOME volume-step setting.
|
-- 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("XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 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("XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 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("XF86AudioMute", hl.dsp.exec_cmd(osd("volume toggle")), { locked = true , description = "Mute" })
|
||||||
bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true , description = "Mute microphone" })
|
bind("XF86AudioMicMute", hl.dsp.exec_cmd(osd("microphone toggle")), { locked = true , description = "Mute microphone" })
|
||||||
|
|
||||||
-- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
|
-- 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 + XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 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 + 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("XF86AudioPlay", hl.dsp.exec_cmd(osd("media play-pause")), { locked = true , description = "Play or pause" })
|
||||||
bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl 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("playerctl next"), { locked = true , description = "Next track" })
|
bind("XF86AudioNext", hl.dsp.exec_cmd(osd("media next")), { locked = true , description = "Next track" })
|
||||||
bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true , description = "Previous track" })
|
bind("XF86AudioPrev", hl.dsp.exec_cmd(osd("media previous")), { locked = true , description = "Previous track" })
|
||||||
bind("XF86AudioStop", hl.dsp.exec_cmd("playerctl stop"), { locked = true , description = "Stop playback" })
|
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("XF86MonBrightnessUp", hl.dsp.exec_cmd(osd("brightness up 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("XF86MonBrightnessDown", hl.dsp.exec_cmd(osd("brightness down 5")), { locked = true, repeating = true , description = "Brightness down" })
|
||||||
|
|
||||||
-- Hardware keys GNOME mapped that have obvious equivalents.
|
-- Hardware keys GNOME mapped that have obvious equivalents.
|
||||||
bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
|
bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ hl.config({
|
|||||||
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
|
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
|
||||||
-- Unfocused windows get no colour at all. The gradient only means
|
-- Unfocused windows get no colour at all. The gradient only means
|
||||||
-- something if exactly one window on screen is wearing it.
|
-- 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,
|
resize_on_border = true,
|
||||||
@@ -115,7 +119,11 @@ hl.config({
|
|||||||
disable_hyprland_logo = true,
|
disable_hyprland_logo = true,
|
||||||
disable_splash_rendering = 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
|
-- Variable refresh rate. 3 = enable only for fullscreen windows whose
|
||||||
-- content type is "video" or "game" -- the tag rules.lua applies to
|
-- 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.
|
-- 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({
|
hl.monitor({
|
||||||
output = "DP-2",
|
output = "DP-2",
|
||||||
mode = "4500x3000@60",
|
mode = dp2 and dp2.mode or shipped_mode,
|
||||||
position = "0x0",
|
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
|
-- 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
|
-- 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",
|
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.
|
-- Any monitor not named above: sane defaults rather than nothing.
|
||||||
hl.monitor({
|
hl.monitor({
|
||||||
output = "",
|
output = "",
|
||||||
|
|||||||
@@ -183,7 +183,6 @@ font_size 14.0
|
|||||||
|
|
||||||
#: Cursor customization {{{
|
#: Cursor customization {{{
|
||||||
|
|
||||||
cursor #c8d3f5
|
|
||||||
|
|
||||||
#: Default cursor color. If set to the special value none the cursor
|
#: 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
|
#: 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
|
#: precedence. Also, the cursor colors are modified if the cell
|
||||||
#: background and foreground colors have very low contrast.
|
#: 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 color of text under the cursor. If you want it rendered with
|
||||||
#: the background color of the cell underneath instead, use the
|
#: 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
|
#: robustly with the ever-changing sea of bugs that is Cocoa is too
|
||||||
#: much effort.
|
#: much effort.
|
||||||
|
|
||||||
url_color #4fd6be
|
|
||||||
# url_style curly
|
# url_style curly
|
||||||
|
|
||||||
#: The color and style for highlighting URLs on mouse-over. url_style
|
#: 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
|
#: placed centrally. A value of top-left means the padding will be
|
||||||
#: only at the bottom and right edges.
|
#: 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
|
#: The color for the border of the active window. Set this to none to
|
||||||
#: not draw borders around the active window.
|
#: not draw borders around the active window.
|
||||||
|
|
||||||
inactive_border_color #2f334d
|
|
||||||
|
|
||||||
#: The color for the border of inactive windows.
|
#: 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
|
#: Template to use for active tabs. If not specified falls back to
|
||||||
#: tab_title_template.
|
#: tab_title_template.
|
||||||
|
|
||||||
active_tab_foreground #1e2030
|
|
||||||
active_tab_background #82aaff
|
|
||||||
# active_tab_font_style bold-italic
|
# active_tab_font_style bold-italic
|
||||||
inactive_tab_foreground #c8d3f5
|
|
||||||
inactive_tab_background #42465a
|
|
||||||
# inactive_tab_font_style normal
|
# inactive_tab_font_style normal
|
||||||
|
|
||||||
#: Tab bar colors and styles.
|
#: Tab bar colors and styles.
|
||||||
|
|
||||||
tab_bar_background #222436
|
|
||||||
|
|
||||||
#: Background color for the tab bar. Defaults to using the terminal
|
#: Background color for the tab bar. Defaults to using the terminal
|
||||||
#: background color.
|
#: background color.
|
||||||
@@ -983,8 +973,8 @@ tab_bar_margin_color none
|
|||||||
|
|
||||||
#: Color scheme {{{
|
#: Color scheme {{{
|
||||||
|
|
||||||
foreground #c8d3f5
|
|
||||||
background #222436
|
include current-theme.conf
|
||||||
|
|
||||||
#: The foreground and background colors.
|
#: 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
|
#: How much to dim text that has the DIM/FAINT attribute set. One
|
||||||
#: means no dimming and zero means fully dimmed (i.e. invisible).
|
#: 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
|
#: The foreground and background colors for text selected with the
|
||||||
#: mouse. Setting both of these to none will cause a "reverse video"
|
#: 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
|
#: dull and bright version, for the first 16 colors. You can set the
|
||||||
#: remaining 240 colors as color16 to color255.
|
#: remaining 240 colors as color16 to color255.
|
||||||
|
|
||||||
color0 #1b1d2b
|
|
||||||
color8 #444a73
|
|
||||||
|
|
||||||
#: black
|
#: black
|
||||||
|
|
||||||
color1 #ff757f
|
|
||||||
color9 #ff757f
|
|
||||||
|
|
||||||
#: red
|
#: red
|
||||||
|
|
||||||
color2 #a5e8b5
|
|
||||||
color10 #a5e8b5
|
|
||||||
|
|
||||||
#: green
|
#: green
|
||||||
color3 #ffc777
|
|
||||||
color11 #ffc777
|
|
||||||
|
|
||||||
#: yellow
|
#: yellow
|
||||||
|
|
||||||
color4 #82aaff
|
|
||||||
color12 #82aaff
|
|
||||||
|
|
||||||
#: blue
|
#: blue
|
||||||
|
|
||||||
color5 #c099ff
|
|
||||||
color13 #c099ff
|
|
||||||
|
|
||||||
#: magenta
|
#: magenta
|
||||||
|
|
||||||
color6 #86e1fc
|
|
||||||
color14 #86e1fc
|
|
||||||
|
|
||||||
#: cyan
|
#: cyan
|
||||||
|
|
||||||
color7 #828bb8
|
|
||||||
color15 #c8d3f5
|
|
||||||
|
|
||||||
#: white
|
#: white
|
||||||
|
|
||||||
color16 #ff966c
|
|
||||||
color17 #c53b53
|
|
||||||
|
|
||||||
# mark1_foreground black
|
# 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)
|
vim.fn.winrestview(view)
|
||||||
end,
|
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 {
|
return {
|
||||||
{
|
{
|
||||||
"folke/tokyonight.nvim",
|
"folke/tokyonight.nvim",
|
||||||
opts = {
|
opts = function()
|
||||||
style = "moon",
|
local light = panama.is_light()
|
||||||
|
|
||||||
|
return {
|
||||||
|
style = panama.tokyonight_style(),
|
||||||
|
light_style = "day",
|
||||||
transparent = true,
|
transparent = true,
|
||||||
|
|
||||||
on_colors = function(colors)
|
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.comment = "#a0a7c5"
|
||||||
colors.fg_gutter = "#787f93"
|
colors.fg_gutter = "#787f93"
|
||||||
colors.terminal_black = "#828bb8"
|
colors.terminal_black = "#828bb8"
|
||||||
end,
|
end,
|
||||||
|
|
||||||
on_highlights = function(highlights, colors)
|
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"] = {
|
highlights["@markup.raw.markdown_inline"] = {
|
||||||
bg = colors.terminal_black,
|
bg = light and colors.bg_highlight or colors.terminal_black,
|
||||||
fg = colors.fg,
|
fg = colors.fg,
|
||||||
}
|
}
|
||||||
highlights["RenderMarkdownCodeInline"] = {
|
highlights["RenderMarkdownCodeInline"] = {
|
||||||
bg = colors.terminal_black,
|
bg = light and colors.bg_highlight or colors.terminal_black,
|
||||||
fg = colors.fg,
|
fg = colors.fg,
|
||||||
}
|
}
|
||||||
-- Fix LspReference* readability: DiagnosticUnnecessary dims fg for unused
|
|
||||||
-- imports, making text nearly invisible against LspReferenceText's background
|
-- LspReference* readability: DiagnosticUnnecessary dims fg for unused
|
||||||
highlights["LspReferenceText"] = { bg = colors.fg_gutter, fg = colors.fg }
|
-- imports, making text nearly invisible against LspReferenceText's
|
||||||
highlights["LspReferenceRead"] = { bg = colors.fg_gutter, fg = colors.fg }
|
-- background.
|
||||||
highlights["LspReferenceWrite"] = { bg = colors.fg_gutter, fg = colors.fg }
|
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,
|
end,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"LazyVim/LazyVim",
|
"LazyVim/LazyVim",
|
||||||
|
|||||||
@@ -257,6 +257,24 @@ Singleton {
|
|||||||
detail: "XKB layout name, or a comma-separated list to switch between",
|
detail: "XKB layout name, or a comma-separated list to switch between",
|
||||||
hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" }
|
hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "keyboardVariant", type: "string", def: "", group: "input",
|
||||||
|
// Same shape as the layout list and for the same reason: this is
|
||||||
|
// serialised into an hl.config string.
|
||||||
|
pattern: "^$|^[a-z0-9_]{1,24}(,[a-z0-9_]{1,24})*$",
|
||||||
|
label: "Layout variant",
|
||||||
|
detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout",
|
||||||
|
hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "keyboardOptions", type: "string", def: "", group: "input",
|
||||||
|
// XKB option names are colon-separated pairs in a comma-separated
|
||||||
|
// list, e.g. "compose:ralt,caps:escape".
|
||||||
|
pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$",
|
||||||
|
label: "Keyboard options",
|
||||||
|
detail: "XKB options, such as compose:ralt to make right Alt a compose key",
|
||||||
|
hypr: { path: ["input", "kb_options"], option: "input:kb_options", readAs: "str" }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "numlockByDefault", type: "bool", def: true, group: "input",
|
key: "numlockByDefault", type: "bool", def: true, group: "input",
|
||||||
label: "Num Lock on login",
|
label: "Num Lock on login",
|
||||||
@@ -310,6 +328,167 @@ Singleton {
|
|||||||
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
|
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Pointer ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Every `readAs` below was read back off the running compositor rather
|
||||||
|
// than guessed. Two are not what they look like: touchpad drag lock is
|
||||||
|
// an int with three states, not a switch, and scroll factors are floats
|
||||||
|
// even at their default of exactly 1.
|
||||||
|
{
|
||||||
|
key: "naturalScroll", type: "bool", def: false, group: "pointer",
|
||||||
|
label: "Natural scrolling",
|
||||||
|
detail: "Content follows the direction of your fingers, as on a phone",
|
||||||
|
hypr: { path: ["input", "natural_scroll"], option: "input:natural_scroll", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "accelProfile", type: "enum", def: "flat", group: "pointer",
|
||||||
|
label: "Acceleration",
|
||||||
|
detail: "Flat moves the pointer the same distance however fast you move",
|
||||||
|
options: [
|
||||||
|
{ value: "flat", label: "Flat" },
|
||||||
|
{ value: "adaptive", label: "Adaptive" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["input", "accel_profile"], option: "input:accel_profile", readAs: "str" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "scrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
|
||||||
|
group: "pointer",
|
||||||
|
label: "Scroll speed",
|
||||||
|
detail: "Multiplies how far one notch of the wheel scrolls",
|
||||||
|
hypr: { path: ["input", "scroll_factor"], option: "input:scroll_factor", readAs: "float" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "leftHanded", type: "bool", def: false, group: "pointer",
|
||||||
|
label: "Left-handed",
|
||||||
|
detail: "Swap the primary and secondary buttons",
|
||||||
|
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Touchpad ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Shown only on machines that have one. These are separate from the
|
||||||
|
// pointer settings above because libinput keeps them separate: a mouse
|
||||||
|
// and a touchpad on the same machine can scroll in opposite directions,
|
||||||
|
// and usually should.
|
||||||
|
{
|
||||||
|
key: "touchpadTapToClick", type: "bool", def: true, group: "touchpad",
|
||||||
|
label: "Tap to click",
|
||||||
|
detail: "A tap counts as a click without pressing down",
|
||||||
|
// The Lua config key and the hyprctl option name genuinely differ
|
||||||
|
// here: hl.config wants input.touchpad.tap_to_click, getoption
|
||||||
|
// answers to input:touchpad:tap-to-click. Using either spelling for
|
||||||
|
// both fails -- a hyphen is not a Lua identifier, and the
|
||||||
|
// underscored name is not a known option to getoption. This is what
|
||||||
|
// the two separate fields are for.
|
||||||
|
hypr: { path: ["input", "touchpad", "tap_to_click"], option: "input:touchpad:tap-to-click", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "touchpadNaturalScroll", type: "bool", def: true, group: "touchpad",
|
||||||
|
label: "Natural scrolling",
|
||||||
|
detail: "Content follows the direction of your fingers",
|
||||||
|
hypr: { path: ["input", "touchpad", "natural_scroll"], option: "input:touchpad:natural_scroll", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "touchpadDisableWhileTyping", type: "bool", def: true, group: "touchpad",
|
||||||
|
label: "Disable while typing",
|
||||||
|
detail: "Ignore the touchpad briefly after a keystroke, so a palm cannot move the pointer",
|
||||||
|
hypr: { path: ["input", "touchpad", "disable_while_typing"], option: "input:touchpad:disable_while_typing", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "touchpadScrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1,
|
||||||
|
group: "touchpad",
|
||||||
|
label: "Scroll speed",
|
||||||
|
detail: "Multiplies how far a two-finger scroll travels",
|
||||||
|
hypr: { path: ["input", "touchpad", "scroll_factor"], option: "input:touchpad:scroll_factor", readAs: "float" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "touchpadDragLock", type: "enum", def: 0, group: "touchpad",
|
||||||
|
label: "Drag lock",
|
||||||
|
detail: "Keeps a tap-and-drag active when you lift a finger mid-drag",
|
||||||
|
// An int with three states rather than a switch, which is why this
|
||||||
|
// is an enum: reported as `int` by getoption, not `bool`.
|
||||||
|
options: [
|
||||||
|
{ value: 0, label: "Off" },
|
||||||
|
{ value: 1, label: "On" },
|
||||||
|
{ value: 2, label: "On, until you tap again" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["input", "touchpad", "drag_lock"], option: "input:touchpad:drag_lock", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "touchpadMiddleButtonEmulation", type: "bool", def: false, group: "touchpad",
|
||||||
|
label: "Middle-click by pressing both buttons",
|
||||||
|
detail: "Pressing left and right together acts as a middle click",
|
||||||
|
hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Multitasking ────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// GNOME's Multitasking panel, in Hyprland's terms. The Desktop page
|
||||||
|
// described the first two of these as read-only facts ("Layout:
|
||||||
|
// Tiling"), which was never true -- they are ordinary settings that
|
||||||
|
// happened not to have controls.
|
||||||
|
//
|
||||||
|
// Defaults here are Panama's shipped values from hypr/looks.lua, not
|
||||||
|
// Hyprland's own, so restoring defaults returns the desktop to how it
|
||||||
|
// ships rather than to how Hyprland would behave with no config.
|
||||||
|
{
|
||||||
|
key: "windowLayout", type: "enum", def: "dwindle", group: "multitasking",
|
||||||
|
label: "Tiling layout",
|
||||||
|
detail: "Dwindle splits the focused window; master keeps one large window beside a stack",
|
||||||
|
options: [
|
||||||
|
{ value: "dwindle", label: "Dwindle" },
|
||||||
|
{ value: "master", label: "Master and stack" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["general", "layout"], option: "general:layout", readAs: "str" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "preserveSplit", type: "bool", def: true, group: "multitasking",
|
||||||
|
label: "Keep split direction",
|
||||||
|
detail: "New windows follow the split of the window they replace, instead of always halving the longer side",
|
||||||
|
hypr: { path: ["dwindle", "preserve_split"], option: "dwindle:preserve_split", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "forceSplit", type: "enum", def: 0, group: "multitasking",
|
||||||
|
label: "New windows open",
|
||||||
|
detail: "Where a new window lands relative to the one that was focused",
|
||||||
|
options: [
|
||||||
|
{ value: 0, label: "Where the pointer is" },
|
||||||
|
{ value: 1, label: "Always left or above" },
|
||||||
|
{ value: 2, label: "Always right or below" }
|
||||||
|
],
|
||||||
|
hypr: { path: ["dwindle", "force_split"], option: "dwindle:force_split", readAs: "int" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "windowSnapping", type: "bool", def: true, group: "multitasking",
|
||||||
|
label: "Snap floating windows",
|
||||||
|
detail: "Floating windows stick to screen edges and to each other as you drag them",
|
||||||
|
hypr: { path: ["general", "snap", "enabled"], option: "general:snap:enabled", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "workspaceBackAndForth", type: "bool", def: false, group: "multitasking",
|
||||||
|
label: "Switch back and forth",
|
||||||
|
detail: "Selecting the workspace you are already on returns you to the previous one",
|
||||||
|
hypr: { path: ["binds", "workspace_back_and_forth"], option: "binds:workspace_back_and_forth", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "allowWorkspaceCycles", type: "bool", def: false, group: "multitasking",
|
||||||
|
label: "Wrap around at the ends",
|
||||||
|
detail: "Moving past the last workspace continues from the first",
|
||||||
|
hypr: { path: ["binds", "allow_workspace_cycles"], option: "binds:allow_workspace_cycles", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "focusOnActivate", type: "bool", def: false, group: "multitasking",
|
||||||
|
label: "Let applications take focus",
|
||||||
|
detail: "An application asking for attention is switched to, rather than only highlighted",
|
||||||
|
hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking",
|
||||||
|
label: "Pointer changes active display",
|
||||||
|
detail: "Moving the pointer to another display makes it the active one",
|
||||||
|
hypr: { path: ["misc", "mouse_move_focuses_monitor"], option: "misc:mouse_move_focuses_monitor", readAs: "bool" }
|
||||||
|
},
|
||||||
|
|
||||||
// ── Night light ─────────────────────────────────────────────────────
|
// ── Night light ─────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
|
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
|
||||||
@@ -369,6 +548,73 @@ Singleton {
|
|||||||
detail: "Requires your password when the machine wakes"
|
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 ───────────────────────────────────────────────────
|
// ── Accessibility ───────────────────────────────────────────────────
|
||||||
// Backed by gsettings so GTK applications agree with the shell, and
|
// Backed by gsettings so GTK applications agree with the shell, and
|
||||||
// pushed to the compositor as well where it has its own notion.
|
// pushed to the compositor as well where it has its own notion.
|
||||||
@@ -402,6 +648,47 @@ Singleton {
|
|||||||
detail: "How often Panama updates the current conditions"
|
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 ──────────────────────────────────────────────────
|
// ── Vitals refresh ──────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500,
|
key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500,
|
||||||
@@ -476,13 +763,18 @@ Singleton {
|
|||||||
// A "json" value: the ordered list of desktop entry ids pinned to the
|
// 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
|
// dock. Kept in the shared store so that reordering the dock is covered
|
||||||
// by Restore defaults like everything else, rather than living in its
|
// 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",
|
key: "dockPinned", type: "json", group: "dock",
|
||||||
label: "Pinned applications",
|
label: "Pinned applications",
|
||||||
detail: "Applications that stay in the Dock whether or not they are running",
|
detail: "Applications that stay in the Dock whether or not they are running",
|
||||||
def: [
|
def: [
|
||||||
"org.gnome.Settings", "kitty", "org.gnome.Nautilus",
|
"panama-settings", "kitty", "org.gnome.Nautilus",
|
||||||
"com.bitwarden.desktop", "org.gnome.Software", "helium",
|
"com.bitwarden.desktop", "org.gnome.Software", "helium",
|
||||||
"org.mozilla.thunderbird_esr", "com.slack.Slack",
|
"org.mozilla.thunderbird_esr", "com.slack.Slack",
|
||||||
"app.bluebubbles.BlueBubbles", "rustdesk",
|
"app.bluebubbles.BlueBubbles", "rustdesk",
|
||||||
@@ -508,6 +800,34 @@ Singleton {
|
|||||||
detail: "Shortcuts you have moved from their shipped chord"
|
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 ────────────────────────────────────────────────────────
|
// ── Internal ────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
key: "lastPage", type: "string", def: "home", group: "internal",
|
key: "lastPage", type: "string", def: "home", group: "internal",
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ Singleton {
|
|||||||
// ── Weather ─────────────────────────────────────────────────────────────
|
// ── Weather ─────────────────────────────────────────────────────────────
|
||||||
// Coordinates taken from the GNOME night-light setting, which had already
|
// Coordinates taken from the GNOME night-light setting, which had already
|
||||||
// resolved the location. Uses Open-Meteo, which needs no API key.
|
// resolved the location. Uses Open-Meteo, which needs no API key.
|
||||||
readonly property real latitude: 27.7375
|
readonly property real latitude: DesktopPreferences.get("weatherLatitude")
|
||||||
readonly property real longitude: -82.6861
|
readonly property real longitude: DesktopPreferences.get("weatherLongitude")
|
||||||
// Open-Meteo returns coordinates but no friendly place name. Keep the
|
// Open-Meteo returns coordinates but no friendly place name. Keep the
|
||||||
// label deliberately general rather than exposing precise coordinates in
|
// label deliberately general rather than exposing precise coordinates in
|
||||||
// the UI or guessing at a city from them.
|
// 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 string temperatureUnit: DesktopPreferences.get("temperatureUnit")
|
||||||
readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes")
|
readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes")
|
||||||
|
|
||||||
@@ -41,13 +41,13 @@ Singleton {
|
|||||||
|
|
||||||
// amdgpu exposes utilisation here. Verified present on this machine; the
|
// amdgpu exposes utilisation here. Verified present on this machine; the
|
||||||
// widget hides itself if the path is missing rather than showing zeros.
|
// 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 ─────────────────────────────────────────────────────────
|
// ── Night light ─────────────────────────────────────────────────────────
|
||||||
// Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00.
|
// Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00.
|
||||||
readonly property int nightLightTemperature: DesktopPreferences.get("nightLightTemperature")
|
readonly property int nightLightTemperature: DesktopPreferences.get("nightLightTemperature")
|
||||||
readonly property real nightLightFrom: 17.0
|
readonly property real nightLightFrom: DesktopPreferences.get("nightLightFrom")
|
||||||
readonly property real nightLightTo: 10.0
|
readonly property real nightLightTo: DesktopPreferences.get("nightLightTo")
|
||||||
readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled")
|
readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled")
|
||||||
|
|
||||||
// ── Notifications ───────────────────────────────────────────────────────
|
// ── Notifications ───────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -18,36 +18,47 @@ import QtQuick
|
|||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
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 ─────────────────────────────────────────────────────────────
|
// ── Palette ─────────────────────────────────────────────────────────────
|
||||||
// Canonical Tokyo Night Moon. `accent` matches the GNOME accent exactly.
|
// Canonical Tokyo Night Moon. `accent` matches the GNOME accent exactly.
|
||||||
readonly property color bg: "#222436"
|
readonly property color bg: root.dark ? "#222436" : "#e1e2e7"
|
||||||
readonly property color bgDark: "#1e2030"
|
readonly property color bgDark: root.dark ? "#1e2030" : "#d3d5de"
|
||||||
readonly property color bgHighlight: "#2f334d"
|
readonly property color bgHighlight: root.dark ? "#2f334d" : "#c4c8da"
|
||||||
readonly property color bgPanel: "#2e2f3d" // dock glass surface
|
readonly property color bgPanel: root.dark ? "#2e2f3d" : "#d9dae3" // dock glass surface
|
||||||
readonly property color bgPopover: "#21212f" // Openbar submenu background
|
readonly property color bgPopover: root.dark ? "#21212f" : "#eaeaee" // Openbar submenu background
|
||||||
|
|
||||||
readonly property color fg: "#c8d3f5"
|
readonly property color fg: root.dark ? "#c8d3f5" : "#3760bf"
|
||||||
readonly property color fgDim: "#828bb8"
|
readonly property color fgDim: root.dark ? "#828bb8" : "#6172b0"
|
||||||
readonly property color fgMuted: "#636da6"
|
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
|
||||||
readonly property color gutter: "#3b4261"
|
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
|
||||||
|
|
||||||
// The pair. `accent` is the primary and carries every state meaning
|
// The pair. `accent` is the primary and carries every state meaning
|
||||||
// (focused, active, on). `accentSecondary` is the orchid from the tmux
|
// (focused, active, on). `accentSecondary` is the orchid from the tmux
|
||||||
// theme — it never appears alone, only as the far end of a gradient. That
|
// 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,
|
// 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.
|
// so the pink stops being special the moment it's used as a flat fill.
|
||||||
readonly property color accent: "#82aaff" // blue
|
readonly property color accent: root.dark ? "#82aaff" : "#2e7de9" // blue
|
||||||
readonly property color accentSecondary: "#b172b0" // orchid, from tmux
|
readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux
|
||||||
readonly property color accentAlt: "#65bcff" // blue1, a lighter blue
|
readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
|
||||||
readonly property color cyan: "#86e1fc"
|
readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
|
||||||
readonly property color teal: "#4fd6be"
|
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
|
||||||
readonly property color green: "#c3e88d"
|
readonly property color green: root.dark ? "#c3e88d" : "#587539"
|
||||||
readonly property color yellow: "#ffc777"
|
readonly property color yellow: root.dark ? "#ffc777" : "#8c6c3e"
|
||||||
readonly property color orange: "#ff966c"
|
readonly property color orange: root.dark ? "#ff966c" : "#b15c00"
|
||||||
readonly property color red: "#ff757f"
|
readonly property color red: root.dark ? "#ff757f" : "#f52a65"
|
||||||
readonly property color redDeep: "#c53b53"
|
readonly property color redDeep: root.dark ? "#c53b53" : "#c64343"
|
||||||
readonly property color magenta: "#c099ff"
|
readonly property color magenta: root.dark ? "#c099ff" : "#9854f1"
|
||||||
readonly property color pink: "#fca7ea"
|
readonly property color pink: root.dark ? "#fca7ea" : "#d20065"
|
||||||
|
|
||||||
// Semantic aliases — prefer these in widgets so intent survives a repaint.
|
// Semantic aliases — prefer these in widgets so intent survives a repaint.
|
||||||
readonly property color ok: green
|
readonly property color ok: green
|
||||||
@@ -59,11 +70,14 @@ Singleton {
|
|||||||
// The dock sits on compositor blur, so its fill is deliberately very
|
// The dock sits on compositor blur, so its fill is deliberately very
|
||||||
// translucent. Popovers are near-opaque because they carry text that must
|
// translucent. Popovers are near-opaque because they carry text that must
|
||||||
// stay legible. The bar itself has no surface or alpha token.
|
// stay legible. The bar itself has no surface or alpha token.
|
||||||
readonly property real dockAlpha: 0.34
|
// Light surfaces need more opacity for the same sense of a solid panel: the
|
||||||
readonly property real popoverAlpha: 0.92
|
// same 0.34 that reads as glass over a dark desktop reads as haze over a
|
||||||
readonly property real overlayAlpha: 0.55
|
// light one, and text on it stops being legible.
|
||||||
readonly property real hoverAlpha: 0.14
|
readonly property real dockAlpha: root.dark ? 0.34 : 0.62
|
||||||
readonly property real activeAlpha: 0.24
|
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 ────────────────────────────────────────────────────────────
|
// ── Geometry ────────────────────────────────────────────────────────────
|
||||||
readonly property int barHeight: 36
|
readonly property int barHeight: 36
|
||||||
@@ -90,12 +104,12 @@ Singleton {
|
|||||||
// Adwaita Sans for everything the user reads as text. No exceptions: a
|
// Adwaita Sans for everything the user reads as text. No exceptions: a
|
||||||
// monospaced clock or percentage reads as a terminal readout pasted into a
|
// monospaced clock or percentage reads as a terminal readout pasted into a
|
||||||
// UI, which is the opposite of the intent here.
|
// 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
|
// Nerd Font, used ONLY to draw icon glyphs — never for text. It is the
|
||||||
// pragmatic alternative to freedesktop symbolic icons, which ship with a
|
// pragmatic alternative to freedesktop symbolic icons, which ship with a
|
||||||
// hardcoded dark fill Qt will not recolour (see widgets/ThemedIcon.qml).
|
// 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,
|
// Apply to any text whose digits change in place — clocks, percentages,
|
||||||
// elapsed timers, dimension readouts. Tabular figures all share one
|
// elapsed timers, dimension readouts. Tabular figures all share one
|
||||||
@@ -107,10 +121,13 @@ Singleton {
|
|||||||
readonly property var tabularFigures: ({
|
readonly property var tabularFigures: ({
|
||||||
"tnum": 1
|
"tnum": 1
|
||||||
})
|
})
|
||||||
readonly property int fontSize: 13
|
// The four sizes are a scale, not four independent numbers: they move
|
||||||
readonly property int fontSizeSmall: 11
|
// together so the relationship between body, caption, heading and title
|
||||||
readonly property int fontSizeLarge: 16
|
// survives a change to the base rather than drifting apart.
|
||||||
readonly property int fontSizeTitle: 20
|
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 ──────────────────────────────────────────────────────────────
|
// ── Motion ──────────────────────────────────────────────────────────────
|
||||||
// Event-driven only. Nothing in this shell animates while idle — no pulse,
|
// 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
|
||||||
import Quickshell.Services.Pipewire
|
import Quickshell.Services.Pipewire
|
||||||
import qs.config
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
@@ -16,23 +17,12 @@ Item {
|
|||||||
|
|
||||||
implicitHeight: list.implicitHeight
|
implicitHeight: list.implicitHeight
|
||||||
|
|
||||||
readonly property var nodes: {
|
readonly property var nodes: root.output ? AudioDevices.outputs : AudioDevices.inputs
|
||||||
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 current: root.output ? Pipewire.defaultAudioSink : Pipewire.defaultAudioSource
|
readonly property var current: AudioDevices.current(root.output)
|
||||||
|
|
||||||
function select(node): void {
|
function select(node): void {
|
||||||
if (root.output)
|
AudioDevices.select(root.output, node)
|
||||||
Pipewire.preferredDefaultAudioSink = node;
|
|
||||||
else
|
|
||||||
Pipewire.preferredDefaultAudioSource = node;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ScrollColumn {
|
ScrollColumn {
|
||||||
@@ -52,7 +42,7 @@ Item {
|
|||||||
implicitHeight: 38
|
implicitHeight: 38
|
||||||
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
|
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
|
||||||
iconFallback: "audio-card-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
|
selected: nodeRow.modelData === root.current
|
||||||
onClicked: root.select(nodeRow.modelData)
|
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
|
// Two exist and they are not interchangeable:
|
||||||
// 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
|
// The kernel backlight class, driven by brightnessctl. Laptop panels have it;
|
||||||
// at startup — backlight devices do not appear and disappear.
|
// 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 QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
import qs.config
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property bool available: false
|
property bool backlightAvailable: false
|
||||||
property real value: 0
|
property real backlightValue: 0
|
||||||
|
|
||||||
visible: root.available
|
readonly property int rowCount: (root.backlightAvailable ? 1 : 0) + Brightness.displays.length
|
||||||
implicitHeight: root.available ? 32 : 0
|
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
|
// `-m` is the machine-readable form: name,class,current,percent,max
|
||||||
Process {
|
Process {
|
||||||
@@ -34,18 +54,85 @@ Item {
|
|||||||
const fields = line.split(",");
|
const fields = line.split(",");
|
||||||
if (fields.length < 5 || fields[1] !== "backlight")
|
if (fields.length < 5 || fields[1] !== "backlight")
|
||||||
continue;
|
continue;
|
||||||
root.available = true;
|
root.backlightAvailable = true;
|
||||||
root.value = parseInt(fields[3]) / 100;
|
root.backlightValue = parseInt(fields[3]) / 100;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function apply(v: real): void {
|
function applyBacklight(v: real): void {
|
||||||
root.value = v;
|
root.backlightValue = v;
|
||||||
// Never go fully dark: a 0% backlight looks like a broken shell.
|
// 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)) + "%"]);
|
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
|
// ValueSlider draws its own leading icon, but symbolic icons need
|
||||||
// recolouring to be visible — see ThemedIcon.
|
// recolouring to be visible — see ThemedIcon.
|
||||||
ThemedIcon {
|
ThemedIcon {
|
||||||
@@ -63,7 +150,9 @@ Item {
|
|||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.rightMargin: 32
|
anchors.rightMargin: 32
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
value: root.value
|
value: row.value
|
||||||
onMoved: v => root.apply(v)
|
onMoved: v => row.moved(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,18 @@ import qs.config
|
|||||||
import qs.services
|
import qs.services
|
||||||
|
|
||||||
SettingsPage {
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
title: "About Panama"
|
title: "About Panama"
|
||||||
lede: "A curated Hyprland desktop built around focus, speed, and good taste."
|
lede: "A curated Hyprland desktop built around focus, speed, and good taste."
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (!MachineInfo.scanned)
|
||||||
|
MachineInfo.refresh();
|
||||||
|
if (GraphicsDevices.devices.length === 0)
|
||||||
|
GraphicsDevices.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Panama Desktop"
|
title: "Panama Desktop"
|
||||||
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
|
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
|
||||||
@@ -31,6 +40,52 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What GNOME's About panel answers and this page did not: what am I running
|
||||||
|
// on. The rows come from MachineInfo, except graphics, which is joined from
|
||||||
|
// GraphicsDevices rather than read a second time -- two readouts of the same
|
||||||
|
// hardware are two things that can disagree.
|
||||||
|
SettingsCard {
|
||||||
|
title: "This machine"
|
||||||
|
subtitle: "Hardware and system, as the kernel reports it."
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: MachineInfo.facts
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
id: machineRow
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: machineRow.modelData.label
|
||||||
|
value: machineRow.modelData.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: GraphicsDevices.devices
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
id: gpuRow
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: GraphicsDevices.devices.length > 1
|
||||||
|
? "Graphics " + (gpuRow.index + 1)
|
||||||
|
: "Graphics"
|
||||||
|
value: gpuRow.modelData.name
|
||||||
|
divider: gpuRow.index < GraphicsDevices.devices.length - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
visible: MachineInfo.scanned && MachineInfo.facts.length === 0
|
||||||
|
label: "Hardware"
|
||||||
|
detail: "The system did not report anything readable"
|
||||||
|
value: "Unavailable"
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Design principles"
|
title: "Design principles"
|
||||||
|
|
||||||
|
|||||||
@@ -49,9 +49,18 @@ SettingsPage {
|
|||||||
: "Applied to every display. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
: "Applied to every display. Panama looks in ~/Pictures/Wallpapers, ~/Pictures/Backgrounds, ~/.local/share/backgrounds, and /usr/share/backgrounds."
|
||||||
|
|
||||||
WallpaperPicker {
|
WallpaperPicker {
|
||||||
|
id: wallpapers
|
||||||
width: parent.width
|
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 {
|
ActionRow {
|
||||||
label: "Look for new images"
|
label: "Look for new images"
|
||||||
detail: Wallpaper.scanning
|
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 {
|
SettingsCard {
|
||||||
title: "Windows"
|
title: "Windows"
|
||||||
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
|
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: "showCpu" }
|
||||||
ToggleRow { setting: "showMemory" }
|
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 {
|
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 QtQuick
|
||||||
|
import Quickshell
|
||||||
import Quickshell.Networking
|
import Quickshell.Networking
|
||||||
import Quickshell.Bluetooth
|
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
import qs.modules.quicksettings
|
|
||||||
|
|
||||||
SettingsPage {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
title: "Network & Devices"
|
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: {
|
// Drive the scanners only while this page is the one being shown.
|
||||||
for (const device of Networking.devices.values) {
|
Component.onCompleted: Connectivity.active = true
|
||||||
if (device.type === DeviceType.Wifi)
|
Component.onDestruction: Connectivity.active = false
|
||||||
return device;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
readonly property var bluetoothAdapter: Bluetooth.defaultAdapter
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Wi‑Fi"
|
title: "Wired"
|
||||||
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off"
|
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 {
|
SettingRow {
|
||||||
label: "Wi‑Fi"
|
label: "Wi-Fi"
|
||||||
detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found"
|
detail: Connectivity.wifiEnabled ? "On" : "Off"
|
||||||
controlWidth: 48
|
controlWidth: 48
|
||||||
|
divider: Connectivity.wifiEnabled
|
||||||
|
|
||||||
SettingsToggle {
|
SettingsToggle {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
checked: Networking.wifiEnabled
|
checked: Connectivity.wifiEnabled
|
||||||
enabled: Networking.wifiHardwareEnabled
|
enabled: Connectivity.wifiAvailable
|
||||||
onToggled: value => Networking.wifiEnabled = value
|
onToggled: value => Networking.wifiEnabled = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
WifiList {
|
WifiPanel {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
device: root.wifiDevice
|
visible: Connectivity.wifiEnabled
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Bluetooth"
|
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 {
|
SettingRow {
|
||||||
label: "Bluetooth"
|
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
|
controlWidth: 48
|
||||||
|
divider: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||||
|
|
||||||
SettingsToggle {
|
SettingsToggle {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
checked: root.bluetoothAdapter?.enabled ?? false
|
checked: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||||
enabled: root.bluetoothAdapter !== null
|
enabled: Connectivity.adapter !== null
|
||||||
onToggled: value => {
|
onToggled: value => {
|
||||||
if (root.bluetoothAdapter)
|
if (Connectivity.adapter)
|
||||||
root.bluetoothAdapter.enabled = value;
|
Connectivity.adapter.enabled = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BluetoothList {
|
BluetoothPanel {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
active: true
|
visible: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||||
maxHeight: 220
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
ActionRow {
|
||||||
label: "Advanced Bluetooth settings"
|
label: "Network connections"
|
||||||
detail: "Device details and system-level options"
|
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
|
divider: false
|
||||||
action: "Open panel"
|
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||||
onTriggered: SystemSettings.openGnomePanel("bluetooth")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,16 +49,14 @@ SettingsPage {
|
|||||||
title: "Window layout"
|
title: "Window layout"
|
||||||
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
|
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
|
||||||
|
|
||||||
TextRow {
|
// These were two read-only rows reporting "Tiling" and "Dynamic", which
|
||||||
label: "Layout"
|
// described settings rather than facts -- both are ordinary Hyprland
|
||||||
detail: "Dwindle with preserved split direction"
|
// options that simply had no controls. TextRow's own documentation says
|
||||||
value: "Tiling"
|
// a setting the user could reasonably change does not belong in it.
|
||||||
}
|
ChoiceRow { setting: "windowLayout" }
|
||||||
TextRow {
|
ToggleRow { setting: "preserveSplit" }
|
||||||
label: "Workspace movement"
|
ChoiceRow { setting: "forceSplit" }
|
||||||
detail: "Alt+H / Alt+L, add Shift to move a window"
|
ToggleRow { setting: "windowSnapping" }
|
||||||
value: "Dynamic"
|
|
||||||
}
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
label: "Gaps, corners, and effects"
|
label: "Gaps, corners, and effects"
|
||||||
detail: "Adjusted on the Appearance page, beside a live preview"
|
detail: "Adjusted on the Appearance page, beside a live preview"
|
||||||
@@ -68,6 +66,18 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GNOME's Multitasking panel, in Hyprland's terms.
|
||||||
|
SettingsCard {
|
||||||
|
title: "Workspaces & focus"
|
||||||
|
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
|
||||||
|
|
||||||
|
ToggleRow { setting: "workspaceBackAndForth" }
|
||||||
|
ToggleRow { setting: "allowWorkspaceCycles" }
|
||||||
|
ToggleRow { setting: "focusOnActivate" }
|
||||||
|
ToggleRow { setting: "mouseMoveFocusesMonitor" }
|
||||||
|
ChoiceRow { setting: "followMouse"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Focus"
|
title: "Focus"
|
||||||
|
|
||||||
|
|||||||
@@ -50,10 +50,13 @@ Rectangle {
|
|||||||
|
|
||||||
// Stands in for the wallpaper. Static: an animated gradient here would
|
// Stands in for the wallpaper. Static: an animated gradient here would
|
||||||
// repaint forever behind a settings page.
|
// 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 {
|
gradient: Gradient {
|
||||||
orientation: Gradient.Vertical
|
orientation: Gradient.Vertical
|
||||||
GradientStop { position: 0.0; color: "#2b3050" }
|
GradientStop { position: 0.0; color: Theme.dark ? "#2b3050" : "#b9c0dd" }
|
||||||
GradientStop { position: 1.0; color: "#241f33" }
|
GradientStop { position: 1.0; color: Theme.dark ? "#241f33" : "#cbbcd4" }
|
||||||
}
|
}
|
||||||
|
|
||||||
// The bar, so the preview reads as this desktop and not a generic one.
|
// The bar, so the preview reads as this desktop and not a generic one.
|
||||||
@@ -203,7 +206,7 @@ Rectangle {
|
|||||||
shadowEnabled: true
|
shadowEnabled: true
|
||||||
shadowColor: win.focused && root.glowOn
|
shadowColor: win.focused && root.glowOn
|
||||||
? Theme.alpha(Theme.accent, 0.5)
|
? 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
|
shadowBlur: win.focused && root.glowOn
|
||||||
? Math.min(1.0, root.px(root.glowRange) / 12)
|
? Math.min(1.0, root.px(root.glowRange) / 12)
|
||||||
: Math.min(1.0, root.px(root.shadowRange) / 24)
|
: 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 QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
|
|
||||||
SettingsPage {
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
title: "Displays"
|
title: "Displays"
|
||||||
lede: SystemSettings.monitorDescription || "Reading the active display…"
|
lede: SystemSettings.monitorDescription || "Reading the active display…"
|
||||||
|
|
||||||
SettingsCard {
|
property string selectedOutput: ""
|
||||||
title: SystemSettings.monitorName || "Active display"
|
readonly property var monitor: Displays.monitorNamed(root.selectedOutput)
|
||||||
subtitle: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight} at ${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale · ${SystemSettings.monitorFormat}`
|
?? (Displays.monitors.length > 0 ? Displays.monitors[0] : null)
|
||||||
TextRow { label: "Color mode"; detail: "Wide-gamut SDR desktop at 10-bit"; value: SystemSettings.colorPreset || "wide" }
|
readonly property string currentMode: root.monitor
|
||||||
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 }
|
? 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 {
|
SettingsCard {
|
||||||
title: "Gaming display policy"
|
visible: Displays.monitors.length > 1
|
||||||
subtitle: "These values apply immediately and are restored when Panama starts."
|
title: "Connected display"
|
||||||
SettingRow {
|
subtitle: "Choose the output whose resolution, scale, and rotation you want to adjust."
|
||||||
label: "Game-aware HDR"
|
|
||||||
detail: "Enter HDR only for fullscreen content that requests it"
|
ChoiceGrid {
|
||||||
controlWidth: 48
|
width: parent.width
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.autoHdr; onToggled: value => SystemSettings.setAutoHdr(value) }
|
label: "Display"
|
||||||
}
|
options: Displays.monitors.map(monitor => ({
|
||||||
SettingRow {
|
value: monitor.name,
|
||||||
label: "Content-aware VRR"
|
label: monitor.description || monitor.name
|
||||||
detail: "Enable variable refresh only for game and video content"
|
}))
|
||||||
controlWidth: 48
|
current: root.monitor ? root.monitor.name : ""
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.vrrPolicy === 3; onToggled: value => SystemSettings.setVrrPolicy(value ? 3 : 0) }
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
}
|
|
||||||
SettingRow {
|
|
||||||
label: "Direct scanout for games"
|
|
||||||
detail: "Bypass compositing only for windows classified as games"
|
|
||||||
divider: false
|
divider: false
|
||||||
controlWidth: 48
|
onPicked: value => root.selectedOutput = value
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.directScanoutPolicy === 2; onToggled: value => SystemSettings.setDirectScanoutPolicy(value ? 2 : 0) }
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
SettingsCard {
|
||||||
title: "Night Light"
|
title: "Night Light"
|
||||||
SettingRow {
|
subtitle: NightLight.active
|
||||||
label: "Warm display colors"
|
? "On now, warming the display to reduce blue light."
|
||||||
detail: NightLight.automatic ? "Following the evening schedule" : "Manual control"
|
: "Warms the display in the evening to reduce blue light."
|
||||||
controlWidth: 48
|
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: NightLight.active; onToggled: NightLight.toggle() }
|
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 {
|
SettingRow {
|
||||||
label: "Color temperature"
|
id: brightnessRow
|
||||||
detail: `${NightLight.temperature} K`
|
required property var modelData
|
||||||
divider: false
|
required property int index
|
||||||
controlWidth: 230
|
|
||||||
|
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 {
|
ValueSlider {
|
||||||
anchors.fill: parent
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
value: (6500 - NightLight.temperature) / 4000
|
anchors.right: parent.right
|
||||||
icon: "weather-clear-night-symbolic"
|
width: parent.width
|
||||||
onMoved: value => NightLight.temperature = Math.round((6500 - value * 4000) / 50) * 50
|
value: brightnessRow.modelData.value / 100
|
||||||
|
onMoved: v => Brightness.set(brightnessRow.modelData.bus, Math.round(v * 100))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
SettingsCard {
|
||||||
width: parent.width
|
title: "Gaming display policy"
|
||||||
height: warningText.implicitHeight + 30
|
subtitle: "Applied immediately and restored when Panama starts."
|
||||||
radius: Theme.cardRadius
|
|
||||||
color: Theme.alpha(Theme.warn, 0.085)
|
ToggleRow { setting: "autoHdr" }
|
||||||
border.width: 1
|
ChoiceRow { setting: "vrrPolicy" }
|
||||||
border.color: Theme.alpha(Theme.warn, 0.22)
|
ChoiceRow { setting: "directScanoutPolicy"; divider: false }
|
||||||
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 {
|
||||||
|
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 string sourceName
|
||||||
required property int index
|
required property int index
|
||||||
required property bool featured
|
required property bool featured
|
||||||
|
required property bool canMoveEarlier
|
||||||
|
required property bool canMoveLater
|
||||||
|
|
||||||
signal aliasCommitted(string id, string alias)
|
signal aliasCommitted(string id, string alias)
|
||||||
signal removeRequested(string id)
|
signal removeRequested(string id)
|
||||||
signal moveRequested(string id, int targetIndex)
|
signal moveRequested(string id, int targetIndex)
|
||||||
|
|
||||||
readonly property bool dragging: dragHandler.active
|
|
||||||
|
|
||||||
implicitHeight: 108
|
implicitHeight: 108
|
||||||
radius: Theme.cardRadius
|
radius: Theme.cardRadius
|
||||||
color: root.dragging
|
color: Theme.alpha(Theme.bgDark, 0.7)
|
||||||
? Theme.mix(Theme.bgDark, Theme.accent, 0.09)
|
border.width: 1
|
||||||
: Theme.alpha(Theme.bgDark, 0.7)
|
border.color: Theme.alpha(Theme.fg, 0.07)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
PrismEdge {
|
PrismEdge {
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
inset: root.radius
|
inset: root.radius
|
||||||
opacity: root.dragging ? 0.82 : 0.2
|
opacity: 0.2
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Column {
|
||||||
id: dragHandle
|
id: reorderControls
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 11
|
anchors.leftMargin: 8
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
width: 30
|
width: 30
|
||||||
height: 42
|
spacing: 4
|
||||||
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)
|
|
||||||
|
|
||||||
Text {
|
SettingsButton {
|
||||||
anchors.centerIn: parent
|
width: 30
|
||||||
text: "⠿"
|
height: 29
|
||||||
color: root.dragging ? Theme.accent : Theme.fgDim
|
text: "↑"
|
||||||
font.family: Theme.fontFamily
|
enabled: root.canMoveEarlier
|
||||||
font.pixelSize: 16
|
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 {
|
SettingsButton {
|
||||||
id: handleMouse
|
width: 30
|
||||||
anchors.fill: parent
|
height: 29
|
||||||
hoverEnabled: true
|
text: "↓"
|
||||||
acceptedButtons: Qt.NoButton
|
enabled: root.canMoveLater
|
||||||
cursorShape: Qt.SizeAllCursor
|
activeFocusOnTab: enabled
|
||||||
}
|
onClicked: root.moveRequested(root.favorite.id, root.index + 1)
|
||||||
|
Keys.onReturnPressed: if (enabled) root.moveRequested(root.favorite.id, root.index + 1)
|
||||||
DragHandler {
|
Keys.onSpacePressed: if (enabled) root.moveRequested(root.favorite.id, root.index + 1)
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: aliasFrame
|
id: aliasFrame
|
||||||
anchors.left: dragHandle.right
|
anchors.left: reorderControls.right
|
||||||
anchors.leftMargin: 10
|
anchors.leftMargin: 10
|
||||||
anchors.right: removeButton.left
|
anchors.right: removeButton.left
|
||||||
anchors.rightMargin: 12
|
anchors.rightMargin: 12
|
||||||
@@ -188,16 +156,4 @@ Rectangle {
|
|||||||
Keys.onReturnPressed: root.removeRequested(root.favorite.id)
|
Keys.onReturnPressed: root.removeRequested(root.favorite.id)
|
||||||
Keys.onSpacePressed: 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 {
|
SettingsCard {
|
||||||
title: "Weather"
|
title: "Weather"
|
||||||
subtitle: "Local conditions in the date menu"
|
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" }
|
ChoiceRow { setting: "temperatureUnit" }
|
||||||
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,10 +43,200 @@ SettingsPage {
|
|||||||
return "Home Assistant is unavailable";
|
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 {
|
SettingsCard {
|
||||||
title: "Home Assistant"
|
title: "Home Assistant"
|
||||||
subtitle: root.homeStatus()
|
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 {
|
SettingRow {
|
||||||
label: "Light catalog"
|
label: "Light catalog"
|
||||||
detail: "Panama reads light state through the Home Assistant helper."
|
detail: "Panama reads light state through the Home Assistant helper."
|
||||||
@@ -122,6 +312,8 @@ SettingsPage {
|
|||||||
})
|
})
|
||||||
sourceName: modelData.sourceName
|
sourceName: modelData.sourceName
|
||||||
featured: index < 4
|
featured: index < 4
|
||||||
|
canMoveEarlier: index > 0
|
||||||
|
canMoveLater: index < favoritesGrid.count - 1
|
||||||
onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias)
|
onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias)
|
||||||
onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex)
|
onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex)
|
||||||
onRemoveRequested: id => HomePreferences.remove(id)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Mouse & Touchpad.
|
||||||
|
//
|
||||||
|
// GNOME splits pointing devices out of Keyboard, and so does this: the settings
|
||||||
|
// are unrelated, and burying pointer speed under a page called "Shortcuts" is
|
||||||
|
// where it was before. These all reach Hyprland's input section, which until now
|
||||||
|
// could only be changed by editing hypr/input.lua by hand.
|
||||||
|
//
|
||||||
|
// The touchpad card renders only when a touchpad is attached. On a desktop it
|
||||||
|
// would be worse than useless -- every switch on it appears to work, because the
|
||||||
|
// preference is stored and the compositor accepts an option for a device class
|
||||||
|
// with no members, so the user would be toggling settings that can never affect
|
||||||
|
// anything with nothing to say so.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
title: "Mouse & Touchpad"
|
||||||
|
lede: InputDevices.hasTouchpad
|
||||||
|
? "Pointer behaviour for your mouse and touchpad."
|
||||||
|
: "Pointer behaviour. Touchpad settings appear when a touchpad is attached."
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Mouse"
|
||||||
|
subtitle: "Applied to every pointing device that is not a touchpad."
|
||||||
|
|
||||||
|
SliderRow { setting: "pointerSensitivity" }
|
||||||
|
ChoiceRow { setting: "accelProfile" }
|
||||||
|
ToggleRow { setting: "naturalScroll" }
|
||||||
|
SliderRow { setting: "scrollFactor" }
|
||||||
|
ToggleRow { setting: "leftHanded"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: InputDevices.hasTouchpad
|
||||||
|
title: "Touchpad"
|
||||||
|
subtitle: "Separate from the mouse on purpose: libinput keeps them apart, and a touchpad and a mouse usually want to scroll in opposite directions."
|
||||||
|
|
||||||
|
ToggleRow { setting: "touchpadTapToClick" }
|
||||||
|
ToggleRow { setting: "touchpadNaturalScroll" }
|
||||||
|
ToggleRow { setting: "touchpadDisableWhileTyping" }
|
||||||
|
SliderRow { setting: "touchpadScrollFactor" }
|
||||||
|
ChoiceRow { setting: "touchpadDragLock" }
|
||||||
|
ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Pointer"
|
||||||
|
|
||||||
|
ChoiceRow { setting: "followMouse" }
|
||||||
|
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never" }
|
||||||
|
SliderRow { setting: "cursorSize"; divider: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,10 +50,89 @@ SettingsPage {
|
|||||||
SliderRow { setting: "maxVisibleToasts"; divider: false }
|
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 {
|
SettingsCard {
|
||||||
title: "Focus sessions"
|
title: "Focus sessions"
|
||||||
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
|
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 {
|
SettingRow {
|
||||||
id: durationRow
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Privacy & Security.
|
||||||
|
//
|
||||||
|
// GNOME's Privacy panel covers screen lock, camera and microphone access, file
|
||||||
|
// history, trash, and device security. Panama covers the parts it genuinely
|
||||||
|
// owns and is explicit about the parts it does not.
|
||||||
|
//
|
||||||
|
// The file-history and trash settings are the notable omission, and the reason
|
||||||
|
// is worth stating: those are GNOME preferences enforced by gsd-housekeeping,
|
||||||
|
// which is not running in a Hyprland session. Offering switches for them would
|
||||||
|
// store a preference, change nothing, and give no sign of it -- the exact
|
||||||
|
// failure this codebase keeps designing against. So they are delegated by name
|
||||||
|
// rather than reimplemented as controls that lie.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
title: "Privacy & Security"
|
||||||
|
lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0
|
||||||
|
? "Screen lock, device access, and a machine whose security settings all check out."
|
||||||
|
: "Screen lock, which applications can see you, and how this machine is protected."
|
||||||
|
|
||||||
|
Component.onCompleted: if (!DeviceSecurity.scanned) DeviceSecurity.refresh()
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Screen lock"
|
||||||
|
subtitle: "The same settings as Power & Lock, which is where the idle timings live."
|
||||||
|
|
||||||
|
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
|
||||||
|
ToggleRow { setting: "lockOnSleep"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Camera & microphone"
|
||||||
|
subtitle: PrivacyState.anyActive
|
||||||
|
? "In use right now — the bar shows an indicator whenever this is true."
|
||||||
|
: "Nothing is using your camera or microphone."
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Camera"
|
||||||
|
detail: PrivacyState.cameraActive
|
||||||
|
? "In use by " + (PrivacyState.cameraApp || "an application")
|
||||||
|
: "Not in use"
|
||||||
|
value: PrivacyState.cameraActive ? "Active" : "Idle"
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Microphone"
|
||||||
|
detail: PrivacyState.microphoneActive
|
||||||
|
? "In use by " + (PrivacyState.microphoneApp || "an application")
|
||||||
|
: "Not in use"
|
||||||
|
value: PrivacyState.microphoneActive ? "Active" : "Idle"
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Screen sharing"
|
||||||
|
detail: PrivacyState.screenSharingActive
|
||||||
|
? "Being shared by " + (PrivacyState.screenSharingApp || "an application")
|
||||||
|
: "Not being shared"
|
||||||
|
value: PrivacyState.screenSharingActive ? "Active" : "Idle"
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Device security"
|
||||||
|
subtitle: DeviceSecurity.attentionCount === 0
|
||||||
|
? "Everything below is in its recommended state."
|
||||||
|
: DeviceSecurity.attentionCount + " item"
|
||||||
|
+ (DeviceSecurity.attentionCount === 1 ? "" : "s") + " below may deserve attention."
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: DeviceSecurity.facts
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
id: factRow
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
label: factRow.modelData.label
|
||||||
|
detail: factRow.modelData.detail
|
||||||
|
value: factRow.modelData.value
|
||||||
|
divider: factRow.index < DeviceSecurity.facts.length - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Owned by Fedora"
|
||||||
|
subtitle: "File history and trash retention are GNOME preferences, applied by a housekeeping service that does not run in a Hyprland session. Panama does not offer them as switches, because storing that preference here would change nothing."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "File history & trash"
|
||||||
|
detail: "Opens GNOME Settings, which owns these"
|
||||||
|
action: "Open privacy"
|
||||||
|
divider: false
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("privacy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Region & Language.
|
||||||
|
//
|
||||||
|
// GNOME keeps language and formats under System; the setting itself is the
|
||||||
|
// machine's locale, which localectl owns. Panama does not store a copy of it --
|
||||||
|
// there is exactly one system locale and localectl is where it lives, so a
|
||||||
|
// preference here would be a second source of truth that drifts the moment
|
||||||
|
// anything else changes it.
|
||||||
|
//
|
||||||
|
// Keyboard layout is deliberately not repeated here even though GNOME groups it
|
||||||
|
// with region. It is a compositor setting that applies instantly, it lives on
|
||||||
|
// the Keyboard page with the rest of the typing settings, and showing it twice
|
||||||
|
// invites the two views to disagree.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
title: "Region & Language"
|
||||||
|
lede: SystemLocale.pendingRestart
|
||||||
|
? "Your new language applies to programs started after you sign out and back in."
|
||||||
|
: "The language and regional formats this machine uses."
|
||||||
|
|
||||||
|
Component.onCompleted: if (SystemLocale.locales.length === 0) SystemLocale.refresh()
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Language"
|
||||||
|
subtitle: "Changing this needs your password, and takes effect for programs started afterwards."
|
||||||
|
|
||||||
|
TextRow {
|
||||||
|
label: "Current language"
|
||||||
|
detail: SystemLocale.pendingRestart
|
||||||
|
? "Chosen, but not in use until you sign out and back in"
|
||||||
|
: "Used by programs that ask the system what language to speak"
|
||||||
|
value: SystemLocale.currentLabel || "Reading…"
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchPicker {
|
||||||
|
width: parent.width
|
||||||
|
items: SystemLocale.locales
|
||||||
|
current: SystemLocale.current
|
||||||
|
placeholder: "Search languages and regions"
|
||||||
|
emptyText: SystemLocale.scanning ? "Reading installed locales…" : "No locales are installed"
|
||||||
|
onPicked: value => SystemLocale.set(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: SystemLocale.lastError !== ""
|
||||||
|
title: "Language problem"
|
||||||
|
subtitle: SystemLocale.lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Formats"
|
||||||
|
subtitle: "Dates, times, and numbers follow the language above. Panama's own clock formatting is on the Appearance page."
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Clock and date display"
|
||||||
|
detail: "How Panama itself shows the time"
|
||||||
|
action: "Open appearance"
|
||||||
|
onTriggered: ShellState.openSettings("appearance")
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Regional formats"
|
||||||
|
detail: "Separate per-category formats (LC_TIME, LC_NUMERIC) are owned by Fedora"
|
||||||
|
action: "Open system"
|
||||||
|
divider: false
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("system", "region")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// A searchable list of choices, for settings with far too many values to put in
|
||||||
|
// a dropdown and no useful preview to show.
|
||||||
|
//
|
||||||
|
// SearchPicker {
|
||||||
|
// items: Locale.locales // [{ value, label, detail }]
|
||||||
|
// current: Locale.current
|
||||||
|
// placeholder: "Search languages"
|
||||||
|
// onPicked: value => Locale.set(value)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// FontPicker is the same idea specialised: it draws each candidate in its own
|
||||||
|
// family, which is the whole reason it is a list rather than a text field. This
|
||||||
|
// one is for choices whose only useful preview is their name.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
// SearchField lives with the clipboard module, which is where it was first
|
||||||
|
// needed; FontPicker imports it from the same place.
|
||||||
|
import qs.modules.clipboard
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// [{ value, label, detail }]
|
||||||
|
property var items: []
|
||||||
|
property string current: ""
|
||||||
|
property string emptyText: "Nothing to choose from"
|
||||||
|
property string placeholder: "Search"
|
||||||
|
|
||||||
|
signal picked(string value)
|
||||||
|
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
// Capped and filtered rather than listing everything: 327 locales 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.items.filter(item =>
|
||||||
|
needle === ""
|
||||||
|
? true
|
||||||
|
: (String(item.label).toLowerCase().indexOf(needle) >= 0
|
||||||
|
|| String(item.detail).toLowerCase().indexOf(needle) >= 0));
|
||||||
|
|
||||||
|
// The current choice stays visible while browsing, so it is always
|
||||||
|
// clear what would be replaced.
|
||||||
|
const selected = list.find(item => item.value === root.current);
|
||||||
|
if (needle === "" && selected !== undefined)
|
||||||
|
return [selected].concat(list.filter(item => item.value !== root.current)).slice(0, 12);
|
||||||
|
return list.slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchField {
|
||||||
|
id: filter
|
||||||
|
width: parent.width
|
||||||
|
placeholder: root.placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.matches
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: candidate
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
readonly property bool selected: candidate.modelData.value === root.current
|
||||||
|
|
||||||
|
label: candidate.modelData.label
|
||||||
|
detail: candidate.selected ? "Currently in use" : candidate.modelData.detail
|
||||||
|
controlWidth: 120
|
||||||
|
divider: candidate.index < root.matches.length - 1
|
||||||
|
activatable: !candidate.selected
|
||||||
|
onActivated: root.picked(candidate.modelData.value)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: candidate.selected
|
||||||
|
text: "✓"
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize + 2
|
||||||
|
color: Theme.accent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.matches.length === 0
|
||||||
|
label: root.emptyText
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,14 +120,40 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Fedora system settings"
|
title: "Fedora system settings"
|
||||||
subtitle: "These remain owned by trusted system services and GNOME's mature panels."
|
subtitle: "Panels Panama does not own, because they configure system services rather than the desktop. Each row opens the panel that actually owns it. Printers and online accounts live with the rest of the network hardware, on Network & Devices."
|
||||||
|
|
||||||
|
// This was one row listing five subjects and opening the network panel
|
||||||
|
// regardless. Naming a panel and then not opening it is worse than not
|
||||||
|
// offering it: it looks like a broken button rather than a deliberate
|
||||||
|
// hand-off, and someone looking for printers had to know to navigate
|
||||||
|
// once GNOME Settings appeared on the wrong page.
|
||||||
|
ActionRow {
|
||||||
|
label: "Users"
|
||||||
|
detail: "Accounts, passwords, and automatic login"
|
||||||
|
action: "Open users"
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("system", "users")
|
||||||
|
}
|
||||||
|
|
||||||
ActionRow {
|
ActionRow {
|
||||||
label: "Network, Bluetooth, printers, users, and accounts"
|
label: "Sharing"
|
||||||
detail: "GNOME Settings remains searchable from the launcher too"
|
detail: "Remote desktop, media sharing, and remote login"
|
||||||
|
action: "Open sharing"
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("sharing")
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Colour profiles"
|
||||||
|
detail: "ICC profiles for displays, printers, and scanners"
|
||||||
|
action: "Open colour"
|
||||||
|
onTriggered: SystemSettings.openGnomePanel("color")
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionRow {
|
||||||
|
label: "Digital wellbeing"
|
||||||
|
detail: "Screen time and break reminders"
|
||||||
|
action: "Open wellbeing"
|
||||||
divider: false
|
divider: false
|
||||||
action: "Open network"
|
onTriggered: SystemSettings.openGnomePanel("wellbeing")
|
||||||
onTriggered: SystemSettings.openGnomePanel("network")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,31 @@ Item {
|
|||||||
// scrolls -- the Appearance page pins its live preview here.
|
// scrolls -- the Appearance page pins its live preview here.
|
||||||
property Component header: null
|
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 {
|
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
|
clip: true
|
||||||
contentWidth: width
|
contentWidth: width
|
||||||
contentHeight: layout.implicitHeight + 64
|
contentHeight: layout.implicitHeight + (pinnedHeader.implicitHeight > 0 ? 34 : 64)
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
@@ -40,15 +60,9 @@ Item {
|
|||||||
|
|
||||||
width: parent.width - 68
|
width: parent.width - 68
|
||||||
x: 34
|
x: 34
|
||||||
y: 30
|
y: pinnedHeader.implicitHeight > 0 ? 0 : 30
|
||||||
spacing: 16
|
spacing: 16
|
||||||
|
|
||||||
Loader {
|
|
||||||
width: parent.width
|
|
||||||
active: root.header !== null
|
|
||||||
sourceComponent: root.header
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
visible: root.title !== ""
|
visible: root.title !== ""
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ Rectangle {
|
|||||||
case "notifications": return notificationsPage;
|
case "notifications": return notificationsPage;
|
||||||
case "screen-intelligence": return screenIntelligencePage;
|
case "screen-intelligence": return screenIntelligencePage;
|
||||||
case "shortcuts": return shortcutsPage;
|
case "shortcuts": return shortcutsPage;
|
||||||
|
case "mouse": return mousePage;
|
||||||
|
case "privacy": return privacyPage;
|
||||||
|
case "region": return regionPage;
|
||||||
case "accessibility": return accessibilityPage;
|
case "accessibility": return accessibilityPage;
|
||||||
case "power": return powerPage;
|
case "power": return powerPage;
|
||||||
case "datetime": return dateTimePage;
|
case "datetime": return dateTimePage;
|
||||||
@@ -153,6 +156,9 @@ Rectangle {
|
|||||||
Component { id: notificationsPage; NotificationsPage {} }
|
Component { id: notificationsPage; NotificationsPage {} }
|
||||||
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
|
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
|
||||||
Component { id: shortcutsPage; ShortcutsPage {} }
|
Component { id: shortcutsPage; ShortcutsPage {} }
|
||||||
|
Component { id: mousePage; MousePage {} }
|
||||||
|
Component { id: privacyPage; PrivacyPage {} }
|
||||||
|
Component { id: regionPage; RegionPage {} }
|
||||||
Component { id: servicesPage; ServicesPage {} }
|
Component { id: servicesPage; ServicesPage {} }
|
||||||
Component { id: aboutPage; AboutPage {} }
|
Component { id: aboutPage; AboutPage {} }
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ Rectangle {
|
|||||||
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
||||||
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
|
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
|
||||||
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
|
{ page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" },
|
||||||
{ page: "shortcuts", label: "Input & Shortcuts", icon: "\u{F030C}" },
|
{ page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" },
|
||||||
|
{ page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" },
|
||||||
|
{ page: "privacy", label: "Privacy & Security", icon: "\u{F0483}" },
|
||||||
|
{ page: "region", label: "Region & Language", icon: "\u{F0AC2}" },
|
||||||
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}" },
|
{ page: "accessibility", label: "Accessibility", icon: "\u{F0208}" },
|
||||||
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
|
{ page: "power", label: "Power & Lock", icon: "\u{F0425}" },
|
||||||
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
||||||
|
|||||||
@@ -28,19 +28,19 @@ SettingsPage {
|
|||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Keyboard"
|
title: "Keyboard"
|
||||||
|
|
||||||
|
// These were read-only text, on the grounds that a layout change needed
|
||||||
|
// a compositor reload. It does not: setting input:kb_variant through
|
||||||
|
// hl.config re-keymaps attached keyboards immediately -- verified by
|
||||||
|
// watching active_keymap on a real keyboard change and change back. So
|
||||||
|
// they are real controls.
|
||||||
|
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
|
||||||
|
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
|
||||||
|
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
|
||||||
SliderRow { setting: "keyRepeatDelay" }
|
SliderRow { setting: "keyRepeatDelay" }
|
||||||
SliderRow { setting: "keyRepeatRate" }
|
SliderRow { setting: "keyRepeatRate" }
|
||||||
ToggleRow { setting: "numlockByDefault"; divider: false }
|
ToggleRow { setting: "numlockByDefault"; divider: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
title: "Pointer"
|
|
||||||
|
|
||||||
ChoiceRow { setting: "followMouse" }
|
|
||||||
SliderRow { setting: "pointerSensitivity" }
|
|
||||||
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Hardware input"
|
title: "Hardware input"
|
||||||
|
|
||||||
|
|||||||
@@ -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 QtQuick
|
||||||
import Quickshell.Services.Pipewire
|
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
import qs.modules.quicksettings
|
|
||||||
|
|
||||||
SettingsPage {
|
SettingsPage {
|
||||||
title: "Sound"
|
title: "Sound"
|
||||||
@@ -10,43 +8,58 @@ SettingsPage {
|
|||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Output"
|
title: "Output"
|
||||||
subtitle: Pipewire.defaultAudioSink?.description ?? "No output device"
|
subtitle: AudioDevices.current(true)?.description ?? "No output device"
|
||||||
|
|
||||||
AudioSlider {
|
SoundDeviceList {
|
||||||
width: parent.width
|
|
||||||
node: Pipewire.defaultAudioSink
|
|
||||||
output: true
|
|
||||||
}
|
|
||||||
Rectangle {
|
|
||||||
width: parent.width
|
|
||||||
height: 1
|
|
||||||
color: Theme.alpha(Theme.fg, 0.06)
|
|
||||||
}
|
|
||||||
AudioDeviceList {
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
output: true
|
output: true
|
||||||
maxHeight: 190
|
}
|
||||||
|
|
||||||
|
AudioBalance {
|
||||||
|
width: parent.width
|
||||||
|
node: AudioDevices.current(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Input"
|
title: "Input"
|
||||||
subtitle: Pipewire.defaultAudioSource?.description ?? "No input device"
|
subtitle: AudioDevices.current(false)?.description ?? "No input device"
|
||||||
|
|
||||||
AudioSlider {
|
SoundDeviceList {
|
||||||
width: parent.width
|
|
||||||
node: Pipewire.defaultAudioSource
|
|
||||||
output: false
|
|
||||||
}
|
|
||||||
Rectangle {
|
|
||||||
width: parent.width
|
|
||||||
height: 1
|
|
||||||
color: Theme.alpha(Theme.fg, 0.06)
|
|
||||||
}
|
|
||||||
AudioDeviceList {
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
output: false
|
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,109 @@
|
|||||||
|
// A free-text setting, bound to a schema key by name.
|
||||||
|
//
|
||||||
|
// TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
|
||||||
|
//
|
||||||
|
// For the settings whose value is a short string with too many legal values to
|
||||||
|
// enumerate -- XKB layouts and options, chiefly. Label, explanation, and
|
||||||
|
// validation all come from PreferenceSchema, so a row cannot drift from the
|
||||||
|
// setting it edits.
|
||||||
|
//
|
||||||
|
// Rejected input is shown as rejected rather than silently dropped or quietly
|
||||||
|
// sanitised. Several of these strings are serialised into an hl.config payload,
|
||||||
|
// where stripping an unexpected character would turn a typo into a different
|
||||||
|
// working setting -- so the schema's pattern decides, the field turns red when
|
||||||
|
// it fails, and nothing is committed until it passes.
|
||||||
|
//
|
||||||
|
// Committed on Enter or when focus leaves, not per keystroke: half a layout
|
||||||
|
// name is a valid string that means something else, and each commit is a round
|
||||||
|
// trip to the compositor.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property string setting
|
||||||
|
property string placeholder: ""
|
||||||
|
|
||||||
|
readonly property var spec: PreferenceSchema.spec(root.setting)
|
||||||
|
readonly property string stored: String(DesktopPreferences.get(root.setting) ?? "")
|
||||||
|
|
||||||
|
// Empty is always allowed to be typed through, even where the pattern
|
||||||
|
// forbids it, so a field can be cleared on the way to a new value.
|
||||||
|
readonly property bool valid: input.text === ""
|
||||||
|
|| !root.spec?.pattern
|
||||||
|
|| new RegExp(root.spec.pattern).test(input.text)
|
||||||
|
|
||||||
|
label: root.spec ? root.spec.label : root.setting
|
||||||
|
detail: root.spec ? root.spec.detail : ""
|
||||||
|
controlWidth: 210
|
||||||
|
|
||||||
|
function commit(): void {
|
||||||
|
if (!root.valid || input.text === root.stored)
|
||||||
|
return;
|
||||||
|
SystemSettings.commitPreference(root.setting, input.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revert(): void {
|
||||||
|
input.text = root.stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follows the store when the value changes elsewhere -- a reset, a restored
|
||||||
|
// backup -- but never while the field has focus, which would overwrite what
|
||||||
|
// is being typed.
|
||||||
|
onStoredChanged: if (!input.activeFocus) input.text = root.stored
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: root.controlWidth
|
||||||
|
height: 30
|
||||||
|
radius: 8
|
||||||
|
color: Theme.alpha(Theme.fg, 0.05)
|
||||||
|
border.width: 1
|
||||||
|
border.color: !root.valid
|
||||||
|
? Theme.danger
|
||||||
|
: (input.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12))
|
||||||
|
|
||||||
|
TextInput {
|
||||||
|
id: input
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: 10
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
verticalAlignment: TextInput.AlignVCenter
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
text: root.stored
|
||||||
|
color: root.valid ? Theme.fg : Theme.danger
|
||||||
|
font.family: Theme.fontMono
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
selectByMouse: true
|
||||||
|
selectionColor: Theme.alpha(Theme.accent, 0.35)
|
||||||
|
|
||||||
|
onAccepted: root.commit()
|
||||||
|
|
||||||
|
// Commit, then show what is actually stored. If the compositor
|
||||||
|
// refused the value, the field snaps back to the value in effect
|
||||||
|
// rather than displaying a setting that was never applied; if it
|
||||||
|
// accepted, onStoredChanged brings the field back up to date the
|
||||||
|
// moment the write lands.
|
||||||
|
onActiveFocusChanged: if (!activeFocus) { root.commit(); root.revert(); }
|
||||||
|
|
||||||
|
Keys.onEscapePressed: {
|
||||||
|
root.revert();
|
||||||
|
input.focus = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
visible: input.text === "" && !input.activeFocus
|
||||||
|
text: root.placeholder
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font: input.font
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
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 int columns: Math.max(2, Math.floor(width / 190))
|
||||||
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
|
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
|
||||||
|
|
||||||
@@ -30,7 +43,7 @@ Item {
|
|||||||
spacing: 10
|
spacing: 10
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: Wallpaper.available
|
model: root.shown
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: tile
|
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,19 @@ ApplicationsPage 1.0 ApplicationsPage.qml
|
|||||||
DockPinsEditor 1.0 DockPinsEditor.qml
|
DockPinsEditor 1.0 DockPinsEditor.qml
|
||||||
DockAppPicker 1.0 DockAppPicker.qml
|
DockAppPicker 1.0 DockAppPicker.qml
|
||||||
ShortcutCapture 1.0 ShortcutCapture.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
|
||||||
|
MousePage 1.0 MousePage.qml
|
||||||
|
TextEntryRow 1.0 TextEntryRow.qml
|
||||||
|
PrivacyPage 1.0 PrivacyPage.qml
|
||||||
|
RegionPage 1.0 RegionPage.qml
|
||||||
|
SearchPicker 1.0 SearchPicker.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
+81
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# What this machine is, as JSON: {label, value} pairs in display order.
|
||||||
|
#
|
||||||
|
# GNOME's About panel answers "what am I running on" in one screen -- model,
|
||||||
|
# processor, memory, disk, OS, kernel, windowing system. Panama's About listed
|
||||||
|
# only its own component versions, which answers a different and much narrower
|
||||||
|
# question.
|
||||||
|
#
|
||||||
|
# Graphics is deliberately absent: GraphicsDevices already enumerates GPUs for
|
||||||
|
# the vitals readout, and naming them again here would be a second source of
|
||||||
|
# truth that could disagree with the first. The page joins the two.
|
||||||
|
#
|
||||||
|
# Anything unreadable is omitted rather than reported as "Unknown". These are
|
||||||
|
# facts about hardware, and a row saying "Processor: Unknown" is noise where
|
||||||
|
# simply not having the row is not.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
facts=()
|
||||||
|
|
||||||
|
emit() {
|
||||||
|
[[ -n "${2:-}" ]] || return 0
|
||||||
|
facts+=("$(jq -cn --arg label "$1" --arg value "$2" '{label: $label, value: $value}')")
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Machine ──────────────────────────────────────────────────────────────────
|
||||||
|
# DMI strings are frequently placeholders ("To Be Filled By O.E.M.", "Default
|
||||||
|
# string"). Those are worse than nothing, so they are filtered out.
|
||||||
|
dmi() {
|
||||||
|
local value
|
||||||
|
value="$(cat "/sys/class/dmi/id/$1" 2>/dev/null)" || return 0
|
||||||
|
case "$value" in
|
||||||
|
""|"To Be Filled By O.E.M."*|"Default string"|"System Product Name"|"Unknown"|"None") return 0 ;;
|
||||||
|
esac
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
vendor="$(dmi sys_vendor)"
|
||||||
|
product="$(dmi product_name)"
|
||||||
|
if [[ -n "$vendor" && -n "$product" ]]; then
|
||||||
|
emit "Model" "$vendor $product"
|
||||||
|
else
|
||||||
|
emit "Model" "${product:-$vendor}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
emit "Hostname" "$(hostnamectl hostname 2>/dev/null || hostname 2>/dev/null)"
|
||||||
|
|
||||||
|
# ── Processor ────────────────────────────────────────────────────────────────
|
||||||
|
cpu="$(awk -F': ' '/^model name/ { print $2; exit }' /proc/cpuinfo 2>/dev/null)"
|
||||||
|
threads="$(nproc 2>/dev/null)"
|
||||||
|
if [[ -n "$cpu" ]]; then
|
||||||
|
# The marketing name usually already says "6-Core", so the thread count is
|
||||||
|
# the part that adds information.
|
||||||
|
[[ -n "$threads" ]] && cpu="$cpu ($threads threads)"
|
||||||
|
emit "Processor" "$cpu"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Memory ───────────────────────────────────────────────────────────────────
|
||||||
|
# Reported as the kernel sees it, which is a little under the sticker figure
|
||||||
|
# because firmware and integrated graphics reserve some before Linux starts.
|
||||||
|
emit "Memory" "$(awk '/^MemTotal:/ { printf "%.1f GiB", $2 / 1048576 }' /proc/meminfo 2>/dev/null)"
|
||||||
|
|
||||||
|
# ── Storage ──────────────────────────────────────────────────────────────────
|
||||||
|
read -r size used avail <<<"$(df -h --output=size,used,avail / 2>/dev/null | tail -1)"
|
||||||
|
[[ -n "${size:-}" ]] && emit "Disk" "$avail free of $size"
|
||||||
|
|
||||||
|
# ── Software ─────────────────────────────────────────────────────────────────
|
||||||
|
if [[ -r /etc/os-release ]]; then
|
||||||
|
# Sourced in a subshell so the variables cannot leak into this script.
|
||||||
|
os="$( . /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-$NAME}" )"
|
||||||
|
emit "Operating system" "$os"
|
||||||
|
fi
|
||||||
|
emit "Kernel" "$(uname -r 2>/dev/null)"
|
||||||
|
|
||||||
|
case "${XDG_SESSION_TYPE:-}" in
|
||||||
|
wayland) emit "Windowing system" "Wayland" ;;
|
||||||
|
x11) emit "Windowing system" "X11" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
|
||||||
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,
|
env: Mapping[str, str] | None = None,
|
||||||
legacy: Callable[[], Config] = load_legacy_config,
|
legacy: Callable[[], Config] = load_legacy_config,
|
||||||
) -> 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 = 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()
|
url_value = private_env.get("PANAMA_HOME_ASSISTANT_URL", "").strip()
|
||||||
token_value = private_env.get("PANAMA_HOME_ASSISTANT_TOKEN", "").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
+98
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# System locale, via localectl.
|
||||||
|
#
|
||||||
|
# panama-locale list -> [{value, label, detail}]
|
||||||
|
# panama-locale get -> the current LANG, e.g. en_US.UTF-8
|
||||||
|
# panama-locale set <locale>
|
||||||
|
#
|
||||||
|
# Locale codes are not names. "pt_BR.UTF-8" tells you what it means only if you
|
||||||
|
# already know, which defeats the point of a picker, so codes are resolved
|
||||||
|
# against the iso-codes database into "Portuguese (Brazil)" the way GNOME does.
|
||||||
|
# The code stays visible as the row's detail, because it is what actually gets
|
||||||
|
# written and someone choosing between two Spanish variants needs to see it.
|
||||||
|
#
|
||||||
|
# The join happens in a single jq pass. Doing it per locale meant 327 jq
|
||||||
|
# invocations, which took long enough to be visible when opening the page.
|
||||||
|
#
|
||||||
|
# Setting the locale is a privileged operation: localectl goes through polkit,
|
||||||
|
# which prompts. It also only takes effect for programs started afterwards, so
|
||||||
|
# the caller is responsible for saying a sign-out is needed -- this script does
|
||||||
|
# not pretend the running session changed.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
readonly ISO_LANG=/usr/share/iso-codes/json/iso_639-2.json
|
||||||
|
readonly ISO_COUNTRY=/usr/share/iso-codes/json/iso_3166-1.json
|
||||||
|
|
||||||
|
cmd_get() {
|
||||||
|
localectl status 2>/dev/null \
|
||||||
|
| awk -F'LANG=' '/System Locale:/ { print $2; exit }' \
|
||||||
|
| tr -d '[:space:]'
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_list() {
|
||||||
|
local locales
|
||||||
|
locales="$(localectl list-locales 2>/dev/null)" || locales=""
|
||||||
|
if [[ -z "$locales" ]]; then
|
||||||
|
printf '[]\n'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Without iso-codes installed the codes are still perfectly usable; they
|
||||||
|
# just do not get friendly names. That is a degraded list, not a failure.
|
||||||
|
if [[ ! -r "$ISO_LANG" || ! -r "$ISO_COUNTRY" ]]; then
|
||||||
|
jq -Rn --rawfile raw /dev/stdin \
|
||||||
|
'[$raw | split("\n")[] | select(length > 0) | {value: ., label: ., detail: ""}]' \
|
||||||
|
<<<"$locales"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
jq -Rn \
|
||||||
|
--slurpfile languages "$ISO_LANG" \
|
||||||
|
--slurpfile countries "$ISO_COUNTRY" \
|
||||||
|
--rawfile raw /dev/stdin '
|
||||||
|
# alpha_2 -> name, for both databases. Languages without a two-letter
|
||||||
|
# code cannot appear in a locale name, so they are simply absent.
|
||||||
|
($languages[0]["639-2"] | map(select(.alpha_2)) | INDEX(.alpha_2) | map_values(.name)) as $lang
|
||||||
|
| ($countries[0]["3166-1"] | INDEX(.alpha_2) | map_values(.name)) as $country
|
||||||
|
| [ $raw
|
||||||
|
| split("\n")[]
|
||||||
|
| select(length > 0)
|
||||||
|
| . as $value
|
||||||
|
# en_US.UTF-8 -> ["en", "US"]; the codeset and any @modifier are
|
||||||
|
# not part of the human name.
|
||||||
|
| ($value | split(".")[0] | split("@")[0] | split("_")) as $parts
|
||||||
|
| ($lang[$parts[0]] // $parts[0]) as $language
|
||||||
|
| (if ($parts | length) > 1 then $country[$parts[1]] else null end) as $region
|
||||||
|
| {
|
||||||
|
value: $value,
|
||||||
|
label: (if $region then "\($language) (\($region))" else $language end),
|
||||||
|
detail: $value
|
||||||
|
}
|
||||||
|
]
|
||||||
|
| sort_by(.label)
|
||||||
|
' <<<"$locales"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_set() {
|
||||||
|
local locale="${1:-}"
|
||||||
|
# Constrained rather than passed through: this reaches a privileged
|
||||||
|
# command, and the set of legal locale names is narrow and well known.
|
||||||
|
[[ "$locale" =~ ^[[email protected]]+$ ]] || {
|
||||||
|
printf 'panama-locale: refusing a locale name with unexpected characters\n' >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
localectl list-locales 2>/dev/null | grep -qxF "$locale" || {
|
||||||
|
printf 'panama-locale: %s is not an installed locale\n' "$locale" >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
localectl set-locale "LANG=$locale"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-list}" in
|
||||||
|
list) cmd_list ;;
|
||||||
|
get) cmd_get ;;
|
||||||
|
set) shift; cmd_set "${1:-}" ;;
|
||||||
|
*) printf 'usage: panama-locale [list|get|set <locale>]\n' >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
Executable
+262
@@ -0,0 +1,262 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
readonly PANAMA_OSD_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
brightness_percent() {
|
||||||
|
local output="$1" percent
|
||||||
|
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
|
||||||
|
[[ $percent =~ ^[0-9]+$ ]] || return 1
|
||||||
|
printf '%s\n' "$percent"
|
||||||
|
}
|
||||||
|
|
||||||
|
brightness_error() {
|
||||||
|
local detail="$1" label="External brightness unavailable"
|
||||||
|
if [[ $detail == *udev* || $detail == *accessible* || $detail == *permission* ]]; then
|
||||||
|
label="Brightness needs permission"
|
||||||
|
fi
|
||||||
|
|
||||||
|
show_message dialog-warning-symbolic "$label" || true
|
||||||
|
if command -v notify-send >/dev/null 2>&1; then
|
||||||
|
notify-send --app-name=Panama --icon=display-brightness-symbolic \
|
||||||
|
"Brightness unavailable" "$detail" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
discover_ddc_bus() {
|
||||||
|
local helper="$1" cache_file="$2" list_json focused selected error bus connector
|
||||||
|
|
||||||
|
command -v jq >/dev/null 2>&1 || {
|
||||||
|
brightness_error "jq is required to discover DDC/CI displays."
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
list_json="$("$helper" list 2>/dev/null)" || {
|
||||||
|
brightness_error "The external brightness helper could not inspect connected displays."
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if ! jq -e 'type == "object" and (.displays | type == "array")' >/dev/null 2>&1 <<<"$list_json"; then
|
||||||
|
brightness_error "The external brightness helper returned invalid display information."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
error="$(jq -r '.error // empty' <<<"$list_json")"
|
||||||
|
if [[ -n $error ]]; then
|
||||||
|
brightness_error "$error"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
focused="$(hyprctl -j monitors 2>/dev/null \
|
||||||
|
| jq -r '.[] | select(.focused == true) | .name' 2>/dev/null \
|
||||||
|
| head -n1)"
|
||||||
|
selected="$(jq -r --arg connector "$focused" '
|
||||||
|
([.displays[] | select(.connector == $connector)][0] // .displays[0] // empty)
|
||||||
|
| [.bus, .connector]
|
||||||
|
| @tsv
|
||||||
|
' <<<"$list_json")"
|
||||||
|
IFS=$'\t' read -r bus connector <<<"$selected"
|
||||||
|
if [[ ! $bus =~ ^[0-9]+$ ]]; then
|
||||||
|
brightness_error "No connected monitor exposes DDC/CI brightness control."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
printf '%s\t%s\n' "$bus" "$connector" >"$cache_file"
|
||||||
|
printf '%s\n' "$bus"
|
||||||
|
}
|
||||||
|
|
||||||
|
adjust_ddc_brightness() {
|
||||||
|
local action="$1" step="$2"
|
||||||
|
local helper="${PANAMA_OSD_BRIGHTNESS_HELPER:-$PANAMA_OSD_SCRIPT_DIR/panama-brightness}"
|
||||||
|
local runtime_dir="${PANAMA_OSD_RUNTIME_DIR:-${XDG_RUNTIME_DIR:-/tmp}/panama-osd-${UID}}"
|
||||||
|
local cache_file="$runtime_dir/brightness-bus" lock_file="$runtime_dir/brightness.lock"
|
||||||
|
local bus="" connector="" current target lock_fd
|
||||||
|
|
||||||
|
[[ -x $helper ]] || {
|
||||||
|
brightness_error "The external brightness helper is not installed."
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
mkdir -p "$runtime_dir" || return 0
|
||||||
|
chmod 700 "$runtime_dir" 2>/dev/null || true
|
||||||
|
|
||||||
|
exec {lock_fd}>"$lock_file" || return 0
|
||||||
|
# DDC transactions on one I2C bus cannot safely overlap. A short wait also
|
||||||
|
# sheds an excessive key-repeat backlog instead of replaying it seconds later.
|
||||||
|
flock -w 2 "$lock_fd" || return 0
|
||||||
|
|
||||||
|
if [[ -r $cache_file ]]; then
|
||||||
|
IFS=$'\t' read -r bus connector <"$cache_file" || true
|
||||||
|
[[ $bus =~ ^[0-9]+$ ]] || bus=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n $bus ]]; then
|
||||||
|
current="$("$helper" get "$bus" 2>/dev/null)" || current=""
|
||||||
|
if [[ ! $current =~ ^[0-9]+$ ]]; then
|
||||||
|
: >"$cache_file"
|
||||||
|
bus=""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z $bus ]]; then
|
||||||
|
bus="$(discover_ddc_bus "$helper" "$cache_file")" || return 0
|
||||||
|
current="$("$helper" get "$bus" 2>/dev/null)" || current=""
|
||||||
|
fi
|
||||||
|
if [[ ! $current =~ ^[0-9]+$ ]]; then
|
||||||
|
brightness_error "The selected monitor stopped responding over DDC/CI."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $action == up ]]; then
|
||||||
|
target=$(( current + step ))
|
||||||
|
else
|
||||||
|
target=$(( current - step ))
|
||||||
|
fi
|
||||||
|
(( target > 100 )) && target=100
|
||||||
|
(( target < 0 )) && target=0
|
||||||
|
|
||||||
|
if ! "$helper" set "$bus" "$target" >/dev/null 2>&1; then
|
||||||
|
brightness_error "The selected monitor did not accept the brightness change."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
show_progress brightness "$target" "${target}%"
|
||||||
|
}
|
||||||
|
|
||||||
|
adjust_brightness() {
|
||||||
|
local action="${1:-}" step="${2:-5}" output percent
|
||||||
|
[[ $step =~ ^[0-9]+$ ]] || {
|
||||||
|
printf 'Usage: panama-osd brightness up|down [step]\n' >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
case "$action" in
|
||||||
|
up|down) ;;
|
||||||
|
*) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Laptop panels expose a kernel backlight class and remain the fastest,
|
||||||
|
# most reliable path. Desktops fall through to DDC/CI monitor control.
|
||||||
|
output="$(brightnessctl -m -c backlight 2>/dev/null)" || output=""
|
||||||
|
if percent="$(brightness_percent "$output")"; then
|
||||||
|
if [[ $action == up ]]; then
|
||||||
|
brightnessctl -e4 -n2 -c backlight set "${step}%+" >/dev/null || return 0
|
||||||
|
else
|
||||||
|
brightnessctl -e4 -n2 -c backlight set "${step}%-" >/dev/null || return 0
|
||||||
|
fi
|
||||||
|
output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0
|
||||||
|
percent="$(brightness_percent "$output")" || return 0
|
||||||
|
show_progress brightness "$percent" "${percent}%"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
adjust_ddc_brightness "$action" "$step"
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Device security facts, as JSON.
|
||||||
|
#
|
||||||
|
# Everything here is READ-ONLY and deliberately so. Secure Boot, TPM presence,
|
||||||
|
# disk encryption, SELinux mode and the firewall are set in firmware, at install
|
||||||
|
# time, or by system policy -- none of them is a desktop preference, and a
|
||||||
|
# settings app that offered to toggle them would either fail or do something
|
||||||
|
# far-reaching from a switch that looks like any other.
|
||||||
|
#
|
||||||
|
# What it is for is answering "is this machine set up the way I think it is",
|
||||||
|
# which is the question GNOME's Device Security panel exists to answer and which
|
||||||
|
# otherwise needs five commands and root.
|
||||||
|
#
|
||||||
|
# Each fact is reported as {value, ok} where `ok` marks the reassuring state, so
|
||||||
|
# the UI can highlight what deserves attention without hard-coding the meaning
|
||||||
|
# of each string. Anything that cannot be determined reports "Unknown" with
|
||||||
|
# ok:false rather than guessing, because a security readout that quietly reports
|
||||||
|
# "fine" when it failed to look is worse than no readout.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
fact() {
|
||||||
|
jq -cn --arg label "$1" --arg value "$2" --argjson ok "$3" --arg detail "${4:-}" \
|
||||||
|
'{label: $label, value: $value, ok: $ok, detail: $detail}'
|
||||||
|
}
|
||||||
|
|
||||||
|
facts=()
|
||||||
|
|
||||||
|
# ── Secure Boot ──────────────────────────────────────────────────────────────
|
||||||
|
if command -v mokutil >/dev/null 2>&1; then
|
||||||
|
case "$(mokutil --sb-state 2>/dev/null)" in
|
||||||
|
*"SecureBoot enabled"*) facts+=("$(fact "Secure Boot" "Enabled" true "Firmware verifies the bootloader and kernel signatures")" ) ;;
|
||||||
|
*"SecureBoot disabled"*) facts+=("$(fact "Secure Boot" "Disabled" false "Firmware does not verify what it boots")") ;;
|
||||||
|
*) facts+=("$(fact "Secure Boot" "Unknown" false "The firmware did not report a Secure Boot state")") ;;
|
||||||
|
esac
|
||||||
|
elif [[ -d /sys/firmware/efi ]]; then
|
||||||
|
facts+=("$(fact "Secure Boot" "Unknown" false "Install mokutil to report this")")
|
||||||
|
else
|
||||||
|
facts+=("$(fact "Secure Boot" "Not applicable" false "This machine booted in legacy BIOS mode")")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── TPM ──────────────────────────────────────────────────────────────────────
|
||||||
|
tpm_major="$(cat /sys/class/tpm/tpm0/tpm_version_major 2>/dev/null || true)"
|
||||||
|
if [[ -n "$tpm_major" ]]; then
|
||||||
|
facts+=("$(fact "TPM" "Version $tpm_major" true "A trusted platform module is present and usable")")
|
||||||
|
elif [[ -e /sys/class/tpm/tpm0 ]]; then
|
||||||
|
facts+=("$(fact "TPM" "Present" true "A trusted platform module is present")")
|
||||||
|
else
|
||||||
|
facts+=("$(fact "TPM" "None" false "No trusted platform module, so keys cannot be sealed to this machine")")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Disk encryption ──────────────────────────────────────────────────────────
|
||||||
|
# Counts LUKS mappings rather than naming them: which volume is encrypted is
|
||||||
|
# more detail than this readout needs, and device names are not meaningful here.
|
||||||
|
crypt_count="$(lsblk -o TYPE 2>/dev/null | grep -c '^crypt$' || true)"
|
||||||
|
[[ "$crypt_count" =~ ^[0-9]+$ ]] || crypt_count=0
|
||||||
|
if (( crypt_count > 0 )); then
|
||||||
|
facts+=("$(fact "Disk encryption" "$crypt_count encrypted volume$( (( crypt_count == 1 )) || printf 's')" true "Data at rest is protected by LUKS")")
|
||||||
|
else
|
||||||
|
facts+=("$(fact "Disk encryption" "None" false "No LUKS volume is unlocked on this machine")")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── SELinux ──────────────────────────────────────────────────────────────────
|
||||||
|
if command -v getenforce >/dev/null 2>&1; then
|
||||||
|
case "$(getenforce 2>/dev/null)" in
|
||||||
|
Enforcing) facts+=("$(fact "SELinux" "Enforcing" true "Policy violations are blocked")") ;;
|
||||||
|
Permissive) facts+=("$(fact "SELinux" "Permissive" false "Violations are logged but allowed")") ;;
|
||||||
|
Disabled) facts+=("$(fact "SELinux" "Disabled" false "Mandatory access control is off")") ;;
|
||||||
|
*) facts+=("$(fact "SELinux" "Unknown" false "")") ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Firewall ─────────────────────────────────────────────────────────────────
|
||||||
|
if systemctl list-unit-files firewalld.service >/dev/null 2>&1; then
|
||||||
|
if [[ "$(systemctl is-active firewalld 2>/dev/null)" == "active" ]]; then
|
||||||
|
facts+=("$(fact "Firewall" "Active" true "firewalld is filtering incoming connections")")
|
||||||
|
else
|
||||||
|
facts+=("$(fact "Firewall" "Inactive" false "firewalld is installed but not running")")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '[%s]\n' "$(IFS=,; printf '%s' "${facts[*]}")"
|
||||||
@@ -572,6 +572,28 @@ def command_restore(arguments: list[str]) -> None:
|
|||||||
home_present = False
|
home_present = False
|
||||||
home_data = None
|
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
|
# Restoring remains undoable, but a corrupt current file must not prevent a
|
||||||
# known-good snapshot from recovering the desktop.
|
# known-good snapshot from recovering the desktop.
|
||||||
save_snapshot(require_any=False, validate=False)
|
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,54 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Read-only device security facts: Secure Boot, TPM, disk encryption, SELinux,
|
||||||
|
// firewall.
|
||||||
|
//
|
||||||
|
// Nothing here is a preference. These are set in firmware, at install time, or
|
||||||
|
// by system policy, and a settings app that offered to change them from a
|
||||||
|
// switch would either fail or do something far-reaching from a control that
|
||||||
|
// looks like every other control. What this answers is "is this machine set up
|
||||||
|
// the way I think it is", which otherwise takes five commands and root.
|
||||||
|
//
|
||||||
|
// Read on demand. None of these can change while the desktop is running,
|
||||||
|
// short of a reboot.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-security"
|
||||||
|
|
||||||
|
// [{ label, value, ok, detail }]
|
||||||
|
property var facts: []
|
||||||
|
property bool scanned: false
|
||||||
|
|
||||||
|
// The facts that are not in their reassuring state. The page leads with the
|
||||||
|
// count so a machine that is entirely fine says so in one line instead of
|
||||||
|
// making the user read five rows to find out.
|
||||||
|
readonly property int attentionCount: root.facts.filter(fact => !fact.ok).length
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!query.running)
|
||||||
|
query.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: query
|
||||||
|
command: [root.helperPath]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.facts = Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch (error) {
|
||||||
|
root.facts = [];
|
||||||
|
console.warn("DeviceSecurity: could not parse helper output:", error);
|
||||||
|
}
|
||||||
|
root.scanned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// What input hardware this machine actually has.
|
||||||
|
//
|
||||||
|
// Exists so pages can hide controls for hardware that is not present. A
|
||||||
|
// touchpad card on a desktop is not merely useless -- it is misleading, because
|
||||||
|
// every switch on it appears to work: the preference is stored and Hyprland
|
||||||
|
// accepts the option for a device class it has no member of. The user is left
|
||||||
|
// toggling settings that will never affect anything, with nothing to say so.
|
||||||
|
//
|
||||||
|
// Touchpads are identified by name. libinput exposes them through the same
|
||||||
|
// "mice" list as everything else that reports pointer motion, and Hyprland
|
||||||
|
// passes the device name through, so an Elan or Synaptics touchpad arrives as
|
||||||
|
// something like "elan-touchpad". There is no device-class field to consult.
|
||||||
|
//
|
||||||
|
// Read on demand rather than polled. Input devices do come and go -- a mouse is
|
||||||
|
// unplugged, a receiver is moved -- so this also refreshes when Hyprland says
|
||||||
|
// the device list changed, which is the only moment the answer can differ.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var mice: []
|
||||||
|
property var keyboards: []
|
||||||
|
|
||||||
|
// True when anything that looks like a touchpad is attached.
|
||||||
|
readonly property bool hasTouchpad: root.mice.some(name =>
|
||||||
|
name.includes("touchpad") || name.includes("trackpad"))
|
||||||
|
|
||||||
|
readonly property bool hasMouse: root.mice.length > 0
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!query.running)
|
||||||
|
query.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: query
|
||||||
|
running: true
|
||||||
|
command: ["hyprctl", "-j", "devices"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.mice = (parsed.mice ?? []).map(device => String(device.name ?? "").toLowerCase());
|
||||||
|
root.keyboards = (parsed.keyboards ?? []).map(device => String(device.name ?? "").toLowerCase());
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("InputDevices: could not parse hyprctl devices:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: Hyprland
|
||||||
|
function onRawEvent(event: var): void {
|
||||||
|
if (event.name === "device" || event.name === "configreloaded")
|
||||||
|
root.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// What this machine is: model, processor, memory, disk, OS, kernel.
|
||||||
|
//
|
||||||
|
// Read once, on demand. None of it changes while the desktop is running except
|
||||||
|
// free disk space, and About is not a monitor -- the vitals readout on the Home
|
||||||
|
// page is where live figures belong.
|
||||||
|
//
|
||||||
|
// Graphics is not here. GraphicsDevices already enumerates GPUs for the vitals
|
||||||
|
// readout, and naming them again would be a second source of truth that could
|
||||||
|
// disagree with the first; the About page joins the two instead.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-about"
|
||||||
|
|
||||||
|
// [{ label, value }] in display order.
|
||||||
|
property var facts: []
|
||||||
|
property bool scanned: false
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!query.running)
|
||||||
|
query.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: query
|
||||||
|
command: [root.helperPath]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.facts = Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch (error) {
|
||||||
|
root.facts = [];
|
||||||
|
console.warn("MachineInfo: could not parse helper output:", error);
|
||||||
|
}
|
||||||
|
root.scanned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ Singleton {
|
|||||||
id: root
|
id: root
|
||||||
|
|
||||||
property bool initialized: false
|
property bool initialized: false
|
||||||
|
property bool suppressStatusEvents: false
|
||||||
|
|
||||||
// The manual switch. Ignored while `automatic` is on.
|
// The manual switch. Ignored while `automatic` is on.
|
||||||
property bool enabled: Settings.nightLightEnabledByDefault
|
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
|
// The window wraps midnight (17:00 → 10:00), so the comparison flips when
|
||||||
// `from` is later in the day than `to`.
|
// `from` is later in the day than `to`.
|
||||||
function inWindow(hour: real): bool {
|
function inWindow(hour: real): bool {
|
||||||
@@ -89,8 +106,36 @@ Singleton {
|
|||||||
onEnabledChanged: DesktopPreferences.set("nightLightEnabled", root.enabled)
|
onEnabledChanged: DesktopPreferences.set("nightLightEnabled", root.enabled)
|
||||||
onAutomaticChanged: DesktopPreferences.set("nightLightAutomatic", root.automatic)
|
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: {
|
onActiveChanged: {
|
||||||
if (!root.initialized)
|
if (!root.initialized || root.suppressStatusEvents)
|
||||||
return;
|
return;
|
||||||
StatusEvents.publish({
|
StatusEvents.publish({
|
||||||
key: "display-night-light",
|
key: "display-night-light",
|
||||||
|
|||||||
@@ -37,6 +37,39 @@ Singleton {
|
|||||||
// Cleared when the notification centre is opened. The bar binds to this.
|
// Cleared when the notification centre is opened. The bar binds to this.
|
||||||
property int unreadCount: 0
|
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
|
// Arrival times, keyed by notification id — the protocol carries no
|
||||||
// timestamp. Deliberately formatted once at arrival rather than shown as
|
// timestamp. Deliberately formatted once at arrival rather than shown as
|
||||||
// "5 minutes ago", which would need a clock ticking behind every card.
|
// "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
|
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
|
// history grouped by app, in most-recent-app-first order — the shape
|
||||||
// NotificationCenter.qml renders directly.
|
// NotificationCenter.qml renders directly.
|
||||||
readonly property var groups: {
|
readonly property var groups: {
|
||||||
@@ -85,13 +191,20 @@ Singleton {
|
|||||||
actionIconsSupported: true
|
actionIconsSupported: true
|
||||||
inlineReplySupported: 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
|
// Replayed from before a shell reload. Letting these through would
|
||||||
// re-toast and re-list everything on every edit, so they are left
|
// re-toast and re-list everything on every edit, so they are left
|
||||||
// untracked and allowed to die.
|
// untracked and allowed to die.
|
||||||
if (notification.lastGeneration)
|
if (notification.lastGeneration)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
const appId = root.rememberApplication(notification);
|
||||||
|
if (!root.appRule(appId).enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
// Without this the object is destroyed the instant this returns.
|
// Without this the object is destroyed the instant this returns.
|
||||||
notification.tracked = true;
|
notification.tracked = true;
|
||||||
root.arrivals[notification.id] = new Date();
|
root.arrivals[notification.id] = new Date();
|
||||||
@@ -109,11 +222,10 @@ Singleton {
|
|||||||
if (!root.doNotDisturb)
|
if (!root.doNotDisturb)
|
||||||
root.popups = [notification].concat(root.popups);
|
root.popups = [notification].concat(root.popups);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mutation ────────────────────────────────────────────────────────────
|
// ── Mutation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function pushHistory(n: Notification): void {
|
function pushHistory(n: var): void {
|
||||||
const next = [n].concat(root.history);
|
const next = [n].concat(root.history);
|
||||||
|
|
||||||
// Anything past the cap is released, otherwise it stays tracked
|
// 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
|
// Called from the `closed` signal — the object is on its way out, so this
|
||||||
// only ever removes references, never touches the notification.
|
// only ever removes references, never touches the notification.
|
||||||
function forget(n: Notification): void {
|
function forget(n: var): void {
|
||||||
delete root.arrivals[n.id];
|
delete root.arrivals[n.id];
|
||||||
if (root.history.indexOf(n) !== -1)
|
if (root.history.indexOf(n) !== -1)
|
||||||
root.history = root.history.filter(x => x !== n);
|
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 initializeHome: function(ids) { HomePreferences.initialize(ids); }
|
||||||
property var aliasHome: function(id, alias) { HomePreferences.setAlias(id, alias); }
|
property var aliasHome: function(id, alias) { HomePreferences.setAlias(id, alias); }
|
||||||
property var reloadDesktop: function() { DesktopPreferences.reload(); }
|
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 applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); }
|
||||||
property var reloadKeybinds: function() { Keybinds.applyReload(); }
|
property var reloadKeybinds: function() { Keybinds.applyReload(); }
|
||||||
property var keybindsReloading: function() { return Keybinds.reloading; }
|
property var keybindsReloading: function() { return Keybinds.reloading; }
|
||||||
@@ -47,6 +51,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
property var applyWallpaper: function(path) { Wallpaper.set(path); }
|
property var applyWallpaper: function(path) { Wallpaper.set(path); }
|
||||||
property var reloadShell: function() { Quickshell.reload(false); }
|
property var reloadShell: function() { Quickshell.reload(false); }
|
||||||
|
property var protectedDisplays: ({})
|
||||||
|
|
||||||
readonly property bool busy: listQuery.running || actionRun.running
|
readonly property bool busy: listQuery.running || actionRun.running
|
||||||
|| applyRestoredState.running || settleReload.running
|
|| applyRestoredState.running || settleReload.running
|
||||||
@@ -81,6 +86,10 @@ Singleton {
|
|||||||
root.lastError = actionRun.restoring
|
root.lastError = actionRun.restoring
|
||||||
? "That snapshot could not be restored."
|
? "That snapshot could not be restored."
|
||||||
: "The settings could not be backed up.";
|
: "The settings could not be backed up.";
|
||||||
|
if (actionRun.restoring) {
|
||||||
|
root.setDisplayBlocked(false);
|
||||||
|
root.protectedDisplays = ({});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
root.lastAction = actionRun.restoring ? "restored" : "saved";
|
root.lastAction = actionRun.restoring ? "restored" : "saved";
|
||||||
@@ -89,6 +98,10 @@ Singleton {
|
|||||||
root.lastError = homeReloaded
|
root.lastError = homeReloaded
|
||||||
? ""
|
? ""
|
||||||
: "Desktop settings were restored, but Home favourites could not be reloaded.";
|
: "Desktop settings were restored, but Home favourites could not be reloaded.";
|
||||||
|
if (!homeReloaded) {
|
||||||
|
root.setDisplayBlocked(false);
|
||||||
|
root.protectedDisplays = ({});
|
||||||
|
}
|
||||||
} else
|
} else
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
root.refresh();
|
root.refresh();
|
||||||
@@ -124,6 +137,8 @@ Singleton {
|
|||||||
// from leaving restored Home state stale indefinitely.
|
// from leaving restored Home state stale indefinitely.
|
||||||
if ((!root.keybindsReloading() && !root.systemBusy()) || attempts >= 30) {
|
if ((!root.keybindsReloading() && !root.systemBusy()) || attempts >= 30) {
|
||||||
stop();
|
stop();
|
||||||
|
root.setDisplayBlocked(false);
|
||||||
|
root.protectedDisplays = ({});
|
||||||
root.reloadShell();
|
root.reloadShell();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,6 +177,8 @@ Singleton {
|
|||||||
if (!root.reloadHomeState(text))
|
if (!root.reloadHomeState(text))
|
||||||
return false;
|
return false;
|
||||||
root.reloadDesktop();
|
root.reloadDesktop();
|
||||||
|
if (!root.protectDisplays(root.protectedDisplays))
|
||||||
|
return false;
|
||||||
applyRestoredState.restart();
|
applyRestoredState.restart();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -222,10 +239,18 @@ Singleton {
|
|||||||
function restore(name: string): bool {
|
function restore(name: string): bool {
|
||||||
if (actionRun.running)
|
if (actionRun.running)
|
||||||
return false;
|
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)) {
|
if (!root.snapshots.some(snapshot => snapshot.name === name)) {
|
||||||
root.lastError = "That snapshot is not in the list.";
|
root.lastError = "That snapshot is not in the list.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const currentDisplays = root.readDisplays();
|
||||||
|
root.protectedDisplays = JSON.parse(JSON.stringify(
|
||||||
|
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
|
||||||
|
root.setDisplayBlocked(true);
|
||||||
actionRun.restoring = true;
|
actionRun.restoring = true;
|
||||||
actionRun.exec([root.helperPath, "restore", name]);
|
actionRun.exec([root.helperPath, "restore", name]);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -29,9 +29,13 @@ Singleton {
|
|||||||
"dock": "desktop",
|
"dock": "desktop",
|
||||||
"focus": "desktop",
|
"focus": "desktop",
|
||||||
"display": "displays",
|
"display": "displays",
|
||||||
|
"nightLight": "displays",
|
||||||
"idle": "power",
|
"idle": "power",
|
||||||
"accessibility": "accessibility",
|
"accessibility": "accessibility",
|
||||||
"input": "shortcuts",
|
"input": "shortcuts",
|
||||||
|
"pointer": "mouse",
|
||||||
|
"touchpad": "mouse",
|
||||||
|
"multitasking": "desktop",
|
||||||
"weather": "appearance",
|
"weather": "appearance",
|
||||||
"notifications": "notifications",
|
"notifications": "notifications",
|
||||||
"capture": "screen-intelligence"
|
"capture": "screen-intelligence"
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openSettings(page: string): void {
|
function openSettings(page: string): void {
|
||||||
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "accessibility", "power", "datetime", "applications", "services", "about"];
|
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accessibility", "power", "datetime", "applications", "services", "about"];
|
||||||
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
||||||
DesktopPreferences.set("lastPage", root.settingsPage);
|
DesktopPreferences.set("lastPage", root.settingsPage);
|
||||||
root.settingsOpen = true;
|
root.settingsOpen = true;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// The system locale.
|
||||||
|
//
|
||||||
|
// Named SystemLocale, not Locale: QML has a built-in Locale value type, and a
|
||||||
|
// singleton of that name is silently shadowed by it. Every binding then reads
|
||||||
|
// properties off the wrong thing and the page renders empty with only
|
||||||
|
// "Cannot read property of undefined" to show for it.
|
||||||
|
//
|
||||||
|
// Changing it is privileged: localectl goes through polkit, which prompts
|
||||||
|
// (hyprpolkitagent serves that in this session). It also only applies to
|
||||||
|
// programs started afterwards, so `pendingRestart` goes true once a change is
|
||||||
|
// accepted and the page says a sign-out is needed. Reporting the new locale as
|
||||||
|
// simply "in effect" would be wrong -- almost nothing on screen would be using
|
||||||
|
// it yet.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-locale"
|
||||||
|
|
||||||
|
// [{ value, label, detail }]
|
||||||
|
property var locales: []
|
||||||
|
property string current: ""
|
||||||
|
property bool scanning: false
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
// True once a change has been accepted but the session has not restarted,
|
||||||
|
// so the UI can stop claiming the new locale is already in use.
|
||||||
|
property bool pendingRestart: false
|
||||||
|
|
||||||
|
readonly property string currentLabel: {
|
||||||
|
const match = root.locales.find(locale => locale.value === root.current);
|
||||||
|
return match ? match.label : root.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (root.scanning)
|
||||||
|
return;
|
||||||
|
root.scanning = true;
|
||||||
|
readCurrent.running = true;
|
||||||
|
list.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function set(value: string): void {
|
||||||
|
if (value === root.current)
|
||||||
|
return;
|
||||||
|
apply.command = [root.helperPath, "set", value];
|
||||||
|
apply.pendingValue = value;
|
||||||
|
apply.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: list
|
||||||
|
command: [root.helperPath, "list"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.text);
|
||||||
|
root.locales = Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch (error) {
|
||||||
|
root.locales = [];
|
||||||
|
console.warn("SystemLocale: could not parse the locale list:", error);
|
||||||
|
}
|
||||||
|
root.scanning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: readCurrent
|
||||||
|
command: [root.helperPath, "get"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.current = this.text.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: apply
|
||||||
|
|
||||||
|
property string pendingValue: ""
|
||||||
|
|
||||||
|
// A refused change -- polkit dismissed, or an unknown locale -- must not
|
||||||
|
// move the UI. The value is only adopted on a zero exit.
|
||||||
|
onExited: code => {
|
||||||
|
if (code === 0) {
|
||||||
|
root.current = apply.pendingValue;
|
||||||
|
root.pendingRestart = true;
|
||||||
|
root.lastError = "";
|
||||||
|
} else {
|
||||||
|
root.lastError = "The system did not accept that language. It may have needed a password.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,16 @@ Singleton {
|
|||||||
property string quickshellVersion: "0.3.0"
|
property string quickshellVersion: "0.3.0"
|
||||||
property string lastError: ""
|
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
|
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|
||||||
|| configWrite.running || configVerify.running || bluebubblesQuery.running
|
|| configWrite.running || configVerify.running || bluebubblesQuery.running
|
||||||
|
|
||||||
@@ -196,6 +206,11 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Applying options ────────────────────────────────────────────────────
|
// ── 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 }.
|
// `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
|
// The whole batch is validated before anything is sent, so one bad value
|
||||||
// rejects the batch rather than half-applying it.
|
// rejects the batch rather than half-applying it.
|
||||||
@@ -217,6 +232,9 @@ Singleton {
|
|||||||
if (Object.keys(requested).length === 0)
|
if (Object.keys(requested).length === 0)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
if (root.compositorApplyOverride !== null)
|
||||||
|
return root.compositorApplyOverride(requested);
|
||||||
|
|
||||||
// A write in flight is queued rather than refused. Options are applied
|
// 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 verified one batch at a time, but the callers are a settings UI
|
||||||
// and a startup replay of every compositor-backed preference -- they
|
// 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
|
// Compositor-backed values are re-applied afterwards, since resetting the
|
||||||
// stored value does not by itself tell Hyprland anything.
|
// 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();
|
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
|
// Home accessories keep their own store (panama-home.json), so a reset
|
||||||
// that only cleared the schema store would silently leave a customised
|
// that only cleared the schema store would silently leave a customised
|
||||||
@@ -401,12 +433,33 @@ Singleton {
|
|||||||
HomePreferences.resetHomeDefaults();
|
HomePreferences.resetHomeDefaults();
|
||||||
|
|
||||||
resettleTimer.restart();
|
resettleTimer.restart();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: resettleTimer
|
id: resettleTimer
|
||||||
interval: 60
|
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 {
|
function setAutoHdr(enabled: bool): void {
|
||||||
@@ -444,18 +497,35 @@ Singleton {
|
|||||||
].indexOf(panel) >= 0;
|
].indexOf(panel) >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openGnomePanel(panel: string): bool {
|
// `subpage` reaches the panels GNOME 50 nests under System -- users,
|
||||||
|
// about, datetime, region -- which its own desktop entries open as
|
||||||
|
// `gnome-control-center system users`. Without it, a row labelled "Users"
|
||||||
|
// lands on System's front page and leaves the user to navigate, which is
|
||||||
|
// most of the way to a broken button.
|
||||||
|
function openGnomePanel(panel: string, subpage: string): bool {
|
||||||
if (!root.isGnomePanelAllowed(panel)) {
|
if (!root.isGnomePanelAllowed(panel)) {
|
||||||
root.lastError = "That GNOME Settings panel is not available.";
|
root.lastError = "That GNOME Settings panel is not available.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const command = ["gnome-control-center", panel];
|
||||||
|
if (subpage !== undefined && subpage !== "" && root.isGnomeSubpageAllowed(panel, subpage))
|
||||||
|
command.push(subpage);
|
||||||
Quickshell.execDetached({
|
Quickshell.execDetached({
|
||||||
command: ["gnome-control-center", panel],
|
command: command,
|
||||||
environment: { "XDG_CURRENT_DESKTOP": "GNOME" }
|
environment: { "XDG_CURRENT_DESKTOP": "GNOME" }
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only System nests panels, and only these. Read off the Exec lines of the
|
||||||
|
// gnome-*-panel desktop entries rather than guessed, for the same reason
|
||||||
|
// the panel list above was.
|
||||||
|
function isGnomeSubpageAllowed(panel: string, subpage: string): bool {
|
||||||
|
if (panel !== "system")
|
||||||
|
return false;
|
||||||
|
return ["users", "about", "datetime", "region", "remote-desktop"].indexOf(subpage) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
function openApplication(id: string): bool {
|
function openApplication(id: string): bool {
|
||||||
const commands = {
|
const commands = {
|
||||||
"nextcloud": ["nextcloud"],
|
"nextcloud": ["nextcloud"],
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Singleton {
|
|||||||
property bool scanning: false
|
property bool scanning: false
|
||||||
|
|
||||||
readonly property string configured: DesktopPreferences.get("wallpaperPath")
|
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
|
// Directories searched for wallpapers, in order. Screenshots are
|
||||||
// deliberately excluded: a folder of 300 screenshots is not a wallpaper
|
// deliberately excluded: a folder of 300 screenshots is not a wallpaper
|
||||||
@@ -85,6 +86,7 @@ Singleton {
|
|||||||
id: apply
|
id: apply
|
||||||
|
|
||||||
property string requested: ""
|
property string requested: ""
|
||||||
|
property string storedValue: ""
|
||||||
property var remaining: []
|
property var remaining: []
|
||||||
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
@@ -100,7 +102,7 @@ Singleton {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
DesktopPreferences.set("wallpaperPath", apply.requested);
|
DesktopPreferences.set("wallpaperPath", apply.storedValue);
|
||||||
root.refreshActive();
|
root.refreshActive();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,19 +142,16 @@ Singleton {
|
|||||||
// Applies to every connected output. Returns false when the path is not one
|
// Applies to every connected output. Returns false when the path is not one
|
||||||
// the schema will accept, so a caller can report the refusal.
|
// the schema will accept, so a caller can report the refusal.
|
||||||
function set(path: string): bool {
|
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.";
|
root.lastError = "That file path cannot be used as a wallpaper.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (apply.running)
|
if (apply.running)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
apply.requested = path;
|
apply.requested = effectivePath;
|
||||||
// "" clears the preference without touching what is on screen.
|
apply.storedValue = path;
|
||||||
if (path === "") {
|
|
||||||
DesktopPreferences.set("wallpaperPath", "");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputs = Quickshell.screens.map(screen => screen.name).filter(name => !!name);
|
const outputs = Quickshell.screens.map(screen => screen.name).filter(name => !!name);
|
||||||
if (outputs.length === 0) {
|
if (outputs.length === 0) {
|
||||||
@@ -161,7 +160,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apply.remaining = outputs.slice(1);
|
apply.remaining = outputs.slice(1);
|
||||||
apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${outputs[0]},${path}`]);
|
apply.exec(["hyprctl", "hyprpaper", "wallpaper", `${outputs[0]},${effectivePath}`]);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ ShellRoot {
|
|||||||
property var calls: []
|
property var calls: []
|
||||||
property bool homeInitialized: false
|
property bool homeInitialized: false
|
||||||
property var homeFavorites: []
|
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 {
|
function record(name: string): void {
|
||||||
const next = root.calls.slice();
|
const next = root.calls.slice();
|
||||||
@@ -43,6 +46,17 @@ ShellRoot {
|
|||||||
favorite.id === id ? { id: id, alias: alias } : favorite);
|
favorite.id === id ? { id: id, alias: alias } : favorite);
|
||||||
};
|
};
|
||||||
SettingsBackup.reloadDesktop = function() { root.record("desktop.reload"); };
|
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.applyCompositor = function() { root.record("system.apply"); };
|
||||||
SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); };
|
SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); };
|
||||||
SettingsBackup.keybindsReloading = function() { return false; };
|
SettingsBackup.keybindsReloading = function() { return false; };
|
||||||
@@ -59,17 +73,29 @@ ShellRoot {
|
|||||||
root.calls = [];
|
root.calls = [];
|
||||||
root.homeInitialized = false;
|
root.homeInitialized = false;
|
||||||
root.homeFavorites = [];
|
root.homeFavorites = [];
|
||||||
|
root.displayOperationBusy = false;
|
||||||
|
root.displayBlocked = false;
|
||||||
|
SettingsBackup.protectedDisplays = root.displayGeneration;
|
||||||
}
|
}
|
||||||
|
|
||||||
function apply(output: string): bool {
|
function apply(output: string): bool {
|
||||||
return SettingsBackup.handleRestoreOutput(output);
|
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 {
|
function status(): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
calls: root.calls,
|
calls: root.calls,
|
||||||
initialized: root.homeInitialized,
|
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
|
import qs.services
|
||||||
|
|
||||||
ShellRoot {
|
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 {
|
IpcHandler {
|
||||||
target: "settings-system-test"
|
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 {
|
function panelAllowed(panel: string): bool {
|
||||||
return SystemSettings.isGnomePanelAllowed(panel);
|
return SystemSettings.isGnomePanelAllowed(panel);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import qs.modules.datemenu
|
|||||||
import qs.modules.focus
|
import qs.modules.focus
|
||||||
import qs.modules.signals
|
import qs.modules.signals
|
||||||
import qs.modules.settings
|
import qs.modules.settings
|
||||||
|
import qs.modules.osd
|
||||||
|
|
||||||
// Clipboard and datemenu ship explicit qmldir manifests because they were
|
// Clipboard and datemenu ship explicit qmldir manifests because they were
|
||||||
// added while this daily-driver shell was already running; that also keeps
|
// added while this daily-driver shell was already running; that also keeps
|
||||||
@@ -72,6 +73,11 @@ ShellRoot {
|
|||||||
SignalGlass {}
|
SignalGlass {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Variants {
|
||||||
|
model: Quickshell.screens
|
||||||
|
Osd {}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Single-instance overlays ────────────────────────────────────────────
|
// ── Single-instance overlays ────────────────────────────────────────────
|
||||||
// These are always constructed but only *visible* when ShellState says so.
|
// These are always constructed but only *visible* when ShellState says so.
|
||||||
// They're cheap while hidden, and keeping them alive means opening the
|
// They're cheap while hidden, and keeping them alive means opening the
|
||||||
@@ -108,7 +114,10 @@ ShellRoot {
|
|||||||
IpcHandler {
|
IpcHandler {
|
||||||
target: "focus"
|
target: "focus"
|
||||||
|
|
||||||
function start(): void { FocusSession.startDefault(); }
|
function start(): bool {
|
||||||
|
FocusSession.startDefault();
|
||||||
|
return FocusSession.active;
|
||||||
|
}
|
||||||
function reveal(): void { FocusSession.reveal(); }
|
function reveal(): void { FocusSession.reveal(); }
|
||||||
function dismiss(): void { FocusSession.dismiss(); }
|
function dismiss(): void { FocusSession.dismiss(); }
|
||||||
function pause(): void { FocusSession.pauseOrResume(); }
|
function pause(): void { FocusSession.pauseOrResume(); }
|
||||||
@@ -117,7 +126,12 @@ ShellRoot {
|
|||||||
ShellState.openOverview(FocusSession.workspaceId);
|
ShellState.openOverview(FocusSession.workspaceId);
|
||||||
FocusSession.dismiss();
|
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 {
|
function status(): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
active: FocusSession.active,
|
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 {
|
IpcHandler {
|
||||||
target: "activity"
|
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 {
|
IpcHandler {
|
||||||
target: "kdeconnect"
|
target: "kdeconnect"
|
||||||
function fixture(name: string): void { KdeConnect.applyFixture(name); }
|
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")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Tokyo Night Day — the light counterpart to tokyonight-moon.toml.
|
||||||
|
#
|
||||||
|
# Same schema and the same role for every colour; only the palette differs, so
|
||||||
|
# the launcher keeps its identity when the desktop switches to light rather
|
||||||
|
# than becoming a different-looking application.
|
||||||
|
#
|
||||||
|
# Colours are the Tokyo Night Day palette, taken from kitty/themes/tokyonight-day.conf
|
||||||
|
# so the launcher and the terminal cannot drift apart.
|
||||||
|
|
||||||
|
[meta]
|
||||||
|
version = 1
|
||||||
|
name = "Tokyo Night Day"
|
||||||
|
description = "Tokyo Night Day, matched to the Panama Hyprland desktop."
|
||||||
|
variant = "light"
|
||||||
|
|
||||||
|
[colors.core]
|
||||||
|
background = "#e1e2e7" # bg
|
||||||
|
foreground = "#3760bf" # fg
|
||||||
|
secondary_background = "#d0d5e3" # bg_dark, a shade below the ground
|
||||||
|
border = "#a8aecb" # a visible hairline on a light ground
|
||||||
|
accent = "#2e7de9" # blue
|
||||||
|
accent_foreground = "#e1e2e7"
|
||||||
|
|
||||||
|
[colors.accents]
|
||||||
|
blue = "#2e7de9"
|
||||||
|
green = "#587539"
|
||||||
|
magenta = "#9854f1"
|
||||||
|
orange = "#b15c00"
|
||||||
|
purple = "#7847bd"
|
||||||
|
red = "#f52a65"
|
||||||
|
yellow = "#8c6c3e"
|
||||||
|
cyan = "#007197"
|
||||||
|
|
||||||
|
[colors.main_window]
|
||||||
|
border = "#a8aecb"
|
||||||
|
footer = { background = "colors.core.secondary_background" }
|
||||||
|
|
||||||
|
[colors.settings_window]
|
||||||
|
border = "#c4c8da"
|
||||||
|
|
||||||
|
[colors.shortcut]
|
||||||
|
border = "colors.core.border"
|
||||||
|
|
||||||
|
[colors.text]
|
||||||
|
default = "colors.core.foreground"
|
||||||
|
muted = "#6172b0" # fg_dark, 4.0:1 on the background
|
||||||
|
danger = "#f52a65"
|
||||||
|
success = "#587539"
|
||||||
|
placeholder = "#7079a8" # 3.25:1; Day's own #848cb5 is 2.54:1, too faint to read
|
||||||
|
selection = { background = "#2e7de9", foreground = "#e1e2e7" }
|
||||||
|
|
||||||
|
[colors.text.links]
|
||||||
|
default = "#2e7de9"
|
||||||
|
visited = "#9854f1"
|
||||||
|
|
||||||
|
[colors.input]
|
||||||
|
border = "#c4c8da"
|
||||||
|
border_focus = "#2e7de9"
|
||||||
|
border_error = "#f52a65"
|
||||||
|
|
||||||
|
[colors.button.primary]
|
||||||
|
background = "#d4d6e4" # bg_highlight
|
||||||
|
foreground = "#3760bf"
|
||||||
|
hover = { background = "#c4c8da" }
|
||||||
|
focus = { outline = "colors.core.accent" }
|
||||||
|
|
||||||
|
[colors.list.item.hover]
|
||||||
|
foreground = "#3760bf"
|
||||||
|
secondary_foreground = "#6172b0"
|
||||||
|
|
||||||
|
[colors.list.item.selection]
|
||||||
|
background = "#c3cdf0" # blue side of the Prism selection, lightened
|
||||||
|
foreground = "#3760bf"
|
||||||
|
secondary_background = "#d8c8e4" # orchid side of the Prism selection, lightened
|
||||||
|
secondary_foreground = "#3760bf"
|
||||||
|
|
||||||
|
[colors.grid.item]
|
||||||
|
background = "#d7d9e5"
|
||||||
|
hover = { outline = "#2e7de9" }
|
||||||
|
selection = { outline = "#9854f1" }
|
||||||
|
|
||||||
|
[colors.scrollbars]
|
||||||
|
background = "#c4c8da"
|
||||||
|
|
||||||
|
[colors.loading]
|
||||||
|
bar = "#2e7de9"
|
||||||
|
spinner = "#3760bf"
|
||||||
@@ -6,12 +6,14 @@
|
|||||||
// and emoji-copy extensions, so its clipboard and emoji views are bound
|
// and emoji-copy extensions, so its clipboard and emoji views are bound
|
||||||
// directly in hypr/keybinds.lua.
|
// directly in hypr/keybinds.lua.
|
||||||
|
|
||||||
// Vicinae 0.26 selects a theme per system appearance. Both point to Moon so
|
// Vicinae 0.26 selects a theme per system appearance, and now there is a
|
||||||
// the launcher never flashes or falls back to its stock palette while the
|
// theme for each: the launcher follows Panama's light/dark setting instead of
|
||||||
// desktop appearance is being initialized.
|
// staying dark on a light desktop. Both are authored here rather than using
|
||||||
|
// Vicinae's bundled Tokyo Night, which ships Night and Storm but not the Moon
|
||||||
|
// and Day variants the rest of this desktop uses.
|
||||||
"theme": {
|
"theme": {
|
||||||
"light": {
|
"light": {
|
||||||
"name": "tokyonight-moon",
|
"name": "tokyonight-day",
|
||||||
"icon_theme": "auto"
|
"icon_theme": "auto"
|
||||||
},
|
},
|
||||||
"dark": {
|
"dark": {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Name=Panama Settings
|
|||||||
GenericName=System Settings
|
GenericName=System Settings
|
||||||
Comment=Configure the Panama Hyprland desktop
|
Comment=Configure the Panama Hyprland desktop
|
||||||
Exec=qs ipc call settings open
|
Exec=qs ipc call settings open
|
||||||
Icon=preferences-system-symbolic
|
Icon=panama-settings
|
||||||
Terminal=false
|
Terminal=false
|
||||||
StartupNotify=false
|
StartupNotify=false
|
||||||
Categories=Settings;
|
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 |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user