Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d87f4b6d6a | ||
|
|
9fc1fdbbbb | ||
|
|
dd8d93c387 | ||
|
|
b8832a0174 |
@@ -12,7 +12,7 @@ hl.config({
|
||||
kb_layout = prefs.get("keyboardLayout", "us"),
|
||||
kb_variant = prefs.get("keyboardVariant", ""),
|
||||
kb_model = "",
|
||||
kb_options = prefs.get("keyboardOptions", ""),
|
||||
kb_options = prefs.get("keyboardOptions", "caps:escape_shifted_capslock"),
|
||||
kb_rules = "",
|
||||
|
||||
numlock_by_default = prefs.get("numlockByDefault", true),
|
||||
|
||||
@@ -22,16 +22,15 @@ hl.config({
|
||||
border_size = prefs.get("borderSize", 2),
|
||||
|
||||
col = {
|
||||
-- The prism: blue leads, orchid follows, on a diagonal so the pair
|
||||
-- is visible on both a tall and a wide window. Same two colours as
|
||||
-- the shell's hairline (quickshell/widgets/PrismEdge.qml) and the
|
||||
-- tmux theme this palette came from.
|
||||
-- The focused accent role: blue leads, orchid follows, on a
|
||||
-- diagonal so the pair is visible on both a tall and a wide
|
||||
-- window. ColorScheme never writes this role; a future accent
|
||||
-- picker can own it without fighting light/dark mode.
|
||||
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
|
||||
-- Unfocused windows get no colour at all. The gradient only means
|
||||
-- something if exactly one window on screen is wearing it.
|
||||
-- Follows the colour scheme: a dark neutral is invisible against a
|
||||
-- light desktop. services/ColorScheme.qml applies changes live;
|
||||
-- this is the value a fresh session starts from.
|
||||
-- The neutral inactive role follows the colour scheme because a
|
||||
-- dark neutral disappears against a light desktop.
|
||||
-- services/ColorScheme.qml applies the same values live; this is
|
||||
-- the value a fresh session starts from.
|
||||
inactive_border = prefs.get("colorScheme", "dark") == "light"
|
||||
and "rgba(a8aecb99)" or "rgba(3b426199)",
|
||||
},
|
||||
|
||||
@@ -436,7 +436,7 @@ Singleton {
|
||||
hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" }
|
||||
},
|
||||
{
|
||||
key: "keyboardOptions", type: "string", def: "", group: "input",
|
||||
key: "keyboardOptions", type: "string", def: "caps:escape_shifted_capslock", 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_]+)*$",
|
||||
@@ -503,7 +503,7 @@ Singleton {
|
||||
{
|
||||
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
|
||||
unit: "s",
|
||||
group: "input",
|
||||
group: "pointer",
|
||||
label: "Hide pointer after",
|
||||
detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
|
||||
// Reported as a float even though it is only ever set to whole
|
||||
|
||||
@@ -59,6 +59,36 @@ If it is compositor-backed, add the matching `prefs.get("blurSize", 8)` in
|
||||
`hypr/looks.lua` so the Hyprland config still stands alone with no settings
|
||||
file.
|
||||
|
||||
## Setting ownership
|
||||
|
||||
Every preference has **one primary page**, derived from its schema `group` and
|
||||
the route in `services/SettingsSearch.qml`. Search results always open that
|
||||
owner. A control may appear on a second page only when the same adaptation is
|
||||
part of another established mental model; otherwise use a labelled handoff to
|
||||
the owner instead of duplicating it.
|
||||
|
||||
### Intentional mirrors
|
||||
|
||||
| Setting | Primary page | Mirror | Why the mirror earns its place |
|
||||
|---|---|---|---|
|
||||
| `animationsEnabled` | Appearance | Accessibility | Reduced motion belongs both to visual polish and motion accessibility. |
|
||||
| `cursorInactiveTimeout` | Mouse | Accessibility | Pointer visibility is configured with pointer behaviour but affects motor and visual access. |
|
||||
| `cursorSize` | Accessibility | Mouse | Large cursors are an accessibility adaptation that users also look for beside pointer controls. |
|
||||
| `inactiveOpacity` | Appearance | Accessibility | Window translucency is an appearance choice with a direct readability impact. |
|
||||
| `lockMinutes` | Power | Privacy | Idle timing owns the mechanism; privacy owns the expectation that the unattended desktop locks. |
|
||||
| `lockOnSleep` | Power | Privacy | Suspend owns the transition; privacy owns whether waking requires authentication. |
|
||||
|
||||
Mirrors must remain the same schema-backed control, never a second preference
|
||||
or a copied default. Additions to this table require a concrete discoverability
|
||||
reason and an update to `tests/quickshell/settings-ownership-contract.sh`.
|
||||
|
||||
Window border colour follows the same ownership rule. The inactive border is a
|
||||
**scheme-relative role** owned by `ColorScheme.qml`: it changes only to retain
|
||||
neutral contrast in light and dark modes. The focused Prism border is the
|
||||
accent role owned by the visual theme (and, eventually, an accent picker).
|
||||
`ColorScheme.qml` must never write the focused border, so changing schemes
|
||||
cannot erase a user-selected accent.
|
||||
|
||||
## The rows
|
||||
|
||||
| Component | For |
|
||||
|
||||
@@ -130,7 +130,7 @@ RUNTIME_LINK_TARGETS = (
|
||||
("vicinae", Path("config/dot/vicinae")),
|
||||
)
|
||||
REPAIR_IDS = frozenset((*REPAIR_COMMANDS.keys(), "panama.runtime-links", "panama.vicinae-commands", "panama.caffeine"))
|
||||
PROCESS_NAMES = ("quickshell", "vicinae", "hyprpaper", "hypridle")
|
||||
PROCESS_NAMES = ("vicinae", "hyprpaper", "hypridle")
|
||||
VERSION_PATTERN = re.compile(r"\b\d+(?:\.\d+){0,3}(?:[-+._][A-Za-z0-9._-]+)?\b")
|
||||
REVISION_PATTERN = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE)
|
||||
PROBE_ENVIRONMENT_KEYS = (
|
||||
@@ -436,7 +436,21 @@ def executable_check(check_id: str, title: str, executable: str, config: DoctorC
|
||||
|
||||
|
||||
def check_processes(config: DoctorConfig) -> Check:
|
||||
counts: list[int] = []
|
||||
# `qs` is both the long-running shell and every short-lived IPC client.
|
||||
# Counting it with pgrep races the other parallel health probes and reports
|
||||
# duplicates whenever one of them happens to call `qs ipc`. The instance
|
||||
# list is the authoritative view and contains only actual shells.
|
||||
quickshell = run_command(("qs", "list"), config)
|
||||
if quickshell.state == "ok":
|
||||
quickshell_count = sum(
|
||||
line.startswith("Instance ") for line in quickshell.stdout.splitlines()
|
||||
)
|
||||
elif quickshell.state == "failed":
|
||||
quickshell_count = 0
|
||||
else:
|
||||
return Check("panama.processes", "panama-tools", "Panama processes", "warning", "Process probe is unavailable.")
|
||||
|
||||
counts: list[int] = [quickshell_count]
|
||||
for name in PROCESS_NAMES:
|
||||
result = run_command(("pgrep", "-u", str(os.getuid()), "-x", name), config)
|
||||
if result.state == "ok":
|
||||
|
||||
@@ -24,6 +24,11 @@ Singleton {
|
||||
readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
|
||||
|
||||
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
|
||||
// A neutral contrast role, not an accent. The focused Prism border belongs
|
||||
// to the visual theme and must remain untouched when this role changes.
|
||||
readonly property string inactiveBorderDark: "rgba(3b426199)"
|
||||
readonly property string inactiveBorderLight: "rgba(a8aecb99)"
|
||||
readonly property string inactiveBorder: root.dark ? root.inactiveBorderDark : root.inactiveBorderLight
|
||||
property string lastError: ""
|
||||
|
||||
// Applied one command at a time: Process runs a single command, and several
|
||||
@@ -99,12 +104,10 @@ Singleton {
|
||||
["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
|
||||
];
|
||||
|
||||
// Unfocused window borders. The focused border is the prism gradient and
|
||||
// is already scheme-independent; the inactive one is a flat neutral that
|
||||
// would be invisible against the opposite background.
|
||||
const inactive = root.dark ? "rgba(3b426199)" : "rgba(a8aecb99)";
|
||||
// Unfocused window borders need scheme-relative contrast. The focused
|
||||
// Prism border is deliberately owned by the accent/theme layer.
|
||||
commands.push(["hyprctl", "eval",
|
||||
`hl.config({ general = { col = { inactive_border = "${inactive}" } } })`]);
|
||||
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
|
||||
|
||||
// Applications that predate org.freedesktop.appearance and carry their
|
||||
// own palettes -- terminals, chiefly. Everything that reads the portal
|
||||
|
||||
@@ -165,6 +165,23 @@ ShellRoot {
|
||||
function repair(id: string): bool { return Health.repair(id, true); }
|
||||
}
|
||||
|
||||
// Health checks the wallpaper service through the same typed IPC boundary
|
||||
// as capture and clipboard. This is deliberately read-only: choosing an
|
||||
// image remains an explicit Settings action.
|
||||
IpcHandler {
|
||||
target: "wallpaper"
|
||||
|
||||
function refresh(): void { Wallpaper.refreshActive(); }
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
active: Wallpaper.active,
|
||||
configured: Wallpaper.configured,
|
||||
availableCount: Wallpaper.available.length,
|
||||
lastError: Wallpaper.lastError
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A small diagnostics surface doubles as a deterministic contract harness.
|
||||
// Real producers call StatusEvents.publish() directly; fixtures never run
|
||||
// unless explicitly requested over IPC by the test suite.
|
||||
|
||||
@@ -4,6 +4,13 @@ set -euo pipefail
|
||||
|
||||
case "${1:-}" in
|
||||
--version) printf '%s\n' "${PANAMA_DOCTOR_FIXTURE_QS_VERSION:-Quickshell 0.2.0}" ;;
|
||||
list)
|
||||
case ",${PANAMA_DOCTOR_FIXTURE_PROCESSES:-}," in
|
||||
*,qs:duplicate,*) printf '%s\n' 'Instance fixture-one:' 'Instance fixture-two:' ;;
|
||||
*,qs:missing,*) ;;
|
||||
*) printf '%s\n' 'Instance fixture-one:' ;;
|
||||
esac
|
||||
;;
|
||||
ipc)
|
||||
if [[ "${PANAMA_DOCTOR_FIXTURE_QS:-ready}" == "malformed" ]]; then
|
||||
printf 'fixture-secret-token AA:BB:CC:DD:EE:FF\n'
|
||||
|
||||
@@ -47,6 +47,8 @@ fail() {
|
||||
[[ -f "$service" ]] || fail 'Health.qml is missing'
|
||||
[[ -f "$source_harness" ]] || fail 'health harness is missing'
|
||||
[[ -f "$shell" ]] || fail 'shell.qml is missing'
|
||||
rg -q 'target: "wallpaper"' "$shell" \
|
||||
|| fail 'wallpaper health target is not exported by the shell'
|
||||
|
||||
# shell.qml is not started here: it is the active desktop shell. Keep this
|
||||
# contract static while pinning the typed, redacted IPC boundary it exports.
|
||||
|
||||
@@ -216,11 +216,14 @@ jq -e '.checks[] | select(.id == "panama.caffeine")
|
||||
|| fail 'Caffeine detail exposed inhibitor PIDs'
|
||||
|
||||
# Process counts use only exact authored names and never expose command lines or PIDs.
|
||||
duplicated_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=quickshell:duplicate run_doctor --json)"
|
||||
duplicated_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=qs:duplicate run_doctor --json)"
|
||||
check_status "$duplicated_processes" panama.processes warning
|
||||
! jq -r '.checks[] | select(.id == "panama.processes") | .detail' <<<"$duplicated_processes" | grep -Eq '[0-9]{3,}' \
|
||||
|| fail 'process detail exposed a PID'
|
||||
|
||||
missing_quickshell_process="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=qs:missing run_doctor --json)"
|
||||
check_status "$missing_quickshell_process" panama.processes error
|
||||
|
||||
# Invalid output for a non-Quickshell authored process is not a normal zero
|
||||
# count that can be hidden by the running Quickshell process.
|
||||
malformed_processes="$(PANAMA_DOCTOR_FIXTURE_PROCESSES=hyprpaper:malformed run_doctor --json)"
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# A setting has one schema-routed owner. A second page may mirror it only when
|
||||
# this contract names the exact owner and mirror set. The same ownership rule
|
||||
# keeps colour scheme propagation away from the focused Prism border.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
pages_dir="$repo_dir/config/dot/quickshell/modules/settings"
|
||||
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
|
||||
scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml"
|
||||
looks="$repo_dir/config/dot/hypr/looks.lua"
|
||||
readme="$pages_dir/README.md"
|
||||
|
||||
fail() {
|
||||
printf 'settings ownership contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
python3 - "$pages_dir" "$schema" "$search" <<'PY' \
|
||||
|| fail 'page ownership or intentional mirrors drifted'
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
pages_dir = Path(sys.argv[1])
|
||||
schema_text = Path(sys.argv[2]).read_text(encoding="utf-8")
|
||||
search_text = Path(sys.argv[3]).read_text(encoding="utf-8")
|
||||
|
||||
expected = {
|
||||
"animationsEnabled": {"owner": "appearance", "mirrors": {"accessibility"}},
|
||||
"cursorInactiveTimeout": {"owner": "mouse", "mirrors": {"accessibility"}},
|
||||
"cursorSize": {"owner": "accessibility", "mirrors": {"mouse"}},
|
||||
"inactiveOpacity": {"owner": "appearance", "mirrors": {"accessibility"}},
|
||||
"lockMinutes": {"owner": "power", "mirrors": {"privacy"}},
|
||||
"lockOnSleep": {"owner": "power", "mirrors": {"privacy"}},
|
||||
}
|
||||
|
||||
|
||||
def strip_comments(text: str) -> str:
|
||||
return re.sub(r"//.*", "", text)
|
||||
|
||||
|
||||
def page_name(path: Path) -> str:
|
||||
stem = path.stem.removesuffix("Page")
|
||||
return re.sub(r"(?<!^)(?=[A-Z])", "-", stem).lower()
|
||||
|
||||
|
||||
rows: dict[str, list[str]] = defaultdict(list)
|
||||
row_pattern = re.compile(
|
||||
r"(?:ToggleRow|SliderRow|ChoiceRow|TextEntryRow|TimeOfDayRow)\s*\{(?P<body>.*?)\}",
|
||||
re.S,
|
||||
)
|
||||
for page_path in pages_dir.glob("*Page.qml"):
|
||||
text = strip_comments(page_path.read_text(encoding="utf-8"))
|
||||
for match in row_pattern.finditer(text):
|
||||
setting = re.search(r'setting\s*:\s*"([^"]+)"', match.group("body"))
|
||||
if setting:
|
||||
rows[setting.group(1)].append(page_name(page_path))
|
||||
|
||||
duplicates = {key: set(pages) for key, pages in rows.items() if len(pages) > 1}
|
||||
if set(duplicates) != set(expected):
|
||||
raise SystemExit(
|
||||
f"duplicate keys are {sorted(duplicates)}, expected {sorted(expected)}"
|
||||
)
|
||||
|
||||
group_pages = dict(re.findall(r'"([^"]+)"\s*:\s*"([^"]+)"', search_text))
|
||||
for key, policy in expected.items():
|
||||
wanted_pages = {policy["owner"], *policy["mirrors"]}
|
||||
if duplicates[key] != wanted_pages:
|
||||
raise SystemExit(f"{key} appears on {sorted(duplicates[key])}, expected {sorted(wanted_pages)}")
|
||||
|
||||
block = re.search(
|
||||
r'\{\s*\n\s*key:\s*"' + re.escape(key) + r'"(?P<body>.*?)\n\s*\}',
|
||||
schema_text,
|
||||
re.S,
|
||||
)
|
||||
if not block:
|
||||
raise SystemExit(f"schema entry missing for {key}")
|
||||
group = re.search(r'group:\s*"([^"]+)"', block.group("body"))
|
||||
if not group:
|
||||
raise SystemExit(f"schema group missing for {key}")
|
||||
routed = group_pages.get(group.group(1))
|
||||
if routed != policy["owner"]:
|
||||
raise SystemExit(
|
||||
f"{key} routes to {routed!r}, expected primary owner {policy['owner']!r}"
|
||||
)
|
||||
PY
|
||||
|
||||
for needle in \
|
||||
'## Setting ownership' \
|
||||
'one primary page' \
|
||||
'Intentional mirrors' \
|
||||
'`animationsEnabled`' \
|
||||
'`cursorInactiveTimeout`' \
|
||||
'`cursorSize`' \
|
||||
'`inactiveOpacity`' \
|
||||
'`lockMinutes`' \
|
||||
'`lockOnSleep`' \
|
||||
'scheme-relative role'; do
|
||||
rg -Fq "$needle" "$readme" || fail "README is missing $needle"
|
||||
done
|
||||
|
||||
python3 - "$scheme" "$looks" <<'PY' \
|
||||
|| fail 'scheme-relative border ownership drifted'
|
||||
import re
|
||||
import sys
|
||||
|
||||
scheme = open(sys.argv[1], encoding="utf-8").read()
|
||||
looks = open(sys.argv[2], encoding="utf-8").read()
|
||||
|
||||
dark = re.search(r'property string inactiveBorderDark:\s*"([^"]+)"', scheme)
|
||||
light = re.search(r'property string inactiveBorderLight:\s*"([^"]+)"', scheme)
|
||||
effective = re.search(r'property string inactiveBorder:\s*root\.dark\s*\?\s*root\.inactiveBorderDark\s*:\s*root\.inactiveBorderLight', scheme)
|
||||
if not dark or not light or not effective:
|
||||
raise SystemExit("ColorScheme does not expose the two inactive-border roles")
|
||||
if dark.group(1) not in looks or light.group(1) not in looks:
|
||||
raise SystemExit("Hyprland startup values disagree with the live scheme roles")
|
||||
|
||||
without_comments = re.sub(r"//.*", "", scheme)
|
||||
if re.search(r"(?<![A-Za-z_])active_border\b", without_comments):
|
||||
raise SystemExit("ColorScheme writes the focused border")
|
||||
if 'inactive_border = "${root.inactiveBorder}"' not in scheme:
|
||||
raise SystemExit("ColorScheme does not apply its effective inactive role")
|
||||
PY
|
||||
|
||||
printf 'settings ownership contract: PASS\n'
|
||||
@@ -9,6 +9,7 @@ set -euo pipefail
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/ShortcutsPage.qml"
|
||||
input="$repo_dir/config/dot/hypr/input.lua"
|
||||
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||
|
||||
fail() {
|
||||
printf 'xkb presets contract: %s\n' "$1" >&2
|
||||
@@ -37,7 +38,9 @@ rg -Fq 'option.indexOf(prefix) !== 0' "$page" \
|
||||
|
||||
rg -Fq 'kb_variant = prefs.get("keyboardVariant", "")' "$input" \
|
||||
|| fail 'Hyprland does not replay the stored keyboard variant'
|
||||
rg -Fq 'kb_options = prefs.get("keyboardOptions", "")' "$input" \
|
||||
rg -Fq 'key: "keyboardOptions", type: "string", def: "caps:escape_shifted_capslock"' "$schema" \
|
||||
|| fail 'the shipped XKB default no longer matches the migrated GNOME behavior'
|
||||
rg -Fq 'kb_options = prefs.get("keyboardOptions", "caps:escape_shifted_capslock")' "$input" \
|
||||
|| fail 'Hyprland does not replay the stored keyboard options'
|
||||
|
||||
printf 'xkb presets contract: PASS\n'
|
||||
|
||||
Reference in New Issue
Block a user