Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1f9891849 | ||
|
|
6026bf308b | ||
|
|
14529c011f | ||
|
|
7378c18ab9 | ||
|
|
9ce7040b91 | ||
|
|
09e5f1ad6b | ||
|
|
7541a45e77 | ||
|
|
1a91d00f2c | ||
|
|
19063a3e02 |
@@ -13,3 +13,6 @@
|
||||
# Python helper bytecode is local runtime state.
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Generated from the colour scheme; machine state, not configuration.
|
||||
/config/dot/kitty/current-theme.conf
|
||||
|
||||
@@ -183,7 +183,6 @@ font_size 14.0
|
||||
|
||||
#: Cursor customization {{{
|
||||
|
||||
cursor #c8d3f5
|
||||
|
||||
#: Default cursor color. If set to the special value none the cursor
|
||||
#: will be rendered with a "reverse video" effect. It's color will be
|
||||
@@ -193,7 +192,6 @@ cursor #c8d3f5
|
||||
#: precedence. Also, the cursor colors are modified if the cell
|
||||
#: background and foreground colors have very low contrast.
|
||||
|
||||
cursor_text_color #111325
|
||||
|
||||
#: The color of text under the cursor. If you want it rendered with
|
||||
#: the background color of the cell underneath instead, use the
|
||||
@@ -312,7 +310,6 @@ cursor_shape block
|
||||
#: robustly with the ever-changing sea of bugs that is Cocoa is too
|
||||
#: much effort.
|
||||
|
||||
url_color #4fd6be
|
||||
# url_style curly
|
||||
|
||||
#: The color and style for highlighting URLs on mouse-over. url_style
|
||||
@@ -734,12 +731,10 @@ window_border_width 1pt
|
||||
#: placed centrally. A value of top-left means the padding will be
|
||||
#: only at the bottom and right edges.
|
||||
|
||||
active_border_color #82aaff
|
||||
|
||||
#: The color for the border of the active window. Set this to none to
|
||||
#: not draw borders around the active window.
|
||||
|
||||
inactive_border_color #2f334d
|
||||
|
||||
#: The color for the border of inactive windows.
|
||||
|
||||
@@ -960,16 +955,11 @@ tab_powerline_style round
|
||||
#: Template to use for active tabs. If not specified falls back to
|
||||
#: tab_title_template.
|
||||
|
||||
active_tab_foreground #1e2030
|
||||
active_tab_background #82aaff
|
||||
# active_tab_font_style bold-italic
|
||||
inactive_tab_foreground #c8d3f5
|
||||
inactive_tab_background #42465a
|
||||
# inactive_tab_font_style normal
|
||||
|
||||
#: Tab bar colors and styles.
|
||||
|
||||
tab_bar_background #222436
|
||||
|
||||
#: Background color for the tab bar. Defaults to using the terminal
|
||||
#: background color.
|
||||
@@ -983,8 +973,8 @@ tab_bar_margin_color none
|
||||
|
||||
#: Color scheme {{{
|
||||
|
||||
foreground #c8d3f5
|
||||
background #222436
|
||||
|
||||
include current-theme.conf
|
||||
|
||||
#: The foreground and background colors.
|
||||
|
||||
@@ -1043,8 +1033,6 @@ dim_opacity 1
|
||||
#: How much to dim text that has the DIM/FAINT attribute set. One
|
||||
#: means no dimming and zero means fully dimmed (i.e. invisible).
|
||||
|
||||
selection_foreground #2d3f76
|
||||
selection_background #c8d3f5
|
||||
|
||||
#: The foreground and background colors for text selected with the
|
||||
#: mouse. Setting both of these to none will cause a "reverse video"
|
||||
@@ -1060,47 +1048,29 @@ selection_background #c8d3f5
|
||||
#: dull and bright version, for the first 16 colors. You can set the
|
||||
#: remaining 240 colors as color16 to color255.
|
||||
|
||||
color0 #1b1d2b
|
||||
color8 #444a73
|
||||
|
||||
#: black
|
||||
|
||||
color1 #ff757f
|
||||
color9 #ff757f
|
||||
|
||||
#: red
|
||||
|
||||
color2 #a5e8b5
|
||||
color10 #a5e8b5
|
||||
|
||||
#: green
|
||||
color3 #ffc777
|
||||
color11 #ffc777
|
||||
|
||||
#: yellow
|
||||
|
||||
color4 #82aaff
|
||||
color12 #82aaff
|
||||
|
||||
#: blue
|
||||
|
||||
color5 #c099ff
|
||||
color13 #c099ff
|
||||
|
||||
#: magenta
|
||||
|
||||
color6 #86e1fc
|
||||
color14 #86e1fc
|
||||
|
||||
#: cyan
|
||||
|
||||
color7 #828bb8
|
||||
color15 #c8d3f5
|
||||
|
||||
#: white
|
||||
|
||||
color16 #ff966c
|
||||
color17 #c53b53
|
||||
|
||||
# mark1_foreground black
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Tokyo Night Day — the palette's own light variant.
|
||||
#
|
||||
# The same hues as Moon at a different lightness, so a terminal in light mode
|
||||
# still belongs to this desktop rather than looking like a different machine.
|
||||
# Kept in the same order as tokyonight-moon.conf so the two can be diffed.
|
||||
|
||||
cursor #3760bf
|
||||
cursor_text_color #e1e2e7
|
||||
url_color #118c74
|
||||
active_border_color #2e7de9
|
||||
inactive_border_color #c4c8da
|
||||
active_tab_foreground #e1e2e7
|
||||
active_tab_background #2e7de9
|
||||
inactive_tab_foreground #3760bf
|
||||
inactive_tab_background #c4c8da
|
||||
tab_bar_background #e1e2e7
|
||||
foreground #3760bf
|
||||
background #e1e2e7
|
||||
selection_foreground #e1e2e7
|
||||
selection_background #99a7df
|
||||
color0 #e9e9ed
|
||||
color8 #a1a6c5
|
||||
color1 #f52a65
|
||||
color9 #f52a65
|
||||
color2 #587539
|
||||
color10 #587539
|
||||
color3 #8c6c3e
|
||||
color11 #8c6c3e
|
||||
color4 #2e7de9
|
||||
color12 #2e7de9
|
||||
color5 #9854f1
|
||||
color13 #9854f1
|
||||
color6 #007197
|
||||
color14 #007197
|
||||
color7 #6172b0
|
||||
color15 #3760bf
|
||||
color16 #b15c00
|
||||
color17 #c64343
|
||||
@@ -0,0 +1,42 @@
|
||||
# Tokyo Night Moon — the dark theme Panama ships.
|
||||
#
|
||||
# Extracted from kitty.conf so the two schemes can be swapped. kitty.conf
|
||||
# includes current-theme.conf, which Panama generates from the colour scheme
|
||||
# setting; that generated file is gitignored because it is machine state.
|
||||
#
|
||||
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
|
||||
# remote control. Without it a scheme change would only reach terminals opened
|
||||
# afterwards.
|
||||
|
||||
cursor #c8d3f5
|
||||
cursor_text_color #111325
|
||||
url_color #4fd6be
|
||||
active_border_color #82aaff
|
||||
inactive_border_color #2f334d
|
||||
active_tab_foreground #1e2030
|
||||
active_tab_background #82aaff
|
||||
inactive_tab_foreground #c8d3f5
|
||||
inactive_tab_background #42465a
|
||||
tab_bar_background #222436
|
||||
foreground #c8d3f5
|
||||
background #222436
|
||||
selection_foreground #2d3f76
|
||||
selection_background #c8d3f5
|
||||
color0 #1b1d2b
|
||||
color8 #444a73
|
||||
color1 #ff757f
|
||||
color9 #ff757f
|
||||
color2 #a5e8b5
|
||||
color10 #a5e8b5
|
||||
color3 #ffc777
|
||||
color11 #ffc777
|
||||
color4 #82aaff
|
||||
color12 #82aaff
|
||||
color5 #c099ff
|
||||
color13 #c099ff
|
||||
color6 #86e1fc
|
||||
color14 #86e1fc
|
||||
color7 #828bb8
|
||||
color15 #c8d3f5
|
||||
color16 #ff966c
|
||||
color17 #c53b53
|
||||
@@ -10,3 +10,6 @@ vim.api.nvim_create_autocmd("BufWritePre", {
|
||||
vim.fn.winrestview(view)
|
||||
end,
|
||||
})
|
||||
|
||||
-- Follow the desktop's light/dark setting while running, not only at startup.
|
||||
require("config.panama").watch()
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Panama desktop integration.
|
||||
--
|
||||
-- Neovim is the one application here that neither reads the desktop portal nor
|
||||
-- has a control socket open by default, so it reads the shared settings file
|
||||
-- directly -- the same ~/.config/panama/settings.json that the shell and the
|
||||
-- Hyprland config read.
|
||||
--
|
||||
-- Nothing here may raise. A missing or malformed settings file must cost the
|
||||
-- user their colour scheme preference and nothing else; editing text is more
|
||||
-- important than matching the desktop.
|
||||
|
||||
local M = {}
|
||||
|
||||
local function settings_path()
|
||||
local config_home = os.getenv("XDG_CONFIG_HOME")
|
||||
if config_home == nil or config_home == "" then
|
||||
local home = os.getenv("HOME")
|
||||
if home == nil or home == "" then
|
||||
return nil
|
||||
end
|
||||
config_home = home .. "/.config"
|
||||
end
|
||||
return config_home .. "/panama/settings.json"
|
||||
end
|
||||
|
||||
-- "dark" or "light". Defaults to dark, which is what Panama ships.
|
||||
function M.color_scheme()
|
||||
local path = settings_path()
|
||||
if not path then
|
||||
return "dark"
|
||||
end
|
||||
|
||||
local ok, contents = pcall(function()
|
||||
local file = io.open(path, "r")
|
||||
if not file then
|
||||
return nil
|
||||
end
|
||||
local text = file:read("*a")
|
||||
file:close()
|
||||
return text
|
||||
end)
|
||||
if not ok or not contents or contents == "" then
|
||||
return "dark"
|
||||
end
|
||||
|
||||
local decoded_ok, decoded = pcall(vim.json.decode, contents)
|
||||
if not decoded_ok or type(decoded) ~= "table" then
|
||||
return "dark"
|
||||
end
|
||||
|
||||
return decoded.colorScheme == "light" and "light" or "dark"
|
||||
end
|
||||
|
||||
function M.is_light()
|
||||
return M.color_scheme() == "light"
|
||||
end
|
||||
|
||||
-- tokyonight ships a light variant in the same family, so light mode stays the
|
||||
-- same identity rather than becoming a different editor theme.
|
||||
function M.tokyonight_style()
|
||||
return M.is_light() and "day" or "moon"
|
||||
end
|
||||
|
||||
-- Re-apply the scheme when the window regains focus.
|
||||
--
|
||||
-- Neovim reads the setting once at startup and has no control socket open by
|
||||
-- default, so an editor already running when the desktop scheme flips would
|
||||
-- otherwise stay on the old palette until it was restarted. FocusGained is
|
||||
-- cheap, happens exactly when you would notice the mismatch, and does nothing
|
||||
-- at all unless the scheme actually changed.
|
||||
function M.watch()
|
||||
local applied = M.color_scheme()
|
||||
|
||||
vim.api.nvim_create_autocmd("FocusGained", {
|
||||
group = vim.api.nvim_create_augroup("PanamaColorScheme", { clear = true }),
|
||||
callback = function()
|
||||
local current = M.color_scheme()
|
||||
if current == applied then
|
||||
return
|
||||
end
|
||||
applied = current
|
||||
vim.o.background = current
|
||||
pcall(vim.cmd.colorscheme, current == "light" and "tokyonight-day" or "tokyonight-moon")
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,31 +1,68 @@
|
||||
-- Tokyo Night, following the desktop's colour scheme.
|
||||
--
|
||||
-- Moon when Panama is dark, Day when it is light. Same theme family either way,
|
||||
-- so the editor keeps the identity the rest of the desktop has rather than
|
||||
-- becoming a different-looking application when the scheme flips.
|
||||
--
|
||||
-- The readability fixes below are deliberately dark-only. They were written
|
||||
-- against Moon's palette -- a pale comment colour, a mid-grey gutter -- and
|
||||
-- applying them to Day would put light grey text on a light background, which
|
||||
-- is exactly the legibility problem they exist to solve, inverted.
|
||||
|
||||
local panama = require("config.panama")
|
||||
|
||||
return {
|
||||
{
|
||||
"folke/tokyonight.nvim",
|
||||
opts = {
|
||||
style = "moon",
|
||||
transparent = true,
|
||||
on_colors = function(colors)
|
||||
colors.comment = "#a0a7c5"
|
||||
colors.fg_gutter = "#787f93"
|
||||
colors.terminal_black = "#828bb8"
|
||||
end,
|
||||
on_highlights = function(highlights, colors)
|
||||
-- Fix inline code visibility in markdown
|
||||
highlights["@markup.raw.markdown_inline"] = {
|
||||
bg = colors.terminal_black,
|
||||
fg = colors.fg,
|
||||
}
|
||||
highlights["RenderMarkdownCodeInline"] = {
|
||||
bg = colors.terminal_black,
|
||||
fg = colors.fg,
|
||||
}
|
||||
-- Fix LspReference* readability: DiagnosticUnnecessary dims fg for unused
|
||||
-- imports, making text nearly invisible against LspReferenceText's background
|
||||
highlights["LspReferenceText"] = { bg = colors.fg_gutter, fg = colors.fg }
|
||||
highlights["LspReferenceRead"] = { bg = colors.fg_gutter, fg = colors.fg }
|
||||
highlights["LspReferenceWrite"] = { bg = colors.fg_gutter, fg = colors.fg }
|
||||
end,
|
||||
},
|
||||
opts = function()
|
||||
local light = panama.is_light()
|
||||
|
||||
return {
|
||||
style = panama.tokyonight_style(),
|
||||
light_style = "day",
|
||||
transparent = true,
|
||||
|
||||
on_colors = function(colors)
|
||||
if light then
|
||||
-- Day's defaults are tuned for a light ground and mostly need no
|
||||
-- help. Comments are the exception: the shipped #848cb5 measures
|
||||
-- 2.54:1 against the #e1e2e7 background, well under the 3:1 floor
|
||||
-- for secondary text. This is 3.25:1 -- readable, and still clearly
|
||||
-- dimmer than Normal's 4.52:1 so it does not compete with code.
|
||||
colors.comment = "#7079a8"
|
||||
return
|
||||
end
|
||||
|
||||
colors.comment = "#a0a7c5"
|
||||
colors.fg_gutter = "#787f93"
|
||||
colors.terminal_black = "#828bb8"
|
||||
end,
|
||||
|
||||
on_highlights = function(highlights, colors)
|
||||
-- Inline code in markdown is invisible at both lightnesses without
|
||||
-- an explicit background, because the theme leaves it unset.
|
||||
highlights["@markup.raw.markdown_inline"] = {
|
||||
bg = light and colors.bg_highlight or colors.terminal_black,
|
||||
fg = colors.fg,
|
||||
}
|
||||
highlights["RenderMarkdownCodeInline"] = {
|
||||
bg = light and colors.bg_highlight or colors.terminal_black,
|
||||
fg = colors.fg,
|
||||
}
|
||||
|
||||
-- LspReference* readability: DiagnosticUnnecessary dims fg for unused
|
||||
-- imports, making text nearly invisible against LspReferenceText's
|
||||
-- background.
|
||||
local reference = {
|
||||
bg = light and colors.bg_visual or colors.fg_gutter,
|
||||
fg = colors.fg,
|
||||
}
|
||||
highlights["LspReferenceText"] = reference
|
||||
highlights["LspReferenceRead"] = reference
|
||||
highlights["LspReferenceWrite"] = reference
|
||||
end,
|
||||
}
|
||||
end,
|
||||
},
|
||||
{
|
||||
"LazyVim/LazyVim",
|
||||
|
||||
@@ -257,6 +257,24 @@ Singleton {
|
||||
detail: "XKB layout name, or a comma-separated list to switch between",
|
||||
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",
|
||||
label: "Num Lock on login",
|
||||
@@ -310,6 +328,167 @@ Singleton {
|
||||
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 ─────────────────────────────────────────────────────
|
||||
{
|
||||
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
// Backlight slider, via brightnessctl.
|
||||
// Brightness, from whichever source this machine actually has.
|
||||
//
|
||||
// This machine drives an external DisplayPort monitor and has no backlight
|
||||
// class device at all (brightnessctl only reports keyboard/NIC LEDs), so the
|
||||
// row removes itself rather than sitting there as a dead control. Probed once
|
||||
// at startup — backlight devices do not appear and disappear.
|
||||
// Two exist and they are not interchangeable:
|
||||
//
|
||||
// The kernel backlight class, driven by brightnessctl. Laptop panels have it;
|
||||
// this desktop does not -- brightnessctl reports only keyboard and NIC LEDs.
|
||||
//
|
||||
// DDC/CI, the channel the buttons on a monitor's bezel drive. That is the
|
||||
// only brightness an external display has, and it is per-monitor.
|
||||
//
|
||||
// A machine may have neither, either, or both, so this renders a row per source
|
||||
// found and removes itself entirely when there are none, rather than sitting
|
||||
// there as a dead control.
|
||||
//
|
||||
// Connector labels appear only when there is more than one row. A single
|
||||
// slider needs no explanation of which screen it dims.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.widgets
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property bool available: false
|
||||
property real value: 0
|
||||
property bool backlightAvailable: false
|
||||
property real backlightValue: 0
|
||||
|
||||
visible: root.available
|
||||
implicitHeight: root.available ? 32 : 0
|
||||
readonly property int rowCount: (root.backlightAvailable ? 1 : 0) + Brightness.displays.length
|
||||
readonly property bool labelled: root.rowCount > 1
|
||||
|
||||
visible: root.rowCount > 0
|
||||
implicitHeight: rows.implicitHeight
|
||||
|
||||
// Probing I2C takes on the order of a second, so it waits until the panel
|
||||
// is actually on screen rather than running at shell startup. Monitors do
|
||||
// not come and go, so once is enough.
|
||||
onVisibleChanged: if (visible && !Brightness.scanned) Brightness.refresh()
|
||||
Component.onCompleted: if (root.visible && !Brightness.scanned) Brightness.refresh()
|
||||
|
||||
// `-m` is the machine-readable form: name,class,current,percent,max
|
||||
Process {
|
||||
@@ -34,36 +54,105 @@ Item {
|
||||
const fields = line.split(",");
|
||||
if (fields.length < 5 || fields[1] !== "backlight")
|
||||
continue;
|
||||
root.available = true;
|
||||
root.value = parseInt(fields[3]) / 100;
|
||||
root.backlightAvailable = true;
|
||||
root.backlightValue = parseInt(fields[3]) / 100;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function apply(v: real): void {
|
||||
root.value = v;
|
||||
function applyBacklight(v: real): void {
|
||||
root.backlightValue = v;
|
||||
// Never go fully dark: a 0% backlight looks like a broken shell.
|
||||
Quickshell.execDetached(["brightnessctl", "-c", "backlight", "-q", "set", Math.max(1, Math.round(v * 100)) + "%"]);
|
||||
}
|
||||
|
||||
// ValueSlider draws its own leading icon, but symbolic icons need
|
||||
// recolouring to be visible — see ThemedIcon.
|
||||
ThemedIcon {
|
||||
id: glyph
|
||||
Column {
|
||||
id: rows
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 17
|
||||
icon: "display-brightness-symbolic"
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ValueSlider {
|
||||
anchors.left: glyph.right
|
||||
anchors.leftMargin: 12
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 32
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
value: root.value
|
||||
onMoved: v => root.apply(v)
|
||||
component BrightnessRow: Item {
|
||||
id: row
|
||||
|
||||
property string label: ""
|
||||
property real value: 0
|
||||
signal moved(real value)
|
||||
|
||||
implicitHeight: caption.height + control.height
|
||||
|
||||
Text {
|
||||
id: caption
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 6
|
||||
anchors.rightMargin: 6
|
||||
anchors.top: parent.top
|
||||
visible: root.labelled
|
||||
height: visible ? implicitHeight + 2 : 0
|
||||
text: row.label
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// The slider and its glyph share one strip so the two stay aligned
|
||||
// whether or not a caption sits above them. Anchoring the glyph to
|
||||
// both a caption and a centre line instead would conflict, and an
|
||||
// anchor set to undefined is not released.
|
||||
Item {
|
||||
id: control
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: caption.bottom
|
||||
height: 32
|
||||
|
||||
// ValueSlider draws its own leading icon, but symbolic icons need
|
||||
// recolouring to be visible — see ThemedIcon.
|
||||
ThemedIcon {
|
||||
id: glyph
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 17
|
||||
icon: "display-brightness-symbolic"
|
||||
}
|
||||
|
||||
ValueSlider {
|
||||
anchors.left: glyph.right
|
||||
anchors.leftMargin: 12
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 32
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
value: row.value
|
||||
onMoved: v => row.moved(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,18 @@ import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "About Panama"
|
||||
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 {
|
||||
title: "Panama Desktop"
|
||||
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 {
|
||||
title: "Design principles"
|
||||
|
||||
|
||||
@@ -49,16 +49,14 @@ SettingsPage {
|
||||
title: "Window layout"
|
||||
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
|
||||
|
||||
TextRow {
|
||||
label: "Layout"
|
||||
detail: "Dwindle with preserved split direction"
|
||||
value: "Tiling"
|
||||
}
|
||||
TextRow {
|
||||
label: "Workspace movement"
|
||||
detail: "Alt+H / Alt+L, add Shift to move a window"
|
||||
value: "Dynamic"
|
||||
}
|
||||
// These were two read-only rows reporting "Tiling" and "Dynamic", which
|
||||
// described settings rather than facts -- both are ordinary Hyprland
|
||||
// options that simply had no controls. TextRow's own documentation says
|
||||
// a setting the user could reasonably change does not belong in it.
|
||||
ChoiceRow { setting: "windowLayout" }
|
||||
ToggleRow { setting: "preserveSplit" }
|
||||
ChoiceRow { setting: "forceSplit" }
|
||||
ToggleRow { setting: "windowSnapping" }
|
||||
ActionRow {
|
||||
label: "Gaps, corners, and effects"
|
||||
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 {
|
||||
title: "Focus"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Displays.
|
||||
//
|
||||
// Resolution, refresh rate, scale, and rotation, plus the gaming display
|
||||
// policy that was already here.
|
||||
// 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,
|
||||
@@ -32,7 +32,14 @@ SettingsPage {
|
||||
root.selectedOutput = Displays.monitors.length > 0 ? Displays.monitors[0].name : "";
|
||||
}
|
||||
|
||||
Component.onCompleted: root.syncSelectedOutput()
|
||||
// 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(); }
|
||||
@@ -200,6 +207,45 @@ SettingsPage {
|
||||
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 {
|
||||
id: brightnessRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector
|
||||
detail: modelData.connector ? modelData.connector + " · " + modelData.value + "%"
|
||||
: modelData.value + "%"
|
||||
divider: brightnessRow.index < Brightness.displays.length - 1
|
||||
controlWidth: 190
|
||||
|
||||
ValueSlider {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.right: parent.right
|
||||
width: parent.width
|
||||
value: brightnessRow.modelData.value / 100
|
||||
onMoved: v => Brightness.set(brightnessRow.modelData.bus, Math.round(v * 100))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Gaming display policy"
|
||||
subtitle: "Applied immediately and restored when Panama starts."
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
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 {
|
||||
label: "Network, Bluetooth, printers, users, and accounts"
|
||||
detail: "GNOME Settings remains searchable from the launcher too"
|
||||
label: "Sharing"
|
||||
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
|
||||
action: "Open network"
|
||||
onTriggered: SystemSettings.openGnomePanel("network")
|
||||
onTriggered: SystemSettings.openGnomePanel("wellbeing")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,9 @@ Rectangle {
|
||||
case "notifications": return notificationsPage;
|
||||
case "screen-intelligence": return screenIntelligencePage;
|
||||
case "shortcuts": return shortcutsPage;
|
||||
case "mouse": return mousePage;
|
||||
case "privacy": return privacyPage;
|
||||
case "region": return regionPage;
|
||||
case "accessibility": return accessibilityPage;
|
||||
case "power": return powerPage;
|
||||
case "datetime": return dateTimePage;
|
||||
@@ -153,6 +156,9 @@ Rectangle {
|
||||
Component { id: notificationsPage; NotificationsPage {} }
|
||||
Component { id: screenIntelligencePage; ScreenIntelligencePage {} }
|
||||
Component { id: shortcutsPage; ShortcutsPage {} }
|
||||
Component { id: mousePage; MousePage {} }
|
||||
Component { id: privacyPage; PrivacyPage {} }
|
||||
Component { id: regionPage; RegionPage {} }
|
||||
Component { id: servicesPage; ServicesPage {} }
|
||||
Component { id: aboutPage; AboutPage {} }
|
||||
|
||||
|
||||
@@ -31,7 +31,10 @@ Rectangle {
|
||||
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
||||
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
|
||||
{ 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: "power", label: "Power & Lock", icon: "\u{F0425}" },
|
||||
{ page: "datetime", label: "Date & Time", icon: "\u{F0954}" },
|
||||
|
||||
@@ -28,24 +28,19 @@ SettingsPage {
|
||||
SettingsCard {
|
||||
title: "Keyboard"
|
||||
|
||||
TextRow {
|
||||
label: "Keyboard layout"
|
||||
detail: "XKB layout name. Changing it needs a compositor reload, so it is shown here rather than offered as a control that appears to apply instantly."
|
||||
value: Settings ? DesktopPreferences.get("keyboardLayout") : "us"
|
||||
}
|
||||
// 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: "keyRepeatRate" }
|
||||
ToggleRow { setting: "numlockByDefault"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Pointer"
|
||||
|
||||
ChoiceRow { setting: "followMouse" }
|
||||
SliderRow { setting: "pointerSensitivity" }
|
||||
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Hardware input"
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,3 +46,8 @@ 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
|
||||
|
||||
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[*]}")"
|
||||
+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
+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
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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 ]]
|
||||
}
|
||||
@@ -67,18 +69,151 @@ adjust_microphone() {
|
||||
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) brightnessctl -e4 -n2 set "${step}%+" >/dev/null || return ;;
|
||||
down) brightnessctl -e4 -n2 set "${step}%-" >/dev/null || return ;;
|
||||
up|down) ;;
|
||||
*) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;;
|
||||
esac
|
||||
|
||||
output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0
|
||||
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
|
||||
[[ $percent =~ ^[0-9]+$ ]] || return 0
|
||||
show_progress brightness "$percent" "${percent}%"
|
||||
# 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() {
|
||||
|
||||
+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[*]}")"
|
||||
+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,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ 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: ""
|
||||
|
||||
@@ -92,6 +94,12 @@ Singleton {
|
||||
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,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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,9 @@ Singleton {
|
||||
"idle": "power",
|
||||
"accessibility": "accessibility",
|
||||
"input": "shortcuts",
|
||||
"pointer": "mouse",
|
||||
"touchpad": "mouse",
|
||||
"multitasking": "desktop",
|
||||
"weather": "appearance",
|
||||
"notifications": "notifications",
|
||||
"capture": "screen-intelligence"
|
||||
|
||||
@@ -92,7 +92,7 @@ Singleton {
|
||||
}
|
||||
|
||||
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";
|
||||
DesktopPreferences.set("lastPage", root.settingsPage);
|
||||
root.settingsOpen = true;
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,18 +497,35 @@ Singleton {
|
||||
].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)) {
|
||||
root.lastError = "That GNOME Settings panel is not available.";
|
||||
return false;
|
||||
}
|
||||
const command = ["gnome-control-center", panel];
|
||||
if (subpage !== undefined && subpage !== "" && root.isGnomeSubpageAllowed(panel, subpage))
|
||||
command.push(subpage);
|
||||
Quickshell.execDetached({
|
||||
command: ["gnome-control-center", panel],
|
||||
command: command,
|
||||
environment: { "XDG_CURRENT_DESKTOP": "GNOME" }
|
||||
});
|
||||
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 {
|
||||
const commands = {
|
||||
"nextcloud": ["nextcloud"],
|
||||
|
||||
@@ -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
|
||||
// directly in hypr/keybinds.lua.
|
||||
|
||||
// Vicinae 0.26 selects a theme per system appearance. Both point to Moon so
|
||||
// the launcher never flashes or falls back to its stock palette while the
|
||||
// desktop appearance is being initialized.
|
||||
// Vicinae 0.26 selects a theme per system appearance, and now there is a
|
||||
// theme for each: the launcher follows Panama's light/dark setting instead of
|
||||
// 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": {
|
||||
"light": {
|
||||
"name": "tokyonight-moon",
|
||||
"name": "tokyonight-day",
|
||||
"icon_theme": "auto"
|
||||
},
|
||||
"dark": {
|
||||
|
||||
+38
-10
@@ -70,23 +70,51 @@ for dir in "${dirs[@]}"; do
|
||||
log "Linked $PANAMA_DOT/$dir → $CONFIG/$dir"
|
||||
done
|
||||
|
||||
# kitty.conf ends with `include current-theme.conf`, and that file is generated
|
||||
# from the desktop colour scheme rather than committed -- it is machine state.
|
||||
# A fresh checkout therefore has no such file, and kitty starts by complaining
|
||||
# about a missing include and falling back to its stock colours. Seed it from
|
||||
# the scheme in settings.json (dark unless the user has chosen otherwise) so a
|
||||
# first launch is themed; ColorScheme.qml overwrites it on every change after.
|
||||
KITTY_THEME="$PANAMA_DOT/kitty/current-theme.conf"
|
||||
if [ -e "$KITTY_THEME" ]; then
|
||||
log "Keeping existing kitty theme at $KITTY_THEME"
|
||||
else
|
||||
scheme="dark"
|
||||
settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
|
||||
if [ -r "$settings" ]; then
|
||||
stored="$(jq -r '.colorScheme // "dark"' "$settings" 2>/dev/null || echo dark)"
|
||||
[ "$stored" = "light" ] && scheme="light"
|
||||
fi
|
||||
[ "$scheme" = "light" ] && theme="tokyonight-day" || theme="tokyonight-moon"
|
||||
cp "$PANAMA_DOT/kitty/themes/$theme.conf" "$KITTY_THEME"
|
||||
log "Seeded kitty $scheme theme ($theme) → $KITTY_THEME"
|
||||
fi
|
||||
|
||||
# Vicinae 0.26 discovers user themes from its XDG data directory rather than
|
||||
# from ~/.config/vicinae. Keep the authored theme in Panama with the rest of
|
||||
# the launcher config and expose only that file at Vicinae's runtime path.
|
||||
VICINAE_THEME_DIR="$HOME/.local/share/vicinae/themes"
|
||||
VICINAE_THEME="$VICINAE_THEME_DIR/tokyonight-moon.toml"
|
||||
mkdir -p "$VICINAE_THEME_DIR"
|
||||
|
||||
if [ -L "$VICINAE_THEME" ]; then
|
||||
rm "$VICINAE_THEME"
|
||||
fi
|
||||
# Every authored theme, not just the dark one: vicinae.json selects a theme per
|
||||
# system appearance, so linking only Moon left the launcher falling back to its
|
||||
# stock palette whenever Panama was in light mode.
|
||||
for theme_src in "$PANAMA_DOT"/vicinae/themes/*.toml; do
|
||||
[ -e "$theme_src" ] || continue
|
||||
theme_dst="$VICINAE_THEME_DIR/$(basename "$theme_src")"
|
||||
|
||||
if [ -e "$VICINAE_THEME" ]; then
|
||||
log "Keeping existing Vicinae theme at $VICINAE_THEME"
|
||||
else
|
||||
ln -s "$PANAMA_DOT/vicinae/themes/tokyonight-moon.toml" "$VICINAE_THEME"
|
||||
log "Linked Tokyo Night Moon theme → $VICINAE_THEME"
|
||||
fi
|
||||
if [ -L "$theme_dst" ]; then
|
||||
rm "$theme_dst"
|
||||
fi
|
||||
|
||||
if [ -e "$theme_dst" ]; then
|
||||
log "Keeping existing Vicinae theme at $theme_dst"
|
||||
else
|
||||
ln -s "$theme_src" "$theme_dst"
|
||||
log "Linked Vicinae theme → $theme_dst"
|
||||
fi
|
||||
done
|
||||
|
||||
# Panama-native applications live in the user data directory so launchers can
|
||||
# discover them alongside system desktop entries. Keep each authored file in
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# panama-brightness enumerates monitors from sysfs and speaks DDC/CI to them.
|
||||
#
|
||||
# The parts worth pinning down are the ones that decide whether a slider appears
|
||||
# at all, and whether it appears attached to the right screen:
|
||||
#
|
||||
# * only connectors with something plugged in are probed, because probing an
|
||||
# empty bus costs a timeout each and there are fourteen of them here;
|
||||
# * a panel that cannot report brightness is omitted rather than shown as a
|
||||
# control that does nothing;
|
||||
# * the connector name matches what Hyprland calls the output, since the UI
|
||||
# joins on it to get the monitor's description;
|
||||
# * no I2C access produces the udev command that fixes it, not "no displays".
|
||||
#
|
||||
# Runs entirely against fixtures. Real monitors are never touched: both the
|
||||
# sysfs root and the device root are redirected, and ddcutil is replaced on PATH.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-brightness"
|
||||
|
||||
fail() {
|
||||
printf 'brightness helper contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
fixture="$(mktemp -d /tmp/panama-brightness.XXXXXX)"
|
||||
trap 'rm -rf "$fixture"' EXIT
|
||||
|
||||
mkdir -p "$fixture/drm" "$fixture/dev" "$fixture/bin" "$fixture/i2c"
|
||||
|
||||
# Two connectors with a monitor, two without. DP-2 answers DDC; DP-3 is
|
||||
# connected but does not implement brightness. HDMI-A-1 and DP-1 are empty and
|
||||
# must never be probed at all.
|
||||
make_connector() {
|
||||
local name="$1" bus="$2" status="$3"
|
||||
mkdir -p "$fixture/drm/$name"
|
||||
printf '%s\n' "$status" >"$fixture/drm/$name/status"
|
||||
mkdir -p "$fixture/i2c/i2c-$bus"
|
||||
ln -sfn "$fixture/i2c/i2c-$bus" "$fixture/drm/$name/ddc"
|
||||
}
|
||||
make_connector card1-DP-1 4 disconnected
|
||||
make_connector card1-DP-2 5 connected
|
||||
make_connector card1-DP-3 6 connected
|
||||
make_connector card1-HDMI-A-1 7 disconnected
|
||||
|
||||
# has_accessible_bus only needs one readable/writable node to exist.
|
||||
touch "$fixture/dev/i2c-5"
|
||||
|
||||
# Stub ddcutil. Records every bus it is asked about so the test can prove the
|
||||
# disconnected ones were skipped. Bus 6 refuses, standing in for a panel without
|
||||
# VCP 0x10.
|
||||
#
|
||||
# Bus 5 reports its brightness out of 200 rather than 100. Most panels do use
|
||||
# 100, which is exactly the problem: with a maximum of 100 the scaling
|
||||
# arithmetic is the identity, so a helper that ignored the reported maximum
|
||||
# entirely would pass every assertion. 200 makes reads and writes that skip the
|
||||
# conversion visibly wrong.
|
||||
cat >"$fixture/bin/ddcutil" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
bus=""
|
||||
args=("$@")
|
||||
for ((i = 0; i < ${#args[@]}; i++)); do
|
||||
[[ "${args[$i]}" == "--bus" ]] && bus="${args[$((i + 1))]}"
|
||||
done
|
||||
printf '%s\n' "$bus" >>"$DDCUTIL_PROBE_LOG"
|
||||
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == "setvcp" ]]; then
|
||||
printf 'set %s %s\n' "$bus" "${args[-1]}" >>"$DDCUTIL_SET_LOG"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
case "$bus" in
|
||||
5) printf 'VCP 10 C 120 200\n'; exit 0 ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
STUB
|
||||
chmod +x "$fixture/bin/ddcutil"
|
||||
|
||||
export DDCUTIL_PROBE_LOG="$fixture/probes.log"
|
||||
export DDCUTIL_SET_LOG="$fixture/sets.log"
|
||||
: >"$DDCUTIL_PROBE_LOG"
|
||||
: >"$DDCUTIL_SET_LOG"
|
||||
|
||||
run_helper() {
|
||||
PATH="$fixture/bin:$PATH" \
|
||||
PANAMA_BRIGHTNESS_DRM_ROOT="$fixture/drm" \
|
||||
PANAMA_BRIGHTNESS_DEV_ROOT="$fixture/dev" \
|
||||
"$helper" "$@"
|
||||
}
|
||||
|
||||
# ── Enumeration ──────────────────────────────────────────────────────────────
|
||||
listing="$(run_helper list)"
|
||||
jq -e . >/dev/null 2>&1 <<<"$listing" || fail "list did not emit JSON: $listing"
|
||||
|
||||
[[ "$(jq -r '.displays | length' <<<"$listing")" == "1" ]] \
|
||||
|| fail "expected exactly one controllable display, got: $listing"
|
||||
|
||||
[[ "$(jq -r '.displays[0].connector' <<<"$listing")" == "DP-2" ]] \
|
||||
|| fail "the connector name must match Hyprland's output name: $listing"
|
||||
|
||||
[[ "$(jq -r '.displays[0].bus' <<<"$listing")" == "5" ]] \
|
||||
|| fail "the display was mapped to the wrong I2C bus: $listing"
|
||||
|
||||
# 120 of a maximum of 200 is 60%.
|
||||
[[ "$(jq -r '.displays[0].value' <<<"$listing")" == "60" ]] \
|
||||
|| fail "brightness was not read as a percent of the reported maximum: $listing"
|
||||
|
||||
[[ "$(jq -r '.error' <<<"$listing")" == "" ]] \
|
||||
|| fail "a successful listing must not carry an error: $listing"
|
||||
|
||||
# A connected panel that cannot report brightness is dropped, not listed.
|
||||
jq -e '.displays | map(.connector) | index("DP-3") == null' >/dev/null <<<"$listing" \
|
||||
|| fail 'a display without VCP 0x10 was listed as controllable'
|
||||
|
||||
# ── Disconnected connectors are never probed ─────────────────────────────────
|
||||
if grep -qxE '4|7' "$DDCUTIL_PROBE_LOG"; then
|
||||
fail "a disconnected connector was probed -- each empty bus costs a timeout: $(tr '\n' ' ' <"$DDCUTIL_PROBE_LOG")"
|
||||
fi
|
||||
|
||||
# ── Writes scale to the reported maximum ─────────────────────────────────────
|
||||
run_helper set 5 40
|
||||
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 80" ]] \
|
||||
|| fail "set did not scale to the display's maximum: $(cat "$DDCUTIL_SET_LOG")"
|
||||
|
||||
run_helper set 5 500
|
||||
[[ "$(tail -1 "$DDCUTIL_SET_LOG")" == "set 5 200" ]] \
|
||||
|| fail "an out-of-range percent was not clamped: $(cat "$DDCUTIL_SET_LOG")"
|
||||
|
||||
# ── No I2C access explains itself ────────────────────────────────────────────
|
||||
rm -f "$fixture/dev"/i2c-*
|
||||
denied="$(run_helper list)"
|
||||
[[ "$(jq -r '.displays | length' <<<"$denied")" == "0" ]] \
|
||||
|| fail "displays were reported without I2C access: $denied"
|
||||
grep -q 'udevadm' <<<"$(jq -r '.error' <<<"$denied")" \
|
||||
|| fail "the no-access error must name the command that fixes it, got: $(jq -r '.error' <<<"$denied")"
|
||||
|
||||
printf 'brightness helper contract: PASS\n'
|
||||
@@ -113,6 +113,25 @@ config_home="$(mktemp -d /tmp/panama-displays-config.XXXXXX)"
|
||||
run() { XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"; }
|
||||
status() { run ipc call displays-test status; }
|
||||
|
||||
# The shipped geometry, read from the Hyprland config rather than from the
|
||||
# running compositor.
|
||||
#
|
||||
# Everything below captures "original" from what it observes at start, which is
|
||||
# correct only if the display is already in a good state. A previous run that
|
||||
# failed mid-revert leaves the display changed, and the next run then captures
|
||||
# THAT as the original and faithfully restores the desktop to a broken value.
|
||||
# One flake becomes permanent. So refuse to run from a state that does not match
|
||||
# what the config says, rather than laundering it.
|
||||
shipped_scale="$(sed -n 's/^local shipped_scale *= *\([0-9.]*\).*/\1/p' \
|
||||
"$repo_dir/config/dot/hypr/monitors.lua" | head -1)"
|
||||
[[ -n "$shipped_scale" ]] || fail 'could not read the shipped scale from monitors.lua -- the guard below depends on it, and skipping it silently is how a dirty baseline gets laundered'
|
||||
if [[ -n "$shipped_scale" ]]; then
|
||||
live_scale="$(hyprctl -j monitors | jq -r '.[0].scale')"
|
||||
if ! awk -v a="$live_scale" -v b="$shipped_scale" 'BEGIN { exit !(a == b) }'; then
|
||||
fail "the display is at scale $live_scale but the config ships $shipped_scale -- refusing to capture a dirty state as the baseline. Restore it first: hyprctl eval 'hl.monitor({ output = \"DP-2\", mode = \"4500x3000@60\", scale = $shipped_scale, transform = 0 })'"
|
||||
fi
|
||||
fi
|
||||
|
||||
original_mode=""
|
||||
original_scale=""
|
||||
original_transform=""
|
||||
|
||||
@@ -13,6 +13,20 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
qs ipc call focus end >/dev/null 2>&1 || true
|
||||
|
||||
# focus start records the caffeine state it found so end() can hand it back, and
|
||||
# the assertion below requires caffeine to finish off. Both are only true if
|
||||
# caffeine starts off.
|
||||
#
|
||||
# This test kills and restarts the live shell, so an interrupted run leaves
|
||||
# caffeine on with nothing to turn it back off. The next run then records
|
||||
# "previously on", faithfully restores it, and fails -- and every run after
|
||||
# that fails identically, with the desktop quietly unable to idle or lock the
|
||||
# whole time. Refuse to start from that state rather than latching it in.
|
||||
if [[ "$(qs ipc call caffeine status 2>/dev/null)" != "false" ]]; then
|
||||
fail 'caffeine is on before the session starts -- this test cannot distinguish "restored correctly" from "never released". Turn it off first: qs ipc call caffeine toggle'
|
||||
fi
|
||||
|
||||
qs ipc call focus start >/dev/null
|
||||
|
||||
state_file=$(find "${XDG_STATE_HOME:-$HOME/.local/state}/quickshell/by-shell" -name focus-session.json -print -quit)
|
||||
|
||||
@@ -26,10 +26,62 @@ printf 'brightnessctl' >>"$OSD_TEST_LOG"
|
||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
||||
printf '\n' >>"$OSD_TEST_LOG"
|
||||
if [[ " $* " == *" -m "* && " $* " != *" set "* ]]; then
|
||||
[[ ${BACKLIGHT_AVAILABLE:-true} == true ]] || exit 1
|
||||
printf '%s\n' "${BRIGHTNESS_OUTPUT:-intel_backlight,backlight,500,1000,50%}"
|
||||
fi
|
||||
SH
|
||||
|
||||
cat >"$scratch/bin/panama-brightness" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'panama-brightness' >>"$OSD_TEST_LOG"
|
||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
||||
printf '\n' >>"$OSD_TEST_LOG"
|
||||
|
||||
case "${1:-}" in
|
||||
list)
|
||||
if [[ -n ${DDC_LIST_JSON:-} ]]; then
|
||||
printf '%s\n' "$DDC_LIST_JSON"
|
||||
else
|
||||
printf '%s\n' '{"displays":[],"error":"No displays"}'
|
||||
fi
|
||||
;;
|
||||
get)
|
||||
[[ ${DDC_FAIL_GET_BUS:-} != "${2:-}" ]] || exit 1
|
||||
if [[ -s $OSD_DDC_STATE ]]; then
|
||||
cat "$OSD_DDC_STATE"
|
||||
else
|
||||
printf '%s\n' "${DDC_GET_VALUE:-40}"
|
||||
fi
|
||||
;;
|
||||
set)
|
||||
if [[ -n ${DDC_SET_DELAY:-} ]]; then
|
||||
if ! mkdir "$OSD_DDC_PROBE" 2>/dev/null; then
|
||||
printf 'ddc-overlap\n' >>"$OSD_TEST_LOG"
|
||||
fi
|
||||
sleep "$DDC_SET_DELAY"
|
||||
rmdir "$OSD_DDC_PROBE" 2>/dev/null || true
|
||||
fi
|
||||
printf '%s\n' "${3:-0}" >"$OSD_DDC_STATE"
|
||||
;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
SH
|
||||
|
||||
cat >"$scratch/bin/hyprctl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'hyprctl' >>"$OSD_TEST_LOG"
|
||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
||||
printf '\n' >>"$OSD_TEST_LOG"
|
||||
printf '[{"name":"%s","focused":true}]\n' "${FOCUSED_MONITOR:-DP-2}"
|
||||
SH
|
||||
|
||||
cat >"$scratch/bin/notify-send" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'notify-send' >>"$OSD_TEST_LOG"
|
||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
||||
printf '\n' >>"$OSD_TEST_LOG"
|
||||
SH
|
||||
|
||||
cat >"$scratch/bin/playerctl" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'playerctl' >>"$OSD_TEST_LOG"
|
||||
@@ -53,9 +105,21 @@ SH
|
||||
chmod +x "$scratch/bin/"*
|
||||
|
||||
run_helper() {
|
||||
local runtime="${OSD_RUNTIME_DIR:-$scratch/runtime-default}"
|
||||
mkdir -p "$runtime"
|
||||
PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" \
|
||||
OSD_TEST_FAIL_QS="${OSD_TEST_FAIL_QS:-false}" \
|
||||
PANAMA_OSD_STRICT="${PANAMA_OSD_STRICT:-false}" \
|
||||
PANAMA_OSD_BRIGHTNESS_HELPER="$scratch/bin/panama-brightness" \
|
||||
PANAMA_OSD_RUNTIME_DIR="$runtime" \
|
||||
OSD_DDC_STATE="$runtime/ddc-state" \
|
||||
OSD_DDC_PROBE="$runtime/ddc-probe" \
|
||||
BACKLIGHT_AVAILABLE="${BACKLIGHT_AVAILABLE:-true}" \
|
||||
DDC_LIST_JSON="${DDC_LIST_JSON:-}" \
|
||||
DDC_GET_VALUE="${DDC_GET_VALUE:-40}" \
|
||||
DDC_FAIL_GET_BUS="${DDC_FAIL_GET_BUS:-}" \
|
||||
DDC_SET_DELAY="${DDC_SET_DELAY:-}" \
|
||||
FOCUSED_MONITOR="${FOCUSED_MONITOR:-DP-2}" \
|
||||
"$helper" "$@"
|
||||
}
|
||||
|
||||
@@ -86,9 +150,102 @@ assert_line 'qs <ipc> <call> <osd> <progress> <microphone-muted> <72> <100> <Mut
|
||||
|
||||
: >"$log"
|
||||
run_helper brightness up 5
|
||||
assert_line 'brightnessctl <-e4> <-n2> <set> <5%+>'
|
||||
assert_line 'brightnessctl <-m> <-c> <backlight>'
|
||||
assert_line 'brightnessctl <-e4> <-n2> <-c> <backlight> <set> <5%+>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <50> <100> <50%>'
|
||||
if grep -Fq 'panama-brightness' "$log"; then
|
||||
printf 'osd helper contract: DDC fallback ran despite a native backlight\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
: >"$log"
|
||||
OSD_RUNTIME_DIR="$scratch/runtime-ddc" \
|
||||
BACKLIGHT_AVAILABLE=false \
|
||||
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
|
||||
run_helper brightness up 5
|
||||
assert_line 'hyprctl <-j> <monitors>'
|
||||
assert_line 'panama-brightness <list>'
|
||||
assert_line 'panama-brightness <get> <5>'
|
||||
assert_line 'panama-brightness <set> <5> <45>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <45> <100> <45%>'
|
||||
|
||||
# A cached bus avoids the expensive display scan on subsequent key presses.
|
||||
: >"$log"
|
||||
OSD_RUNTIME_DIR="$scratch/runtime-ddc" \
|
||||
BACKLIGHT_AVAILABLE=false \
|
||||
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":45}],"error":""}' \
|
||||
run_helper brightness down 5
|
||||
assert_line 'panama-brightness <get> <5>'
|
||||
assert_line 'panama-brightness <set> <5> <40>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <40> <100> <40%>'
|
||||
if grep -Fq 'panama-brightness <list>' "$log"; then
|
||||
printf 'osd helper contract: cached DDC bus triggered another display scan\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A disconnected cached monitor is discarded and rediscovered once.
|
||||
mkdir -p "$scratch/runtime-ddc-stale"
|
||||
printf '9\tDP-9\n' >"$scratch/runtime-ddc-stale/brightness-bus"
|
||||
: >"$log"
|
||||
OSD_RUNTIME_DIR="$scratch/runtime-ddc-stale" \
|
||||
BACKLIGHT_AVAILABLE=false \
|
||||
DDC_FAIL_GET_BUS=9 \
|
||||
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
|
||||
run_helper brightness up 5
|
||||
assert_line 'panama-brightness <get> <9>'
|
||||
assert_line 'panama-brightness <list>'
|
||||
assert_line 'panama-brightness <get> <5>'
|
||||
assert_line 'panama-brightness <set> <5> <45>'
|
||||
|
||||
# If the focused output is not DDC-capable, use the first discovered display.
|
||||
: >"$log"
|
||||
OSD_RUNTIME_DIR="$scratch/runtime-ddc-first" \
|
||||
BACKLIGHT_AVAILABLE=false \
|
||||
FOCUSED_MONITOR='eDP-1' \
|
||||
DDC_GET_VALUE=35 \
|
||||
DDC_LIST_JSON='{"displays":[{"bus":3,"connector":"HDMI-A-1","value":35},{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
|
||||
run_helper brightness down 10
|
||||
assert_line 'panama-brightness <get> <3>'
|
||||
assert_line 'panama-brightness <set> <3> <25>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <brightness> <25> <100> <25%>'
|
||||
|
||||
# Permission and discovery errors must be visible, never masquerade as 0%.
|
||||
: >"$log"
|
||||
OSD_RUNTIME_DIR="$scratch/runtime-ddc-error" \
|
||||
BACKLIGHT_AVAILABLE=false \
|
||||
DDC_LIST_JSON='{"displays":[],"error":"Run sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm"}' \
|
||||
run_helper brightness up 5
|
||||
assert_line 'qs <ipc> <call> <osd> <message> <dialog-warning-symbolic> <Brightness needs permission>'
|
||||
assert_line 'notify-send <--app-name=Panama> <--icon=display-brightness-symbolic> <Brightness unavailable> <Run sudo udevadm control --reload-rules && sudo udevadm trigger --subsystem-match=i2c-dev --subsystem-match=drm>'
|
||||
if grep -Fq 'osd> <progress> <brightness>' "$log"; then
|
||||
printf 'osd helper contract: unavailable brightness rendered a false percentage\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Separate key-repeat processes must not overlap their DDC transactions.
|
||||
: >"$log"
|
||||
OSD_RUNTIME_DIR="$scratch/runtime-ddc-lock" \
|
||||
BACKLIGHT_AVAILABLE=false \
|
||||
DDC_SET_DELAY=0.15 \
|
||||
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
|
||||
run_helper brightness up 5 &
|
||||
first_pid=$!
|
||||
OSD_RUNTIME_DIR="$scratch/runtime-ddc-lock" \
|
||||
BACKLIGHT_AVAILABLE=false \
|
||||
DDC_SET_DELAY=0.15 \
|
||||
DDC_LIST_JSON='{"displays":[{"bus":5,"connector":"DP-2","value":40}],"error":""}' \
|
||||
run_helper brightness up 5 &
|
||||
second_pid=$!
|
||||
wait "$first_pid"
|
||||
wait "$second_pid"
|
||||
if grep -Fqx 'ddc-overlap' "$log"; then
|
||||
printf 'osd helper contract: concurrent DDC transactions overlapped\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ $(<"$scratch/runtime-ddc-lock/ddc-state") != 50 ]]; then
|
||||
printf 'osd helper contract: serialized key repeats did not both apply\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
: >"$log"
|
||||
run_helper media next
|
||||
|
||||
Reference in New Issue
Block a user