Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1360a80f07 | ||
|
|
8be3fc2fdd | ||
|
|
2d69ce7648 | ||
|
|
d87f4b6d6a |
@@ -21,3 +21,4 @@ __pycache__/
|
||||
/config/dot/gtk-3.0/settings.ini
|
||||
/config/dot/gtk-4.0/settings.ini
|
||||
/config/dot/tmux/current-theme.conf
|
||||
/config/dot/hypr/hyprlock.conf
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# hyprlock — lock screen
|
||||
# hyprlock — lock screen.
|
||||
#
|
||||
# Tokyo Night Moon, matching quickshell/config/Theme.qml:
|
||||
# accent #82aaff fg #c8d3f5 dim #828bb8 bg #222436 red #ff757f
|
||||
# GENERATED FILE. Edit hyprlock.conf.template and re-run
|
||||
# quickshell/scripts/panama-theme-apps; editing this copy is overwritten on the
|
||||
# next colour scheme change.
|
||||
#
|
||||
# The colours here follow the desktop's light/dark setting. They used to be
|
||||
# hardcoded Tokyo Night Moon, which meant the one screen you see most often
|
||||
# stayed dark when everything else went light.
|
||||
#
|
||||
# hyprlock takes rgba(r, g, b, a) in DECIMAL rather than hex, which is why the
|
||||
# template carries "R, G, B" triples where the rest of Panama uses hex. The two
|
||||
# Pango markup values are the exception and want ##rrggbb.
|
||||
#
|
||||
# hyprlang syntax, not Lua — hyprlock is a separate project from Hyprland.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -45,7 +54,7 @@ background {
|
||||
vibrancy_darkness = 0.05
|
||||
|
||||
# Shown if the screenshot is unavailable.
|
||||
color = rgba(34, 36, 54, 1.0)
|
||||
color = rgba(@BG@, 1.0)
|
||||
|
||||
zindex = -1
|
||||
}
|
||||
@@ -54,7 +63,7 @@ background {
|
||||
label {
|
||||
monitor =
|
||||
text = cmd[update:1000] date +"%-I:%M"
|
||||
color = rgba(200, 211, 245, 1.0)
|
||||
color = rgba(@FG@, 1.0)
|
||||
font_size = 120
|
||||
font_family = Adwaita Sans Light
|
||||
position = 0, 260
|
||||
@@ -65,7 +74,7 @@ label {
|
||||
label {
|
||||
monitor =
|
||||
text = cmd[update:60000] date +"%A, %B %-d"
|
||||
color = rgba(130, 139, 184, 1.0)
|
||||
color = rgba(@MUTED@, 1.0)
|
||||
font_size = 24
|
||||
font_family = Adwaita Sans
|
||||
position = 0, 160
|
||||
@@ -84,18 +93,18 @@ input-field {
|
||||
outline_thickness = 2
|
||||
rounding = 26
|
||||
|
||||
outer_color = rgba(130, 170, 255, 0.9)
|
||||
inner_color = rgba(46, 47, 61, 0.85)
|
||||
font_color = rgba(200, 211, 245, 1.0)
|
||||
check_color = rgba(130, 170, 255, 1.0)
|
||||
fail_color = rgba(255, 117, 127, 1.0)
|
||||
outer_color = rgba(@ACCENT@, 0.9)
|
||||
inner_color = rgba(@FIELD@, 0.85)
|
||||
font_color = rgba(@FG@, 1.0)
|
||||
check_color = rgba(@ACCENT@, 1.0)
|
||||
fail_color = rgba(@ERROR@, 1.0)
|
||||
|
||||
dots_size = 0.25
|
||||
dots_spacing = 0.3
|
||||
dots_center = true
|
||||
|
||||
placeholder_text = <span foreground="##828bb8"><i>Password</i></span>
|
||||
fail_text = <span foreground="##ff757f"><i>$FAIL ($ATTEMPTS)</i></span>
|
||||
placeholder_text = <span foreground="##@MUTED_HEX@"><i>Password</i></span>
|
||||
fail_text = <span foreground="##@ERROR_HEX@"><i>$FAIL ($ATTEMPTS)</i></span>
|
||||
|
||||
fade_on_empty = false
|
||||
hide_input = false
|
||||
@@ -105,7 +114,7 @@ input-field {
|
||||
label {
|
||||
monitor =
|
||||
text = $USER
|
||||
color = rgba(200, 211, 245, 0.9)
|
||||
color = rgba(@FG@, 0.9)
|
||||
font_size = 16
|
||||
font_family = Adwaita Sans
|
||||
position = 0, -110
|
||||
@@ -195,9 +195,21 @@ bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = t
|
||||
bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
|
||||
|
||||
-- Window cycling (GNOME: cycle-windows on SUPER+Tab).
|
||||
bind(mod .. " + Tab", hl.dsp.window.cycle_next({ next = true }), { description = "Next window" })
|
||||
bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { description = "Previous window" })
|
||||
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
|
||||
-- showing what you are choosing between.
|
||||
--
|
||||
-- The gesture needs three binds, not two. Tab steps the selection, and the
|
||||
-- switch is only COMMITTED when the modifier is released -- which is the sole
|
||||
-- way the compositor can tell the gesture is finished. That release bind is on
|
||||
-- the bare modifier, so it fires on EVERY Super release in the session; the
|
||||
-- handler returns immediately when no switch is open, which is why this is
|
||||
-- affordable.
|
||||
--
|
||||
-- The release bind carries no description on purpose: it is not a shortcut
|
||||
-- anyone would look up or rebind, and the Shortcuts page lists what it finds.
|
||||
bind(mod .. " + Tab", hl.dsp.exec_cmd(qs("switcher", "next")), { description = "Next window" })
|
||||
bind(mod .. " + SHIFT + Tab", hl.dsp.exec_cmd(qs("switcher", "previous")), { description = "Previous window" })
|
||||
bind(mod, hl.dsp.exec_cmd(qs("switcher", "commit")), { release = true, description = "Commit window switch" })
|
||||
-- Jump back to the previously focused window.
|
||||
bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
|
||||
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,9 @@ Pill {
|
||||
horizontalPadding: 8
|
||||
onActivated: ShellState.toggle("activity")
|
||||
|
||||
// Right-click opens the settings that govern this widget. Camera, microphone and screen-sharing state is a privacy readout.
|
||||
onSecondaryActivated: ShellState.openSettings("privacy")
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: {
|
||||
|
||||
@@ -15,6 +15,9 @@ Pill {
|
||||
horizontalPadding: 8
|
||||
onActivated: ShellState.openDateMenu("agenda")
|
||||
|
||||
// Right-click opens the settings that govern this widget. The same place the clock leads, since this is the calendar's own reminder.
|
||||
onSecondaryActivated: ShellState.openSettings("datetime")
|
||||
|
||||
ToolTip.visible: root.hovered && root.visible
|
||||
ToolTip.delay: 500
|
||||
ToolTip.text: CalendarAgenda.nextEvent?.summary ?? "Upcoming event"
|
||||
|
||||
@@ -26,6 +26,11 @@ Pill {
|
||||
|
||||
onActivated: ShellState.toggleDateMenu("agenda")
|
||||
|
||||
// Right-click opens the settings that govern this widget. Timezone and clock format live on Date & Time. The
|
||||
// format toggles are mirrored on Appearance, but someone right-clicking a
|
||||
// clock is far more often after the time itself than its typography.
|
||||
onSecondaryActivated: ShellState.openSettings("datetime")
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Qt.formatDateTime(clock.date, root.format)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import QtQuick
|
||||
import Quickshell.Services.Mpris
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Pill {
|
||||
@@ -34,6 +35,9 @@ Pill {
|
||||
onActivated: if (root.player?.canTogglePlaying)
|
||||
root.player.togglePlaying()
|
||||
|
||||
// Right-click opens the settings that govern this widget. Output device and per-application volume.
|
||||
onSecondaryActivated: ShellState.openSettings("sound")
|
||||
|
||||
// Scroll up = previous, down = next — the same direction as the workspace
|
||||
// switcher, so the whole bar scrolls consistently.
|
||||
onScrolled: delta => {
|
||||
|
||||
@@ -24,6 +24,9 @@ Pill {
|
||||
|
||||
onActivated: root.requestQuickSettings()
|
||||
|
||||
// Right-click opens the settings that govern this widget. Network and Bluetooth, which is most of what these glyphs report.
|
||||
onSecondaryActivated: ShellState.openSettings("connectivity")
|
||||
|
||||
// ── Audio ───────────────────────────────────────────────────────────────
|
||||
// Without a tracker, volume and muted silently read as zero/false.
|
||||
PwObjectTracker {
|
||||
|
||||
@@ -13,6 +13,10 @@ import qs.widgets
|
||||
Pill {
|
||||
id: root
|
||||
|
||||
// Right-click opens the settings that govern this widget. Which readouts
|
||||
// appear in the bar, and how often they update.
|
||||
onSecondaryActivated: ShellState.openSettings("appearance")
|
||||
|
||||
interactive: false
|
||||
|
||||
Row {
|
||||
|
||||
@@ -10,6 +10,9 @@ import qs.widgets
|
||||
Pill {
|
||||
id: root
|
||||
|
||||
// Right-click opens the settings that govern this widget. Location, units and refresh interval are all on Home.
|
||||
onSecondaryActivated: ShellState.openSettings("home")
|
||||
|
||||
interactive: false
|
||||
visible: Weather.available
|
||||
|
||||
|
||||
@@ -300,7 +300,10 @@ SettingsPage {
|
||||
|
||||
ToggleRow { setting: "showCpu" }
|
||||
ToggleRow { setting: "showMemory" }
|
||||
ToggleRow { setting: "showGpu"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
|
||||
ToggleRow { setting: "showGpu"; divider: true }
|
||||
// Refresh interval was on the Home page, which split one concept across
|
||||
// two pages -- what the vitals show here, how often they update there.
|
||||
SliderRow { setting: "vitalsIntervalMs"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
|
||||
|
||||
// Only worth asking when there is a choice to make.
|
||||
ChoiceGrid {
|
||||
|
||||
@@ -117,12 +117,6 @@ SettingsPage {
|
||||
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "System vitals"
|
||||
subtitle: "Processor, memory, and graphics activity in the bar"
|
||||
SliderRow { setting: "vitalsIntervalMs"; divider: false }
|
||||
}
|
||||
|
||||
Grid {
|
||||
id: summaryCards
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// The Alt-Tab overlay.
|
||||
//
|
||||
// Deliberately a list of names rather than thumbnails: at a glance you are
|
||||
// looking for "the other terminal", and a row of small live previews is both
|
||||
// slower to read and considerably more expensive to draw than the gesture
|
||||
// deserves. The dock already renders app identity this way, so the two agree.
|
||||
//
|
||||
// Only present while a switch is in progress -- there is nothing to keep alive
|
||||
// between gestures, and a hidden always-loaded overlay is a surface that can
|
||||
// go wrong while nobody is looking at it.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Loader {
|
||||
id: root
|
||||
|
||||
// Plain `modelData`, not `required property var screen`. Variants supplies
|
||||
// modelData, and shell.qml's own comment warns about exactly this: declaring
|
||||
// `required property var screen` means the screen never resolves, the window
|
||||
// is constructed and silently never maps, and NOTHING is logged. Bar and
|
||||
// Dock both take the screen this way.
|
||||
property var modelData: null
|
||||
|
||||
active: WindowSwitcherState.open
|
||||
asynchronous: false
|
||||
|
||||
sourceComponent: PanelWindow {
|
||||
screen: root.modelData
|
||||
// Overlay so it sits above the focused window it is describing.
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.namespace: "qs-switcher"
|
||||
// Nothing here is clickable: the gesture is driven entirely from the
|
||||
// keyboard, and taking input would steal focus from the compositor
|
||||
// mid-switch, which is the one thing that would break it.
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
color: "transparent"
|
||||
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(560, parent.width - 96)
|
||||
implicitHeight: layout.implicitHeight + 24
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.bgPopover, 0.97)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.09)
|
||||
|
||||
Column {
|
||||
id: layout
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 12
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: WindowSwitcherState.windows
|
||||
|
||||
Rectangle {
|
||||
id: row
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool current: row.index === WindowSwitcherState.index
|
||||
readonly property string appId: row.modelData?.wayland?.appId ?? ""
|
||||
readonly property var entry: DesktopEntries.heuristicLookup(row.appId)
|
||||
|
||||
width: parent.width
|
||||
height: 44
|
||||
radius: 10
|
||||
border.width: 0
|
||||
color: row.current ? Theme.alpha(Theme.accent, 0.20) : "transparent"
|
||||
|
||||
Image {
|
||||
id: icon
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 24
|
||||
height: 24
|
||||
sourceSize.width: 24
|
||||
sourceSize.height: 24
|
||||
source: row.entry?.icon ? Quickshell.iconPath(row.entry.icon, true) : ""
|
||||
visible: source !== ""
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: icon.visible ? icon.right : parent.left
|
||||
anchors.leftMargin: icon.visible ? 12 : 14
|
||||
anchors.right: appName.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
// A window with no title yet is still a window you
|
||||
// can switch to; naming it after its application is
|
||||
// better than an empty row.
|
||||
text: row.modelData?.title || row.entry?.name || row.appId
|
||||
elide: Text.ElideRight
|
||||
color: row.current ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: row.current ? Font.DemiBold : Font.Normal
|
||||
}
|
||||
|
||||
Text {
|
||||
id: appName
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: (row.entry?.name ?? "") !== "" && row.entry.name !== row.modelData?.title
|
||||
text: row.entry?.name ?? ""
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,59 @@ case "$scheme" in
|
||||
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# ── hyprlock ─────────────────────────────────────────────────────────────────
|
||||
# The lock screen. hyprlock is launched fresh on every lock (`pidof hyprlock ||
|
||||
# hyprlock`), so it reads this file each time and needs no restart.
|
||||
#
|
||||
# It takes rgba(r, g, b, a) in DECIMAL, not hex, so the palette is expressed as
|
||||
# "R, G, B" triples here rather than the hex used everywhere else. The two
|
||||
# _HEX values are the exception: they sit inside Pango markup, where hyprlock
|
||||
# wants ##rrggbb.
|
||||
lock_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hypr"
|
||||
lock_template="$lock_dir/hyprlock.conf.template"
|
||||
|
||||
if [[ "$scheme" == "light" ]]; then
|
||||
lock_fg="55, 96, 191" # #3760bf
|
||||
lock_muted="97, 114, 176" # #6172b0
|
||||
lock_accent="46, 125, 233" # #2e7de9
|
||||
lock_error="245, 42, 101" # #f52a65
|
||||
lock_bg="225, 226, 231" # #e1e2e7
|
||||
lock_field="208, 213, 227" # #d0d5e3
|
||||
lock_muted_hex="6172b0"
|
||||
lock_error_hex="f52a65"
|
||||
else
|
||||
lock_fg="200, 211, 245" # #c8d3f5
|
||||
lock_muted="130, 139, 184" # #828bb8
|
||||
lock_accent="130, 170, 255" # #82aaff
|
||||
lock_error="255, 117, 127" # #ff757f
|
||||
lock_bg="34, 36, 54" # #222436
|
||||
lock_field="46, 47, 61" # #2e2f3d
|
||||
lock_muted_hex="828bb8"
|
||||
lock_error_hex="ff757f"
|
||||
fi
|
||||
|
||||
status_hyprlock="skipped"
|
||||
if [[ -r "$lock_template" ]]; then
|
||||
# Written atomically: a lock triggered mid-write would otherwise read a
|
||||
# truncated config and fall back to hyprlock's own defaults, which is a
|
||||
# bright grey screen with none of this desktop's identity.
|
||||
if sed -e "s/@FG@/$lock_fg/g" \
|
||||
-e "s/@MUTED@/$lock_muted/g" \
|
||||
-e "s/@ACCENT@/$lock_accent/g" \
|
||||
-e "s/@ERROR@/$lock_error/g" \
|
||||
-e "s/@BG@/$lock_bg/g" \
|
||||
-e "s/@FIELD@/$lock_field/g" \
|
||||
-e "s/@MUTED_HEX@/$lock_muted_hex/g" \
|
||||
-e "s/@ERROR_HEX@/$lock_error_hex/g" \
|
||||
"$lock_template" >"$lock_dir/hyprlock.conf.tmp" 2>/dev/null \
|
||||
&& mv "$lock_dir/hyprlock.conf.tmp" "$lock_dir/hyprlock.conf" 2>/dev/null; then
|
||||
status_hyprlock="written"
|
||||
else
|
||||
rm -f "$lock_dir/hyprlock.conf.tmp"
|
||||
status_hyprlock="failed"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── tmux ─────────────────────────────────────────────────────────────────────
|
||||
# Generated like kitty's: tmux.conf sources current-theme.conf, and that file is
|
||||
# machine state rather than configuration. Running servers are re-sourced so an
|
||||
@@ -146,4 +199,5 @@ jq -cn \
|
||||
--arg gtk "$status_gtk" \
|
||||
--arg btop "$status_btop" \
|
||||
--arg tmux "$status_tmux" \
|
||||
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux}'
|
||||
--arg hyprlock "$status_hyprlock" \
|
||||
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux, hyprlock: $hyprlock}'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,7 +47,7 @@ Singleton {
|
||||
"edges": "desktop",
|
||||
"master": "desktop",
|
||||
"notices": "desktop",
|
||||
"weather": "appearance",
|
||||
"weather": "home",
|
||||
"notifications": "notifications",
|
||||
"capture": "screen-intelligence"
|
||||
})
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
pragma Singleton
|
||||
|
||||
// Alt-Tab, with something on screen while you do it.
|
||||
//
|
||||
// Named WindowSwitcherState rather than WindowSwitcher: the overlay component
|
||||
// in modules/switcher already owns that name, and a singleton sharing it is
|
||||
// silently shadowed wherever both are imported -- the same failure that made an
|
||||
// earlier Locale singleton resolve to QML's built-in type instead.
|
||||
//
|
||||
// Super+Tab already cycled windows; nothing was drawn, so you were choosing
|
||||
// blind and could only confirm by arriving. This holds the selection while a
|
||||
// switch is in progress and lets the overlay render it.
|
||||
//
|
||||
// MOST-RECENTLY-USED ORDER
|
||||
//
|
||||
// The list is ordered by when each window last had focus, not by when it was
|
||||
// opened, because that is what makes the gesture useful: one Tab returns to the
|
||||
// window you just came from, which is the overwhelmingly common case. Creation
|
||||
// order would send you to whichever window happens to be first in Hyprland's
|
||||
// list, which is arbitrary from the user's point of view.
|
||||
//
|
||||
// Hyprland does not report an MRU order, so it is tracked here: every time a
|
||||
// toplevel becomes active it moves to the front. Addresses are used as the key
|
||||
// because they are stable for a window's lifetime, where titles and app ids are
|
||||
// not.
|
||||
//
|
||||
// HOW A SWITCH ENDS
|
||||
//
|
||||
// The compositor fires a bind on Super RELEASE, which commits. That is the only
|
||||
// way to know the gesture is over -- there is no "modifier released" signal
|
||||
// otherwise. It means close() runs on every Super release in the session, so it
|
||||
// must be cheap and a no-op when nothing is open.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property bool open: false
|
||||
property int index: 0
|
||||
|
||||
// Window addresses, most recently focused first.
|
||||
property var recent: []
|
||||
|
||||
// The switch candidates, resolved fresh each time the gesture starts.
|
||||
property var windows: []
|
||||
|
||||
readonly property var selected: (root.index >= 0 && root.index < root.windows.length)
|
||||
? root.windows[root.index] : null
|
||||
|
||||
// Ordered by the MRU list, with anything unseen appended in Hyprland's own
|
||||
// order so a brand new window is still reachable.
|
||||
function orderedWindows(): var {
|
||||
const all = (Hyprland.toplevels?.values ?? []).filter(t => t && t.wayland && t.wayland.appId);
|
||||
const byAddress = {};
|
||||
for (const toplevel of all)
|
||||
byAddress[String(toplevel.address)] = toplevel;
|
||||
|
||||
const ordered = [];
|
||||
for (const address of root.recent) {
|
||||
const match = byAddress[address];
|
||||
if (match) {
|
||||
ordered.push(match);
|
||||
delete byAddress[address];
|
||||
}
|
||||
}
|
||||
for (const toplevel of all)
|
||||
if (byAddress[String(toplevel.address)])
|
||||
ordered.push(toplevel);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
// Starts the gesture if it is not already running, then steps. The first
|
||||
// Tab lands on the PREVIOUS window rather than the current one, which is
|
||||
// what every other implementation of this gesture does.
|
||||
function step(forward: bool): void {
|
||||
if (!root.open) {
|
||||
root.windows = root.orderedWindows();
|
||||
if (root.windows.length < 2)
|
||||
return;
|
||||
root.open = true;
|
||||
root.index = forward ? 1 : root.windows.length - 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.windows.length === 0)
|
||||
return;
|
||||
const count = root.windows.length;
|
||||
root.index = forward
|
||||
? (root.index + 1) % count
|
||||
: (root.index - 1 + count) % count;
|
||||
}
|
||||
|
||||
// Runs on every Super release in the session, so it does as little as
|
||||
// possible when no switch is in progress.
|
||||
function commit(): void {
|
||||
if (!root.open)
|
||||
return;
|
||||
const target = root.selected;
|
||||
root.open = false;
|
||||
root.windows = [];
|
||||
root.index = 0;
|
||||
if (target && target.wayland)
|
||||
target.wayland.activate();
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
root.open = false;
|
||||
root.windows = [];
|
||||
root.index = 0;
|
||||
}
|
||||
|
||||
// Focus changes maintain the MRU order. This runs whether or not a switch
|
||||
// is in progress, because ordinary clicking between windows is most of how
|
||||
// the order is established.
|
||||
Connections {
|
||||
target: Hyprland
|
||||
function onActiveToplevelChanged(): void {
|
||||
const active = Hyprland.activeToplevel;
|
||||
if (!active || !active.address)
|
||||
return;
|
||||
const address = String(active.address);
|
||||
const next = [address];
|
||||
for (const existing of root.recent)
|
||||
if (existing !== address)
|
||||
next.push(existing);
|
||||
// Bounded: a session can accumulate a lot of closed addresses, and
|
||||
// this list is only ever used to order what is currently open.
|
||||
root.recent = next.slice(0, 64);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import qs.config
|
||||
import qs.services
|
||||
import qs.modules.bar
|
||||
import qs.modules.dock
|
||||
import qs.modules.switcher
|
||||
import qs.modules.overview
|
||||
import qs.modules.quicksettings
|
||||
import qs.modules.notifications
|
||||
@@ -68,6 +69,13 @@ ShellRoot {
|
||||
Dock {}
|
||||
}
|
||||
|
||||
// The Alt-Tab overlay. Present only while a switch is in progress; the
|
||||
// Loader inside it keeps the window unbuilt the rest of the time.
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
WindowSwitcher {}
|
||||
}
|
||||
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
SignalGlass {}
|
||||
@@ -103,6 +111,18 @@ ShellRoot {
|
||||
// Every parameter AND the return type must be annotated, or Quickshell
|
||||
// silently declines to register the function — it will not warn you.
|
||||
|
||||
// Driven entirely from keybinds: Super+Tab steps, and a bind on Super
|
||||
// RELEASE commits. `commit` therefore runs on every Super release in the
|
||||
// session, so it returns immediately when no switch is open.
|
||||
IpcHandler {
|
||||
target: "switcher"
|
||||
|
||||
function next(): void { WindowSwitcherState.step(true); }
|
||||
function previous(): void { WindowSwitcherState.step(false); }
|
||||
function commit(): void { WindowSwitcherState.commit(); }
|
||||
function cancel(): void { WindowSwitcherState.cancel(); }
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "overview"
|
||||
function toggle(): void { ShellState.toggle("overview"); }
|
||||
|
||||
@@ -89,6 +89,38 @@ elif [ -d "$PANAMA_DOT/tmux/themes" ]; then
|
||||
log "Seeded tmux $tmux_scheme theme ($tmux_name) → $TMUX_THEME"
|
||||
fi
|
||||
|
||||
# hyprlock.conf is generated from a template on every colour scheme change and
|
||||
# is not committed. Seed it so the FIRST lock of a fresh install is themed --
|
||||
# without it hyprlock falls back to its own defaults, which is a bare grey
|
||||
# screen with none of this desktop's identity, and the first time anyone would
|
||||
# find out is when they walked away from the machine.
|
||||
HYPRLOCK_TEMPLATE="$PANAMA_DOT/hypr/hyprlock.conf.template"
|
||||
HYPRLOCK_CONF="$PANAMA_DOT/hypr/hyprlock.conf"
|
||||
if [ -e "$HYPRLOCK_CONF" ]; then
|
||||
log "Keeping existing hyprlock config at $HYPRLOCK_CONF"
|
||||
elif [ -r "$HYPRLOCK_TEMPLATE" ]; then
|
||||
lock_scheme="dark"
|
||||
lock_prefs="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
|
||||
if [ -r "$lock_prefs" ]; then
|
||||
lock_stored="$(jq -r '.colorScheme // "dark"' "$lock_prefs" 2>/dev/null || echo dark)"
|
||||
[ "$lock_stored" = "light" ] && lock_scheme="light"
|
||||
fi
|
||||
if [ "$lock_scheme" = "light" ]; then
|
||||
sed -e "s/@FG@/55, 96, 191/g" -e "s/@MUTED@/97, 114, 176/g" \
|
||||
-e "s/@ACCENT@/46, 125, 233/g" -e "s/@ERROR@/245, 42, 101/g" \
|
||||
-e "s/@BG@/225, 226, 231/g" -e "s/@FIELD@/208, 213, 227/g" \
|
||||
-e "s/@MUTED_HEX@/6172b0/g" -e "s/@ERROR_HEX@/f52a65/g" \
|
||||
"$HYPRLOCK_TEMPLATE" > "$HYPRLOCK_CONF"
|
||||
else
|
||||
sed -e "s/@FG@/200, 211, 245/g" -e "s/@MUTED@/130, 139, 184/g" \
|
||||
-e "s/@ACCENT@/130, 170, 255/g" -e "s/@ERROR@/255, 117, 127/g" \
|
||||
-e "s/@BG@/34, 36, 54/g" -e "s/@FIELD@/46, 47, 61/g" \
|
||||
-e "s/@MUTED_HEX@/828bb8/g" -e "s/@ERROR_HEX@/ff757f/g" \
|
||||
"$HYPRLOCK_TEMPLATE" > "$HYPRLOCK_CONF"
|
||||
fi
|
||||
log "Seeded hyprlock $lock_scheme theme → $HYPRLOCK_CONF"
|
||||
fi
|
||||
|
||||
# btop reads themes from its own config directory, but OWNS btop.conf -- it
|
||||
# rewrites that file on exit -- so only the theme files are exposed, per file,
|
||||
# and the config itself is left to btop. panama-theme-apps edits the single
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The lock screen follows the colour scheme.
|
||||
#
|
||||
# It did not. hyprlock.conf shipped with Tokyo Night Moon hardcoded in six
|
||||
# places, so choosing light mode left the one screen a user sees most often
|
||||
# stubbornly dark. Every other surface -- kitty, GTK, the launcher, btop, tmux,
|
||||
# neovim -- had been taught to follow the scheme; this was the last one.
|
||||
#
|
||||
# It is also the worst place to discover a theming bug, because you find out
|
||||
# while locked out of the machine and cannot fix it from there. Hence a test.
|
||||
#
|
||||
# Generated into a fixture, never the live config: this contract must not
|
||||
# retheme the lock screen of the desktop it is running on.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
template="$repo_dir/config/dot/hypr/hyprlock.conf.template"
|
||||
theme_apps="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
|
||||
|
||||
fail() {
|
||||
printf 'lock screen theme contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -r "$template" ]] || fail 'hyprlock.conf.template is missing, so nothing generates the lock screen'
|
||||
|
||||
# The committed template must carry no literal colours. One left behind is a
|
||||
# colour that silently stays dark in light mode -- exactly the original bug.
|
||||
literal="$(grep -vE '^\s*#' "$template" | grep -oE 'rgba\([0-9]+, *[0-9]+, *[0-9]+' || true)"
|
||||
[[ -z "$literal" ]] \
|
||||
|| fail "the template still contains hardcoded colours, which will not follow the scheme: $literal"
|
||||
|
||||
fixture="$(mktemp -d /tmp/panama-lockscreen.XXXXXX)"
|
||||
trap 'rm -rf "$fixture"' EXIT
|
||||
mkdir -p "$fixture/hypr"
|
||||
cp "$template" "$fixture/hypr/"
|
||||
|
||||
for scheme in dark light; do
|
||||
XDG_CONFIG_HOME="$fixture" "$theme_apps" "$scheme" >/dev/null 2>&1
|
||||
generated="$fixture/hypr/hyprlock.conf"
|
||||
|
||||
[[ -r "$generated" ]] || fail "no hyprlock.conf was generated for $scheme"
|
||||
|
||||
# An unsubstituted placeholder is not a parse error to hyprlock; it is an
|
||||
# invalid colour it quietly ignores, falling back to its own default.
|
||||
leftover="$(grep -oE '@[A-Z_]+@' "$generated" || true)"
|
||||
[[ -z "$leftover" ]] \
|
||||
|| fail "$scheme left placeholders unsubstituted: $leftover"
|
||||
|
||||
# Every colour hyprlock is given must be a complete decimal triple. It
|
||||
# takes rgba(r, g, b, a), NOT hex, and a hex value here is silently ignored.
|
||||
while read -r colour; do
|
||||
[[ -n "$colour" ]] || continue
|
||||
grep -qE '^rgba\([0-9]{1,3}, [0-9]{1,3}, [0-9]{1,3}$' <<<"$colour" \
|
||||
|| fail "$scheme produced a malformed colour: $colour"
|
||||
done < <(grep -vE '^\s*#' "$generated" | grep -oE 'rgba\([^)]*' | sed 's/,[^,]*$//')
|
||||
|
||||
# The Pango markup values are hex, and hyprlock wants them doubled-hashed.
|
||||
while read -r pango; do
|
||||
[[ -n "$pango" ]] || continue
|
||||
grep -qE '^##[0-9a-f]{6}$' <<<"$pango" \
|
||||
|| fail "$scheme produced malformed Pango markup colour: $pango"
|
||||
done < <(grep -vE '^\s*#' "$generated" | grep -oE '##[0-9a-fA-F]{6}')
|
||||
|
||||
# The whole background block, not a fixed number of lines after it: the
|
||||
# colour sits near the end, after the blur and noise settings.
|
||||
background="$(awk '/^background \{/,/^\}/' "$generated" \
|
||||
| grep -vE '^\s*#' | grep -oE 'color = rgba\([0-9]+' | grep -oE '[0-9]+$' | head -1)"
|
||||
[[ -n "$background" ]] || fail "$scheme produced no background colour"
|
||||
|
||||
# The point of the whole exercise: a light lock screen must actually be
|
||||
# light. 128 splits the two cleanly for these palettes.
|
||||
if [[ "$scheme" == "light" ]]; then
|
||||
(( background > 128 )) \
|
||||
|| fail "light mode produced a DARK lock screen background (red channel $background) -- the original bug"
|
||||
else
|
||||
(( background < 128 )) \
|
||||
|| fail "dark mode produced a LIGHT lock screen background (red channel $background)"
|
||||
fi
|
||||
done
|
||||
|
||||
# The two schemes must actually differ, or the substitution is a no-op that
|
||||
# passes every check above.
|
||||
XDG_CONFIG_HOME="$fixture" "$theme_apps" dark >/dev/null 2>&1
|
||||
dark_hash="$(sha256sum "$fixture/hypr/hyprlock.conf" | cut -d' ' -f1)"
|
||||
XDG_CONFIG_HOME="$fixture" "$theme_apps" light >/dev/null 2>&1
|
||||
light_hash="$(sha256sum "$fixture/hypr/hyprlock.conf" | cut -d' ' -f1)"
|
||||
[[ "$dark_hash" != "$light_hash" ]] \
|
||||
|| fail 'the light and dark lock screens are byte-identical, so the scheme is not being applied'
|
||||
|
||||
printf 'lock screen theme contract: PASS\n'
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Anything instantiated per screen must take its screen from `modelData`.
|
||||
#
|
||||
# Variants supplies each delegate a `modelData` holding the screen. A component
|
||||
# that instead declares `required property var screen` is constructed, never
|
||||
# receives a screen, and its window silently never maps -- with nothing logged,
|
||||
# no error, and no visible failure beyond the surface simply not being there.
|
||||
#
|
||||
# shell.qml has warned about this in a comment since the Bar hit it. The comment
|
||||
# did not stop the window switcher hitting it again, which is the argument for a
|
||||
# test: the failure is invisible, so review does not catch it either.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
shell_file="$repo_dir/config/dot/quickshell/shell.qml"
|
||||
modules="$repo_dir/config/dot/quickshell/modules"
|
||||
|
||||
fail() {
|
||||
printf 'per-screen surface contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -r "$shell_file" ]] || fail "cannot read shell.qml"
|
||||
|
||||
# The component named inside each `Variants { model: Quickshell.screens ... }`.
|
||||
delegates="$(awk '
|
||||
/Variants \{/ { inside = 1; next }
|
||||
inside && /model: Quickshell.screens/ { armed = 1; next }
|
||||
armed && /^[[:space:]]*[A-Z][A-Za-z]* *\{/ {
|
||||
match($0, /[A-Z][A-Za-z]*/)
|
||||
print substr($0, RSTART, RLENGTH)
|
||||
armed = 0; inside = 0
|
||||
}
|
||||
' "$shell_file" | sort -u)"
|
||||
|
||||
[[ -n "$delegates" ]] || fail 'found no per-screen delegates -- this contract is not reading shell.qml correctly'
|
||||
|
||||
checked=0
|
||||
while read -r name; do
|
||||
[[ -n "$name" ]] || continue
|
||||
|
||||
file="$(find "$modules" -name "$name.qml" -print -quit 2>/dev/null)"
|
||||
[[ -n "$file" ]] || fail "shell.qml instantiates $name per screen, but $name.qml was not found"
|
||||
|
||||
if grep -qE '^\s*required property var screen\b' "$file"; then
|
||||
fail "$name declares 'required property var screen', but Variants supplies modelData -- the window is built and never maps, silently. Use 'property var modelData' and bind screen to it, as Bar and Dock do."
|
||||
fi
|
||||
|
||||
grep -qE '^\s*property var modelData' "$file" \
|
||||
|| fail "$name is instantiated per screen but never declares 'property var modelData', so it cannot know which screen it is on"
|
||||
|
||||
grep -qE 'screen: (root\.)?modelData' "$file" \
|
||||
|| fail "$name declares modelData but never binds a screen to it"
|
||||
|
||||
checked=$((checked + 1))
|
||||
done <<<"$delegates"
|
||||
|
||||
printf 'per-screen surface contract: PASS (%d per-screen surfaces)\n' "$checked"
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Every "open the settings for this" jump must land somewhere real.
|
||||
#
|
||||
# ShellState.openSettings() validates its argument against an allow-list and
|
||||
# falls back to Home for anything unknown. That fallback is sensible and it is
|
||||
# also completely silent: a typo, or a page renamed later, turns a right-click
|
||||
# into "opens Settings on the wrong page" with nothing logged and no error.
|
||||
#
|
||||
# Before this, exactly four places in the entire shell could reach Settings, so
|
||||
# the risk was small. The bar now offers a jump on every widget, which makes the
|
||||
# fallback worth guarding.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
shell_state="$repo_dir/config/dot/quickshell/services/ShellState.qml"
|
||||
modules="$repo_dir/config/dot/quickshell/modules"
|
||||
|
||||
fail() {
|
||||
printf 'settings jump contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
allowed_line="$(grep -m1 'const allowed = \[' "$shell_state")" \
|
||||
|| fail 'could not find the allow-list in ShellState'
|
||||
|
||||
jumps="$(grep -rhoE 'openSettings\("[a-z-]+"\)' "$modules" 2>/dev/null \
|
||||
| sed 's/openSettings("//; s/")//' | sort -u)"
|
||||
[[ -n "$jumps" ]] || fail 'found no settings jumps at all -- this contract is not reading the modules correctly'
|
||||
|
||||
count=0
|
||||
while read -r page; do
|
||||
[[ -n "$page" ]] || continue
|
||||
grep -qF "\"$page\"" <<<"$allowed_line" \
|
||||
|| fail "a jump opens \"$page\", which ShellState does not allow -- openSettings falls back to Home silently, so this reads as a right-click that goes to the wrong page"
|
||||
count=$((count + 1))
|
||||
done <<<"$jumps"
|
||||
|
||||
# The bar is where a person looks first, and Pill has offered a right-click
|
||||
# signal all along that nothing connected -- so the gesture did nothing on every
|
||||
# widget in the bar. Anything built on Pill that can be configured should say so.
|
||||
for widget in Clock WeatherWidget VitalsWidget StatusCluster MediaWidget; do
|
||||
file="$modules/bar/$widget.qml"
|
||||
[[ -r "$file" ]] || continue
|
||||
grep -q 'onSecondaryActivated' "$file" \
|
||||
|| fail "$widget has no right-click jump; Pill routes right-click to secondaryActivated, so leaving it unconnected makes the gesture silently inert"
|
||||
done
|
||||
|
||||
printf 'settings jump contract: PASS (%d distinct destinations)\n' "$count"
|
||||
+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'
|
||||
@@ -50,7 +50,12 @@ PY
|
||||
home_page="$repo_dir/config/dot/quickshell/modules/settings/HomePage.qml"
|
||||
require_row "$home_page" ChoiceRow temperatureUnit
|
||||
require_row "$home_page" SliderRow weatherRefreshMinutes
|
||||
require_row "$home_page" SliderRow vitalsIntervalMs
|
||||
# vitalsIntervalMs moved to Appearance, beside the toggles it governs. It sat
|
||||
# on Home while showCpu/showMemory/showGpu sat on Appearance -- one concept
|
||||
# across two pages, which the ownership rule forbids and which made a search
|
||||
# for it open a page that did not contain it.
|
||||
appearance_page="$repo_dir/config/dot/quickshell/modules/settings/AppearancePage.qml"
|
||||
require_row "$appearance_page" SliderRow vitalsIntervalMs
|
||||
|
||||
notifications_page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml"
|
||||
for setting in notificationTimeoutMs notificationTimeoutCriticalMs notificationHistoryLimit maxVisibleToasts; do
|
||||
|
||||
Reference in New Issue
Block a user