diff --git a/.gitignore b/.gitignore
index daf51bf..56620dd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,6 +26,11 @@ __pycache__/
/config/dot/gtk-4.0/settings.ini
/config/dot/tmux/current-theme.conf
/config/dot/hypr/hyprlock.conf
+# The Firefox chrome override sheet, rendered from the active theme. It sits
+# beside custom.css because chrome/ is symlinked into the profile as one
+# directory; custom.css imports it and stays the file a person edits.
+/config/firefox/chrome/panama-theme.css
+/config/firefox/chrome/panama-theme.css.tmp
# Build products of the Vicinae extension. The source is the repository's; the
# dependency tree and the bundle it produces are machine state, rebuilt by
diff --git a/README.md b/README.md
index 15154df..849deb0 100644
--- a/README.md
+++ b/README.md
@@ -136,7 +136,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests
-162 of them, under `tests/`. Run the lot, or a subset by pattern:
+165 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh
panama test # everything
diff --git a/config/dot/gtk-3.0/gtk.css b/config/dot/gtk-3.0/gtk.css
index 2ca5ff9..6c431ec 100644
--- a/config/dot/gtk-3.0/gtk.css
+++ b/config/dot/gtk-3.0/gtk.css
@@ -1,36 +1,63 @@
-@define-color accent_color rgba(192,153,255,0.8);
-@define-color accent_bg_color rgba(130,170,255,0.8);
-@define-color accent_fg_color #ffffff;
-@define-color destructive_color #ff7b63;
-@define-color destructive_bg_color #c01c28;
-@define-color destructive_fg_color #ffffff;
-@define-color success_color #8ff0a4;
-@define-color success_bg_color #26a269;
-@define-color success_fg_color #ffffff;
-@define-color warning_color #f8e45c;
-@define-color warning_bg_color #cd9309;
-@define-color warning_fg_color rgba(0, 0, 0, 0.8);
-@define-color error_color #ff7b63;
-@define-color error_bg_color #c01c28;
-@define-color error_fg_color #ffffff;
-@define-color window_bg_color rgba(46,47,61,0.98);
-@define-color window_fg_color #ffffff;
-@define-color view_bg_color rgba(46,47,61,0.98);
-@define-color view_fg_color #ffffff;
-@define-color headerbar_bg_color rgba(46,47,61,0.98);
-@define-color headerbar_fg_color #ffffff;
-@define-color headerbar_border_color #ffffff;
-@define-color headerbar_backdrop_color @window_bg_color;
-@define-color headerbar_shade_color rgba(46,47,61,0.05);
-@define-color card_bg_color rgba(180, 200, 255, 0.15);
-@define-color card_fg_color #ffffff;
-@define-color card_shade_color rgba(0, 0, 0, 0.36);
-@define-color dialog_bg_color rgba(46,47,61,0.98);
-@define-color dialog_fg_color #ffffff;
-@define-color popover_bg_color rgba(46,47,61,0.98);
-@define-color popover_fg_color #ffffff;
-@define-color shade_color rgba(0, 0, 0, 0);
-@define-color scrollbar_outline_color rgba(0, 0, 0, 0.5);
+/* Panama regenerates the block below from the active theme's palette, and only
+ * the block: every rule outside the markers belongs to the vendored theme and
+ * is never touched. Edit the theme, not this file -- the next theme change
+ * overwrites whatever is between the markers. The values shipped here are Tokyo
+ * Moon, the default dark theme, so the file is valid before it has ever been
+ * rewritten. See quickshell/scripts/panama-theme-apps. */
+/* PANAMA THEME BEGIN */
+/* Generated from the "moon" theme by panama-theme-apps. Edit the
+ * theme, not this block -- the next theme change overwrites it. */
+@define-color accent_bg_color #82aaff;
+@define-color accent_color #65bcff;
+@define-color accent_fg_color #1e2030;
+@define-color destructive_bg_color #c53b53;
+@define-color destructive_color #ff757f;
+@define-color destructive_fg_color #c8d3f5;
+@define-color success_bg_color #c3e88d;
+@define-color success_color #c3e88d;
+@define-color success_fg_color #1e2030;
+@define-color warning_bg_color #ffc777;
+@define-color warning_color #ffc777;
+@define-color warning_fg_color #1e2030;
+@define-color error_bg_color #c53b53;
+@define-color error_color #ff757f;
+@define-color error_fg_color #c8d3f5;
+@define-color window_bg_color #2e2f3d;
+@define-color window_fg_color #c8d3f5;
+@define-color view_bg_color #222436;
+@define-color view_fg_color #c8d3f5;
+@define-color headerbar_bg_color #2e2f3d;
+@define-color headerbar_fg_color #c8d3f5;
+@define-color headerbar_border_color #3b4261;
+@define-color headerbar_backdrop_color #1e2030;
+@define-color headerbar_shade_color rgba(30, 32, 48, 0.08);
+@define-color headerbar_darker_shade_color rgba(30, 32, 48, 0.9);
+@define-color sidebar_bg_color #1e2030;
+@define-color sidebar_fg_color #c8d3f5;
+@define-color sidebar_backdrop_color #1e2030;
+@define-color sidebar_shade_color rgba(59, 66, 97, 0.08);
+@define-color sidebar_border_color rgba(59, 66, 97, 0.36);
+@define-color secondary_sidebar_bg_color #1e2030;
+@define-color secondary_sidebar_fg_color #c8d3f5;
+@define-color secondary_sidebar_backdrop_color #1e2030;
+@define-color secondary_sidebar_shade_color rgba(30, 32, 48, 0.25);
+@define-color secondary_sidebar_border_color rgba(59, 66, 97, 0.36);
+@define-color card_bg_color #2f334d;
+@define-color card_fg_color #c8d3f5;
+@define-color card_shade_color rgba(30, 32, 48, 0.25);
+@define-color dialog_bg_color #21212f;
+@define-color dialog_fg_color #c8d3f5;
+@define-color popover_bg_color #21212f;
+@define-color popover_fg_color #c8d3f5;
+@define-color popover_shade_color rgba(30, 32, 48, 0.25);
+@define-color thumbnail_bg_color #2f334d;
+@define-color thumbnail_fg_color #c8d3f5;
+@define-color shade_color rgba(30, 32, 48, 0.25);
+@define-color scrollbar_outline_color rgba(30, 32, 48, 0.5);
+/* PANAMA THEME END */
+
+/* The GNOME named palette below is reference, not theme: applications ask for
+ * @red_3 by name and expect GNOME's red. It stays outside the block. */
@define-color blue_1 #99c1f1;
@define-color blue_2 #62a0ea;
@define-color blue_3 #3584e4;
diff --git a/config/dot/gtk-4.0/gtk-dark.css b/config/dot/gtk-4.0/gtk-dark.css
old mode 100755
new mode 100644
index e534575..d558181
--- a/config/dot/gtk-4.0/gtk-dark.css
+++ b/config/dot/gtk-4.0/gtk-dark.css
@@ -1,53 +1,64 @@
/* GTK NAMED COLORS ---------------- use responsibly! */
-@define-color accent_bg_color RGB(130 170 255 / 80%);
-
-@define-color destructive_bg_color #c01c28;
-@define-color destructive_fg_color white;
-@define-color success_bg_color #26a269;
-@define-color success_fg_color white;
-@define-color warning_bg_color #cd9309;
-@define-color warning_fg_color RGB(0 0 0 / 80%);
-@define-color error_bg_color #c01c28;
-@define-color error_fg_color white;
-@define-color accent_color oklab(from @accent_bg_color max(l, 0.85) a b);
-@define-color destructive_color oklab(from @destructive_bg_color max(l, 0.85) a b);
-@define-color success_color oklab(from @success_bg_color max(l, 0.85) a b);
-@define-color warning_color oklab(from @warning_bg_color max(l, 0.85) a b);
-@define-color error_color oklab(from @error_bg_color max(l, 0.85) a b);
-
+/* Panama regenerates the block below from the active theme's palette, and only
+ * the block: every rule outside the markers belongs to the vendored theme and
+ * is never touched. Edit the theme, not this file -- the next theme change
+ * overwrites whatever is between the markers. The values shipped here are Tokyo
+ * Moon, the default dark theme, so the file is valid before it has ever been
+ * rewritten. See quickshell/scripts/panama-theme-apps. */
+/* PANAMA THEME BEGIN */
+/* Generated from the "moon" theme by panama-theme-apps. Edit the
+ * theme, not this block -- the next theme change overwrites it. */
+@define-color accent_bg_color #82aaff;
+@define-color accent_color #65bcff;
+@define-color accent_fg_color #1e2030;
+@define-color destructive_bg_color #c53b53;
+@define-color destructive_color #ff757f;
+@define-color destructive_fg_color #c8d3f5;
+@define-color success_bg_color #c3e88d;
+@define-color success_color #c3e88d;
+@define-color success_fg_color #1e2030;
+@define-color warning_bg_color #ffc777;
+@define-color warning_color #ffc777;
+@define-color warning_fg_color #1e2030;
+@define-color error_bg_color #c53b53;
+@define-color error_color #ff757f;
+@define-color error_fg_color #c8d3f5;
@define-color window_bg_color #2e2f3d;
-@define-color window_fg_color white;
-@define-color view_bg_color @window_bg_color
-@define-color view_fg_color white;
-@define-color headerbar_bg_color @window_bg_color
-@define-color headerbar_fg_color white;
-@define-color headerbar_border_color white;
-@define-color headerbar_backdrop_color @window_bg_color;
-@define-color headerbar_shade_color RGB(46 47 61 / 8%);
-@define-color headerbar_darker_shade_color RGB(0, 0, 6, 0.9);
+@define-color window_fg_color #c8d3f5;
+@define-color view_bg_color #222436;
+@define-color view_fg_color #c8d3f5;
+@define-color headerbar_bg_color #2e2f3d;
+@define-color headerbar_fg_color #c8d3f5;
+@define-color headerbar_border_color #3b4261;
+@define-color headerbar_backdrop_color #1e2030;
+@define-color headerbar_shade_color rgba(30, 32, 48, 0.08);
+@define-color headerbar_darker_shade_color rgba(30, 32, 48, 0.9);
+@define-color sidebar_bg_color #1e2030;
+@define-color sidebar_fg_color #c8d3f5;
+@define-color sidebar_backdrop_color #1e2030;
+@define-color sidebar_shade_color rgba(59, 66, 97, 0.08);
+@define-color sidebar_border_color rgba(59, 66, 97, 0.36);
+@define-color secondary_sidebar_bg_color #1e2030;
+@define-color secondary_sidebar_fg_color #c8d3f5;
+@define-color secondary_sidebar_backdrop_color #1e2030;
+@define-color secondary_sidebar_shade_color rgba(30, 32, 48, 0.25);
+@define-color secondary_sidebar_border_color rgba(59, 66, 97, 0.36);
+@define-color card_bg_color #2f334d;
+@define-color card_fg_color #c8d3f5;
+@define-color card_shade_color rgba(30, 32, 48, 0.25);
+@define-color dialog_bg_color #21212f;
+@define-color dialog_fg_color #c8d3f5;
+@define-color popover_bg_color #21212f;
+@define-color popover_fg_color #c8d3f5;
+@define-color popover_shade_color rgba(30, 32, 48, 0.25);
+@define-color thumbnail_bg_color #2f334d;
+@define-color thumbnail_fg_color #c8d3f5;
+@define-color shade_color rgba(30, 32, 48, 0.25);
+@define-color scrollbar_outline_color rgba(30, 32, 48, 0.5);
+/* PANAMA THEME END */
-@define-color sidebar_bg_color #3c3c4b;
-@define-color sidebar_fg_color white;
-@define-color sidebar_backdrop_color #3c3c4b;
-@define-color sidebar_shade_color RGB(60 60 75/8%);
-@define-color sidebar_border_color RGB(75 75 90/36%);
-@define-color secondary_sidebar_bg_color #3c3c4b;
-@define-color secondary_sidebar_fg_color white;
-@define-color secondary_sidebar_backdrop_color #3c3c4b;
-@define-color secondary_sidebar_shade_color RGB(0 0 6/25%);
-@define-color secondary_sidebar_border_color RGB(0 0 6/36%);
-@define-color card_bg_color RGB(180 200 255/15%);
-@define-color card_fg_color white;
-@define-color card_shade_color RGB(46 47 61/8%);
-@define-color dialog_bg_color @window_bg_color;
-@define-color dialog_fg_color white;
-@define-color popover_bg_color @window_bg_color;
-@define-color popover_fg_color white;
-@define-color popover_shade_color RGB(0 0 6/25%);
-@define-color thumbnail_bg_color @window_bg_color;
-@define-color thumbnail_fg_color white;
-@define-color shade_color RGB(0 0 6/25%);
-@define-color scrollbar_outline_color RGB(0 0 12/95%);
+/* The GNOME named palette below is reference, not theme: applications ask for
+ * @red_3 by name and expect GNOME's red. It stays outside the block. */
@define-color blue_1 #99c1f1;
@define-color blue_2 #62a0ea;
@define-color blue_3 #3584e4;
diff --git a/config/dot/gtk-4.0/gtk.css b/config/dot/gtk-4.0/gtk.css
old mode 100755
new mode 100644
index 339135a..9df11ad
--- a/config/dot/gtk-4.0/gtk.css
+++ b/config/dot/gtk-4.0/gtk.css
@@ -1,53 +1,64 @@
/* GTK NAMED COLORS ---------------- use responsibly! */
-@define-color accent_bg_color RGB(130 170 255 / 80%);
-
-@define-color destructive_bg_color #c01c28;
-@define-color destructive_fg_color white;
-@define-color success_bg_color #26a269;
-@define-color success_fg_color white;
-@define-color warning_bg_color #cd9309;
-@define-color warning_fg_color RGB(0 0 0 / 80%);
-@define-color error_bg_color #c01c28;
-@define-color error_fg_color white;
-@define-color accent_color oklab(from @accent_bg_color max(l, 0.85) a b);
-@define-color destructive_color oklab(from @destructive_bg_color max(l, 0.85) a b);
-@define-color success_color oklab(from @success_bg_color max(l, 0.85) a b);
-@define-color warning_color oklab(from @warning_bg_color max(l, 0.85) a b);
-@define-color error_color oklab(from @error_bg_color max(l, 0.85) a b);
-
+/* Panama regenerates the block below from the active theme's palette, and only
+ * the block: every rule outside the markers belongs to the vendored theme and
+ * is never touched. Edit the theme, not this file -- the next theme change
+ * overwrites whatever is between the markers. The values shipped here are Tokyo
+ * Moon, the default dark theme, so the file is valid before it has ever been
+ * rewritten. See quickshell/scripts/panama-theme-apps. */
+/* PANAMA THEME BEGIN */
+/* Generated from the "moon" theme by panama-theme-apps. Edit the
+ * theme, not this block -- the next theme change overwrites it. */
+@define-color accent_bg_color #82aaff;
+@define-color accent_color #65bcff;
+@define-color accent_fg_color #1e2030;
+@define-color destructive_bg_color #c53b53;
+@define-color destructive_color #ff757f;
+@define-color destructive_fg_color #c8d3f5;
+@define-color success_bg_color #c3e88d;
+@define-color success_color #c3e88d;
+@define-color success_fg_color #1e2030;
+@define-color warning_bg_color #ffc777;
+@define-color warning_color #ffc777;
+@define-color warning_fg_color #1e2030;
+@define-color error_bg_color #c53b53;
+@define-color error_color #ff757f;
+@define-color error_fg_color #c8d3f5;
@define-color window_bg_color #2e2f3d;
-@define-color window_fg_color white;
-@define-color view_bg_color @window_bg_color
-@define-color view_fg_color white;
-@define-color headerbar_bg_color @window_bg_color
-@define-color headerbar_fg_color white;
-@define-color headerbar_border_color white;
-@define-color headerbar_backdrop_color @window_bg_color;
-@define-color headerbar_shade_color RGB(46 47 61 / 8%);
-@define-color headerbar_darker_shade_color RGB(0, 0, 6, 0.9);
+@define-color window_fg_color #c8d3f5;
+@define-color view_bg_color #222436;
+@define-color view_fg_color #c8d3f5;
+@define-color headerbar_bg_color #2e2f3d;
+@define-color headerbar_fg_color #c8d3f5;
+@define-color headerbar_border_color #3b4261;
+@define-color headerbar_backdrop_color #1e2030;
+@define-color headerbar_shade_color rgba(30, 32, 48, 0.08);
+@define-color headerbar_darker_shade_color rgba(30, 32, 48, 0.9);
+@define-color sidebar_bg_color #1e2030;
+@define-color sidebar_fg_color #c8d3f5;
+@define-color sidebar_backdrop_color #1e2030;
+@define-color sidebar_shade_color rgba(59, 66, 97, 0.08);
+@define-color sidebar_border_color rgba(59, 66, 97, 0.36);
+@define-color secondary_sidebar_bg_color #1e2030;
+@define-color secondary_sidebar_fg_color #c8d3f5;
+@define-color secondary_sidebar_backdrop_color #1e2030;
+@define-color secondary_sidebar_shade_color rgba(30, 32, 48, 0.25);
+@define-color secondary_sidebar_border_color rgba(59, 66, 97, 0.36);
+@define-color card_bg_color #2f334d;
+@define-color card_fg_color #c8d3f5;
+@define-color card_shade_color rgba(30, 32, 48, 0.25);
+@define-color dialog_bg_color #21212f;
+@define-color dialog_fg_color #c8d3f5;
+@define-color popover_bg_color #21212f;
+@define-color popover_fg_color #c8d3f5;
+@define-color popover_shade_color rgba(30, 32, 48, 0.25);
+@define-color thumbnail_bg_color #2f334d;
+@define-color thumbnail_fg_color #c8d3f5;
+@define-color shade_color rgba(30, 32, 48, 0.25);
+@define-color scrollbar_outline_color rgba(30, 32, 48, 0.5);
+/* PANAMA THEME END */
-@define-color sidebar_bg_color #3c3c4b;
-@define-color sidebar_fg_color white;
-@define-color sidebar_backdrop_color #3c3c4b;
-@define-color sidebar_shade_color RGB(60 60 75/8%);
-@define-color sidebar_border_color RGB(75 75 90/36%);
-@define-color secondary_sidebar_bg_color #3c3c4b;
-@define-color secondary_sidebar_fg_color white;
-@define-color secondary_sidebar_backdrop_color #3c3c4b;
-@define-color secondary_sidebar_shade_color RGB(0 0 6/25%);
-@define-color secondary_sidebar_border_color RGB(0 0 6/36%);
-@define-color card_bg_color RGB(180 200 255/15%);
-@define-color card_fg_color white;
-@define-color card_shade_color RGB(46 47 61/8%);
-@define-color dialog_bg_color @window_bg_color;
-@define-color dialog_fg_color white;
-@define-color popover_bg_color @window_bg_color;
-@define-color popover_fg_color white;
-@define-color popover_shade_color RGB(0 0 6/25%);
-@define-color thumbnail_bg_color @window_bg_color;
-@define-color thumbnail_fg_color white;
-@define-color shade_color RGB(0 0 6/25%);
-@define-color scrollbar_outline_color RGB(0 0 12/95%);
+/* The GNOME named palette below is reference, not theme: applications ask for
+ * @red_3 by name and expect GNOME's red. It stays outside the block. */
@define-color blue_1 #99c1f1;
@define-color blue_2 #62a0ea;
@define-color blue_3 #3584e4;
diff --git a/config/dot/kitty/themes/tokyonight-moon.conf b/config/dot/kitty/themes/tokyonight-moon.conf
index b16ba24..2039307 100644
--- a/config/dot/kitty/themes/tokyonight-moon.conf
+++ b/config/dot/kitty/themes/tokyonight-moon.conf
@@ -1,8 +1,11 @@
# 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 color scheme
-# setting; that generated file is gitignored because it is machine state.
+# Reference, not the source of the running colours. kitty.conf includes
+# current-theme.conf, which panama-theme-apps RENDERS from the active theme's
+# palette -- so any of the ten themes reaches the terminal, not only the two
+# Tokyo Nights. This file used to be copied into that one; it is kept because
+# it is a complete kitty theme somebody may want to include directly, and
+# because it is what Moon is supposed to look like.
#
# 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
diff --git a/config/dot/quickshell/accent-controls-harness.qml b/config/dot/quickshell/accent-controls-harness.qml
index 79e212c..63882aa 100644
--- a/config/dot/quickshell/accent-controls-harness.qml
+++ b/config/dot/quickshell/accent-controls-harness.qml
@@ -1,40 +1,79 @@
+// Headless theme editor, for accent-controls-contract.
+//
+// The two halves of the editor that write colour: the four wells
+// (ThemeEditorWells + ColorWell), which are the fast path and own hex
+// validation and the eyedropper, and the six HSV rows (AccentEditor), which
+// are the fine-tune behind a disclosure.
+//
+// AccentEditor commits on a debounce rather than per move, so `adjust` returns
+// what is *pending*, not what is stored -- the caller waits and reads `status`
+// again. That is the behaviour under test as much as the colour itself: a
+// slider that wrote on every move spent a whole drag in apply-and-verify round
+// trips and left the desktop repainting behind the pointer.
+
import Quickshell
import Quickshell.Io
import QtQuick
+import qs.config
import qs.modules.settings
import qs.services
ShellRoot {
Item {
width: 680
- height: editor.implicitHeight
+ height: wells.implicitHeight + editor.implicitHeight
+
+ ThemeEditorWells {
+ id: wells
+ width: parent.width
+ }
AccentEditor {
id: editor
+ y: wells.implicitHeight
width: parent.width
}
-
- ThemeProfilePicker {
- width: parent.width
- visible: false
- }
-
- AccentPicker {
- width: parent.width
- visible: false
- }
}
IpcHandler {
target: "accent-controls-test"
function status(): string {
- return JSON.stringify(ThemeProfiles.activeProfile);
+ return JSON.stringify({
+ id: ThemeProfiles.activeProfile.id,
+ name: ThemeProfiles.activeProfile.name,
+ scheme: ThemeProfiles.activeProfile.scheme,
+ accent: String(ThemeProfiles.activeProfile.accent),
+ secondary: String(ThemeProfiles.activeProfile.secondary),
+ shipped: ThemeProfiles.activeProfile.shipped === true,
+ accentName: String(DesktopPreferences.get("accentName")),
+ bg: String(ThemeProfiles.activePalette.bg),
+ fg: String(ThemeProfiles.activePalette.fg),
+ fgDim: String(ThemeProfiles.activePalette.fgDim),
+ pending: editor.pending !== null,
+ wellError: wells.lastError
+ });
}
+ // Moves one HSV slider. The commit is debounced, so the answer is the
+ // pair the sliders are showing, not the stored one.
function adjust(target: string, channel: string, ratio: real): string {
editor.changeChannel(target, channel, ratio);
+ return JSON.stringify({
+ shown: { accent: editor.shownAccent, secondary: editor.shownSecondary },
+ pending: editor.pending !== null,
+ stored: {
+ accent: String(ThemeProfiles.activeProfile.accent),
+ secondary: String(ThemeProfiles.activeProfile.secondary)
+ }
+ });
+ }
+
+ // The one path every well takes -- typed hex, colour wheel and
+ // eyedropper all end here, so validation cannot differ between them.
+ function well(which: string, hex: string): string {
+ wells.apply(which, hex);
return status();
}
}
diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml
index 7b61ec1..c8d69df 100644
--- a/config/dot/quickshell/config/PreferenceSchema.qml
+++ b/config/dot/quickshell/config/PreferenceSchema.qml
@@ -971,6 +971,20 @@ Singleton {
key: "wallpaperPerMonitor", type: "json", def: ({}), group: "wallpaper", internal: true,
label: "Per-display backgrounds", detail: "Background assigned to each connected display"
},
+ // Video wallpapers play through mpvpaper (services/VideoWallpaper.qml);
+ // the active video rides wallpaperPath like any still, routed by
+ // extension. These two only shape discovery and the battery policy.
+ {
+ key: "videoWallpaperDir", type: "string", def: "Videos/Wallpapers", group: "wallpaper",
+ pattern: "^~?/?[A-Za-z0-9 ._/+@'-]{1,160}$",
+ label: "Video wallpaper folder",
+ detail: "Where the picker looks for videos. Relative to your home folder unless it starts with /"
+ },
+ {
+ key: "videoWallpaperPauseOnBattery", type: "bool", def: true, group: "wallpaper",
+ label: "Pause video wallpaper on battery",
+ detail: "Freezes on the current frame and resumes on wall power"
+ },
// ── Lock-screen appearance ─────────────────────────────────────────
// scripts/panama-lock validates these again before generating a state
@@ -1129,6 +1143,22 @@ Singleton {
detail: "Named custom colour schemes and accent pairs",
internal: true
},
+ // The remembered theme per scheme: flipping light/dark lands on the
+ // theme you last chose for that side, never a forced default.
+ {
+ key: "themeDark", type: "string", def: "moon", group: "appearance",
+ pattern: "^[a-z0-9][a-z0-9-]{0,63}$",
+ label: "Dark theme",
+ detail: "The theme applied while the desktop is dark",
+ internal: true
+ },
+ {
+ key: "themeLight", type: "string", def: "day", group: "appearance",
+ pattern: "^[a-z0-9][a-z0-9-]{0,63}$",
+ label: "Light theme",
+ detail: "The theme applied while the desktop is light",
+ internal: true
+ },
// ── Application themes ─────────────────────────────────────────────
// ColorScheme owns GTK's light/dark theme. These are the two theme
@@ -1248,20 +1278,18 @@ Singleton {
{ value: "right", label: "Right" }
]
},
+ // Panama's own windows (Settings) draw their own titlebar and follow
+ // the same rules as GNOME apps. Off is pure Hyprland: Super+Q closes,
+ // Super+drag moves, Escape still works.
{
- key: "titlebarMaximizeButton", type: "bool", def: false, group: "titlebar",
- label: "Maximize button",
- detail: "Show a maximize button in application titlebars that support it"
- },
- {
- key: "titlebarDoubleClick", type: "enum", def: "toggle-maximize", group: "titlebar",
- label: "Double-click titlebar",
- detail: "Choose what a double-click on an application titlebar does",
- options: [
- { value: "toggle-maximize", label: "Toggle maximize" },
- { value: "none", label: "Do nothing" }
- ]
+ key: "panamaTitlebar", type: "bool", def: true, group: "titlebar",
+ label: "Titlebar on Panama windows",
+ detail: "Hide it and the window is pure Hyprland — Super+Q closes, Super+drag moves"
},
+ // titlebarMaximizeButton and titlebarDoubleClick used to live here.
+ // Removed on purpose: Hyprland has no minimize, maximize is noise in a
+ // tiler, and DesktopStyle now pushes a close-only button-layout and
+ // leaves GNOME's double-click default alone.
// ── Accessibility ───────────────────────────────────────────────────
// Backed by gsettings so GTK applications agree with the shell, and
diff --git a/config/dot/quickshell/config/Theme.qml b/config/dot/quickshell/config/Theme.qml
index e54d8d1..93fb35c 100644
--- a/config/dot/quickshell/config/Theme.qml
+++ b/config/dot/quickshell/config/Theme.qml
@@ -20,28 +20,31 @@ Singleton {
id: root
// ── Color scheme ───────────────────────────────────────────────────────
- // Tokyo Night ships an official light variant (Day), so light mode is that
- // rather than a palette invented to merely not be dark. The two share the
- // same hues at different lightness, which is what lets the Prism identity
- // survive the switch: blue still leads into orchid, it is simply a darker
- // blue on a lighter ground.
+ // The scheme still exists as a boolean because alphas and a handful of
+ // mix directions depend on which side of the ground the theme sits — but
+ // the palette itself now comes from the active theme record.
//
- // Every token below is a binding on this, so flipping it repaints the whole
+ // Every token below is a binding, so selecting a theme repaints the whole
// shell without anything needing to know it happened.
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
// ── Palette ─────────────────────────────────────────────────────────────
- // Canonical Tokyo Night Moon. `accent` matches the GNOME accent exactly.
- readonly property color bg: root.dark ? "#222436" : "#e1e2e7"
- readonly property color bgDark: root.dark ? "#1e2030" : "#d3d5de"
- readonly property color bgHighlight: root.dark ? "#2f334d" : "#c4c8da"
- readonly property color bgPanel: root.dark ? "#2e2f3d" : "#d9dae3" // dock glass surface
- readonly property color bgPopover: root.dark ? "#21212f" : "#eaeaee" // Openbar submenu background
+ // The active theme's palette — ThemeProfiles resolves it and it is always
+ // complete: a shipped theme carries its own, a custom theme without one
+ // inherits its scheme's default (Tokyo Moon or Tokyo Day) from the
+ // catalog. Nothing here needs a per-token fallback.
+ readonly property var palette: ThemeProfiles.activePalette
- readonly property color fg: root.dark ? "#c8d3f5" : "#3760bf"
- readonly property color fgDim: root.dark ? "#828bb8" : "#6172b0"
- readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
- readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
+ readonly property color bg: root.palette.bg
+ readonly property color bgDark: root.palette.bgDark
+ readonly property color bgHighlight: root.palette.bgHighlight
+ readonly property color bgPanel: root.palette.bgPanel // dock glass surface
+ readonly property color bgPopover: root.palette.bgPopover // Openbar submenu background
+
+ readonly property color fg: root.palette.fg
+ readonly property color fgDim: root.palette.fgDim
+ readonly property color fgMuted: root.palette.fgMuted
+ readonly property color gutter: root.palette.gutter
// ── The accent ──────────────────────────────────────────────────────────
//
@@ -75,16 +78,16 @@ Singleton {
// shell-wide colour roles.
readonly property color accent: root.activeProfile.accent
readonly property color accentSecondary: root.activeProfile.secondary
- readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
- readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
- readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
- readonly property color green: root.dark ? "#c3e88d" : "#587539"
- readonly property color yellow: root.dark ? "#ffc777" : "#8c6c3e"
- readonly property color orange: root.dark ? "#ff966c" : "#b15c00"
- readonly property color red: root.dark ? "#ff757f" : "#f52a65"
- readonly property color redDeep: root.dark ? "#c53b53" : "#c64343"
- readonly property color magenta: root.dark ? "#c099ff" : "#9854f1"
- readonly property color pink: root.dark ? "#fca7ea" : "#d20065"
+ readonly property color accentAlt: root.palette.accentAlt
+ readonly property color cyan: root.palette.cyan
+ readonly property color teal: root.palette.teal
+ readonly property color green: root.palette.green
+ readonly property color yellow: root.palette.yellow
+ readonly property color orange: root.palette.orange
+ readonly property color red: root.palette.red
+ readonly property color redDeep: root.palette.redDeep
+ readonly property color magenta: root.palette.magenta
+ readonly property color pink: root.palette.pink
// Semantic aliases — prefer these in widgets so intent survives a repaint.
readonly property color ok: green
diff --git a/config/dot/quickshell/config/themes.json b/config/dot/quickshell/config/themes.json
new file mode 100644
index 0000000..b1a9158
--- /dev/null
+++ b/config/dot/quickshell/config/themes.json
@@ -0,0 +1,217 @@
+{
+ "comment": "The shipped theme catalog — the one source of truth for full palettes. services/ThemeCatalog.qml reads it with FileView, scripts read it with jq, contracts read it with fs. The 19 palette keys are exactly Theme.qml's color tokens; ansi's 16 feed generated terminal themes. moon and day must stay byte-identical to the shell's pre-theme literals: theme-catalog-contract pins that.",
+ "defaultDark": "moon",
+ "defaultLight": "day",
+ "themes": [
+ {
+ "id": "moon", "name": "Tokyo Moon", "scheme": "dark",
+ "accent": "#82aaff", "secondary": "#b172b0",
+ "palette": {
+ "bg": "#222436", "bgDark": "#1e2030", "bgHighlight": "#2f334d",
+ "bgPanel": "#2e2f3d", "bgPopover": "#21212f",
+ "fg": "#c8d3f5", "fgDim": "#828bb8", "fgMuted": "#636da6",
+ "gutter": "#3b4261", "accentAlt": "#65bcff",
+ "cyan": "#86e1fc", "teal": "#4fd6be", "green": "#c3e88d",
+ "yellow": "#ffc777", "orange": "#ff966c", "red": "#ff757f",
+ "redDeep": "#c53b53", "magenta": "#c099ff", "pink": "#fca7ea"
+ },
+ "ansi": {
+ "black": "#1b1d2b", "red": "#ff757f", "green": "#c3e88d",
+ "yellow": "#ffc777", "blue": "#82aaff", "magenta": "#c099ff",
+ "cyan": "#86e1fc", "white": "#828bb8",
+ "brightBlack": "#444a73", "brightRed": "#ff8d94", "brightGreen": "#c7fb6d",
+ "brightYellow": "#ffd8ab", "brightBlue": "#9ab8ff", "brightMagenta": "#caabff",
+ "brightCyan": "#b2ebff", "brightWhite": "#c8d3f5"
+ }
+ },
+ {
+ "id": "moon-rose", "name": "Moon Rose", "scheme": "dark",
+ "accent": "#ff757f", "secondary": "#c099ff",
+ "palette": {
+ "bg": "#222436", "bgDark": "#1e2030", "bgHighlight": "#2f334d",
+ "bgPanel": "#2e2f3d", "bgPopover": "#21212f",
+ "fg": "#c8d3f5", "fgDim": "#828bb8", "fgMuted": "#636da6",
+ "gutter": "#3b4261", "accentAlt": "#fca7ea",
+ "cyan": "#86e1fc", "teal": "#4fd6be", "green": "#c3e88d",
+ "yellow": "#ffc777", "orange": "#ff966c", "red": "#ff757f",
+ "redDeep": "#c53b53", "magenta": "#c099ff", "pink": "#fca7ea"
+ },
+ "ansi": {
+ "black": "#1b1d2b", "red": "#ff757f", "green": "#c3e88d",
+ "yellow": "#ffc777", "blue": "#82aaff", "magenta": "#c099ff",
+ "cyan": "#86e1fc", "white": "#828bb8",
+ "brightBlack": "#444a73", "brightRed": "#ff8d94", "brightGreen": "#c7fb6d",
+ "brightYellow": "#ffd8ab", "brightBlue": "#9ab8ff", "brightMagenta": "#caabff",
+ "brightCyan": "#b2ebff", "brightWhite": "#c8d3f5"
+ }
+ },
+ {
+ "id": "mocha", "name": "Catppuccin Mocha", "scheme": "dark",
+ "accent": "#cba6f7", "secondary": "#f5c2e7",
+ "palette": {
+ "bg": "#1e1e2e", "bgDark": "#181825", "bgHighlight": "#313244",
+ "bgPanel": "#27273a", "bgPopover": "#1b1b2a",
+ "fg": "#cdd6f4", "fgDim": "#9399b2", "fgMuted": "#7f849c",
+ "gutter": "#45475a", "accentAlt": "#89dceb",
+ "cyan": "#89dceb", "teal": "#94e2d5", "green": "#a6e3a1",
+ "yellow": "#f9e2af", "orange": "#fab387", "red": "#f38ba8",
+ "redDeep": "#b04a5a", "magenta": "#cba6f7", "pink": "#f5c2e7"
+ },
+ "ansi": {
+ "black": "#45475a", "red": "#f38ba8", "green": "#a6e3a1",
+ "yellow": "#f9e2af", "blue": "#89b4fa", "magenta": "#f5c2e7",
+ "cyan": "#94e2d5", "white": "#bac2de",
+ "brightBlack": "#585b70", "brightRed": "#f38ba8", "brightGreen": "#a6e3a1",
+ "brightYellow": "#f9e2af", "brightBlue": "#89b4fa", "brightMagenta": "#f5c2e7",
+ "brightCyan": "#94e2d5", "brightWhite": "#a6adc8"
+ }
+ },
+ {
+ "id": "nord", "name": "Nord", "scheme": "dark",
+ "accent": "#88c0d0", "secondary": "#81a1c1",
+ "palette": {
+ "bg": "#2e3440", "bgDark": "#272c36", "bgHighlight": "#3b4252",
+ "bgPanel": "#353c4a", "bgPopover": "#2a303c",
+ "fg": "#d8dee9", "fgDim": "#9aa4b8", "fgMuted": "#7b8598",
+ "gutter": "#4c566a", "accentAlt": "#8fbcbb",
+ "cyan": "#88c0d0", "teal": "#8fbcbb", "green": "#a3be8c",
+ "yellow": "#ebcb8b", "orange": "#d08770", "red": "#bf616a",
+ "redDeep": "#a54e57", "magenta": "#b48ead", "pink": "#c9a3c0"
+ },
+ "ansi": {
+ "black": "#3b4252", "red": "#bf616a", "green": "#a3be8c",
+ "yellow": "#ebcb8b", "blue": "#81a1c1", "magenta": "#b48ead",
+ "cyan": "#88c0d0", "white": "#e5e9f0",
+ "brightBlack": "#4c566a", "brightRed": "#bf616a", "brightGreen": "#a3be8c",
+ "brightYellow": "#ebcb8b", "brightBlue": "#81a1c1", "brightMagenta": "#b48ead",
+ "brightCyan": "#8fbcbb", "brightWhite": "#eceff4"
+ }
+ },
+ {
+ "id": "gruvbox", "name": "Gruvbox", "scheme": "dark",
+ "accent": "#fabd2f", "secondary": "#fe8019",
+ "palette": {
+ "bg": "#282828", "bgDark": "#1d2021", "bgHighlight": "#3c3836",
+ "bgPanel": "#32302f", "bgPopover": "#211f1e",
+ "fg": "#ebdbb2", "fgDim": "#a89984", "fgMuted": "#928374",
+ "gutter": "#504945", "accentAlt": "#83a598",
+ "cyan": "#83a598", "teal": "#8ec07c", "green": "#b8bb26",
+ "yellow": "#fabd2f", "orange": "#fe8019", "red": "#fb4934",
+ "redDeep": "#cc241d", "magenta": "#d3869b", "pink": "#dfa7be"
+ },
+ "ansi": {
+ "black": "#282828", "red": "#cc241d", "green": "#98971a",
+ "yellow": "#d79921", "blue": "#458588", "magenta": "#b16286",
+ "cyan": "#689d6a", "white": "#a89984",
+ "brightBlack": "#928374", "brightRed": "#fb4934", "brightGreen": "#b8bb26",
+ "brightYellow": "#fabd2f", "brightBlue": "#83a598", "brightMagenta": "#d3869b",
+ "brightCyan": "#8ec07c", "brightWhite": "#ebdbb2"
+ }
+ },
+ {
+ "id": "everforest", "name": "Everforest", "scheme": "dark",
+ "accent": "#a7c080", "secondary": "#e69875",
+ "palette": {
+ "bg": "#2d353b", "bgDark": "#272e33", "bgHighlight": "#3d484d",
+ "bgPanel": "#343f44", "bgPopover": "#293136",
+ "fg": "#d3c6aa", "fgDim": "#9da9a0", "fgMuted": "#7a8478",
+ "gutter": "#475258", "accentAlt": "#7fbbb3",
+ "cyan": "#7fbbb3", "teal": "#83c092", "green": "#a7c080",
+ "yellow": "#dbbc7f", "orange": "#e69875", "red": "#e67e80",
+ "redDeep": "#c05a5e", "magenta": "#d699b6", "pink": "#dfa7c1"
+ },
+ "ansi": {
+ "black": "#3d484d", "red": "#e67e80", "green": "#a7c080",
+ "yellow": "#dbbc7f", "blue": "#7fbbb3", "magenta": "#d699b6",
+ "cyan": "#83c092", "white": "#d3c6aa",
+ "brightBlack": "#475258", "brightRed": "#e67e80", "brightGreen": "#a7c080",
+ "brightYellow": "#dbbc7f", "brightBlue": "#7fbbb3", "brightMagenta": "#d699b6",
+ "brightCyan": "#83c092", "brightWhite": "#d3c6aa"
+ }
+ },
+ {
+ "id": "day", "name": "Tokyo Day", "scheme": "light",
+ "accent": "#2e7de9", "secondary": "#9854f1",
+ "palette": {
+ "bg": "#e1e2e7", "bgDark": "#d3d5de", "bgHighlight": "#c4c8da",
+ "bgPanel": "#d9dae3", "bgPopover": "#eaeaee",
+ "fg": "#3760bf", "fgDim": "#6172b0", "fgMuted": "#848cb5",
+ "gutter": "#a8aecb", "accentAlt": "#007197",
+ "cyan": "#007197", "teal": "#118c74", "green": "#587539",
+ "yellow": "#8c6c3e", "orange": "#b15c00", "red": "#f52a65",
+ "redDeep": "#c64343", "magenta": "#9854f1", "pink": "#d20065"
+ },
+ "ansi": {
+ "black": "#b4b5b9", "red": "#f52a65", "green": "#587539",
+ "yellow": "#8c6c3e", "blue": "#2e7de9", "magenta": "#9854f1",
+ "cyan": "#007197", "white": "#6172b0",
+ "brightBlack": "#a1a6c5", "brightRed": "#ff4774", "brightGreen": "#5c8524",
+ "brightYellow": "#a27629", "brightBlue": "#358aff", "brightMagenta": "#a463ff",
+ "brightCyan": "#007ea8", "brightWhite": "#3760bf"
+ }
+ },
+ {
+ "id": "latte", "name": "Catppuccin Latte", "scheme": "light",
+ "accent": "#8839ef", "secondary": "#ea76cb",
+ "palette": {
+ "bg": "#eff1f5", "bgDark": "#e6e9ef", "bgHighlight": "#ccd0da",
+ "bgPanel": "#e2e6ee", "bgPopover": "#f5f7fa",
+ "fg": "#4c4f69", "fgDim": "#6c6f85", "fgMuted": "#8c8fa1",
+ "gutter": "#acb0be", "accentAlt": "#209fb5",
+ "cyan": "#04a5e5", "teal": "#179299", "green": "#40a02b",
+ "yellow": "#df8e1d", "orange": "#fe640b", "red": "#d20f39",
+ "redDeep": "#e64553", "magenta": "#8839ef", "pink": "#ea76cb"
+ },
+ "ansi": {
+ "black": "#5c5f77", "red": "#d20f39", "green": "#40a02b",
+ "yellow": "#df8e1d", "blue": "#1e66f5", "magenta": "#ea76cb",
+ "cyan": "#179299", "white": "#acb0be",
+ "brightBlack": "#6c6f85", "brightRed": "#d20f39", "brightGreen": "#40a02b",
+ "brightYellow": "#df8e1d", "brightBlue": "#1e66f5", "brightMagenta": "#ea76cb",
+ "brightCyan": "#179299", "brightWhite": "#bcc0cc"
+ }
+ },
+ {
+ "id": "gruvbox-light", "name": "Gruvbox Light", "scheme": "light",
+ "accent": "#b57614", "secondary": "#af3a03",
+ "palette": {
+ "bg": "#fbf1c7", "bgDark": "#f2e5bc", "bgHighlight": "#ebdbb2",
+ "bgPanel": "#f0e6c0", "bgPopover": "#fdf6d8",
+ "fg": "#3c3836", "fgDim": "#665c54", "fgMuted": "#7c6f64",
+ "gutter": "#bdae93", "accentAlt": "#076678",
+ "cyan": "#076678", "teal": "#427b58", "green": "#79740e",
+ "yellow": "#b57614", "orange": "#af3a03", "red": "#9d0006",
+ "redDeep": "#cc241d", "magenta": "#8f3f71", "pink": "#b16286"
+ },
+ "ansi": {
+ "black": "#fbf1c7", "red": "#cc241d", "green": "#98971a",
+ "yellow": "#d79921", "blue": "#458588", "magenta": "#b16286",
+ "cyan": "#689d6a", "white": "#7c6f64",
+ "brightBlack": "#928374", "brightRed": "#9d0006", "brightGreen": "#79740e",
+ "brightYellow": "#b57614", "brightBlue": "#076678", "brightMagenta": "#8f3f71",
+ "brightCyan": "#427b58", "brightWhite": "#3c3836"
+ }
+ },
+ {
+ "id": "everforest-light", "name": "Everforest Light", "scheme": "light",
+ "accent": "#8da101", "secondary": "#f57d26",
+ "palette": {
+ "bg": "#fdf6e3", "bgDark": "#f4f0d9", "bgHighlight": "#e6e2cc",
+ "bgPanel": "#efebd4", "bgPopover": "#faf3dc",
+ "fg": "#5c6a72", "fgDim": "#829181", "fgMuted": "#939f91",
+ "gutter": "#a6b0a0", "accentAlt": "#3a94c5",
+ "cyan": "#3a94c5", "teal": "#35a77c", "green": "#8da101",
+ "yellow": "#dfa000", "orange": "#f57d26", "red": "#f85552",
+ "redDeep": "#e66868", "magenta": "#df69ba", "pink": "#df69ba"
+ },
+ "ansi": {
+ "black": "#5c6a72", "red": "#f85552", "green": "#8da101",
+ "yellow": "#dfa000", "blue": "#3a94c5", "magenta": "#df69ba",
+ "cyan": "#35a77c", "white": "#e0dcc7",
+ "brightBlack": "#829181", "brightRed": "#f85552", "brightGreen": "#8da101",
+ "brightYellow": "#dfa000", "brightBlue": "#3a94c5", "brightMagenta": "#df69ba",
+ "brightCyan": "#35a77c", "brightWhite": "#fdf6e3"
+ }
+ }
+ ]
+}
diff --git a/config/dot/quickshell/manual/05-making-it-yours.md b/config/dot/quickshell/manual/05-making-it-yours.md
index 645ce57..e4970ed 100644
--- a/config/dot/quickshell/manual/05-making-it-yours.md
+++ b/config/dot/quickshell/manual/05-making-it-yours.md
@@ -20,11 +20,49 @@ to change you can type it and never touch the sidebar.
## Appearance
-Panama ships Tokyo Night Moon in a light and a dark variant, with eight
-accent colours. Changing either repaints the shell, the terminal, GTK
-applications, the lock screen and the system monitor at once.
+The Appearance page opens on **Themes**. At the top are light and dark, side by
+side, each showing the theme it will land on; below them are two galleries —
+six dark themes and four light ones, including Catppuccin, Nord, Gruvbox and
+Everforest alongside Panama's own Tokyo Night Moon and Day.
-The wallpaper is per-monitor if you want it to be.
+Light and dark remember separately. Pick Everforest for the dark side and
+Catppuccin Latte for the light one, and flipping between them lands on each
+rather than resetting to the shipped pair.
+
+Choosing a theme repaints the shell, the terminal, GTK applications, the lock
+screen and the system monitor at once — not just the shell's own windows.
+
+### Build your own
+
+The **Theme editor** tab is four colours: primary, secondary, background and
+foreground. Each has a swatch, a hex field you can type into, a colour wheel
+and an eyedropper for sampling anything on screen. Background and foreground
+are not single colours — the panels, popovers, dividers and dimmed text are all
+mixed from them, so moving the background moves the whole family with it.
+
+Under that, a saturation slider for the whole palette at once, a fine-tune for
+the exact hue of each end of the accent, and the effects — corners, blur,
+shadows, glow and motion. Save it with a name and it appears in the galleries
+above beside the shipped ones, effects included. Editing a shipped theme does
+not damage it: it makes you a copy.
+
+### Wallpapers
+
+The wallpaper is per-monitor if you want it to be, and it can be a video.
+
+Videos loop muted and decode on the GPU. They pause themselves while a game is
+running, and on battery unless you say otherwise; while one is playing a pill
+appears in the bar, and clicking it pauses immediately — for a call, a
+recording, or just quiet. The lock screen shows a still frame from the video,
+because it cannot play motion.
+
+### Titlebars
+
+There is no minimize button, on Panama's windows or on GNOME applications.
+Hyprland has no minimize, so the button would do nothing; maximize means very
+little in a tiler. You get close, on whichever side you prefer. Panama's own
+titlebar can be turned off entirely, which leaves the Settings window bare:
+`Super + Q` closes it, `Super + drag` moves it, `Escape` still works.
## Applications
diff --git a/config/dot/quickshell/modules/bar/Bar.qml b/config/dot/quickshell/modules/bar/Bar.qml
index 4dc11d5..5cbc75f 100644
--- a/config/dot/quickshell/modules/bar/Bar.qml
+++ b/config/dot/quickshell/modules/bar/Bar.qml
@@ -118,6 +118,10 @@ PanelWindow {
anchors.verticalCenter: parent.verticalCenter
}
+ WallpaperIndicator {
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
FocusIndicator {
anchors.verticalCenter: parent.verticalCenter
}
diff --git a/config/dot/quickshell/modules/bar/WallpaperIndicator.qml b/config/dot/quickshell/modules/bar/WallpaperIndicator.qml
new file mode 100644
index 0000000..e278de3
--- /dev/null
+++ b/config/dot/quickshell/modules/bar/WallpaperIndicator.qml
@@ -0,0 +1,40 @@
+// Visible only while a video wallpaper is active: one click pauses or resumes
+// it. Exists because a playing wallpaper has real costs the user may want to
+// stop right now — a remote desktop session, a recording, or just quiet —
+// without opening Settings. Same conditional pattern as ActivityIndicator.
+
+import QtQuick
+import qs.config
+import qs.services
+import qs.widgets
+
+Pill {
+ id: root
+
+ visible: VideoWallpaper.active
+ horizontalPadding: 10
+
+ onActivated: VideoWallpaper.togglePause()
+ onSecondaryActivated: ShellState.openSettings("appearance")
+
+ Accessible.name: VideoWallpaper.paused ? "Resume video wallpaper" : "Pause video wallpaper"
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: VideoWallpaper.paused ? "\u{F040A}" : "\u{F03E4}" // play / pause
+ color: VideoWallpaper.paused ? Theme.fgDim : Theme.warn
+ font.family: Theme.fontMono
+ font.pixelSize: 13
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: VideoWallpaper.paused
+ ? (VideoWallpaper.gamePaused ? "Paused for game"
+ : (VideoWallpaper.batteryPaused ? "Paused on battery" : "Paused"))
+ : "Wallpaper"
+ color: Theme.fgDim
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ }
+}
diff --git a/config/dot/quickshell/modules/settings/AccentEditor.qml b/config/dot/quickshell/modules/settings/AccentEditor.qml
index 93ca53d..f591f2b 100644
--- a/config/dot/quickshell/modules/settings/AccentEditor.qml
+++ b/config/dot/quickshell/modules/settings/AccentEditor.qml
@@ -1,10 +1,18 @@
-// Advanced accent editing remains a labelled, keyboard-operable extension of
-// the named fast path. Hue is always accompanied by saturation, value, a
-// numeric readout, and the two-colour preview supplied by Appearance.
+// Hue, saturation and value for both ends of the Prism accent.
+//
+// This is the fine-tune behind a disclosure, not the fast path: the four wells
+// above cover what most edits are, and these six rows are for the last few
+// degrees. Each row is a labelled, keyboard-operable slider with a numeric
+// readout, because a hue ring alone tells someone with a colour vision
+// deficiency nothing.
+//
+// Committed on a debounce rather than per move. Each move used to write BOTH
+// accent preferences, so a single drag across the hue row spent the whole
+// gesture in apply-and-verify round trips and left the desktop repainting
+// behind the pointer. The sliders now track the drag locally and one commit
+// lands once it stops.
import QtQuick
-import Quickshell
-import Quickshell.Io
import qs.config
import qs.services
import qs.widgets
@@ -16,13 +24,18 @@ Column {
width: parent ? parent.width : 620
spacing: 0
- property string pickerTarget: "primary"
- property string lastError: ""
+ // The pair being dragged; null means "nothing pending, show what is stored".
+ property var pending: null
- readonly property var primaryHsv: ThemeProfileModel.hexToHsv(
- ThemeProfiles.activeProfile.accent) || ({ h: 0, s: 0, v: 0 })
- readonly property var secondaryHsv: ThemeProfileModel.hexToHsv(
- ThemeProfiles.activeProfile.secondary) || ({ h: 0, s: 0, v: 0 })
+ readonly property string shownAccent: editor.pending
+ ? editor.pending.accent : String(ThemeProfiles.activeProfile.accent)
+ readonly property string shownSecondary: editor.pending
+ ? editor.pending.secondary : String(ThemeProfiles.activeProfile.secondary)
+
+ readonly property var primaryHsv: ThemeProfileModel.hexToHsv(editor.shownAccent)
+ || ({ h: 0, s: 0, v: 0 })
+ readonly property var secondaryHsv: ThemeProfileModel.hexToHsv(editor.shownSecondary)
+ || ({ h: 0, s: 0, v: 0 })
function changeChannel(target: string, channel: string, ratio: real): void {
const source = target === "primary" ? editor.primaryHsv : editor.secondaryHsv;
@@ -30,34 +43,17 @@ Column {
next[channel] = Math.round(Math.max(0, Math.min(1, ratio))
* (channel === "h" ? 360 : 100));
const changed = ThemeProfileModel.hsvToHex(next.h, next.s, next.v);
- const primary = target === "primary" ? changed : ThemeProfiles.activeProfile.accent;
- const secondary = target === "secondary" ? changed : ThemeProfiles.activeProfile.secondary;
- ThemeProfiles.setAccentPair(primary, secondary);
- }
-
- function pick(target: string): void {
- if (screenPicker.running)
+ if (!changed)
return;
- editor.pickerTarget = target;
- editor.lastError = "";
- screenPicker.exec(["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]);
- }
-
- function acceptPicked(value: string): void {
- const picked = String(value).trim().toLowerCase();
- if (ThemeProfileModel.hexToHsv(picked) === null) {
- editor.lastError = "The sampled colour was not valid.";
- return;
- }
- const primary = editor.pickerTarget === "primary"
- ? picked : ThemeProfiles.activeProfile.accent;
- const secondary = editor.pickerTarget === "secondary"
- ? picked : ThemeProfiles.activeProfile.secondary;
- ThemeProfiles.setAccentPair(primary, secondary);
+ editor.pending = {
+ accent: target === "primary" ? changed : editor.shownAccent,
+ secondary: target === "secondary" ? changed : editor.shownSecondary
+ };
+ commitTimer.restart();
}
component HsvRow: SettingRow {
- id: root
+ id: row
required property string target
required property string channel
@@ -77,19 +73,18 @@ Column {
activeFocusOnTab: true
Accessible.role: Accessible.Slider
- Accessible.name: root.label
- Accessible.description: Math.round(root.channelValue) + root.suffix
- + ", range 0 to " + root.channelMaximum
+ Accessible.name: row.label
+ Accessible.description: Math.round(row.channelValue) + row.suffix
+ + ", range 0 to " + row.channelMaximum
Accessible.focusable: true
Accessible.focused: activeFocus
Accessible.onIncreaseAction: keyboardSlider.step(1)
Accessible.onDecreaseAction: keyboardSlider.step(-1)
function step(direction: int): void {
- const increment = root.channel === "h" ? 1 : 1;
- const value = Math.max(0, Math.min(root.channelMaximum,
- root.channelValue + direction * increment));
- editor.changeChannel(root.target, root.channel, value / root.channelMaximum);
+ const value = Math.max(0, Math.min(row.channelMaximum,
+ row.channelValue + direction));
+ editor.changeChannel(row.target, row.channel, value / row.channelMaximum);
}
Keys.onPressed: event => {
@@ -100,10 +95,10 @@ Column {
keyboardSlider.step(1);
event.accepted = true;
} else if (event.key === Qt.Key_Home) {
- editor.changeChannel(root.target, root.channel, 0);
+ editor.changeChannel(row.target, row.channel, 0);
event.accepted = true;
} else if (event.key === Qt.Key_End) {
- editor.changeChannel(root.target, root.channel, 1);
+ editor.changeChannel(row.target, row.channel, 1);
event.accepted = true;
}
}
@@ -117,13 +112,14 @@ Column {
radius: 9
color: "transparent"
border.width: keyboardSlider.activeFocus ? 2 : 1
- border.color: keyboardSlider.activeFocus ? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.08)
+ border.color: keyboardSlider.activeFocus
+ ? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.08)
ValueSlider {
anchors.fill: parent
anchors.margins: 4
- value: root.channelMaximum > 0 ? root.channelValue / root.channelMaximum : 0
- onMoved: ratio => editor.changeChannel(root.target, root.channel, ratio)
+ value: row.channelMaximum > 0 ? row.channelValue / row.channelMaximum : 0
+ onMoved: ratio => editor.changeChannel(row.target, row.channel, ratio)
}
}
@@ -133,7 +129,7 @@ Column {
anchors.verticalCenter: parent.verticalCenter
width: 56
horizontalAlignment: Text.AlignRight
- text: Math.round(root.channelValue) + root.suffix
+ text: Math.round(row.channelValue) + row.suffix
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
@@ -165,54 +161,26 @@ Column {
HsvRow {
target: "secondary"; channel: "v"; channelValue: editor.secondaryHsv.v; channelMaximum: 100
label: "Secondary value"; detail: "Brightness from black to full colour"
- }
-
- SettingRow {
- label: "Pick colour from screen"
- detail: editor.lastError !== ""
- ? editor.lastError
- : "Sample either end of the accent gradient with hyprpicker"
divider: false
- controlWidth: 330
+ }
- Row {
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- spacing: 7
-
- SettingsButton {
- text: "Pick primary from screen"
- enabled: !screenPicker.running
- activeFocusOnTab: enabled
- border.width: activeFocus ? 2 : 1
- border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
- onClicked: editor.pick("primary")
- Keys.onReturnPressed: if (enabled) editor.pick("primary")
- Keys.onSpacePressed: if (enabled) editor.pick("primary")
- }
-
- SettingsButton {
- text: "Pick secondary from screen"
- enabled: !screenPicker.running
- activeFocusOnTab: enabled
- border.width: activeFocus ? 2 : 1
- border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
- onClicked: editor.pick("secondary")
- Keys.onReturnPressed: if (enabled) editor.pick("secondary")
- Keys.onSpacePressed: if (enabled) editor.pick("secondary")
- }
+ Timer {
+ id: commitTimer
+ interval: 200
+ onTriggered: {
+ if (editor.pending === null)
+ return;
+ ThemeProfiles.setAccentPair(editor.pending.accent, editor.pending.secondary);
+ releaseTimer.restart();
}
}
- Process {
- id: screenPicker
-
- stdout: StdioCollector {
- onStreamFinished: editor.acceptPicked(this.text)
- }
- onExited: (exitCode, exitStatus) => {
- if (exitCode !== 0)
- editor.lastError = "Screen colour picking was cancelled or unavailable.";
- }
+ // Hands the sliders back to the stored pair. If the write was refused they
+ // snap back to what is really in effect rather than showing a colour
+ // nothing accepted.
+ Timer {
+ id: releaseTimer
+ interval: 160
+ onTriggered: editor.pending = null
}
}
diff --git a/config/dot/quickshell/modules/settings/AccentPicker.qml b/config/dot/quickshell/modules/settings/AccentPicker.qml
deleted file mode 100644
index 7784333..0000000
--- a/config/dot/quickshell/modules/settings/AccentPicker.qml
+++ /dev/null
@@ -1,122 +0,0 @@
-// Choosing the desktop accent.
-//
-// Each swatch is drawn as the GRADIENT it will actually produce, not a flat
-// dot, because the gradient is the thing being chosen -- the focused window
-// border, the bar hairline and every active state are the two colors meeting.
-// A row of flat circles would misrepresent all of them.
-//
-// Named accents rather than a color wheel: each name carries a curated pair
-// per scheme, so every choice stays legible in both light and dark. See
-// config/Theme.qml for the palette and the reasoning.
-
-import QtQuick
-import qs.config
-import qs.services
-
-Flow {
- id: root
-
- spacing: 10
-
- readonly property string current: ThemeProfiles.activeAccentName
-
- // The schema's own option list, not Theme.accents directly -- two lists
- // hand-kept in sync is how they drift. This is the same pattern
- // ChoiceRow.qml uses for every other enum row.
- readonly property var spec: PreferenceSchema.spec("accentName")
- readonly property var options: root.spec && root.spec.options ? root.spec.options : []
-
- Repeater {
- // The schema's option order is the palette's order, so blue is first
- // because it is what Panama ships.
- model: root.options
-
- Column {
- id: entry
-
- required property var modelData
-
- readonly property string name: entry.modelData.value
- readonly property var pair: Theme.accents[entry.name]
- readonly property bool selected: entry.name === root.current
- readonly property color start: Theme.dark ? entry.pair.dark : entry.pair.light
- readonly property color end: Theme.dark ? entry.pair.darkSecondary : entry.pair.lightSecondary
-
- spacing: 5
-
- // The hit target is the whole swatch+label unit, not just the
- // 46px circle: the label exists specifically so someone with a
- // color vision deficiency can identify an accent without it, and
- // a label that cannot itself be tapped defeats that.
- HoverHandler {
- cursorShape: Qt.PointingHandCursor
- }
-
- TapHandler {
- onTapped: {
- if (!SystemSettings.commitPreference("accentName", entry.name))
- console.warn("AccentPicker: commitPreference rejected accent", entry.name);
- }
- }
-
- Rectangle {
- id: swatch
-
- width: 46
- height: 46
- radius: 23
- anchors.horizontalCenter: parent.horizontalCenter
- color: "transparent"
- // The ring sits outside the gradient rather than over it, so a
- // selected swatch still shows its true colors.
- border.width: swatch.activeFocus || entry.selected ? 2 : 1
- border.color: swatch.activeFocus
- ? Theme.accentSecondary
- : (entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14))
- activeFocusOnTab: true
-
- Accessible.role: Accessible.Button
- Accessible.name: entry.pair.label + " accent"
- Accessible.description: String(entry.start) + " to " + String(entry.end)
- Accessible.focusable: true
- Accessible.focused: activeFocus
-
- function choose(): void {
- if (!ThemeProfiles.useCuratedAccent(entry.modelData))
- console.warn("AccentPicker: accent was rejected", entry.modelData);
- }
-
- Keys.onReturnPressed: swatch.choose()
- Keys.onSpacePressed: swatch.choose()
-
- Rectangle {
- anchors.fill: parent
- anchors.margins: entry.selected ? 4 : 3
- radius: width / 2
- border.width: 0
- gradient: Gradient {
- orientation: Gradient.Horizontal
- GradientStop { position: 0.0; color: entry.start }
- GradientStop { position: 1.0; color: entry.end }
- }
- }
-
- HoverHandler { cursorShape: Qt.PointingHandCursor }
- TapHandler { onTapped: swatch.choose() }
- }
-
- // Always shown, not a tooltip. Telling swatches apart by color is
- // exactly what someone with a color vision deficiency cannot do,
- // and it is the reason the palette is named rather than freeform --
- // hiding the names behind a hover would waste that.
- Text {
- anchors.horizontalCenter: parent.horizontalCenter
- text: entry.modelData.label
- color: entry.selected ? Theme.fg : Theme.fgMuted
- font.family: Theme.fontFamily
- font.pixelSize: Theme.fontSizeSmall
- font.weight: entry.selected ? Font.DemiBold : Font.Normal
- }
- }
- }
-}
diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml
index 4be6aba..a09ff3c 100644
--- a/config/dot/quickshell/modules/settings/AppearancePage.qml
+++ b/config/dot/quickshell/modules/settings/AppearancePage.qml
@@ -19,16 +19,25 @@ SettingsPage {
property string expandedPicker: ""
- // Which group of cards is on screen. Theme leads deliberately: light and
+ // Whether the accent's HSV fine-tune is open. Collapsed by default: it is
+ // six sliders that answer a question the four colour wells above have
+ // usually already answered.
+ property bool fineTuneOpen: false
+
+ // Which group of cards is on screen. Themes leads deliberately: light and
// dark is the control reached most often, and it used to be the third
// section down, below a wallpaper grid and the whole lock screen.
- property string tab: "theme"
+ property string tab: "themes"
// Arriving from another page that named a section opens on it. Taken once
- // rather than bound, so the tabs still work normally afterwards.
+ // rather than bound, so the tabs still work normally afterwards. The old
+ // `theme` id still resolves: deep links, IPC calls and muscle memory all
+ // hold it, and it means the same thing this tab does.
Component.onCompleted: {
const section = ShellState.takeSettingsSection();
- if (section !== "")
+ if (section === "theme")
+ root.tab = "themes";
+ else if (section !== "")
root.tab = section;
}
@@ -59,7 +68,8 @@ SettingsPage {
SettingsTabs {
tabs: [
- { value: "theme", label: "Theme" },
+ { value: "themes", label: "Themes" },
+ { value: "editor", label: "Theme editor" },
{ value: "background", label: "Background" },
{ value: "type", label: "Typography" },
{ value: "windows", label: "Windows" },
@@ -110,6 +120,47 @@ SettingsPage {
}
}
+ SettingsCard {
+ visible: root.tab === "background"
+ title: "Video playback"
+ subtitle: VideoWallpaper.lastError !== ""
+ ? VideoWallpaper.lastError
+ : "Videos loop muted and decode on the GPU. Considerate by default — a video wallpaper should never cost you a frame you care about."
+
+ TextEntryRow { setting: "videoWallpaperDir"; placeholder: "Videos/Wallpapers" }
+
+ ToggleRow { setting: "videoWallpaperPauseOnBattery" }
+
+ TextRow {
+ label: "During games and fullscreen"
+ detail: "Pauses automatically — not optional, and free"
+ value: "Automatic"
+ }
+
+ TextRow {
+ label: "Pause from the bar"
+ detail: "While a video plays, a pill sits in the bar — one click pauses it, for remote desktop or just quiet"
+ value: ""
+ }
+
+ TextRow {
+ label: "Lock screen"
+ detail: "Uses a still frame of the video — hyprlock cannot play motion"
+ value: "Still frame"
+ }
+
+ ActionRow {
+ label: "Look for new videos"
+ detail: VideoWallpaper.available
+ ? VideoWallpaper.candidates.length + " video" + (VideoWallpaper.candidates.length === 1 ? "" : "s") + " found"
+ : "mpvpaper is not installed — run panama update to pick it up"
+ action: "Rescan"
+ divider: false
+ enabled: VideoWallpaper.available
+ onTriggered: VideoWallpaper.rescan()
+ }
+ }
+
SettingsCard {
visible: root.tab === "background"
title: "Lock screen"
@@ -129,45 +180,220 @@ SettingsPage {
ToggleRow { setting: "lockFadeOnEmpty"; divider: false }
}
- SettingsCard {
- visible: root.tab === "theme"
- // Not "Color scheme" any more: the card holds the saved themes as well
- // as the scheme, and a theme carries both ends of the accent with it.
- title: "Theme"
- subtitle: ColorScheme.lastError !== ""
- ? ColorScheme.lastError
- : "Start with Moon, Moon Rose, or Day; saved themes capture the scheme and both ends of the Prism accent."
+ // ── Themes ──────────────────────────────────────────────────────────────
+ //
+ // The gallery is the page, not a card on it. Cards would put a heading and
+ // a border around each of two lists that are already visually separate, and
+ // the theme cards themselves are the only surfaces here that need edges.
- ThemeProfilePicker {
+ ThemeModeHero {
+ visible: root.tab === "themes"
+ width: parent.width
+ }
+
+ Text {
+ visible: root.tab === "themes" && ThemeCatalog.lastError !== ""
+ width: parent.width
+ text: ThemeCatalog.lastError
+ color: Theme.warn
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ wrapMode: Text.WordWrap
+ }
+
+ ThemeGallery {
+ visible: root.tab === "themes"
+ width: parent.width
+ scheme: "dark"
+ heading: "Dark themes"
+ }
+
+ ThemeGallery {
+ visible: root.tab === "themes"
+ width: parent.width
+ scheme: "light"
+ heading: "Light themes"
+ }
+
+ // Dashed rather than solid, and the width of the page: this is a door out
+ // of the gallery, not an eleventh theme in it.
+ Rectangle {
+ id: buildLink
+
+ visible: root.tab === "themes"
+ width: parent.width
+ implicitHeight: buildCopy.implicitHeight + 30
+ radius: Theme.cardRadius + 2
+ color: Theme.alpha(Theme.accent, buildHover.hovered ? 0.12 : 0.07)
+ border.width: buildLink.activeFocus ? 2 : 1
+ border.color: buildLink.activeFocus
+ ? Theme.accentSecondary : Theme.alpha(Theme.accent, 0.35)
+ activeFocusOnTab: true
+
+ Accessible.role: Accessible.Button
+ Accessible.name: "Build your own theme"
+ Accessible.description: "Opens the theme editor"
+ Accessible.focusable: true
+ Accessible.focused: buildLink.activeFocus
+
+ Keys.onReturnPressed: root.tab = "editor"
+ Keys.onSpacePressed: root.tab = "editor"
+
+ Text {
+ id: buildGlyph
+ anchors.left: parent.left
+ anchors.leftMargin: 17
+ anchors.verticalCenter: parent.verticalCenter
+ text: "◐"
+ color: Theme.accent
+ font.family: Theme.fontFamily
+ font.pixelSize: 20
+ }
+
+ Column {
+ id: buildCopy
+
+ anchors.left: buildGlyph.right
+ anchors.leftMargin: 13
+ anchors.right: buildArrow.left
+ anchors.rightMargin: 13
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 3
+
+ Text {
+ width: parent.width
+ text: "Build your own theme"
+ color: Theme.fg
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ font.weight: Font.DemiBold
+ }
+
+ Text {
+ width: parent.width
+ text: "Primary, secondary, background, foreground, effects — every colour yours, saved as a theme that lives in the galleries above"
+ color: Theme.fgDim
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ wrapMode: Text.WordWrap
+ }
+ }
+
+ Text {
+ id: buildArrow
+ anchors.right: parent.right
+ anchors.rightMargin: 17
+ anchors.verticalCenter: parent.verticalCenter
+ text: "→"
+ color: Theme.accent
+ font.family: Theme.fontFamily
+ font.pixelSize: 17
+ }
+
+ HoverHandler {
+ id: buildHover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ onTapped: root.tab = "editor"
+ }
+ }
+
+ // ── Theme editor ────────────────────────────────────────────────────────
+
+ SettingsCard {
+ id: editorCard
+
+ visible: root.tab === "editor"
+ title: "Your theme"
+ // The identity of what is being edited, always. Failures get their own
+ // line below rather than displacing it -- a subtitle that sometimes
+ // says which theme you are editing and sometimes says something went
+ // wrong answers neither question reliably.
+ subtitle: ThemeProfiles.activeProfile.shipped === true
+ ? ThemeProfiles.activeProfile.name
+ + " — editing it forks a copy you can name below"
+ : "Based on " + ThemeProfiles.activeProfile.name
+ + " · every change previews live on the real desktop"
+
+ Text {
+ width: parent.width
+ visible: wells.lastError !== "" || ColorScheme.lastError !== ""
+ text: wells.lastError !== "" ? wells.lastError : ColorScheme.lastError
+ color: Theme.warn
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ wrapMode: Text.WordWrap
+ bottomPadding: 10
+ }
+
+ ThemeStartChips {
+ width: parent.width
+ bottomPadding: 13
+ }
+
+ SegmentRow {
+ label: "Scheme"
+ detail: "Flipping sides returns to the theme you last chose there"
+ options: [{ value: "dark", label: "Dark" }, { value: "light", label: "Light" }]
+ value: ThemeProfiles.scheme
+ onSelected: value => ThemeProfiles.setScheme(value)
+ }
+
+ Item { width: 1; height: 13 }
+
+ ThemeEditorWells {
+ id: wells
width: parent.width
}
- // Drawn as the gradient each accent produces rather than a flat dot,
- // because the gradient is what is being chosen.
- AccentPicker {
+ Item { width: 1; height: 13 }
+
+ ThemeSaturationRow {
width: parent.width
}
+ ActionRow {
+ label: "Fine-tune"
+ detail: "Hue, saturation and value for each end of the accent"
+ action: root.fineTuneOpen ? "Close" : "Open"
+ onTriggered: root.fineTuneOpen = !root.fineTuneOpen
+ }
+
AccentEditor {
+ visible: root.fineTuneOpen
+ width: parent.width
+ }
+
+ ThemeSaveRow {
width: parent.width
}
}
+ // One selector for every font. The two always-open lists and the
+ // open-one-at-a-time disclosure both retire: a closed dropdown per row
+ // reads as five choices instead of a wall of samples, and each row names
+ // what it actually controls — the old tab never said which font was the
+ // shell's and which was the applications'.
SettingsCard {
visible: root.tab === "type"
- title: "Shell typography"
- subtitle: Fonts.lastError !== ""
- ? Fonts.lastError
- : "Every piece of text in the shell. Samples are drawn in the font they name."
+ title: "Fonts"
+ subtitle: {
+ if (Fonts.lastError !== "")
+ return Fonts.lastError;
+ if (DesktopStyle.lastError !== "")
+ return DesktopStyle.lastError;
+ return "Each row names what it controls. The menu shows every family in its own face — type to filter.";
+ }
- SliderRow { setting: "interfaceFontSize" }
-
- TextRow {
- label: "Interface font"
+ SettingRow {
+ label: "Interface"
detail: Fonts.interfaceMissing
- ? "Not installed on this machine — fontconfig is substituting something else"
- : "Used for all shell text"
- value: Fonts.interfaceFont
+ ? "Not installed — fontconfig is substituting something else"
+ : "The shell — bar, dock, notifications, this window"
+ divider: false
+ controlWidth: 8
}
FontPicker {
@@ -178,12 +404,13 @@ SettingsPage {
onPicked: family => Fonts.setInterface(family)
}
- TextRow {
- label: "Icon font"
+ SettingRow {
+ label: "Icons"
detail: Fonts.iconMissing
? "Not installed — the shell's glyphs will not draw correctly"
- : "Draws the shell's glyphs, so only Nerd Fonts are offered"
- value: Fonts.iconFont
+ : "Glyphs only, never text — needs a Nerd Font"
+ divider: false
+ controlWidth: 8
}
FontPicker {
@@ -193,77 +420,68 @@ SettingsPage {
emptyText: "No Nerd Fonts installed"
onPicked: family => Fonts.setIcon(family)
}
- }
- SettingsCard {
- visible: root.tab === "type"
- title: "Application typography"
- subtitle: DesktopStyle.lastError !== ""
- ? DesktopStyle.lastError
- : "Fonts used by applications that follow the desktop defaults. Open one family at a time to keep the page calm."
-
- ActionRow {
- label: "Application font"
- detail: DesktopStyle.applicationFont
- action: root.expandedPicker === "application-font" ? "Close" : "Choose"
- onTriggered: root.expandedPicker = root.expandedPicker === "application-font" ? "" : "application-font"
+ SettingRow {
+ label: "Application"
+ detail: "GTK and libadwaita apps that follow the desktop default"
+ divider: false
+ controlWidth: 8
}
FontPicker {
- visible: root.expandedPicker === "application-font"
width: parent.width
families: Fonts.interfaceFonts
current: DesktopStyle.applicationFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No application fonts found"
- onPicked: family => {
- if (DesktopStyle.setApplicationFont(family))
- root.expandedPicker = "";
- }
+ onPicked: family => DesktopStyle.setApplicationFont(family)
}
- SliderRow { setting: "applicationFontSize" }
-
- ActionRow {
- label: "Document font"
- detail: DesktopStyle.documentFont
- action: root.expandedPicker === "document-font" ? "Close" : "Choose"
- onTriggered: root.expandedPicker = root.expandedPicker === "document-font" ? "" : "document-font"
+ SettingRow {
+ label: "Document"
+ detail: "Long-form reading, where applications ask for it"
+ divider: false
+ controlWidth: 8
}
FontPicker {
- visible: root.expandedPicker === "document-font"
width: parent.width
families: Fonts.interfaceFonts
current: DesktopStyle.documentFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No document fonts found"
- onPicked: family => {
- if (DesktopStyle.setDocumentFont(family))
- root.expandedPicker = "";
- }
+ onPicked: family => DesktopStyle.setDocumentFont(family)
}
- SliderRow { setting: "documentFontSize" }
-
- ActionRow {
- label: "Monospace font"
- detail: DesktopStyle.monospaceFont
- action: root.expandedPicker === "monospace-font" ? "Close" : "Choose"
- onTriggered: root.expandedPicker = root.expandedPicker === "monospace-font" ? "" : "monospace-font"
+ SettingRow {
+ label: "Monospace"
+ detail: "Terminals and code, wherever fixed width is asked for"
+ divider: false
+ controlWidth: 8
}
FontPicker {
- visible: root.expandedPicker === "monospace-font"
width: parent.width
families: Fonts.monospaceFonts
current: DesktopStyle.monospaceFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No monospace fonts found"
- onPicked: family => {
- if (DesktopStyle.setMonospaceFont(family))
- root.expandedPicker = "";
- }
+ onPicked: family => DesktopStyle.setMonospaceFont(family)
}
+ }
+
+ SettingsCard {
+ visible: root.tab === "type"
+ title: "Sizes"
+ subtitle: "The interface size moves the shell's whole scale; the rest are per role."
+
+ SliderRow { setting: "interfaceFontSize" }
+ SliderRow { setting: "applicationFontSize" }
+ SliderRow { setting: "documentFontSize" }
+ SliderRow { setting: "monospaceFontSize"; divider: false }
+ }
+
+ SettingsCard {
+ visible: root.tab === "type"
+ title: "Rendering"
- SliderRow { setting: "monospaceFontSize" }
ChoiceRow { setting: "fontHinting" }
ChoiceRow { setting: "fontAntialiasing"; divider: false }
}
@@ -322,11 +540,10 @@ SettingsPage {
SettingsCard {
visible: root.tab === "windows"
title: "Titlebars"
- subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
+ subtitle: "One rule for GNOME applications and Panama's own windows. There is no minimize or maximize toggle: Hyprland has no minimize, so the buttons would be lies."
- ChoiceRow { setting: "titlebarButtonSide" }
- ToggleRow { setting: "titlebarMaximizeButton" }
- ChoiceRow { setting: "titlebarDoubleClick"; divider: false }
+ ToggleRow { setting: "panamaTitlebar" }
+ ChoiceRow { setting: "titlebarButtonSide"; divider: false }
}
SettingsCard {
@@ -344,10 +561,13 @@ SettingsPage {
SliderRow { setting: "fullscreenOpacity"; divider: false }
}
+ // Effects belong to the theme now: saving snapshots all ten values into the
+ // record, and applying a theme that carries them puts them back. They sit
+ // in the editor rather than beside the galleries for exactly that reason.
SettingsCard {
- visible: root.tab === "theme"
+ visible: root.tab === "editor"
title: "Effects"
- subtitle: "Each of these costs frame time. Turning one off is a legitimate way to buy it back while gaming."
+ subtitle: "Part of your theme — corners, blur, shadows, glow and motion save with it. Each costs frame time; turning one off is a legitimate way to buy it back while gaming."
ToggleRow { setting: "blurEnabled" }
SliderRow { setting: "blurSize" }
diff --git a/config/dot/quickshell/modules/settings/ColorWell.qml b/config/dot/quickshell/modules/settings/ColorWell.qml
new file mode 100644
index 0000000..33ab631
--- /dev/null
+++ b/config/dot/quickshell/modules/settings/ColorWell.qml
@@ -0,0 +1,221 @@
+// One color of a theme, with the three honest ways to change it.
+//
+// The swatch is the value, large enough to judge against the card it sits on.
+// Under it: the hex, typed and committed on Enter or when focus leaves --
+// never per keystroke, because a half-typed "#82a" is a real color and would
+// repaint the whole desktop on the way to the one you meant. A rejected value
+// marks the field and snaps back rather than quietly keeping bad text.
+//
+// The wheel and the eyedropper are the other two, and all three are keyboard
+// reachable: this component is the reason the old accent editor could not be
+// operated without a mouse.
+
+import QtQuick
+import qs.config
+
+Rectangle {
+ id: root
+
+ property string role: ""
+ property string swatchColor: "#000000"
+ property bool picking: false
+
+ // A hex the caller accepted. Rejected values never reach here.
+ signal committed(value: string)
+ signal wheelRequested
+ signal pickRequested
+
+ // Set while the field holds something that is not a #rrggbb.
+ property bool invalid: false
+
+ implicitHeight: body.implicitHeight + 22
+ radius: Theme.cardRadius
+ color: Theme.alpha(Theme.fg, 0.045)
+ border.width: 1
+ border.color: Theme.alpha(Theme.fg, 0.09)
+
+ function normalized(value: string): string {
+ const text = String(value).trim().toLowerCase();
+ const prefixed = text.startsWith("#") ? text : "#" + text;
+ return /^#[0-9a-f]{6}$/.test(prefixed) ? prefixed : "";
+ }
+
+ function commit(): void {
+ const hex = root.normalized(hexInput.text);
+ if (hex === "") {
+ root.invalid = true;
+ return;
+ }
+ root.invalid = false;
+ hexInput.text = hex;
+ if (hex !== String(root.swatchColor).toLowerCase())
+ root.committed(hex);
+ }
+
+ // An accepted change elsewhere -- a theme click, the eyedropper, the HSV
+ // sliders -- replaces what is shown, unless it would yank the field out
+ // from under someone mid-edit.
+ onSwatchColorChanged: if (!hexInput.activeFocus) {
+ hexInput.text = String(root.swatchColor).toLowerCase();
+ root.invalid = false;
+ }
+
+ // The wheel and eyedropper buttons: square, glyph-only, and each its own
+ // focus stop with a spoken name, because a 30px square with a ⌖ in it is
+ // otherwise unidentifiable to anything but sight.
+ component IconButton: Rectangle {
+ id: button
+
+ property string glyph: ""
+ property string title: ""
+ property bool enabled: true
+
+ signal triggered
+
+ width: 30
+ height: 30
+ radius: 8
+ opacity: button.enabled ? 1 : 0.45
+ color: hover.hovered && button.enabled
+ ? Theme.alpha(Theme.fg, 0.16) : Theme.alpha(Theme.fg, 0.08)
+ border.width: button.activeFocus ? 2 : 1
+ border.color: button.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.1)
+ activeFocusOnTab: button.enabled
+
+ Accessible.role: Accessible.Button
+ Accessible.name: button.title
+ Accessible.focusable: true
+ Accessible.focused: button.activeFocus
+
+ Keys.onReturnPressed: if (button.enabled) button.triggered()
+ Keys.onSpacePressed: if (button.enabled) button.triggered()
+
+ Text {
+ anchors.centerIn: parent
+ text: button.glyph
+ color: Theme.fg
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ }
+
+ HoverHandler {
+ id: hover
+ enabled: button.enabled
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ enabled: button.enabled
+ onTapped: button.triggered()
+ }
+ }
+
+ Column {
+ id: body
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: 11
+ spacing: 7
+
+ Text {
+ text: root.role
+ color: Theme.fgMuted
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.DemiBold
+ font.capitalization: Font.AllUppercase
+ font.letterSpacing: 0.6
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 44
+ radius: 9
+ color: root.swatchColor
+ border.width: 1
+ border.color: Theme.alpha(Theme.fg, 0.18)
+ }
+
+ Item {
+ width: parent.width
+ height: 30
+
+ Rectangle {
+ id: hexField
+
+ anchors.left: parent.left
+ anchors.right: buttons.left
+ anchors.rightMargin: 6
+ anchors.verticalCenter: parent.verticalCenter
+ height: 30
+ radius: 8
+ color: Theme.alpha(Theme.fg, 0.07)
+ border.width: hexInput.activeFocus || root.invalid ? 2 : 1
+ border.color: root.invalid
+ ? Theme.danger
+ : (hexInput.activeFocus
+ ? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.12))
+
+ TextInput {
+ id: hexInput
+
+ anchors.fill: parent
+ anchors.leftMargin: 9
+ anchors.rightMargin: 9
+ activeFocusOnTab: true
+ verticalAlignment: TextInput.AlignVCenter
+ maximumLength: 7
+ text: String(root.swatchColor).toLowerCase()
+ color: root.invalid ? Theme.danger : Theme.fg
+ selectionColor: Theme.alpha(Theme.accent, 0.4)
+ selectedTextColor: Theme.fg
+ font.family: Theme.fontMono
+ font.pixelSize: Theme.fontSizeSmall
+ clip: true
+
+ Accessible.role: Accessible.EditableText
+ Accessible.name: root.role + " colour, hex"
+
+ onTextEdited: root.invalid = false
+ onAccepted: root.commit()
+
+ onActiveFocusChanged: {
+ if (hexInput.activeFocus)
+ return;
+ // Leaving with something unusable in the field reverts
+ // rather than leaving a lie on screen.
+ if (root.normalized(hexInput.text) === "") {
+ root.invalid = false;
+ hexInput.text = String(root.swatchColor).toLowerCase();
+ return;
+ }
+ root.commit();
+ }
+ }
+ }
+
+ Row {
+ id: buttons
+
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 6
+
+ IconButton {
+ glyph: "◐"
+ title: "Open the colour wheel for " + root.role
+ onTriggered: root.wheelRequested()
+ }
+
+ IconButton {
+ glyph: "⌖"
+ title: "Pick " + root.role + " from the screen"
+ enabled: !root.picking
+ onTriggered: root.pickRequested()
+ }
+ }
+ }
+ }
+}
diff --git a/config/dot/quickshell/modules/settings/DesktopPreview.qml b/config/dot/quickshell/modules/settings/DesktopPreview.qml
index 8a5814c..f1b170d 100644
--- a/config/dot/quickshell/modules/settings/DesktopPreview.qml
+++ b/config/dot/quickshell/modules/settings/DesktopPreview.qml
@@ -50,13 +50,13 @@ Rectangle {
// Stands in for the wallpaper. Static: an animated gradient here would
// repaint forever behind a settings page.
- // Follows the color scheme. A preview that stays dark while the shell
- // around it is light does not read as "your desktop" -- it reads as a
- // screenshot of someone else's.
+ // Derived from the active theme's palette rather than fixed hexes, so the
+ // preview re-grounds itself when a theme is chosen -- a Gruvbox desktop
+ // whose preview stayed Tokyo-blue would read as someone else's.
gradient: Gradient {
orientation: Gradient.Vertical
- GradientStop { position: 0.0; color: Theme.dark ? "#2b3050" : "#b9c0dd" }
- GradientStop { position: 1.0; color: Theme.dark ? "#241f33" : "#cbbcd4" }
+ GradientStop { position: 0.0; color: Theme.mix(Theme.bgHighlight, Theme.accent, 0.12) }
+ GradientStop { position: 1.0; color: Theme.mix(Theme.bgDark, Theme.accentSecondary, 0.10) }
}
// The bar, so the preview reads as this desktop and not a generic one.
@@ -209,7 +209,8 @@ Rectangle {
shadowEnabled: true
shadowColor: win.focused && root.glowOn
? Theme.alpha(Theme.accent, 0.5)
- : Theme.alpha(Theme.dark ? "#15161e" : "#6172b0", Theme.dark ? 0.85 : 0.45)
+ : Theme.alpha(Theme.dark ? Theme.mix(Theme.bgDark, "#000000", 0.35) : Theme.fgDim,
+ Theme.dark ? 0.85 : 0.45)
shadowBlur: win.focused && root.glowOn
? Math.min(1.0, root.px(root.glowRange) / 12)
: Math.min(1.0, root.px(root.shadowRange) / 24)
diff --git a/config/dot/quickshell/modules/settings/FontPicker.qml b/config/dot/quickshell/modules/settings/FontPicker.qml
index 16b256d..ecc6028 100644
--- a/config/dot/quickshell/modules/settings/FontPicker.qml
+++ b/config/dot/quickshell/modules/settings/FontPicker.qml
@@ -1,8 +1,13 @@
// Choosing a font family.
//
-// Each candidate is rendered IN the font it names. A list of family names set
-// in the current font tells you nothing about what you are choosing, and the
-// whole point of picking a typeface is seeing it.
+// A closed dropdown: the button shows the current family rendered in its own
+// face, and opening it reveals a search field and the matches — each drawn IN
+// the font it names, because a list of family names set in the current font
+// tells you nothing about what you are choosing.
+//
+// Closed by default on purpose. This used to be an always-open search-and-list
+// that put twenty-four sample rows on the Typography tab before you asked for
+// any of them.
import QtQuick
import qs.config
@@ -16,6 +21,7 @@ Column {
property var families: []
property string current: ""
property string emptyText: "No fonts found"
+ property bool open: false
signal picked(string family)
@@ -31,50 +37,122 @@ Column {
return list.slice(0, 12);
}
- SearchField {
- id: filter
- width: parent.width
- placeholder: "Search installed fonts"
+ function choose(family: string): void {
+ root.picked(family);
+ root.open = false;
+ filter.text = "";
}
- Repeater {
- model: root.matches
+ // ── The closed state: one button ────────────────────────────────────────
- SettingRow {
- id: candidate
+ Rectangle {
+ id: closedButton
- required property var modelData
- required property int index
+ width: parent.width
+ height: 38
+ radius: 10
+ color: buttonMouse.containsMouse || root.open || activeFocus
+ ? Theme.alpha(Theme.fg, 0.09)
+ : Theme.alpha(Theme.fg, 0.055)
+ border.width: root.open || activeFocus ? 2 : 1
+ border.color: root.open || activeFocus
+ ? Theme.alpha(Theme.accent, 0.55)
+ : Theme.alpha(Theme.fg, 0.07)
+ activeFocusOnTab: true
- readonly property bool selected: candidate.modelData === root.current
+ Accessible.role: Accessible.ComboBox
+ Accessible.name: (root.current !== "" ? root.current : "Choose a font")
+ Keys.onReturnPressed: root.open = !root.open
+ Keys.onSpacePressed: root.open = !root.open
- label: candidate.modelData
- detail: candidate.selected ? "Currently in use" : ""
- controlWidth: 210
- divider: candidate.index < root.matches.length - 1
- activatable: !candidate.selected
- onActivated: root.picked(candidate.modelData)
+ Text {
+ anchors.left: parent.left
+ anchors.leftMargin: 13
+ anchors.right: chevron.left
+ anchors.rightMargin: 8
+ anchors.verticalCenter: parent.verticalCenter
+ text: root.current !== "" ? root.current : "Choose a font"
+ elide: Text.ElideRight
+ // The current family draws itself — the closed state is a sample.
+ font.family: root.current !== "" ? root.current : Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ color: root.current !== "" ? Theme.fg : Theme.fgMuted
+ }
- Text {
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- width: 200
- horizontalAlignment: Text.AlignRight
- elide: Text.ElideRight
- // The sample is drawn in the candidate family, which is the
- // entire reason this is a list rather than a text field.
- text: "Handgloves 0123"
- font.family: candidate.modelData
- font.pixelSize: Theme.fontSize + 1
- color: candidate.selected ? Theme.accent : Theme.fgDim
- }
+ Text {
+ id: chevron
+ anchors.right: parent.right
+ anchors.rightMargin: 12
+ anchors.verticalCenter: parent.verticalCenter
+ text: root.open ? "▴" : "▾"
+ color: Theme.fgMuted
+ font.family: Theme.fontFamily
+ font.pixelSize: 11
+ }
+
+ MouseArea {
+ id: buttonMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.open = !root.open
}
}
- SettingRow {
+ Item { width: 1; height: root.open ? 8 : 0 }
+
+ // ── The open state: search plus matches ─────────────────────────────────
+
+ Column {
width: parent.width
- visible: root.matches.length === 0
- label: root.emptyText
- divider: false
+ visible: root.open
+ spacing: 0
+
+ SearchField {
+ id: filter
+ width: parent.width
+ placeholder: "Search installed fonts"
+ }
+
+ Repeater {
+ model: root.open ? root.matches : []
+
+ SettingRow {
+ id: candidate
+
+ required property var modelData
+ required property int index
+
+ readonly property bool selected: candidate.modelData === root.current
+
+ label: candidate.modelData
+ detail: candidate.selected ? "Currently in use" : ""
+ controlWidth: 210
+ divider: candidate.index < root.matches.length - 1
+ activatable: !candidate.selected
+ onActivated: root.choose(candidate.modelData)
+
+ Text {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ width: 200
+ horizontalAlignment: Text.AlignRight
+ elide: Text.ElideRight
+ // The sample is drawn in the candidate family, which is the
+ // entire reason this is a list rather than a text field.
+ text: "Handgloves 0123"
+ font.family: candidate.modelData
+ font.pixelSize: Theme.fontSize + 1
+ color: candidate.selected ? Theme.accent : Theme.fgDim
+ }
+ }
+ }
+
+ SettingRow {
+ width: parent.width
+ visible: root.matches.length === 0
+ label: root.emptyText
+ divider: false
+ }
}
}
diff --git a/config/dot/quickshell/modules/settings/README.md b/config/dot/quickshell/modules/settings/README.md
index bb0d0eb..e071b77 100644
--- a/config/dot/quickshell/modules/settings/README.md
+++ b/config/dot/quickshell/modules/settings/README.md
@@ -78,6 +78,73 @@ The final card is the ownership boundary. Network configuration and the exact
Users, Sharing, Color profiles, and Digital wellbeing handoffs open GNOME
Settings because Fedora's system services own those areas.
+## Appearance
+
+Six tabs, in the order the questions are actually asked: **Themes**, **Theme
+editor**, **Background**, **Typography**, **Windows**, **Shell**. Themes leads
+because light and dark is the control reached most often, and it used to be the
+third section down, under a wallpaper grid and the whole lock screen. The old
+`theme` section id still resolves to it, so deep links and IPC calls keep
+working.
+
+### Themes
+
+`config/themes.json` is the catalog: ten shipped themes, each a full palette of
+nineteen tokens plus sixteen ANSI colors. `services/ThemeCatalog.qml` reads it
+with a `FileView` and carries Moon and Day as an embedded fallback, so the
+shell renders correctly for the instant before the file loads and forever on a
+machine where it is missing.
+
+The tab is two galleries — dark and light — rather than a card each. Selection
+is **per mode**: `themeDark` and `themeLight` remember what you chose on each
+side, so flipping light and dark lands on your theme for that side rather than
+resetting to the shipped defaults.
+
+`services/ThemeProfiles.qml` resolves the active record; `Theme.qml` reads
+every one of its nineteen color tokens from `ThemeProfiles.activePalette`.
+There are no palette ternaries left in `Theme.qml`, which is what makes a
+tenth theme a data change rather than a code change.
+
+### Theme editor
+
+Four wells — primary, secondary, background, foreground — a saturation slider,
+an effects card, and the six HSV rows demoted to a fine-tune behind a
+disclosure. Background and foreground are never stored alone: the five surfaces
+and two text tints are mixed from them, so changing the ground moves the whole
+family instead of leaving twelve tokens pointing at the old one.
+
+Every route that writes color — typed hex, the colour wheel, the eyedropper,
+the HSV sliders — ends in `ThemeProfiles.commitActive`, which also recomputes
+`accentName` as the nearest curated accent. That is what keeps GNOME's accent
+enum, kitty's border and the lock screen from going stale after a custom edit.
+Editing a shipped theme forks it into a named custom carrying the whole
+palette; saving snapshots the palette, the terminal colors, and all ten effect
+values into the record.
+
+The HSV rows commit on a **debounce**, not per move. Writing on every move
+meant one drag across the hue row spent the whole gesture in apply-and-verify
+round trips with the desktop repainting behind the pointer.
+
+### Backgrounds, still and moving
+
+Stills go through hyprpaper. Videos go through mpvpaper, supervised by
+`services/VideoWallpaper.qml`, and Panama owns the pause policy rather than the
+compositor: the video pauses whenever a game runs, on battery if the preference
+says so, and whenever the bar pill is clicked — over mpv's JSON IPC socket, so
+resuming does not restart the clip. hyprpaper's service is stopped while a
+video plays, because both claim the background layer and stacking within a
+layer is creation order. The lock screen gets a cached still frame; hyprlock
+cannot play motion, and pretending otherwise would show a black screen.
+
+### The honest titlebar
+
+There is no minimize or maximize control, and no setting for one. Hyprland has
+no minimize — it receives the request and does nothing with it — and maximize
+is noise in a tiler, so `DesktopStyle` pushes a close-only GNOME button layout
+and Panama's own Settings titlebar shows one button. `panamaTitlebar` turns
+that bar off entirely, leaving the window pure Hyprland: Super+Q closes,
+Super+drag moves, Escape still works.
+
## Adding a setting
One schema entry. That is the whole job.
@@ -141,13 +208,15 @@ or a copied default. Additions to this table require a concrete discoverability
reason and an update to `tests/quickshell/settings-ownership-contract`.
Window border color 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**, driven by the chosen `accentName` and also owned by
-`ColorScheme.qml`: each accent carries a separate pair for light and dark, so
-`ColorScheme.qml` restates the focused border alongside the inactive one on
-every scheme change, rather than leaving a scheme flip to erase a
-user-selected accent.
+neutral contrast role — a **scheme-relative role** in the sense that it exists
+to stay legible on either ground — owned by `ColorScheme.qml`, and it is drawn
+from the active theme's `gutter` rather than from a hardcoded pair. The two
+literals that used to live there were correct for two of the ten shipped themes
+and for no custom one. The focused Prism border is the **accent role**, driven
+by the chosen `accentName` and also owned by `ColorScheme.qml`: each accent
+carries a separate pair for light and dark. Both roles are restated on a scheme
+change, an accent change, and a theme change, rather than leaving a flip to
+erase a user-selected accent or a theme switch to leave the border behind.
## The rows
diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml
index b1f4b52..c47100b 100644
--- a/config/dot/quickshell/modules/settings/SettingsShell.qml
+++ b/config/dot/quickshell/modules/settings/SettingsShell.qml
@@ -40,18 +40,30 @@ Rectangle {
onPageRequested: page => ShellState.openSettings(page)
}
+ // The one titlebar Panama draws, so it follows Panama's own titlebar
+ // rules: the close button sits on the configured side, there is no
+ // minimize (Hyprland has no minimize — the button this bar used to show
+ // was a lie), and turning the titlebar off leaves the window pure
+ // Hyprland: Super+Q closes, Super+drag moves, Escape still works.
Rectangle {
id: titlebar
+
+ readonly property bool shown: DesktopPreferences.get("panamaTitlebar") !== false
+ readonly property bool buttonsLeft: DesktopPreferences.get("titlebarButtonSide") === "left"
+
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
- height: 48
+ height: shown ? 48 : 0
+ visible: shown
color: Theme.alpha(Theme.bgDark, 0.97)
border.width: 0
Text {
- anchors.left: parent.left
+ anchors.left: titlebar.buttonsLeft ? undefined : parent.left
+ anchors.right: titlebar.buttonsLeft ? parent.right : undefined
anchors.leftMargin: 18
+ anchors.rightMargin: 18
anchors.verticalCenter: parent.verticalCenter
text: "Settings"
color: Theme.fg
@@ -60,24 +72,42 @@ Rectangle {
font.weight: Font.DemiBold
}
- Row {
- anchors.right: parent.right
+ Rectangle {
+ id: closeButton
+
+ anchors.left: titlebar.buttonsLeft ? parent.left : undefined
+ anchors.right: titlebar.buttonsLeft ? undefined : parent.right
+ anchors.leftMargin: 12
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
- spacing: 7
+ width: 28
+ height: 28
+ radius: 9
+ color: closeMouse.containsMouse || activeFocus
+ ? Theme.alpha(Theme.danger, 0.17) : Theme.alpha(Theme.fg, 0)
+ border.width: activeFocus ? 2 : 0
+ border.color: Theme.alpha(Theme.danger, 0.6)
+ activeFocusOnTab: true
- Rectangle {
- width: 28; height: 28; radius: 9
- color: minMouse.containsMouse ? Theme.alpha(Theme.fg, 0.12) : Theme.alpha(Theme.fg, 0)
- Text { anchors.centerIn: parent; text: "—"; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
- MouseArea { id: minMouse; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: if (root.hostWindow) root.hostWindow.minimized = true }
+ Accessible.role: Accessible.Button
+ Accessible.name: "Close Settings"
+ Keys.onReturnPressed: ShellState.closeSettings()
+ Keys.onSpacePressed: ShellState.closeSettings()
+
+ Text {
+ anchors.centerIn: parent
+ text: "×"
+ color: closeMouse.containsMouse || closeButton.activeFocus ? Theme.danger : Theme.fgDim
+ font.family: Theme.fontFamily
+ font.pixelSize: 18
}
- Rectangle {
- width: 28; height: 28; radius: 9
- color: closeMouse.containsMouse ? Theme.alpha(Theme.danger, 0.17) : Theme.alpha(Theme.fg, 0)
- Text { anchors.centerIn: parent; text: "×"; color: closeMouse.containsMouse ? Theme.danger : Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: 18 }
- MouseArea { id: closeMouse; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: ShellState.closeSettings() }
+ MouseArea {
+ id: closeMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: ShellState.closeSettings()
}
}
diff --git a/config/dot/quickshell/modules/settings/ThemeCard.qml b/config/dot/quickshell/modules/settings/ThemeCard.qml
new file mode 100644
index 0000000..b751e84
--- /dev/null
+++ b/config/dot/quickshell/modules/settings/ThemeCard.qml
@@ -0,0 +1,207 @@
+// One theme, drawn as the desktop it produces.
+//
+// A row of flat swatches would name a theme's colors without showing what any
+// of them does. So the card is a miniature of the real thing: the ground, a
+// window sitting on it wearing the accent as a border, the two text weights
+// the shell actually reads with, and only then the four remaining colors as
+// dots. Clicking applies it immediately -- there is no preview mode here,
+// because the whole shell is the preview.
+
+import QtQuick
+import qs.config
+import qs.services
+
+Rectangle {
+ id: root
+
+ // A catalog record or a saved profile -- the same shape either way.
+ required property var theme
+
+ signal chosen
+ signal removed
+
+ // A custom profile saved before palettes existed carries none of its own
+ // and inherits its scheme's default, exactly as ThemeProfiles resolves it.
+ readonly property var palette: root.theme.palette
+ ?? ThemeCatalog.defaultPalette(root.theme.scheme)
+
+ readonly property bool active: root.theme.id === ThemeProfiles.activeId
+ readonly property bool shipped: root.theme.shipped === true
+ readonly property bool isDefault: root.theme.id === (root.theme.scheme === "light"
+ ? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark)
+
+ implicitHeight: preview.height + caption.height + 4
+ radius: Theme.cardRadius + 2
+ color: Theme.alpha(Theme.fg, 0.04)
+ border.width: 2
+ border.color: root.activeFocus
+ ? Theme.accentSecondary
+ : (root.active ? Theme.accent : Theme.alpha(Theme.fg, 0.12))
+ clip: true
+ activeFocusOnTab: true
+
+ Accessible.role: Accessible.Button
+ Accessible.name: root.theme.name
+ Accessible.description: (root.theme.scheme === "light" ? "Light theme" : "Dark theme")
+ + (root.active ? ", selected" : "")
+ Accessible.focusable: true
+ Accessible.focused: root.activeFocus
+
+ Keys.onReturnPressed: root.chosen()
+ Keys.onSpacePressed: root.chosen()
+
+ Rectangle {
+ id: preview
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: 2
+ height: 84
+ color: root.palette.bg
+ border.width: 0
+
+ Rectangle {
+ anchors.fill: parent
+ anchors.margins: 11
+ radius: 7
+ color: root.palette.bgPanel
+ border.width: 2
+ border.color: root.theme.accent
+
+ Column {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: 8
+ spacing: 7
+
+ Rectangle {
+ width: parent.width * 0.64
+ height: 4
+ radius: 2
+ color: root.palette.fg
+ border.width: 0
+ }
+
+ Row {
+ spacing: 4
+
+ Repeater {
+ model: [root.theme.accent, root.theme.secondary,
+ root.palette.fgDim, root.palette.fgMuted]
+
+ Rectangle {
+ required property var modelData
+ width: 9
+ height: 9
+ radius: 5
+ color: modelData
+ border.width: 0
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Item {
+ id: caption
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: preview.bottom
+ anchors.leftMargin: 12
+ anchors.rightMargin: 10
+ height: 30
+
+ Text {
+ id: name
+ anchors.left: parent.left
+ anchors.right: marks.left
+ anchors.rightMargin: 6
+ anchors.verticalCenter: parent.verticalCenter
+ text: root.theme.name + (root.isDefault ? " · today's default" : "")
+ color: Theme.fg
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.DemiBold
+ elide: Text.ElideRight
+ }
+
+ Row {
+ id: marks
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 6
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: root.active
+ text: "✓"
+ color: Theme.accent
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ font.weight: Font.DemiBold
+ }
+
+ // Only saved themes can be removed, and the affordance is a
+ // separate focus stop so Tab never lands on "delete" when the
+ // person meant "select".
+ Rectangle {
+ id: remove
+
+ anchors.verticalCenter: parent.verticalCenter
+ visible: !root.shipped
+ width: 22
+ height: 22
+ radius: 7
+ color: removeHover.hovered
+ ? Theme.alpha(Theme.danger, 0.22)
+ : Theme.alpha(Theme.fg, 0.07)
+ border.width: remove.activeFocus ? 2 : 1
+ border.color: remove.activeFocus
+ ? Theme.danger : Theme.alpha(Theme.fg, 0.08)
+ activeFocusOnTab: visible
+
+ Accessible.role: Accessible.Button
+ Accessible.name: "Delete " + root.theme.name
+ Accessible.focusable: true
+ Accessible.focused: remove.activeFocus
+
+ Keys.onReturnPressed: root.removed()
+ Keys.onSpacePressed: root.removed()
+
+ Text {
+ anchors.centerIn: parent
+ text: "✕"
+ color: Theme.danger
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ }
+
+ HoverHandler {
+ id: removeHover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ // An exclusive grab, so the delete press does not also reach
+ // the card's own tap handler underneath and select the theme
+ // on its way out.
+ TapHandler {
+ gesturePolicy: TapHandler.ReleaseWithinBounds
+ onTapped: root.removed()
+ }
+ }
+ }
+ }
+
+ HoverHandler {
+ id: cardHover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ onTapped: root.chosen()
+ }
+}
diff --git a/config/dot/quickshell/modules/settings/ThemeEditorWells.qml b/config/dot/quickshell/modules/settings/ThemeEditorWells.qml
new file mode 100644
index 0000000..edcab70
--- /dev/null
+++ b/config/dot/quickshell/modules/settings/ThemeEditorWells.qml
@@ -0,0 +1,119 @@
+// The four colors a theme is actually made of.
+//
+// Primary and Secondary are the two ends of the Prism accent. Background and
+// Foreground are the ground and the text, and neither is stored alone: the
+// surfaces (bgDark, bgPanel, bgHighlight, bgPopover, gutter) and the two text
+// tints (fgDim, fgMuted) are mixed from them, so changing the ground moves the
+// whole family with it rather than leaving twelve tokens pointing at the old
+// one.
+//
+// One eyedropper process and one colour wheel are shared by all four wells --
+// the target is remembered rather than duplicated four times.
+
+import QtQuick
+import QtQuick.Dialogs
+import Quickshell
+import Quickshell.Io
+import qs.config
+import qs.services
+
+Grid {
+ id: root
+
+ width: parent ? parent.width : 620
+ columns: root.width < 520 ? 1 : 2
+ spacing: 12
+
+ // Which well the wheel or the eyedropper is currently answering.
+ property string target: "primary"
+ property string lastError: ""
+
+ readonly property real cellWidth: root.columns > 1
+ ? (root.width - root.spacing) / 2 : root.width
+
+ function currentColor(which: string): string {
+ if (which === "primary")
+ return String(ThemeProfiles.activeProfile.accent);
+ if (which === "secondary")
+ return String(ThemeProfiles.activeProfile.secondary);
+ if (which === "background")
+ return String(ThemeProfiles.activePalette.bg);
+ return String(ThemeProfiles.activePalette.fg);
+ }
+
+ // Every route through this component ends here, so the wheel, the
+ // eyedropper and the typed hex cannot drift apart.
+ function apply(which: string, hex: string): void {
+ const value = String(hex).trim().toLowerCase();
+ if (!/^#[0-9a-f]{6}$/.test(value)) {
+ root.lastError = "That is not a colour this theme can use.";
+ return;
+ }
+ root.lastError = "";
+ if (which === "primary")
+ ThemeProfiles.setAccentPair(value, ThemeProfiles.activeProfile.secondary);
+ else if (which === "secondary")
+ ThemeProfiles.setAccentPair(ThemeProfiles.activeProfile.accent, value);
+ else if (which === "background")
+ ThemeProfiles.setGroundColors(value, ThemeProfiles.activePalette.fg);
+ else
+ ThemeProfiles.setGroundColors(ThemeProfiles.activePalette.bg, value);
+ }
+
+ function pick(which: string): void {
+ if (screenPicker.running)
+ return;
+ root.target = which;
+ root.lastError = "";
+ screenPicker.exec(["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]);
+ }
+
+ function openWheel(which: string): void {
+ root.target = which;
+ wheel.selectedColor = root.currentColor(which);
+ wheel.open();
+ }
+
+ Repeater {
+ model: [
+ { key: "primary", role: "Primary" },
+ { key: "secondary", role: "Secondary" },
+ { key: "background", role: "Background" },
+ { key: "foreground", role: "Foreground" },
+ ]
+
+ ColorWell {
+ required property var modelData
+
+ width: root.cellWidth
+ role: modelData.role
+ swatchColor: root.currentColor(modelData.key)
+ picking: screenPicker.running
+ onCommitted: value => root.apply(modelData.key, value)
+ onWheelRequested: root.openWheel(modelData.key)
+ onPickRequested: root.pick(modelData.key)
+ }
+ }
+
+ ColorDialog {
+ id: wheel
+ title: "Choose a colour"
+ // Qt hands back a color value, not a string; toString() on it is
+ // "#aarrggbb" when there is alpha, so the channels are read directly.
+ onAccepted: root.apply(root.target, Qt.rgba(
+ wheel.selectedColor.r, wheel.selectedColor.g,
+ wheel.selectedColor.b, 1).toString())
+ }
+
+ Process {
+ id: screenPicker
+
+ stdout: StdioCollector {
+ onStreamFinished: root.apply(root.target, this.text)
+ }
+ onExited: (exitCode, exitStatus) => {
+ if (exitCode !== 0)
+ root.lastError = "Screen colour picking was cancelled or unavailable.";
+ }
+ }
+}
diff --git a/config/dot/quickshell/modules/settings/ThemeGallery.qml b/config/dot/quickshell/modules/settings/ThemeGallery.qml
new file mode 100644
index 0000000..a010ad4
--- /dev/null
+++ b/config/dot/quickshell/modules/settings/ThemeGallery.qml
@@ -0,0 +1,56 @@
+// Every theme for one scheme, shipped and saved together.
+//
+// Saved themes are not a second-class list below the shipped ones: a theme you
+// made is a theme, and once it is in the gallery it is chosen the same way. The
+// grid drops to two columns on a narrowly tiled window, because a third card at
+// 900px is narrower than its own caption.
+
+import QtQuick
+import qs.config
+import qs.services
+
+Column {
+ id: root
+
+ required property string scheme
+ property string heading: ""
+
+ width: parent ? parent.width : 620
+ spacing: 10
+
+ readonly property var themes: ThemeProfiles.profiles.filter(
+ profile => profile.scheme === root.scheme)
+
+ readonly property int columns: root.width < 640 ? 2 : 3
+ readonly property real cellWidth: root.columns > 0
+ ? (root.width - (root.columns - 1) * 12) / root.columns : 200
+
+ Text {
+ text: root.heading
+ color: Theme.fgMuted
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.DemiBold
+ font.capitalization: Font.AllUppercase
+ font.letterSpacing: 0.7
+ }
+
+ Grid {
+ width: parent.width
+ columns: root.columns
+ spacing: 12
+
+ Repeater {
+ model: root.themes
+
+ ThemeCard {
+ required property var modelData
+
+ width: root.cellWidth
+ theme: modelData
+ onChosen: ThemeProfiles.selectProfile(modelData.id)
+ onRemoved: ThemeProfiles.deleteProfile(modelData.id)
+ }
+ }
+ }
+}
diff --git a/config/dot/quickshell/modules/settings/ThemeModeHero.qml b/config/dot/quickshell/modules/settings/ThemeModeHero.qml
new file mode 100644
index 0000000..3a5e27d
--- /dev/null
+++ b/config/dot/quickshell/modules/settings/ThemeModeHero.qml
@@ -0,0 +1,186 @@
+// Light or dark, as two desktops rather than two words.
+//
+// This is the control reached most often in the whole of Appearance, so it
+// leads the category and it is drawn, not labelled: each card is the scheme's
+// remembered theme -- yours, not the shipped default -- rendered as a bar, a
+// focused window and the text on it. Choosing a side returns to whatever theme
+// you last chose for that side; it never resets you to Moon or Day.
+
+import QtQuick
+import qs.config
+import qs.services
+
+Row {
+ id: root
+
+ width: parent ? parent.width : 620
+ spacing: 14
+
+ // The record the scheme would land on, which is the honest thing to show.
+ // themeForScheme already falls back to the catalog default when the
+ // remembered id no longer exists.
+ function themeFor(scheme: string): var {
+ const id = ThemeProfiles.themeForScheme(scheme);
+ const record = ThemeCatalog.byId(id)
+ ?? ThemeProfiles.customProfiles.find(profile => profile.id === id);
+ if (record)
+ return record;
+ const fallback = ThemeCatalog.byId(scheme === "light"
+ ? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark);
+ return fallback ?? { scheme: scheme, accent: Theme.accent,
+ secondary: Theme.accentSecondary, palette: ThemeCatalog.defaultPalette(scheme) };
+ }
+
+ Repeater {
+ model: [
+ { scheme: "dark", label: "Dark" },
+ { scheme: "light", label: "Light" },
+ ]
+
+ Rectangle {
+ id: card
+
+ required property var modelData
+
+ readonly property var theme: root.themeFor(card.modelData.scheme)
+ readonly property var palette: card.theme.palette
+ ?? ThemeCatalog.defaultPalette(card.modelData.scheme)
+ readonly property bool selected: ThemeProfiles.scheme === card.modelData.scheme
+
+ width: (root.width - root.spacing) / 2
+ implicitHeight: scene.height + caption.height + 4
+ radius: Theme.cardRadius + 4
+ color: Theme.alpha(Theme.fg, 0.04)
+ border.width: 2
+ border.color: card.activeFocus
+ ? Theme.accentSecondary
+ : (card.selected ? Theme.accent : Theme.alpha(Theme.fg, 0.13))
+ clip: true
+ activeFocusOnTab: true
+
+ Accessible.role: Accessible.Button
+ Accessible.name: card.modelData.label + " mode"
+ Accessible.description: card.theme.name
+ ? card.theme.name + (card.selected ? ", selected" : "")
+ : ""
+ Accessible.focusable: true
+ Accessible.focused: card.activeFocus
+
+ function choose(): void {
+ ThemeProfiles.setScheme(card.modelData.scheme);
+ }
+
+ Keys.onReturnPressed: card.choose()
+ Keys.onSpacePressed: card.choose()
+
+ Rectangle {
+ id: scene
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: 2
+ height: 110
+ color: card.palette.bg
+ border.width: 0
+
+ Column {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: 15
+ spacing: 10
+
+ // The bar, edge to edge on the real desktop and so drawn
+ // wide and short here.
+ Rectangle {
+ width: parent.width * 0.6
+ height: 8
+ radius: 4
+ color: card.palette.bgDark
+ border.width: 1
+ border.color: card.palette.bgHighlight
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 56
+ radius: 8
+ color: card.palette.bgPanel
+ border.width: 2
+ border.color: card.theme.accent
+
+ Column {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: 9
+ spacing: 6
+
+ Rectangle {
+ width: parent.width * 0.6
+ height: 5
+ radius: 3
+ color: card.palette.fg
+ border.width: 0
+ }
+
+ Rectangle {
+ width: parent.width * 0.42
+ height: 5
+ radius: 3
+ color: card.palette.fgDim
+ border.width: 0
+ }
+ }
+ }
+ }
+ }
+
+ Item {
+ id: caption
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: scene.bottom
+ anchors.leftMargin: 14
+ anchors.rightMargin: 12
+ height: 34
+
+ Text {
+ anchors.left: parent.left
+ anchors.right: tick.left
+ anchors.rightMargin: 6
+ anchors.verticalCenter: parent.verticalCenter
+ text: card.modelData.label
+ + (card.theme.name ? " · " + card.theme.name : "")
+ color: Theme.fg
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ font.weight: Font.DemiBold
+ elide: Text.ElideRight
+ }
+
+ Text {
+ id: tick
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ visible: card.selected
+ text: "✓"
+ color: Theme.accent
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ font.weight: Font.DemiBold
+ }
+ }
+
+ HoverHandler {
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ onTapped: card.choose()
+ }
+ }
+ }
+}
diff --git a/config/dot/quickshell/modules/settings/ThemeProfilePicker.qml b/config/dot/quickshell/modules/settings/ThemeProfilePicker.qml
deleted file mode 100644
index db68d14..0000000
--- a/config/dot/quickshell/modules/settings/ThemeProfilePicker.qml
+++ /dev/null
@@ -1,123 +0,0 @@
-// Named themes are the first layer of Appearance: shipped profiles stay
-// immutable, while saved profiles can be selected or removed in place.
-
-import QtQuick
-import qs.config
-import qs.services
-
-Column {
- id: root
-
- width: parent ? parent.width : 620
- spacing: 0
-
- Repeater {
- model: ThemeProfiles.profiles
-
- SettingRow {
- id: profileRow
-
- required property var modelData
-
- label: profileRow.modelData.name
- detail: (profileRow.modelData.scheme === "light" ? "Light" : "Dark")
- + " · " + profileRow.modelData.accent + " → " + profileRow.modelData.secondary
- controlWidth: profileRow.modelData.shipped ? 88 : 170
-
- Row {
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- spacing: 6
-
- SettingsButton {
- id: useButton
- text: profileRow.modelData.id === ThemeProfiles.activeProfile.id ? "Selected" : "Use"
- enabled: profileRow.modelData.id !== ThemeProfiles.activeProfile.id
- activeFocusOnTab: enabled
- border.width: activeFocus ? 2 : (tone === "accent" ? 0 : 1)
- border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
- onClicked: ThemeProfiles.selectProfile(profileRow.modelData.id)
- Keys.onReturnPressed: if (enabled) ThemeProfiles.selectProfile(profileRow.modelData.id)
- Keys.onSpacePressed: if (enabled) ThemeProfiles.selectProfile(profileRow.modelData.id)
- }
-
- SettingsButton {
- id: deleteButton
- visible: !profileRow.modelData.shipped
- text: "Delete"
- activeFocusOnTab: visible
- border.width: activeFocus ? 2 : 1
- border.color: activeFocus ? Theme.danger : Theme.alpha(Theme.fg, 0.08)
- onClicked: ThemeProfiles.deleteProfile(profileRow.modelData.id)
- Keys.onReturnPressed: ThemeProfiles.deleteProfile(profileRow.modelData.id)
- Keys.onSpacePressed: ThemeProfiles.deleteProfile(profileRow.modelData.id)
- }
- }
- }
- }
-
- SettingRow {
- label: "Save current theme"
- detail: "Stores this scheme and accent pair under a unique name"
- divider: false
- controlWidth: 292
-
- Row {
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- spacing: 7
-
- Rectangle {
- width: 196
- height: 31
- radius: 8
- color: Theme.alpha(Theme.fg, 0.05)
- border.width: nameInput.activeFocus ? 2 : 1
- border.color: nameInput.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12)
-
- TextInput {
- id: nameInput
- anchors.fill: parent
- anchors.leftMargin: 10
- anchors.rightMargin: 10
- activeFocusOnTab: true
- verticalAlignment: TextInput.AlignVCenter
- maximumLength: 40
- color: Theme.fg
- selectionColor: Theme.alpha(Theme.accent, 0.35)
- font.family: Theme.fontFamily
- font.pixelSize: Theme.fontSizeSmall
- Accessible.name: "Theme profile name"
-
- Text {
- anchors.verticalCenter: parent.verticalCenter
- visible: nameInput.text === "" && !nameInput.activeFocus
- text: "Theme name"
- color: Theme.fgMuted
- font: nameInput.font
- }
-
- onAccepted: saveButton.save()
- }
- }
-
- SettingsButton {
- id: saveButton
- text: "Save"
- tone: "accent"
- activeFocusOnTab: true
- border.width: activeFocus ? 2 : 0
- border.color: activeFocus ? Theme.fg : "transparent"
-
- function save(): void {
- if (ThemeProfiles.saveProfile(nameInput.text))
- nameInput.text = "";
- }
-
- onClicked: save()
- Keys.onReturnPressed: save()
- Keys.onSpacePressed: save()
- }
- }
- }
-}
diff --git a/config/dot/quickshell/modules/settings/ThemeSaturationRow.qml b/config/dot/quickshell/modules/settings/ThemeSaturationRow.qml
new file mode 100644
index 0000000..a6fddbe
--- /dev/null
+++ b/config/dot/quickshell/modules/settings/ThemeSaturationRow.qml
@@ -0,0 +1,191 @@
+// Saturation, as a nudge rather than a stored number.
+//
+// There is no saturation preference: `setSaturation` re-saturates the palette
+// that is in effect right now, which makes it compounding -- committing 1.5
+// twice really is 2.25. So the row keeps the reading it has shown you and
+// commits the RATIO between the old reading and the new one, which makes the
+// number on screen an honest total rather than a lever that lies the second
+// time it is pulled. Selecting a different theme resets the reading to neutral,
+// because the new palette is a new ground.
+//
+// The commit happens on release only. A drag through 0.6 → 0.9 → 1.4 would
+// otherwise rewrite all nineteen tokens at every pixel, and each rewrite is
+// lossy: HSV saturation cannot be recovered once it has been rounded to a hex.
+
+import QtQuick
+import qs.config
+import qs.services
+
+SettingRow {
+ id: root
+
+ label: "Saturation"
+ detail: "Shifts every derived surface and text tone together"
+ controlWidth: 280
+
+ // 1.0 is the palette as it stands. Range 0 – 2, shown as 0 – 200%.
+ property real shown: 1
+ property real committed: 1
+
+ readonly property real maximum: 2
+
+ // A theme change replaces the ground this row was measuring against.
+ readonly property string activeId: ThemeProfiles.activeId
+ onActiveIdChanged: {
+ root.shown = 1;
+ root.committed = 1;
+ }
+
+ function apply(): void {
+ if (Math.abs(root.shown - root.committed) < 0.005)
+ return;
+ // Dividing by the last reading turns an absolute slider into the
+ // relative factor the service actually takes.
+ const factor = root.committed > 0.02 ? root.shown / root.committed : root.maximum;
+ if (ThemeProfiles.setSaturation(Math.max(0, Math.min(root.maximum, factor))))
+ root.committed = root.shown;
+ else
+ root.shown = root.committed;
+ }
+
+ function moveTo(ratio: real): void {
+ root.shown = Math.round(Math.max(0, Math.min(1, ratio)) * root.maximum * 100) / 100;
+ }
+
+ function step(direction: int): void {
+ root.moveTo((root.shown + direction * 0.05) / root.maximum);
+ root.apply();
+ }
+
+ Item {
+ id: control
+
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ width: 270
+ height: 32
+ activeFocusOnTab: true
+
+ Accessible.role: Accessible.Slider
+ Accessible.name: root.label
+ Accessible.description: Math.round(root.shown * 100) + " percent, range 0 to 200"
+ Accessible.focusable: true
+ Accessible.focused: control.activeFocus
+ Accessible.onIncreaseAction: root.step(1)
+ Accessible.onDecreaseAction: root.step(-1)
+
+ Keys.onPressed: event => {
+ if (event.key === Qt.Key_Left || event.key === Qt.Key_Down) {
+ root.step(-1);
+ event.accepted = true;
+ } else if (event.key === Qt.Key_Right || event.key === Qt.Key_Up) {
+ root.step(1);
+ event.accepted = true;
+ } else if (event.key === Qt.Key_Home) {
+ root.moveTo(0);
+ root.apply();
+ event.accepted = true;
+ } else if (event.key === Qt.Key_End) {
+ root.moveTo(1);
+ root.apply();
+ event.accepted = true;
+ }
+ }
+
+ Rectangle {
+ id: frame
+
+ anchors.left: parent.left
+ anchors.right: readout.left
+ anchors.rightMargin: 10
+ anchors.verticalCenter: parent.verticalCenter
+ height: 24
+ radius: 9
+ color: "transparent"
+ border.width: control.activeFocus ? 2 : 1
+ border.color: control.activeFocus
+ ? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.08)
+
+ // Written out rather than reusing ValueSlider: this row needs the
+ // release, and ValueSlider reports only movement.
+ Rectangle {
+ id: track
+
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.margins: 4
+ height: 10
+ radius: 5
+ color: Theme.alpha(Theme.fg, 0.12)
+ border.width: 0
+
+ Rectangle {
+ id: fill
+
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: parent.width * Math.max(0, Math.min(1, root.shown / root.maximum))
+ radius: parent.radius
+ border.width: 0
+ gradient: Gradient {
+ orientation: Gradient.Horizontal
+ GradientStop { position: 0.0; color: Theme.accent }
+ GradientStop { position: 1.0; color: Theme.accentSecondary }
+ }
+ }
+
+ Rectangle {
+ width: 16
+ height: 16
+ radius: 8
+ border.width: 0
+ color: Theme.fg
+ anchors.verticalCenter: parent.verticalCenter
+ x: Math.max(0, Math.min(parent.width - width, fill.width - width / 2))
+ visible: drag.containsMouse || drag.pressed
+ }
+
+ MouseArea {
+ id: drag
+
+ anchors.fill: parent
+ anchors.margins: -8
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+
+ function ratioAt(mouseX: real): real {
+ return (mouseX - 8) / track.width;
+ }
+
+ onPressed: event => root.moveTo(drag.ratioAt(event.x))
+ onPositionChanged: event => {
+ if (drag.pressed)
+ root.moveTo(drag.ratioAt(event.x));
+ }
+ onReleased: root.apply()
+ onWheel: event => {
+ root.moveTo((root.shown + (event.angleDelta.y > 0 ? 0.05 : -0.05))
+ / root.maximum);
+ root.apply();
+ }
+ }
+ }
+ }
+
+ Text {
+ id: readout
+
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ width: 56
+ horizontalAlignment: Text.AlignRight
+ text: Math.round(root.shown * 100) + "%"
+ color: Theme.fgDim
+ font.family: Theme.fontFamily
+ font.features: Theme.tabularFigures
+ font.pixelSize: Theme.fontSizeSmall
+ }
+ }
+}
diff --git a/config/dot/quickshell/modules/settings/ThemeSaveRow.qml b/config/dot/quickshell/modules/settings/ThemeSaveRow.qml
new file mode 100644
index 0000000..aac120d
--- /dev/null
+++ b/config/dot/quickshell/modules/settings/ThemeSaveRow.qml
@@ -0,0 +1,105 @@
+// Naming what you built.
+//
+// Saving snapshots the palette, the terminal colours and the ten effect values
+// as they stand, so a saved theme restores the whole look rather than two
+// accent hexes. Delete appears only for a saved theme, because a shipped one
+// cannot be removed -- it is read from the catalog every launch.
+
+import QtQuick
+import qs.config
+import qs.services
+
+SettingRow {
+ id: root
+
+ readonly property bool custom: ThemeProfiles.activeProfile.shipped !== true
+
+ label: "Save as"
+ detail: root.custom
+ ? "Saving again under a new name keeps both"
+ : "Editing a shipped theme forks it; give the fork a name to keep it"
+ divider: false
+ controlWidth: root.custom ? 380 : 292
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 7
+
+ Rectangle {
+ width: 196
+ height: 31
+ radius: 8
+ color: Theme.alpha(Theme.fg, 0.05)
+ border.width: nameInput.activeFocus ? 2 : 1
+ border.color: nameInput.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12)
+
+ TextInput {
+ id: nameInput
+
+ anchors.fill: parent
+ anchors.leftMargin: 10
+ anchors.rightMargin: 10
+ activeFocusOnTab: true
+ verticalAlignment: TextInput.AlignVCenter
+ maximumLength: 40
+ color: Theme.fg
+ selectionColor: Theme.alpha(Theme.accent, 0.35)
+ selectedTextColor: Theme.fg
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+
+ Accessible.role: Accessible.EditableText
+ Accessible.name: "Theme name"
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: nameInput.text === "" && !nameInput.activeFocus
+ text: "Theme name"
+ color: Theme.fgMuted
+ font: nameInput.font
+ }
+
+ onAccepted: saveButton.save()
+ }
+ }
+
+ SettingsButton {
+ id: saveButton
+
+ text: "Save theme"
+ tone: "accent"
+ activeFocusOnTab: true
+ border.width: activeFocus ? 2 : 0
+ border.color: activeFocus ? Theme.fg : "transparent"
+
+ function save(): void {
+ if (ThemeProfiles.saveProfile(nameInput.text))
+ nameInput.text = "";
+ }
+
+ onClicked: saveButton.save()
+ Keys.onReturnPressed: saveButton.save()
+ Keys.onSpacePressed: saveButton.save()
+ }
+
+ SettingsButton {
+ id: deleteButton
+
+ visible: root.custom
+ text: "Delete"
+ tone: "danger"
+ activeFocusOnTab: visible
+ border.width: activeFocus ? 2 : 1
+ border.color: activeFocus ? Theme.danger : Theme.alpha(Theme.danger, 0.45)
+
+ function remove(): void {
+ ThemeProfiles.deleteProfile(ThemeProfiles.activeId);
+ }
+
+ onClicked: deleteButton.remove()
+ Keys.onReturnPressed: deleteButton.remove()
+ Keys.onSpacePressed: deleteButton.remove()
+ }
+ }
+}
diff --git a/config/dot/quickshell/modules/settings/ThemeStartChips.qml b/config/dot/quickshell/modules/settings/ThemeStartChips.qml
new file mode 100644
index 0000000..2494b87
--- /dev/null
+++ b/config/dot/quickshell/modules/settings/ThemeStartChips.qml
@@ -0,0 +1,98 @@
+// Where the theme you are editing came from.
+//
+// Editing a shipped theme forks it into a saved copy rather than changing what
+// ships, so "start from" is the honest name for this row: picking a chip
+// abandons the current edits and begins again from that theme. Only themes of
+// the current scheme are offered -- a light theme cannot be the starting point
+// for a dark one without also flipping the desktop underneath the editor.
+
+import QtQuick
+import qs.config
+import qs.services
+
+Flow {
+ id: root
+
+ width: parent ? parent.width : 620
+ spacing: 8
+
+ readonly property var themes: ThemeProfiles.profiles.filter(
+ profile => profile.scheme === ThemeProfiles.scheme)
+
+ Repeater {
+ model: root.themes
+
+ Rectangle {
+ id: chip
+
+ required property var modelData
+
+ readonly property bool active: chip.modelData.id === ThemeProfiles.activeId
+
+ implicitWidth: caption.implicitWidth + dot.width + 26
+ implicitHeight: 31
+ radius: Theme.pillRadius
+ color: chip.active
+ ? Theme.alpha(Theme.accent, 0.12)
+ : Theme.alpha(Theme.fg, chipHover.hovered ? 0.12 : 0.06)
+ border.width: chip.activeFocus || chip.active ? 2 : 1
+ border.color: chip.activeFocus
+ ? Theme.accentSecondary
+ : (chip.active ? Theme.accent : Theme.alpha(Theme.fg, 0.1))
+ activeFocusOnTab: true
+
+ Accessible.role: Accessible.Button
+ Accessible.name: "Start from " + chip.modelData.name
+ Accessible.focusable: true
+ Accessible.focused: chip.activeFocus
+
+ function choose(): void {
+ ThemeProfiles.selectProfile(chip.modelData.id);
+ }
+
+ Keys.onReturnPressed: chip.choose()
+ Keys.onSpacePressed: chip.choose()
+
+ // The gradient, not a flat dot: the pair is what the chip stands
+ // for, exactly as on the accent swatches it replaces.
+ Rectangle {
+ id: dot
+
+ anchors.left: parent.left
+ anchors.leftMargin: 7
+ anchors.verticalCenter: parent.verticalCenter
+ width: 17
+ height: 17
+ radius: 9
+ border.width: 0
+ gradient: Gradient {
+ orientation: Gradient.Horizontal
+ GradientStop { position: 0.0; color: chip.modelData.accent }
+ GradientStop { position: 1.0; color: chip.modelData.secondary }
+ }
+ }
+
+ Text {
+ id: caption
+
+ anchors.left: dot.right
+ anchors.leftMargin: 8
+ anchors.verticalCenter: parent.verticalCenter
+ text: chip.modelData.name
+ color: chip.active ? Theme.fg : Theme.fgDim
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: chip.active ? Font.DemiBold : Font.Medium
+ }
+
+ HoverHandler {
+ id: chipHover
+ cursorShape: Qt.PointingHandCursor
+ }
+
+ TapHandler {
+ onTapped: chip.choose()
+ }
+ }
+ }
+}
diff --git a/config/dot/quickshell/modules/settings/WallpaperPicker.qml b/config/dot/quickshell/modules/settings/WallpaperPicker.qml
index dcec498..cd32ede 100644
--- a/config/dot/quickshell/modules/settings/WallpaperPicker.qml
+++ b/config/dot/quickshell/modules/settings/WallpaperPicker.qml
@@ -34,16 +34,24 @@ Item {
property var setAssignmentAction: function(output, path) { Wallpaper.setAssignment(output, path); }
readonly property int collapsedRows: 2
- readonly property var shown: root.expanded
- ? Wallpaper.available
- : Wallpaper.available.slice(0, root.columns * root.collapsedRows)
+ // Videos join the grid only in single mode — slideshow and per-display
+ // are hyprpaper's modes and stills-only.
+ readonly property var catalog: root.mode === "single"
+ ? Wallpaper.available.concat(VideoWallpaper.candidates)
+ : Wallpaper.available
- readonly property int hidden: Wallpaper.available.length - root.shown.length
+ readonly property var shown: root.expanded
+ ? root.catalog
+ : root.catalog.slice(0, root.columns * root.collapsedRows)
+
+ readonly property int hidden: root.catalog.length - root.shown.length
readonly property int columns: Math.max(2, Math.floor(width / 190))
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
function isCurrent(path: string): bool {
+ if (VideoWallpaper.active)
+ return VideoWallpaper.path === path;
return root.activeByOutput[root.selectedOutput] === path;
}
@@ -80,6 +88,7 @@ Item {
required property var modelData
readonly property bool current: root.isCurrent(tile.modelData)
+ readonly property bool video: VideoWallpaper.isVideo(tile.modelData)
readonly property bool member: root.mode === "slideshow" && root.selected(tile.modelData)
width: root.cellWidth
@@ -93,7 +102,8 @@ Item {
id: thumbnail
anchors.fill: parent
- source: "file://" + tile.modelData
+ visible: !tile.video
+ source: tile.video ? "" : "file://" + tile.modelData
fillMode: Image.PreserveAspectCrop
asynchronous: true
// Decode to roughly the size actually drawn. Without this a
@@ -120,6 +130,48 @@ Item {
}
}
+ // A video tile draws a play badge instead of a thumbnail —
+ // decoding a frame per tile is exactly the cost this feature
+ // is designed to avoid paying casually.
+ Rectangle {
+ anchors.fill: parent
+ visible: tile.video
+ radius: parent.radius
+ gradient: Gradient {
+ GradientStop { position: 0; color: Theme.alpha(Theme.accent, 0.14) }
+ GradientStop { position: 1; color: Theme.alpha(Theme.bgDark, 0.9) }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ text: "\u{F040A}"
+ color: Theme.alpha(Theme.fg, 0.8)
+ font.family: Theme.fontMono
+ font.pixelSize: 26
+ }
+
+ Rectangle {
+ anchors.top: parent.top
+ anchors.right: parent.right
+ anchors.margins: 7
+ width: videoBadge.implicitWidth + 14
+ height: 20
+ radius: Theme.pillRadius
+ color: Theme.alpha(Theme.bgDark, 0.75)
+
+ Text {
+ id: videoBadge
+ anchors.centerIn: parent
+ text: "VIDEO"
+ color: Theme.fgDim
+ font.family: Theme.fontFamily
+ font.pixelSize: 9
+ font.weight: Font.DemiBold
+ font.letterSpacing: 0.5
+ }
+ }
+ }
+
Text {
anchors.centerIn: parent
visible: thumbnail.status === Image.Loading
diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir
index e52dfa9..51e312e 100644
--- a/config/dot/quickshell/modules/settings/qmldir
+++ b/config/dot/quickshell/modules/settings/qmldir
@@ -83,7 +83,13 @@ PrivacyPage 1.0 PrivacyPage.qml
RegionPage 1.0 RegionPage.qml
SearchPicker 1.0 SearchPicker.qml
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
-AccentPicker 1.0 AccentPicker.qml
TextFieldRow 1.0 TextFieldRow.qml
AccentEditor 1.0 AccentEditor.qml
-ThemeProfilePicker 1.0 ThemeProfilePicker.qml
+ThemeCard 1.0 ThemeCard.qml
+ThemeGallery 1.0 ThemeGallery.qml
+ThemeModeHero 1.0 ThemeModeHero.qml
+ColorWell 1.0 ColorWell.qml
+ThemeEditorWells 1.0 ThemeEditorWells.qml
+ThemeSaturationRow 1.0 ThemeSaturationRow.qml
+ThemeStartChips 1.0 ThemeStartChips.qml
+ThemeSaveRow 1.0 ThemeSaveRow.qml
diff --git a/config/dot/quickshell/scripts/panama-doctor b/config/dot/quickshell/scripts/panama-doctor
index e18b800..8b68ca2 100755
--- a/config/dot/quickshell/scripts/panama-doctor
+++ b/config/dot/quickshell/scripts/panama-doctor
@@ -119,7 +119,7 @@ CHECK_ORDER = (
"desktop.hyprland", "desktop.quickshell", "desktop.notifications", "desktop.portals",
"desktop.document-portal", "desktop.portal-stability",
"desktop.hyprpaper", "desktop.hypridle", "desktop.hyprlock", "desktop.vicinae", "input.pipewire",
- "input.clipboard", "input.wallpaper", "input.capture", "input.ocr", "input.brightness",
+ "input.clipboard", "input.wallpaper", "input.video-wallpaper", "input.capture", "input.ocr", "input.brightness",
"integration.nextcloud", "integration.rustdesk", "integration.kdeconnect", "integration.bluebubbles",
"integration.home-assistant", "integration.calendar", "panama.updates", "panama.runtime-links", "panama.vicinae-commands",
"panama.selected-terminal", "panama.selected-launcher", "panama.processes", "panama.caffeine",
@@ -190,6 +190,7 @@ CHECK_TITLES = {
"input.pipewire": "PipeWire",
"input.clipboard": "Clipboard",
"input.wallpaper": "Wallpaper",
+ "input.video-wallpaper": "Video wallpaper",
"input.capture": "Capture",
"input.ocr": "OCR",
"input.brightness": "External monitor brightness",
@@ -581,6 +582,28 @@ def check_bluebubbles(config: DoctorConfig) -> Check:
return Check("integration.bluebubbles", "integrations", "BlueBubbles", "warning", "BlueBubbles installation probe timed out.", Action("open", "Open BlueBubbles"))
+def check_video_wallpaper(config: DoctorConfig) -> Check:
+ """A configured video wallpaper needs mpvpaper; without a video stored the
+ check is simply not applicable. Missing mpvpaper with a video configured is
+ the silent failure that reads as "my wallpaper disappeared"."""
+ settings_path = config.config_home / "panama" / "settings.json"
+ video = ""
+ try:
+ stored = json.loads(settings_path.read_text())
+ candidate = str(stored.get("wallpaperPath", ""))
+ if re.search(r"\.(mp4|mkv|webm)$", candidate, re.IGNORECASE):
+ video = candidate
+ except (OSError, ValueError):
+ pass
+ if video == "":
+ return Check("input.video-wallpaper", "input-media", "Video wallpaper", "unconfigured", "No video wallpaper is configured.")
+ if shutil.which("mpvpaper") is None:
+ return Check("input.video-wallpaper", "input-media", "Video wallpaper", "warning", "A video wallpaper is configured but mpvpaper is not installed.")
+ if not Path(video).is_file():
+ return Check("input.video-wallpaper", "input-media", "Video wallpaper", "warning", "The configured video wallpaper file is missing.")
+ return Check("input.video-wallpaper", "input-media", "Video wallpaper", "ok", "mpvpaper is installed and the video exists.")
+
+
def check_home_assistant(config: DoctorConfig) -> Check:
configured = all(name in os.environ for name in ("PANAMA_HOME_ASSISTANT_URL", "PANAMA_HOME_ASSISTANT_TOKEN"))
helper = config.config_home / "quickshell" / "scripts" / "panama-home-assistant"
@@ -780,7 +803,7 @@ def collect_checks(config: DoctorConfig) -> list[Check]:
probes: dict[str, Callable[[], Check]] = {
"desktop.hyprland": lambda: check_hyprland(config), "desktop.quickshell": lambda: check_quickshell(config), "desktop.notifications": lambda: check_notifications(config), "desktop.portals": lambda: check_portals(config), "desktop.portal-stability": lambda: check_portal_stability(config), "desktop.document-portal": lambda: check_document_portal(config),
"desktop.hyprpaper": lambda: service_check("desktop.hyprpaper", "Hyprpaper", "hyprpaper", config, Action("repair", "Restart Hyprpaper")), "desktop.hypridle": lambda: service_check("desktop.hypridle", "Hypridle", "hypridle", config, Action("repair", "Restart Hypridle")), "desktop.hyprlock": lambda: check_hyprlock(config), "desktop.vicinae": lambda: service_check("desktop.vicinae", "Vicinae", "vicinae", config, Action("repair", "Restart Vicinae")), "input.pipewire": lambda: service_check("input.pipewire", "PipeWire", "pipewire", config),
- "input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config),
+ "input.clipboard": lambda: simple_ipc_check("input.clipboard", "Clipboard", "clipboard", config), "input.wallpaper": lambda: simple_ipc_check("input.wallpaper", "Wallpaper", "wallpaper", config), "input.video-wallpaper": lambda: check_video_wallpaper(config), "input.capture": lambda: simple_ipc_check("input.capture", "Capture", "capture", config), "input.ocr": lambda: executable_check("input.ocr", "OCR", "tesseract", config), "input.brightness": lambda: check_brightness(config),
"integration.nextcloud": lambda: check_nextcloud(config), "integration.rustdesk": lambda: check_rustdesk(config), "integration.kdeconnect": lambda: check_kdeconnect(config), "integration.bluebubbles": lambda: check_bluebubbles(config), "integration.home-assistant": lambda: check_home_assistant(config), "integration.calendar": lambda: check_calendar(config),
"panama.updates": lambda: check_updates(config), "panama.runtime-links": lambda: check_runtime_links(config), "panama.vicinae-commands": lambda: check_vicinae_commands(config), "panama.selected-terminal": lambda: executable_check("panama.selected-terminal", "Selected terminal", "kitty", config), "panama.selected-launcher": lambda: executable_check("panama.selected-launcher", "Selected launcher", "vicinae", config), "panama.processes": lambda: check_processes(config), "panama.caffeine": lambda: check_caffeine(config),
}
diff --git a/config/dot/quickshell/scripts/panama-lock b/config/dot/quickshell/scripts/panama-lock
index fb91e1f..31f2df0 100755
--- a/config/dot/quickshell/scripts/panama-lock
+++ b/config/dot/quickshell/scripts/panama-lock
@@ -8,7 +8,9 @@
set -euo pipefail
-# The accents, from the one file that holds them.
+# The accents and the theme resolver, from the one file that holds them. The
+# lock screen renders the same theme panama-theme-apps does, resolved by the
+# same function rather than by a second copy of the lookup.
# shellcheck source=./panama-palette
source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/panama-palette"
@@ -87,6 +89,17 @@ valid_path() {
resolve_wallpaper_path() {
local candidate="$1"
+ # A video wallpaper cannot play on the lock screen; VideoWallpaper.qml
+ # grabs a still frame when one is selected, and that frame stands in.
+ # A video with no cached frame falls through to the shipped image.
+ if [[ "$candidate" =~ \.(mp4|mkv|webm)$ ]]; then
+ local frame="${XDG_STATE_HOME:-$HOME/.local/state}/panama/video-wallpaper-frame.png"
+ if valid_path "$frame"; then
+ resolved_wallpaper="$frame"
+ return
+ fi
+ candidate=""
+ fi
if valid_path "$candidate"; then
resolved_wallpaper="$candidate"
return
@@ -101,14 +114,6 @@ resolve_wallpaper_path() {
fi
}
-# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by
-# hand -- there is no shared source between QML and a shell script), primary
-# hue per scheme only: the lock screen shows a flat focus ring, not the
-# two-stop gradient the window border does. Falls back to blue for an unknown
-# name, matching Theme.accentPair's own fallback.
-
-# hyprlock wants "R, G, B" decimal, not hex.
-
load_preferences() {
if [[ -r "$settings" ]] && jq -e 'type == "object"' "$settings" >/dev/null 2>&1; then
settings_valid=true
@@ -153,28 +158,35 @@ load_preferences() {
5) blur_passes=5; blur_size=12 ;;
esac
- if [[ "$color_scheme" == light ]]; then
- background_color='rgba(225, 226, 231, 1.0)'
- foreground_color='rgba(55, 96, 191, 1.0)'
- dim_color='rgba(97, 114, 176, 1.0)'
- error_color='rgba(245, 42, 101, 1.0)'
- field_color='rgba(208, 213, 227, 0.85)'
- dim_hex='6172b0'
- error_hex='f52a65'
- else
- background_color='rgba(34, 36, 54, 1.0)'
- foreground_color='rgba(200, 211, 245, 1.0)'
- dim_color='rgba(130, 139, 184, 1.0)'
- error_color='rgba(255, 117, 127, 1.0)'
- field_color='rgba(46, 47, 61, 0.85)'
- dim_hex='828bb8'
- error_hex='ff757f'
- fi
+ # The colours come from the ACTIVE THEME, resolved by panama-palette from
+ # settings.json and config/themes.json -- the same function
+ # panama-theme-apps renders hyprlock.conf.template through. They used to be
+ # two hardcoded Tokyo Night tables here, a third copy in panama-theme-apps
+ # and a fourth in link-dotfiles, which is why choosing any other theme left
+ # the lock screen in last season's colour with nothing to say why.
+ eval "$(theme_vars "$(resolve_theme "$color_scheme")")"
- # The focus ring is the accent role, not a scheme-relative one: it follows
- # accentName the same way ColorScheme.qml's active_border does, and needs
- # both the accent and the scheme since each accent carries a separate pair.
- accent_rgb="$(hex_to_rgb "$(accent_hex "$accent_name" "$color_scheme")")"
+ # An unreadable themes.json is the only way to arrive here without a
+ # palette. Refusing is right: the seeded fallback config is themed, and
+ # hyprlock's own defaults -- which is what a config full of empty colours
+ # resolves to -- are a bright grey screen.
+ for token in bg fg fgMuted red bgPanel; do
+ palette_token="pal_$token"
+ [[ -n "${!palette_token:-}" ]] || return 1
+ done
+
+ background_color="rgba($(hex_to_rgb "$pal_bg"), 1.0)"
+ foreground_color="rgba($(hex_to_rgb "$pal_fg"), 1.0)"
+ dim_color="rgba($(hex_to_rgb "$pal_fgMuted"), 1.0)"
+ error_color="rgba($(hex_to_rgb "$pal_red"), 1.0)"
+ field_color="rgba($(hex_to_rgb "$pal_bgPanel"), 0.85)"
+ dim_hex="$pal_fgMuted"
+ error_hex="$pal_red"
+
+ # The focus ring is the theme's accent. accentName is still read as the
+ # fallback: a stored custom theme may carry no accent of its own, and the
+ # curated pair for the recorded name is a better answer than none.
+ accent_rgb="$(hex_to_rgb "$(hex_or "${theme_accent:-}" "$(accent_hex "$accent_name" "$color_scheme")")")"
accent_color="rgba($accent_rgb, 1.0)"
accent_ring_color="rgba($accent_rgb, 0.9)"
}
@@ -329,7 +341,10 @@ write_status() {
}
generate() {
- load_preferences
+ if ! load_preferences; then
+ write_status false "$fallback" true "The lock-screen colors could not be resolved."
+ return 1
+ fi
mkdir -p "$state_dir" || return 1
if ! emit_config >"$temporary"; then
rm -f "$temporary" 2>/dev/null || true
diff --git a/config/dot/quickshell/scripts/panama-palette b/config/dot/quickshell/scripts/panama-palette
index 7b4ac9c..abbf9e5 100755
--- a/config/dot/quickshell/scripts/panama-palette
+++ b/config/dot/quickshell/scripts/panama-palette
@@ -1,12 +1,14 @@
#!/usr/bin/env bash
-# The accent palette, for the shell scripts that need it.
+# The accent palette and the theme resolver, for the shell scripts that need
+# them.
#
# Sourced, not run:
#
# source "$(dirname "$0")/panama-palette"
# accent_hex orchid dark # -> c099ff
# hex_to_rgb c099ff # -> 192, 153, 255
+# eval "$(theme_vars "$(resolve_theme dark)")" # -> pal_bg, ansi_red, ...
#
# Why this exists: the same eight accents were written out by hand in five
# places -- Theme.qml through ThemeProfileModel.js, hypr/looks.lua,
@@ -79,3 +81,208 @@ hex_to_rgb() {
local hex="${1#\#}"
printf '%d, %d, %d' "0x${hex:0:2}" "0x${hex:2:2}" "0x${hex:4:2}"
}
+
+# ── Themes ───────────────────────────────────────────────────────────────────
+#
+# An accent is one colour; a THEME is the whole palette plus that accent pair.
+# Shipped themes live in config/themes.json, custom ones in settings.json's
+# themeProfiles array, and the two shell generators that render application
+# configuration -- panama-theme-apps and panama-lock -- both have to resolve
+# the same active theme from those two files. Doing that twice is how the
+# hyprlock colours came to be written out in three places; the resolution
+# lives here for the same reason accent_hex does.
+
+# The catalog lives beside the shell config, resolved from this script's own
+# location so it is correct wherever the repository is checked out.
+PANAMA_THEMES="${PANAMA_THEMES:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)/config/themes.json}"
+
+# Read at call time rather than baked in at source time, so a caller (or a
+# test) that sets XDG_CONFIG_HOME after sourcing still gets the right file.
+panama_settings_file() {
+ printf '%s' "${PANAMA_SETTINGS:-${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json}"
+}
+
+# The complete theme record for one scheme, as compact JSON:
+#
+# { id, scheme, accent, secondary, palette: {19 keys}, ansi: {16 keys} }
+#
+# Resolution order, per scheme: the per-mode preference (themeDark/themeLight),
+# then themeProfileId when it names a theme of that scheme, then the catalog's
+# default for the scheme. A record found in either place wins only where it
+# actually carries a value -- palette and ansi are MERGED over the scheme
+# default's, so a custom theme saved with only an accent (every profile stored
+# before themes existed) still resolves to a complete palette rather than to
+# nothing.
+resolve_theme() {
+ local scheme="${1:-dark}" settings settings_json='{}' catalog_json='{}'
+ [[ "$scheme" == "light" ]] || scheme="dark"
+
+ settings="$(panama_settings_file)"
+ if [[ -r "$settings" ]] && jq -e 'type == "object"' "$settings" >/dev/null 2>&1; then
+ settings_json="$(cat "$settings")"
+ fi
+ if [[ -r "$PANAMA_THEMES" ]] && jq -e 'type == "object"' "$PANAMA_THEMES" >/dev/null 2>&1; then
+ catalog_json="$(cat "$PANAMA_THEMES")"
+ fi
+
+ jq -cn --arg scheme "$scheme" --argjson settings "$settings_json" \
+ --argjson catalog "$catalog_json" '
+ def shipped: ($catalog.themes // []) | map(select(type == "object"));
+ def customs: ($settings.themeProfiles // []) | map(select(type == "object"));
+ def defaultId($s): if $s == "light" then ($catalog.defaultLight // "day")
+ else ($catalog.defaultDark // "moon") end;
+ def byId($id): (shipped | map(select(.id == $id)) | first)
+ // (customs | map(select(.id == $id)) | first);
+ def forScheme($id): byId($id) | select(. != null and ((.scheme // "dark") == $scheme));
+
+ (byId(defaultId($scheme)) // {}) as $base
+ | ( forScheme($settings[if $scheme == "light" then "themeLight" else "themeDark" end] // "")
+ // forScheme($settings.themeProfileId // "")
+ // $base ) as $theme
+ | { id: ($theme.id // defaultId($scheme)),
+ scheme: $scheme,
+ accent: ($theme.accent // $base.accent // ""),
+ secondary: ($theme.secondary // $base.secondary // ""),
+ palette: (($base.palette // {}) + ($theme.palette // {})),
+ ansi: (($base.ansi // {}) + ($theme.ansi // {})) }
+ ' 2>/dev/null \
+ || printf '{"id":"","scheme":"%s","accent":"","secondary":"","palette":{},"ansi":{}}' "$scheme"
+}
+
+# A theme record as eval-able shell assignments, so a generator reads a colour
+# as "$pal_bg" rather than paying a jq invocation per token:
+#
+# eval "$(theme_vars "$(resolve_theme dark)")"
+# printf 'background #%s\n' "$pal_bg"
+#
+# The optional prefix lets one script hold two themes at once -- Firefox needs
+# both schemes in a single stylesheet so the browser can flip on its own.
+#
+# Every value is validated to six lowercase hex digits before it is emitted and
+# anything else becomes the empty string, so the eval can never run something
+# a settings file said. A caller must therefore treat "" as "not themed" rather
+# than substituting it into a config.
+theme_vars() {
+ local json="${1:-\{\}}" prefix="${2:-}"
+ jq -r --arg p "$prefix" '
+ def hex: if type == "string"
+ and (ascii_downcase | ltrimstr("#") | test("^[0-9a-f]{6}$"))
+ then (ascii_downcase | ltrimstr("#")) else "" end;
+ ((.palette // {}) | to_entries[]
+ | select(.key | test("^[A-Za-z][A-Za-z0-9]*$"))
+ | "\($p)pal_\(.key)=\(.value | hex)"),
+ ((.ansi // {}) | to_entries[]
+ | select(.key | test("^[A-Za-z][A-Za-z0-9]*$"))
+ | "\($p)ansi_\(.key)=\(.value | hex)"),
+ "\($p)theme_accent=\(.accent | hex)",
+ "\($p)theme_secondary=\(.secondary | hex)",
+ "\($p)theme_id=\(((.id // "") | if test("^[A-Za-z0-9._-]+$") then . else "" end))"
+ ' <<<"$json" 2>/dev/null || true
+}
+
+# First non-empty of the two. Written out because `[[ -n $a ]] && printf $a`
+# returns 1 when it is empty, which `set -e` treats as a failure.
+hex_or() {
+ if [[ -n "${1:-}" ]]; then printf '%s' "$1"; else printf '%s' "${2:-}"; fi
+}
+
+# Blend two colours: mix_hex . Used to derive
+# the in-between surfaces (a selected row, a grid tile) that no palette token
+# names but every application theme wants.
+mix_hex() {
+ local base="${1#\#}" tint="${2#\#}" percent="${3:-50}" offset out=""
+ local from to blended
+ for offset in 0 2 4; do
+ from=$((16#${base:offset:2}))
+ to=$((16#${tint:offset:2}))
+ blended=$(( (from * (100 - percent) + to * percent) / 100 ))
+ out+="$(printf '%02x' "$blended")"
+ done
+ printf '%s' "$out"
+}
+
+# Perceived luminance, 0-255. Not the arithmetic mean: green reads far brighter
+# than blue at the same value, and the mean puts white text on a yellow accent.
+luma_hex() {
+ local hex="${1#\#}" red green blue
+ red=$((16#${hex:0:2})); green=$((16#${hex:2:2})); blue=$((16#${hex:4:2}))
+ printf '%d' $(( (red * 299 + green * 587 + blue * 114) / 1000 ))
+}
+
+# Whether dark text belongs on top of this colour.
+is_light_hex() {
+ (( $(luma_hex "$1") >= 140 ))
+}
+
+# The colour to draw text in on top of a filled surface -- an accent button, a
+# selected row. Takes two of the theme's own colours rather than black and
+# white, so the result keeps the theme's cast.
+#
+# The two candidates are compared by their own luminance rather than trusted to
+# arrive in a fixed order. Passing "the dark one" first only works on a dark
+# theme: on a light one the foreground IS the dark colour, which is how a light
+# theme ended up with blue label text on a blue button.
+on_hex() {
+ local surface="${1:-}" first="${2:-}" second="${3:-}" first_luma second_luma
+ [[ -n "$first" ]] || { printf '%s' "$second"; return; }
+ [[ -n "$second" && -n "$surface" ]] || { printf '%s' "$first"; return; }
+
+ first_luma="$(luma_hex "$first")"
+ second_luma="$(luma_hex "$second")"
+ if is_light_hex "$surface"; then
+ (( first_luma <= second_luma )) && printf '%s' "$first" || printf '%s' "$second"
+ else
+ (( first_luma >= second_luma )) && printf '%s' "$first" || printf '%s' "$second"
+ fi
+}
+
+# The lock screen, rendered from a theme into hyprlock's config language.
+#
+# This lives here rather than in panama-theme-apps because two other callers
+# need the same eight substitutions: setup/scripts/link-dotfiles seeds the
+# fallback config at install time, and panama-lock renders the generated one.
+# All three used to carry their own table of literal rgba triples, and the copy
+# most likely to be missed was the lock screen's -- which fails silently, with
+# the machine locked in last season's colour and nothing to say why.
+#
+# hyprlock takes rgba(r, g, b, a) in DECIMAL rather than hex, which is why the
+# template carries "R, G, B" triples. The two _HEX placeholders are the
+# exception: they sit inside Pango markup, which wants ##rrggbb.
+#
+# render_hyprlock [accent-name]
+#
+# Returns non-zero without touching the destination when the template is
+# unreadable or the theme resolves without a palette -- a half-substituted
+# config is not a parse error to hyprlock, it is a set of colours it quietly
+# ignores in favour of its own bright grey defaults.
+render_hyprlock() {
+ local scheme="${1:-dark}" template="${2:-}" destination="${3:-}" accent_name="${4:-blue}"
+ [[ -r "$template" && -n "$destination" ]] || return 1
+ (
+ set -euo pipefail
+ eval "$(theme_vars "$(resolve_theme "$scheme")")"
+
+ local accent
+ accent="$(hex_or "${theme_accent:-}" "$(accent_hex "$accent_name" "$scheme")")"
+ for value in "${pal_fg:-}" "${pal_fgMuted:-}" "${pal_red:-}" "${pal_bg:-}" \
+ "${pal_bgPanel:-}" "$accent"; do
+ [[ -n "$value" ]] || exit 1
+ done
+
+ # 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.
+ local temporary="$destination.tmp.$$"
+ sed -e "s/@FG@/$(hex_to_rgb "$pal_fg")/g" \
+ -e "s/@MUTED@/$(hex_to_rgb "$pal_fgMuted")/g" \
+ -e "s/@ACCENT@/$(hex_to_rgb "$accent")/g" \
+ -e "s/@ERROR@/$(hex_to_rgb "$pal_red")/g" \
+ -e "s/@BG@/$(hex_to_rgb "$pal_bg")/g" \
+ -e "s/@FIELD@/$(hex_to_rgb "$pal_bgPanel")/g" \
+ -e "s/@MUTED_HEX@/$pal_fgMuted/g" \
+ -e "s/@ERROR_HEX@/$pal_red/g" \
+ "$template" >"$temporary" 2>/dev/null \
+ && mv "$temporary" "$destination" 2>/dev/null \
+ || { rm -f "$temporary" 2>/dev/null || true; exit 1; }
+ )
+}
diff --git a/config/dot/quickshell/scripts/panama-theme-apps b/config/dot/quickshell/scripts/panama-theme-apps
index a666f47..8295910 100755
--- a/config/dot/quickshell/scripts/panama-theme-apps
+++ b/config/dot/quickshell/scripts/panama-theme-apps
@@ -1,38 +1,52 @@
#!/usr/bin/env bash
-# Propagates the color scheme to applications that do not read the desktop
+# Propagates the active THEME 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.
+# nothing from us for light-or-dark: 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.
+# A scheme is not a theme, though. The portal can say "dark"; it cannot say
+# "Catppuccin Mocha". Everything below is an application that carries its own
+# palette, and every one of them used to be handed a copy of a Tokyo Night file
+# -- which is why choosing any other theme restyled the shell and nothing else.
+# They are now RENDERED from the active theme's palette instead:
#
-# GTK3 is the other one. Under GNOME, gnome-settings-daemon publishes the theme
-# over XSETTINGS; under Hyprland nothing does, so ~/.config/gtk-3.0/settings.ini
-# is authoritative for GTK3 applications. Pinned to dark, it contradicted the
-# scheme in light mode, so it is generated from a template here instead.
+# kitty current-theme.conf, plus a live `set-colors` over its socket
+# tmux current-theme.conf, re-sourced into running servers
+# btop a generated panama.theme, named by btop.conf's color_theme
+# hyprlock the eight placeholders in hyprlock.conf.template
+# GTK 3/4 the @define-color block in gtk.css, between the markers
+# Vicinae panama-dark.toml / panama-light.toml in its data directory
+# Firefox an Edge-Frfox override sheet, both schemes in one file
#
# panama-theme-apps dark|light [accent]
#
+# The two arguments are the desktop's scheme and its nearest curated accent
+# name. They remain the interface because that is what ColorScheme.qml knows
+# and what GNOME's accent-color enum needs -- but the palette no longer comes
+# from them. This script resolves the active theme itself, from settings.json
+# and config/themes.json, through scripts/panama-palette. panama-lock resolves
+# it exactly the same way, from the same function.
+#
# 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
+# already open change now. Without the second, a theme change appears to do
# nothing until you open a new window.
#
-# kitty's active_border_color and hyprlock's focus ring are also the accent
-# role, not just the scheme. The lookup lives in scripts/panama-palette,
-# which reads config/palette.json -- the one place the eight accents are
-# written down for everything outside QML.
+# The tokyonight-* files under kitty/themes, tmux/themes and btop/themes are
+# still shipped -- a user may name one directly -- but nothing copies them any
+# more.
set -euo pipefail
-# The accents, from the one file that holds them.
+# The accents and the theme resolver, from the one file that holds them.
# shellcheck source=./panama-palette
-source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/panama-palette"
+script_dir="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
+source "$script_dir/panama-palette"
scheme="${1:-dark}"
case "$scheme" in
@@ -50,87 +64,153 @@ case "$accent" in
*) accent=blue ;;
esac
-# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by
-# hand -- there is no shared source between QML and a shell script), primary
-# hue per scheme only: both consumers below draw a flat color, not the
-# two-stop gradient the window border does.
+config_home="${XDG_CONFIG_HOME:-$HOME/.config}"
+data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
-# hyprlock wants "R, G, B" decimal, not hex.
+# ── The theme ────────────────────────────────────────────────────────────────
+# The active one for everything that renders a single palette, and BOTH schemes
+# for Firefox -- its stylesheet carries light-dark() pairs so the browser flips
+# on its own, without this script being re-run.
+eval "$(theme_vars "$(resolve_theme "$scheme")")"
+eval "$(theme_vars "$(resolve_theme dark)" dark_)"
+eval "$(theme_vars "$(resolve_theme light)" light_)"
-accent_border_hex="$(accent_hex "$accent" "$scheme")"
+# The accent pair falls back to the curated accent named on the command line.
+# A stored custom theme is allowed to carry an accent and no palette; one that
+# carries neither is still a real selection and should not render an empty
+# colour into a config file.
+theme_accent="$(hex_or "${theme_accent:-}" "$(accent_hex "$accent" "$scheme")")"
+theme_secondary="$(hex_or "${theme_secondary:-}" "$(accent_secondary_hex "$accent" "$scheme")")"
+dark_theme_accent="$(hex_or "${dark_theme_accent:-}" "$(accent_hex "$accent" dark)")"
+light_theme_accent="$(hex_or "${light_theme_accent:-}" "$(accent_hex "$accent" light)")"
+
+# Whether there is enough of a palette to render from at all. An unreadable
+# themes.json is the only way to get here, and the honest answer is then to
+# leave every application's existing colours alone rather than to write half a
+# theme over them.
+palette_ok=true
+for token in bg bgDark bgHighlight bgPanel bgPopover fg fgDim fgMuted gutter \
+ accentAlt cyan teal green yellow orange red redDeep magenta pink; do
+ name="pal_$token"
+ [[ -n "${!name:-}" ]] || palette_ok=false
+done
+
+ansi_ok=true
+for token in black red green yellow blue magenta cyan white \
+ brightBlack brightRed brightGreen brightYellow brightBlue \
+ brightMagenta brightCyan brightWhite; do
+ name="ansi_$token"
+ [[ -n "${!name:-}" ]] || ansi_ok=false
+done
+
+both_schemes_ok=true
+for token in bg bgDark bgHighlight bgPanel bgPopover fg fgDim fgMuted gutter red; do
+ for side in dark light; do
+ name="${side}_pal_$token"
+ [[ -n "${!name:-}" ]] || both_schemes_ok=false
+ done
+done
+
+# GTK and Firefox want rgba(), not hex, wherever a colour carries transparency.
+rgba_of() {
+ printf 'rgba(%s, %s)' "$(hex_to_rgb "$1")" "$2"
+}
+
+# Text on a filled accent, in this theme's own grounds rather than in black and
+# white: a pastel accent takes the theme's darkest surface, a saturated one
+# takes its lightest text.
+accent_fg="$(on_hex "$theme_accent" "${pal_bgDark:-}" "${pal_fg:-}")"
+
+# Replace one file in place, but only when the result actually differs, and say
+# which happened: "written", "unchanged", or "failed". Several of these targets
+# are tracked files reached through the dotfile symlinks, and rewriting them
+# with identical bytes on every accent sample would show up as a dirty working
+# tree for no change at all.
+#
+# A word rather than an exit status because every caller is under `set -e`,
+# where a helper that reports by returning 2 aborts the script instead.
+replace_if_changed() {
+ local destination="$1" temporary="$2"
+ if [[ -e "$destination" ]] && cmp -s "$temporary" "$destination"; then
+ rm -f "$temporary"
+ printf 'unchanged'
+ return 0
+ fi
+ if mv "$temporary" "$destination" 2>/dev/null; then
+ printf 'written'
+ else
+ rm -f "$temporary"
+ printf 'failed'
+ fi
+}
# ── 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"
+# The substitutions themselves live in panama-palette's render_hyprlock: this
+# script, panama-lock and setup/scripts/link-dotfiles all need them, and when
+# each carried its own copy the lock screen was the one that silently kept the
+# old colours.
+lock_dir="$config_home/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_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_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
-
-# The focus ring is the accent role, not a scheme-relative one -- follows
-# accentName the same way scripts/panama-lock's own generator does.
-lock_accent="$(hex_to_rgb "$accent_border_hex")"
-
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 gray 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
+ if render_hyprlock "$scheme" "$lock_template" "$lock_dir/hyprlock.conf" "$accent"; 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
-# open session changes now instead of at next launch -- tmux applies a
-# source-file to every attached client immediately.
-tmux_dir="${XDG_CONFIG_HOME:-$HOME/.config}/tmux"
-tmux_theme="$tmux_dir/themes/tokyonight-moon.conf"
-[[ "$scheme" == "light" ]] && tmux_theme="$tmux_dir/themes/tokyonight-day.conf"
+# tmux.conf sources current-theme.conf, and that file is machine state rather
+# than configuration. Running servers are re-sourced so an open session changes
+# now instead of at next launch -- tmux applies a source-file to every attached
+# client immediately.
+tmux_dir="$config_home/tmux"
+
+render_tmux() {
+ printf '# Generated by Panama from the "%s" theme. Do not edit.\n' "${theme_id:-custom}"
+ printf '#\n# tmux.conf sources this file; panama-theme-apps rewrites it on every theme\n'
+ printf '# change. The tokyonight-* files beside it are shipped for anyone who wants\n'
+ printf '# to source one directly, and are no longer copied here.\n\n'
+
+ printf 'set -g mode-style "fg=#%s,bg=#%s"\n\n' "$theme_accent" "$pal_gutter"
+
+ printf 'set -g message-style "fg=#%s,bg=#%s"\n' "$theme_accent" "$pal_gutter"
+ printf 'set -g message-command-style "fg=#%s,bg=#%s"\n\n' "$theme_accent" "$pal_gutter"
+
+ printf 'set -g pane-border-style "fg=#%s"\n' "$theme_accent"
+ printf 'set -g pane-active-border-style "fg=#%s"\n\n' "$theme_secondary"
+
+ printf 'set -g status-style "fg=#%s,bg=#%s"\n' "$theme_secondary" "$pal_gutter"
+ printf 'set -g status-bg "#%s"\n\n' "$pal_bg"
+
+ printf 'set -g status-left "#[fg=#%s,bg=#%s,bold] #S #[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]"\n' \
+ "$ansi_black" "$theme_secondary" "$ansi_black" "$theme_secondary"
+ printf 'set -g status-right "#[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]#[fg=#%s,bg=#%s] #{prefix_highlight} #[fg=#%s,bg=#%s]#[fg=#%s,bg=#%s] %%Y/%%m/%%d %%I:%%M %%p #[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]#[fg=#%s,bg=#%s,bold,italics] #h "\n\n' \
+ "$ansi_black" "$theme_accent" "$theme_secondary" "$pal_gutter" \
+ "$pal_gutter" "$pal_gutter" "$theme_secondary" "$pal_gutter" \
+ "$theme_accent" "$pal_gutter" "$ansi_black" "$theme_secondary"
+
+ printf 'setw -g window-status-format "#[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]#[fg=#%s,bg=#%s] #I #W #F #[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]"\n' \
+ "$ansi_black" "$pal_gutter" "$theme_accent" "$pal_gutter" "$ansi_black" "$pal_gutter"
+ printf 'setw -g window-status-current-format "#[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]#[fg=#%s,bg=#%s,bold] #I #W #F #[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]"\n\n' \
+ "$ansi_black" "$pal_gutter" "$theme_secondary" "$pal_gutter" "$pal_gutter" "$pal_gutter"
+
+ printf 'setw -g window-status-separator ""\n'
+}
status_tmux="skipped"
-if [[ -r "$tmux_theme" ]]; then
- if cp "$tmux_theme" "$tmux_dir/current-theme.conf.tmp" 2>/dev/null \
- && mv "$tmux_dir/current-theme.conf.tmp" "$tmux_dir/current-theme.conf" 2>/dev/null; then
- status_tmux="written"
+if [[ -d "$tmux_dir" ]] && [[ "$palette_ok" == true ]] && [[ "$ansi_ok" == true ]]; then
+ if render_tmux >"$tmux_dir/current-theme.conf.tmp" 2>/dev/null; then
+ status_tmux="$(replace_if_changed "$tmux_dir/current-theme.conf" "$tmux_dir/current-theme.conf.tmp")"
# Only if a server is actually running; `tmux source-file` would
# otherwise start one just to theme it.
- if command -v tmux >/dev/null 2>&1 && tmux has-session 2>/dev/null; then
+ if [[ "$status_tmux" != "failed" ]] \
+ && command -v tmux >/dev/null 2>&1 && tmux has-session 2>/dev/null; then
tmux source-file "$tmux_dir/current-theme.conf" 2>/dev/null \
&& status_tmux="applied to running sessions"
fi
@@ -141,31 +221,98 @@ if [[ -r "$tmux_theme" ]]; then
fi
# ── btop ─────────────────────────────────────────────────────────────────────
-# Only the color_theme line is rewritten, in place. btop OWNS btop.conf -- it
-# rewrites the whole file on exit -- so the config is not symlinked into Panama
-# and not replaced wholesale here; just this one value is edited, and btop keeps
-# it on the next write.
+# The theme is generated into btop's own themes directory and btop.conf is
+# pointed at it by name. Only the color_theme line is rewritten, in place: btop
+# OWNS btop.conf -- it rewrites the whole file on exit -- so the config is not
+# symlinked into Panama and not replaced wholesale here.
#
# btop reads its theme once at startup, so a running instance keeps the old
# colors until it is restarted. That is acceptable for a monitor you open when
# you want it, and forcing a restart would kill a process the user is watching.
-btop_conf="${XDG_CONFIG_HOME:-$HOME/.config}/btop/btop.conf"
-btop_theme="tokyonight-moon"
-[[ "$scheme" == "light" ]] && btop_theme="tokyonight-day"
+btop_conf="$config_home/btop/btop.conf"
+btop_theme_dir="$config_home/btop/themes"
+
+render_btop() {
+ printf '# Generated by Panama from the "%s" theme. Do not edit.\n' "${theme_id:-custom}"
+ printf '#\n# The *_start/_mid/_end triples are gradients btop draws meters with: low,\n'
+ printf '# middle, and high. They run cool -> warm -> hot so a saturated resource is\n'
+ printf '# obvious at a glance without reading the number.\n\n'
+
+ printf 'theme[main_bg]="#%s"\n' "$pal_bg"
+ printf 'theme[main_fg]="#%s"\n' "$pal_fg"
+ printf 'theme[title]="#%s"\n' "$pal_fg"
+ printf 'theme[hi_fg]="#%s"\n' "$theme_accent"
+ printf 'theme[selected_bg]="#%s"\n' "$pal_gutter"
+ printf 'theme[selected_fg]="#%s"\n' "$theme_accent"
+ printf 'theme[inactive_fg]="#%s"\n' "$pal_fgMuted"
+ printf 'theme[proc_misc]="#%s"\n\n' "$pal_green"
+
+ # The four boxes share one hairline, a step brighter than the gutter so the
+ # frame reads as structure rather than as content.
+ local box; box="$(mix_hex "$pal_gutter" "$pal_fgDim" 30)"
+ printf 'theme[cpu_box]="#%s"\n' "$box"
+ printf 'theme[mem_box]="#%s"\n' "$box"
+ printf 'theme[net_box]="#%s"\n' "$box"
+ printf 'theme[proc_box]="#%s"\n' "$box"
+ printf 'theme[div_line]="#%s"\n\n' "$pal_gutter"
+
+ printf 'theme[temp_start]="#%s"\n' "$theme_accent"
+ printf 'theme[temp_mid]="#%s"\n' "$pal_yellow"
+ printf 'theme[temp_end]="#%s"\n\n' "$pal_red"
+
+ printf 'theme[cpu_start]="#%s"\n' "$theme_accent"
+ printf 'theme[cpu_mid]="#%s"\n' "$pal_magenta"
+ printf 'theme[cpu_end]="#%s"\n\n' "$pal_red"
+
+ printf 'theme[free_start]="#%s"\n' "$pal_gutter"
+ printf 'theme[free_mid]="#%s"\n' "$pal_accentAlt"
+ printf 'theme[free_end]="#%s"\n\n' "$pal_cyan"
+
+ printf 'theme[cached_start]="#%s"\n' "$pal_cyan"
+ printf 'theme[cached_mid]="#%s"\n' "$theme_accent"
+ printf 'theme[cached_end]="#%s"\n\n' "$pal_magenta"
+
+ printf 'theme[available_start]="#%s"\n' "$pal_yellow"
+ printf 'theme[available_mid]="#%s"\n' "$pal_orange"
+ printf 'theme[available_end]="#%s"\n\n' "$pal_red"
+
+ printf 'theme[used_start]="#%s"\n' "$pal_green"
+ printf 'theme[used_mid]="#%s"\n' "$pal_yellow"
+ printf 'theme[used_end]="#%s"\n\n' "$pal_red"
+
+ printf 'theme[download_start]="#%s"\n' "$pal_gutter"
+ printf 'theme[download_mid]="#%s"\n' "$theme_accent"
+ printf 'theme[download_end]="#%s"\n\n' "$pal_cyan"
+
+ printf 'theme[upload_start]="#%s"\n' "$pal_gutter"
+ printf 'theme[upload_mid]="#%s"\n' "$pal_magenta"
+ printf 'theme[upload_end]="#%s"\n' "$pal_pink"
+}
status_btop="skipped"
-if [[ -w "$btop_conf" ]]; then
- if sed -i "s|^color_theme *=.*|color_theme = \"$btop_theme\"|" "$btop_conf" 2>/dev/null; then
- status_btop="written"
+if [[ "$palette_ok" == true ]] && mkdir -p "$btop_theme_dir" 2>/dev/null; then
+ if render_btop >"$btop_theme_dir/panama.theme.tmp" 2>/dev/null; then
+ status_btop="$(replace_if_changed "$btop_theme_dir/panama.theme" "$btop_theme_dir/panama.theme.tmp")"
else
+ rm -f "$btop_theme_dir/panama.theme.tmp"
status_btop="failed"
fi
+
+ if [[ "$status_btop" != "failed" && -w "$btop_conf" ]]; then
+ sed -i 's|^color_theme *=.*|color_theme = "panama"|' "$btop_conf" 2>/dev/null \
+ || status_btop="failed"
+ fi
fi
-# ── GTK ──────────────────────────────────────────────────────────────────────
+# ── GTK settings.ini ─────────────────────────────────────────────────────────
# adw-gtk3, not Adwaita: no Adwaita GTK theme is installed on Fedora 44, and
# naming a theme that does not exist makes GTK fall back to its light default --
# which made dark mode silently produce light windows.
+#
+# GTK3 is the reason this file is generated at all. Under GNOME,
+# gnome-settings-daemon publishes the theme over XSETTINGS; under Hyprland
+# nothing does, so ~/.config/gtk-3.0/settings.ini is authoritative for GTK3
+# applications.
if [[ "$scheme" == "light" ]]; then
gtk_theme="adw-gtk3"
prefer_dark=0
@@ -176,7 +323,7 @@ fi
status_gtk="skipped"
for gtk_version in 3.0 4.0; do
- gtk_dir="${XDG_CONFIG_HOME:-$HOME/.config}/gtk-$gtk_version"
+ gtk_dir="$config_home/gtk-$gtk_version"
template="$gtk_dir/settings.ini.template"
[[ -r "$template" ]] || continue
@@ -192,6 +339,138 @@ for gtk_version in 3.0 4.0; do
fi
done
+# ── GTK gtk.css ──────────────────────────────────────────────────────────────
+# settings.ini names a theme; these are the colours that theme is overridden
+# with. libadwaita reads @define-color from the user stylesheet, which is how
+# Nautilus, Papers and every other GTK application end up wearing the Panama
+# palette rather than adw-gtk3's stock greys.
+#
+# Only the marked block is rewritten. Everything else in these files is the
+# structural CSS of a vendored theme -- a thousand lines of rounding, padding
+# and window controls that has nothing to do with colour, and that regenerating
+# the file wholesale would destroy.
+#
+# All three files get the ACTIVE theme rather than one being pinned light and
+# another dark. gtk-4.0/gtk.css was a byte-for-byte copy of the dark one, so a
+# light desktop drew dark GTK4 windows; rendering the active theme into each is
+# what actually fixes that, whichever of the two GTK decides to read.
+gtk_css_block() {
+ local shade sidebar_line
+ shade="$(rgba_of "$pal_bgDark" 0.25)"
+ sidebar_line="$(rgba_of "$pal_gutter" 0.36)"
+
+ printf '/* Generated from the "%s" theme by panama-theme-apps. Edit the\n' "${theme_id:-custom}"
+ printf ' * theme, not this block -- the next theme change overwrites it. */\n'
+
+ printf '@define-color accent_bg_color #%s;\n' "$theme_accent"
+ printf '@define-color accent_color #%s;\n' "$pal_accentAlt"
+ printf '@define-color accent_fg_color #%s;\n' "$accent_fg"
+ printf '@define-color destructive_bg_color #%s;\n' "$pal_redDeep"
+ printf '@define-color destructive_color #%s;\n' "$pal_red"
+ printf '@define-color destructive_fg_color #%s;\n' "$(on_hex "$pal_redDeep" "$pal_bgDark" "$pal_fg")"
+ printf '@define-color success_bg_color #%s;\n' "$pal_green"
+ printf '@define-color success_color #%s;\n' "$pal_green"
+ printf '@define-color success_fg_color #%s;\n' "$(on_hex "$pal_green" "$pal_bgDark" "$pal_fg")"
+ printf '@define-color warning_bg_color #%s;\n' "$pal_yellow"
+ printf '@define-color warning_color #%s;\n' "$pal_yellow"
+ printf '@define-color warning_fg_color #%s;\n' "$(on_hex "$pal_yellow" "$pal_bgDark" "$pal_fg")"
+ printf '@define-color error_bg_color #%s;\n' "$pal_redDeep"
+ printf '@define-color error_color #%s;\n' "$pal_red"
+ printf '@define-color error_fg_color #%s;\n' "$(on_hex "$pal_redDeep" "$pal_bgDark" "$pal_fg")"
+
+ printf '@define-color window_bg_color #%s;\n' "$pal_bgPanel"
+ printf '@define-color window_fg_color #%s;\n' "$pal_fg"
+ printf '@define-color view_bg_color #%s;\n' "$pal_bg"
+ printf '@define-color view_fg_color #%s;\n' "$pal_fg"
+ printf '@define-color headerbar_bg_color #%s;\n' "$pal_bgPanel"
+ printf '@define-color headerbar_fg_color #%s;\n' "$pal_fg"
+ printf '@define-color headerbar_border_color #%s;\n' "$pal_gutter"
+ printf '@define-color headerbar_backdrop_color #%s;\n' "$pal_bgDark"
+ printf '@define-color headerbar_shade_color %s;\n' "$(rgba_of "$pal_bgDark" 0.08)"
+ printf '@define-color headerbar_darker_shade_color %s;\n' "$(rgba_of "$pal_bgDark" 0.9)"
+
+ printf '@define-color sidebar_bg_color #%s;\n' "$pal_bgDark"
+ printf '@define-color sidebar_fg_color #%s;\n' "$pal_fg"
+ printf '@define-color sidebar_backdrop_color #%s;\n' "$pal_bgDark"
+ printf '@define-color sidebar_shade_color %s;\n' "$(rgba_of "$pal_gutter" 0.08)"
+ printf '@define-color sidebar_border_color %s;\n' "$sidebar_line"
+ printf '@define-color secondary_sidebar_bg_color #%s;\n' "$pal_bgDark"
+ printf '@define-color secondary_sidebar_fg_color #%s;\n' "$pal_fg"
+ printf '@define-color secondary_sidebar_backdrop_color #%s;\n' "$pal_bgDark"
+ printf '@define-color secondary_sidebar_shade_color %s;\n' "$shade"
+ printf '@define-color secondary_sidebar_border_color %s;\n' "$sidebar_line"
+
+ printf '@define-color card_bg_color #%s;\n' "$pal_bgHighlight"
+ printf '@define-color card_fg_color #%s;\n' "$pal_fg"
+ printf '@define-color card_shade_color %s;\n' "$shade"
+ printf '@define-color dialog_bg_color #%s;\n' "$pal_bgPopover"
+ printf '@define-color dialog_fg_color #%s;\n' "$pal_fg"
+ printf '@define-color popover_bg_color #%s;\n' "$pal_bgPopover"
+ printf '@define-color popover_fg_color #%s;\n' "$pal_fg"
+ printf '@define-color popover_shade_color %s;\n' "$shade"
+ printf '@define-color thumbnail_bg_color #%s;\n' "$pal_bgHighlight"
+ printf '@define-color thumbnail_fg_color #%s;\n' "$pal_fg"
+ printf '@define-color shade_color %s;\n' "$shade"
+ printf '@define-color scrollbar_outline_color %s;\n' "$(rgba_of "$pal_bgDark" 0.5)"
+}
+
+# Substitutes the block between the markers, leaving every other byte alone.
+# The block is handed over in a file rather than through `awk -v`, which
+# processes backslash escapes in the value it is given.
+write_gtk_block() {
+ local target="$1" block_file="$2" temporary="$1.tmp"
+ if ! grep -q 'PANAMA THEME BEGIN' "$target" 2>/dev/null \
+ || ! grep -q 'PANAMA THEME END' "$target" 2>/dev/null; then
+ printf 'no marker'
+ return 0
+ fi
+
+ awk -v block="$block_file" '
+ index($0, "PANAMA THEME BEGIN") {
+ print
+ while ((getline line < block) > 0) print line
+ close(block)
+ skipping = 1
+ next
+ }
+ index($0, "PANAMA THEME END") { skipping = 0; print; next }
+ !skipping { print }
+ ' "$target" >"$temporary" 2>/dev/null || true
+
+ if [[ ! -s "$temporary" ]]; then
+ rm -f "$temporary"
+ printf 'failed'
+ return 0
+ fi
+ replace_if_changed "$target" "$temporary"
+}
+
+status_gtkcss="skipped"
+if [[ "$palette_ok" == true ]]; then
+ gtk_block_file="$(mktemp "${TMPDIR:-/tmp}/panama-gtk-block.XXXXXX")"
+ if gtk_css_block >"$gtk_block_file" 2>/dev/null; then
+ for gtk_css in "$config_home/gtk-3.0/gtk.css" \
+ "$config_home/gtk-4.0/gtk.css" \
+ "$config_home/gtk-4.0/gtk-dark.css"; do
+ [[ -w "$gtk_css" ]] || continue
+ gtk_css_result="$(write_gtk_block "$gtk_css" "$gtk_block_file")"
+ # The worst outcome across the three files is the one worth
+ # reporting: two written and one missing its markers is not
+ # "written".
+ case "$gtk_css_result" in
+ failed) status_gtkcss="failed" ;;
+ "no marker") [[ "$status_gtkcss" == "failed" ]] || status_gtkcss="no marker" ;;
+ unchanged) [[ "$status_gtkcss" == "skipped" ]] && status_gtkcss="unchanged" || true ;;
+ written) [[ "$status_gtkcss" == "skipped" || "$status_gtkcss" == "unchanged" ]] \
+ && status_gtkcss="written" || true ;;
+ esac
+ done
+ else
+ status_gtkcss="failed"
+ fi
+ rm -f "$gtk_block_file"
+fi
+
# ── libadwaita accent ────────────────────────────────────────────────────────
# Files, Papers, Loupe and every other libadwaita application read their accent
# from the Settings portal, not from anything Panama draws, so without this they
@@ -199,10 +478,9 @@ done
#
# GNOME's accent-color is a FIXED enum of nine names -- there is no hex here to
# match, only a nearest member to pick. Which one each Panama accent maps to is
-# recorded in config/palette.json alongside its colours, for the same reason
-# accent lookup once was: there was no shared source between QML and shell.
-# There is now -- config/palette.json -- and palette-contract fails when the
-# QML table and it disagree.
+# recorded in config/palette.json alongside its colours, and this is the one
+# place the command line's accent NAME is still the input rather than the
+# theme's own hex: an enum cannot take a colour.
#
# The portal must route Settings to the gnome backend for this to reach
# anything; xdg-desktop-portal-gtk cannot serve accent-color at all. See
@@ -222,41 +500,350 @@ if command -v gsettings >/dev/null 2>&1; then
fi
fi
-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"
+# ── Vicinae ──────────────────────────────────────────────────────────────────
+# The launcher selects a theme per system appearance, so both schemes are
+# rendered every time and vicinae.json names them permanently. They are written
+# straight into Vicinae's data directory rather than into the repository's
+# themes folder: that folder is symlinked into ~/.local/share/vicinae/themes,
+# and a generated file inside it would be a machine's colours showing up as a
+# change to the repository.
+vicinae_dir="$data_home/vicinae/themes"
+
+render_vicinae() {
+ (
+ set -euo pipefail
+ eval "$(theme_vars "$(resolve_theme "$1")")"
+ local variant="$1"
+ local core_accent core_secondary selection secondary_selection tile on_accent
+ core_accent="$(hex_or "${theme_accent:-}" "$(accent_hex "$2" "$1")")"
+ core_secondary="$(hex_or "${theme_secondary:-}" "$(accent_secondary_hex "$2" "$1")")"
+ on_accent="$(on_hex "$core_accent" "$pal_bgDark" "$pal_fg")"
+ # The two halves of the Prism selection, mixed into the highlight
+ # surface rather than named as tokens -- no palette key describes
+ # "a row that is selected".
+ selection="$(mix_hex "$pal_bgHighlight" "$core_accent" 30)"
+ secondary_selection="$(mix_hex "$pal_bgHighlight" "$core_secondary" 30)"
+ tile="$(mix_hex "$pal_bg" "$pal_bgHighlight" 50)"
+
+ printf '# Generated by Panama from the "%s" theme. Do not edit.\n' "${theme_id:-custom}"
+ printf '# panama-theme-apps rewrites this file on every theme change.\n\n'
+
+ printf '[meta]\nversion = 1\n'
+ printf 'name = "Panama %s"\n' "$variant"
+ printf 'description = "The active Panama theme, rendered for Vicinae."\n'
+ printf 'variant = "%s"\n\n' "$variant"
+
+ printf '[colors.core]\n'
+ printf 'background = "#%s"\n' "$pal_bg"
+ printf 'foreground = "#%s"\n' "$pal_fg"
+ printf 'secondary_background = "#%s"\n' "$pal_bgDark"
+ printf 'border = "#%s"\n' "$pal_gutter"
+ printf 'accent = "#%s"\n' "$core_accent"
+ printf 'accent_foreground = "#%s"\n\n' "$on_accent"
+
+ printf '[colors.accents]\n'
+ # The terminal blue, not accentAlt: on a light theme accentAlt is often
+ # the same cyan the next line already names.
+ printf 'blue = "#%s"\n' "$(hex_or "${ansi_blue:-}" "$pal_accentAlt")"
+ printf 'green = "#%s"\n' "$pal_green"
+ printf 'magenta = "#%s"\n' "$pal_magenta"
+ printf 'orange = "#%s"\n' "$pal_orange"
+ printf 'purple = "#%s"\n' "$pal_pink"
+ printf 'red = "#%s"\n' "$pal_red"
+ printf 'yellow = "#%s"\n' "$pal_yellow"
+ printf 'cyan = "#%s"\n\n' "$pal_cyan"
+
+ printf '[colors.main_window]\n'
+ printf 'border = "#%s"\n' "$pal_gutter"
+ printf 'footer = { background = "colors.core.secondary_background" }\n\n'
+
+ printf '[colors.settings_window]\n'
+ printf 'border = "#%s"\n\n' "$pal_gutter"
+
+ printf '[colors.shortcut]\n'
+ printf 'border = "colors.core.border"\n\n'
+
+ printf '[colors.text]\n'
+ printf 'default = "colors.core.foreground"\n'
+ printf 'muted = "#%s"\n' "$pal_fgDim"
+ printf 'danger = "#%s"\n' "$pal_red"
+ printf 'success = "#%s"\n' "$pal_green"
+ printf 'placeholder = "#%s"\n' "$pal_fgMuted"
+ printf 'selection = { background = "#%s", foreground = "#%s" }\n\n' "$core_accent" "$on_accent"
+
+ printf '[colors.text.links]\n'
+ printf 'default = "#%s"\n' "$core_accent"
+ printf 'visited = "#%s"\n\n' "$pal_magenta"
+
+ printf '[colors.input]\n'
+ printf 'border = "#%s"\n' "$pal_gutter"
+ printf 'border_focus = "#%s"\n' "$core_accent"
+ printf 'border_error = "#%s"\n\n' "$pal_red"
+
+ printf '[colors.button.primary]\n'
+ printf 'background = "#%s"\n' "$pal_bgHighlight"
+ printf 'foreground = "#%s"\n' "$pal_fg"
+ printf 'hover = { background = "#%s" }\n' "$pal_gutter"
+ printf 'focus = { outline = "colors.core.accent" }\n\n'
+
+ printf '[colors.list.item.hover]\n'
+ printf 'foreground = "#%s"\n' "$pal_fg"
+ printf 'secondary_foreground = "#%s"\n\n' "$pal_fgDim"
+
+ printf '[colors.list.item.selection]\n'
+ printf 'background = "#%s"\n' "$selection"
+ printf 'foreground = "#%s"\n' "$pal_fg"
+ printf 'secondary_background = "#%s"\n' "$secondary_selection"
+ printf 'secondary_foreground = "#%s"\n\n' "$pal_fg"
+
+ printf '[colors.grid.item]\n'
+ printf 'background = "#%s"\n' "$tile"
+ printf 'hover = { outline = "#%s" }\n' "$core_accent"
+ printf 'selection = { outline = "#%s" }\n\n' "$core_secondary"
+
+ printf '[colors.scrollbars]\n'
+ printf 'background = "#%s"\n\n' "$pal_gutter"
+
+ printf '[colors.loading]\n'
+ printf 'bar = "#%s"\n' "$core_accent"
+ printf 'spinner = "#%s"\n' "$pal_fg"
+ )
+}
+
+status_vicinae="skipped"
+if [[ "$both_schemes_ok" == true ]] && mkdir -p "$vicinae_dir" 2>/dev/null; then
+ status_vicinae="written"
+ for vicinae_scheme in dark light; do
+ target="$vicinae_dir/panama-$vicinae_scheme.toml"
+ if render_vicinae "$vicinae_scheme" "$accent" >"$target.tmp" 2>/dev/null; then
+ [[ "$(replace_if_changed "$target" "$target.tmp")" == "failed" ]] \
+ && status_vicinae="failed" || true
+ else
+ rm -f "$target.tmp"
+ status_vicinae="failed"
+ fi
+ done
+fi
+
+# ── Firefox ──────────────────────────────────────────────────────────────────
+# Edge-Frfox, the vendored chrome theme, takes its colours from GTK system
+# colours on Linux -- AccentColor, -moz-dialog, Field. Those follow the portal,
+# which knows light from dark and nothing about which theme is on. So Firefox
+# was the one surface that stayed grey-blue whatever Panama was wearing.
+#
+# This file is generated beside custom.css and imported by it, rather than being
+# custom.css itself: custom.css is the seam a person edits, and regenerating it
+# on every theme change would eat their overrides. It carries BOTH schemes as
+# light-dark() pairs, so Firefox flips with the portal without this script
+# running again -- and so a private window, which forces its own dark scheme,
+# is themed too.
+#
+# Chrome only. about: pages are styled from userContent.css, which is an
+# upstream file and does not import this one.
+firefox_chrome="${PANAMA_FIREFOX_CHROME:-$(cd "$script_dir/../../../.." 2>/dev/null && pwd)/config/firefox/chrome}"
+
+render_firefox() {
+ # light-dark() pairs, built from the two resolved palettes. Firefox picks
+ # the half that matches the chrome's own color-scheme.
+ pair() {
+ local light="light_pal_$1" dark="dark_pal_$1"
+ printf 'light-dark(#%s, #%s)' "${!light}" "${!dark}"
+ }
+ local accents on_light on_dark pressed
+ accents="light-dark(#$light_theme_accent, #$dark_theme_accent)"
+ on_light="$(on_hex "$light_theme_accent" "$light_pal_bgDark" "$light_pal_fg")"
+ on_dark="$(on_hex "$dark_theme_accent" "$dark_pal_bgDark" "$dark_pal_fg")"
+ # A pressed button has to read as a step past hover, and no palette token
+ # sits between the gutter and the text -- so it is mixed.
+ pressed="light-dark(#$(mix_hex "$light_pal_gutter" "$light_pal_fg" 15), #$(mix_hex "$dark_pal_gutter" "$dark_pal_fg" 15))"
+
+ printf '/* Generated by panama-theme-apps. Do not edit -- custom.css imports this,\n'
+ printf ' * and the next theme change overwrites it. Local Firefox tweaks belong in\n'
+ printf ' * custom.css, which is never regenerated.\n'
+ printf ' *\n'
+ printf ' * Both schemes are here as light-dark() pairs so the browser follows the\n'
+ printf ' * portal on its own. :root:root out-specifies Edge-Frfox colors.css, which\n'
+ printf ' * uses :root:not([lwtheme]) -- a plain :root would lose the tie. */\n\n'
+
+ printf ':root:root:not([lwtheme]),\n'
+ printf ':root:root[privatebrowsingmode="temporary"] {\n'
+
+ printf ' /* Toolbars */\n'
+ printf ' --lwt-accent-color: %s !important;\n' "$(pair bg)"
+ printf ' --lwt-accent-color-inactive: %s !important;\n' "$(pair bgDark)"
+ printf ' --lwt-text-color: %s !important;\n' "$(pair fg)"
+ printf ' --lwt-text-color-inactive: %s !important;\n' "$(pair fgDim)"
+ printf ' --toolbar-bgcolor: %s !important;\n' "$(pair bgPanel)"
+ printf ' --toolbar-non-lwt-bgcolor: var(--toolbar-bgcolor) !important;\n'
+ printf ' --toolbar-color: %s !important;\n' "$(pair fg)"
+ printf ' --toolbarbutton-icon-fill: var(--toolbar-color) !important;\n'
+ printf ' --toolbarbutton-icon-fill-attention: %s !important;\n' "$accents"
+ printf ' --chrome-content-separator-color: %s !important;\n' "$(pair gutter)"
+ printf ' --newtab-background-color: %s !important;\n' "$(pair bg)"
+
+ printf ' /* Tabs */\n'
+ printf ' --tab-selected-bgcolor: var(--toolbar-bgcolor) !important;\n'
+ printf ' --tab-selected-textcolor: %s !important;\n' "$(pair fg)"
+ printf ' --tab-selected-textcolor-inactive: %s !important;\n' "$(pair fgDim)"
+ printf ' --tab-attention-icon-color: %s !important;\n' "$accents"
+
+ printf ' /* URL bar */\n'
+ printf ' --toolbar-field-background-color: %s !important;\n' "$(pair bgHighlight)"
+ printf ' --toolbar-field-color: %s !important;\n' "$(pair fg)"
+ printf ' --toolbar-field-border-color: transparent !important;\n'
+ printf ' --toolbar-field-focus-background-color: %s !important;\n' "$(pair bgHighlight)"
+ printf ' --toolbar-field-focus-color: %s !important;\n' "$(pair fg)"
+ printf ' --toolbar-field-focus-border-color: %s !important;\n' "$accents"
+ printf ' --urlbar-box-bgcolor: %s !important;\n' "$(pair bgPopover)"
+ printf ' --urlbar-box-hover-bgcolor: %s !important;\n' "$(pair bgHighlight)"
+ printf ' --urlbar-box-active-bgcolor: %s !important;\n' "$(pair gutter)"
+ printf ' --urlbar-box-focus-bgcolor: var(--urlbar-box-hover-bgcolor) !important;\n'
+ printf ' --link-color: %s !important;\n' "$accents"
+ printf ' --uc-urlbarView-accent-color: %s !important;\n' "$accents"
+ printf ' --urlbarView-highlight-background: %s !important;\n' "$(pair bgHighlight)"
+ printf ' --urlbarView-highlight-color: %s !important;\n' "$(pair fg)"
+ printf ' --urlbarView-hover-background: %s !important;\n' "$(pair gutter)"
+ printf ' --urlbarView-separator-color: %s !important;\n' "$(pair gutter)"
+
+ printf ' /* Menus and panels */\n'
+ printf ' --arrowpanel-background: %s !important;\n' "$(pair bgPopover)"
+ printf ' --arrowpanel-color: %s !important;\n' "$(pair fg)"
+ printf ' --arrowpanel-border-color: %s !important;\n' "$(pair gutter)"
+ printf ' --panel-separator-color: %s !important;\n' "$(pair gutter)"
+ printf ' --panel-description-color: %s !important;\n' "$(pair fgDim)"
+ printf ' --panel-disabled-color: %s !important;\n' "$(pair fgMuted)"
+ printf ' --panel-item-hover-bgcolor: %s !important;\n' "$(pair bgHighlight)"
+ printf ' --panel-item-active-bgcolor: %s !important;\n' "$(pair gutter)"
+
+ printf ' /* Buttons */\n'
+ printf ' --button-primary-bgcolor: %s !important;\n' "$accents"
+ printf ' --button-primary-color: light-dark(#%s, #%s) !important;\n' "$on_light" "$on_dark"
+ printf ' --button-bgcolor: %s !important;\n' "$(pair bgHighlight)"
+ printf ' --button-color: %s !important;\n' "$(pair fg)"
+ printf ' --button-hover-bgcolor: %s !important;\n' "$(pair gutter)"
+ printf ' --button-active-bgcolor: %s !important;\n' "$pressed"
+
+ printf ' /* Inputs */\n'
+ printf ' --focus-outline-color: %s !important;\n' "$accents"
+ printf ' --input-bgcolor: %s !important;\n' "$(pair bgPopover)"
+ printf ' --input-color: %s !important;\n' "$(pair fg)"
+ printf ' --input-border-color: %s !important;\n' "$(pair gutter)"
+ printf ' --error-text-color: %s !important;\n' "$(pair red)"
+ printf ' --input-error-border-color: %s !important;\n' "$(pair red)"
+
+ printf ' /* Sidebar */\n'
+ printf ' --sidebar-background-color: %s !important;\n' "$(pair bgPanel)"
+ printf ' --sidebar-text-color: %s !important;\n' "$(pair fg)"
+ printf ' --sidebar-border-color: %s !important;\n' "$(pair gutter)"
+
+ # Chrome-level dialogs -- the bookmark editor, the print sheet. Edge-Frfox
+ # points these at the panel colours; the in-content names are what the
+ # dialog itself reads.
+ printf '\n &[dialogroot] {\n'
+ printf ' --in-content-page-background: %s !important;\n' "$(pair bgPopover)"
+ printf ' --in-content-page-color: %s !important;\n' "$(pair fg)"
+ printf ' --in-content-accent-color: %s !important;\n' "$accents"
+ printf ' --in-content-primary-button-background: %s !important;\n' "$accents"
+ printf ' --in-content-primary-button-text-color: light-dark(#%s, #%s) !important;\n' \
+ "$on_light" "$on_dark"
+ printf ' --in-content-button-background: %s !important;\n' "$(pair bgHighlight)"
+ printf ' --in-content-button-text-color: %s !important;\n' "$(pair fg)"
+ printf ' --in-content-box-background: %s !important;\n' "$(pair bg)"
+ printf ' --in-content-box-border-color: %s !important;\n' "$(pair gutter)"
+ printf ' }\n'
+
+ printf '}\n'
+}
+
+status_firefox="skipped"
+if [[ "$both_schemes_ok" == true && -d "$firefox_chrome" && -w "$firefox_chrome" ]]; then
+ firefox_target="$firefox_chrome/panama-theme.css"
+ if render_firefox >"$firefox_target.tmp" 2>/dev/null; then
+ status_firefox="$(replace_if_changed "$firefox_target" "$firefox_target.tmp")"
+ else
+ rm -f "$firefox_target.tmp"
+ status_firefox="failed"
+ fi
+fi
+
+# ── kitty ────────────────────────────────────────────────────────────────────
+# kitty.conf ends with `include current-theme.conf`; that file is machine state
+# and is rendered here rather than copied from a per-scheme file, so any theme
+# reaches the terminal rather than only the two Tokyo Nights.
+kitty_dir="$config_home/kitty"
+kitty_theme="$kitty_dir/current-theme.conf"
+
+render_kitty() {
+ printf '# Generated by Panama from the "%s" theme. Do not edit.\n' "${theme_id:-custom}"
+ printf '#\n# kitty.conf includes this file; panama-theme-apps rewrites it on every theme\n'
+ printf '# change and pushes the same colours to running terminals over the kitty\n'
+ printf '# control socket. The tokyonight-* files beside it are shipped for anyone who\n'
+ printf '# wants to include one directly, and are no longer copied here.\n\n'
+
+ printf 'foreground #%s\n' "$pal_fg"
+ printf 'background #%s\n' "$pal_bg"
+ # The classic terminal inversion: selected text wears the foreground as its
+ # ground. Derived rather than named -- no palette token is "selection".
+ printf 'selection_foreground #%s\n' "$pal_bg"
+ printf 'selection_background #%s\n' "$pal_fg"
+ printf 'cursor #%s\n' "$pal_fg"
+ printf 'cursor_text_color #%s\n' "$pal_bg"
+ printf 'url_color #%s\n\n' "$pal_teal"
+
+ # The focused border is the accent role; the unfocused one is the gutter,
+ # the palette's neutral contrast surface.
+ printf 'active_border_color #%s\n' "$theme_accent"
+ printf 'inactive_border_color #%s\n\n' "$pal_gutter"
+
+ printf 'tab_bar_background #%s\n' "$pal_bg"
+ printf 'active_tab_foreground #%s\n' "$pal_bgDark"
+ printf 'active_tab_background #%s\n' "$theme_accent"
+ printf 'inactive_tab_foreground #%s\n' "$pal_fgDim"
+ printf 'inactive_tab_background #%s\n\n' "$pal_bgHighlight"
+
+ printf 'color0 #%s\n' "$ansi_black"
+ printf 'color8 #%s\n' "$ansi_brightBlack"
+ printf 'color1 #%s\n' "$ansi_red"
+ printf 'color9 #%s\n' "$ansi_brightRed"
+ printf 'color2 #%s\n' "$ansi_green"
+ printf 'color10 #%s\n' "$ansi_brightGreen"
+ printf 'color3 #%s\n' "$ansi_yellow"
+ printf 'color11 #%s\n' "$ansi_brightYellow"
+ printf 'color4 #%s\n' "$ansi_blue"
+ printf 'color12 #%s\n' "$ansi_brightBlue"
+ printf 'color5 #%s\n' "$ansi_magenta"
+ printf 'color13 #%s\n' "$ansi_brightMagenta"
+ printf 'color6 #%s\n' "$ansi_cyan"
+ printf 'color14 #%s\n' "$ansi_brightCyan"
+ printf 'color7 #%s\n' "$ansi_white"
+ printf 'color15 #%s\n' "$ansi_brightWhite"
+ # 16 and 17 are the extended slots tokyonight uses for orange and a deep
+ # red; some tools (delta, bat) reference them.
+ printf 'color16 #%s\n' "$pal_orange"
+ printf 'color17 #%s\n' "$pal_redDeep"
+}
status_kitty="skipped"
-if [[ -r "$theme_file" ]]; then
+if [[ -d "$kitty_dir" ]] && [[ "$palette_ok" == true ]] && [[ "$ansi_ok" == true ]]; then
# Written atomically: kitty may read this while a new window is starting.
- # The accent override is appended after the copied theme rather than
- # edited into the tracked theme file: kitty applies settings top to
- # bottom, so a later active_border_color line wins, and this file is
- # itself generated -- the tracked per-scheme theme stays scheme-only.
- if cp "$theme_file" "$kitty_dir/current-theme.conf.tmp" 2>/dev/null \
- && # Anything else on this machine that wants to know. Runs after every generator
-# above, so a hook sees the finished state rather than a half-applied one, and
-# a broken hook cannot cost you a theme change -- panama-hook reports and steps
-# over. See bin/panama-hook.
-"${PANAMA_PATH:-$HOME/.local/share/Panama}/bin/panama-hook" theme-set "$scheme" "$accent" || true
-
-printf 'active_border_color #%s\n' "$accent_border_hex" >>"$kitty_dir/current-theme.conf.tmp" \
- && mv "$kitty_dir/current-theme.conf.tmp" "$kitty_dir/current-theme.conf" 2>/dev/null; then
- status_kitty="written"
+ if render_kitty >"$kitty_theme.tmp" 2>/dev/null; then
+ status_kitty="$(replace_if_changed "$kitty_theme" "$kitty_theme.tmp")"
else
- rm -f "$kitty_dir/current-theme.conf.tmp"
+ rm -f "$kitty_theme.tmp"
+ status_kitty="failed"
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
+ if [[ "$status_kitty" != "failed" ]] \
+ && 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 \
- && kitty @ --to "unix:@${socket#@}" set-colors --override "active_border_color=#$accent_border_hex" >/dev/null 2>&1; then
+ if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$kitty_theme" >/dev/null 2>&1 \
+ && kitty @ --to "unix:@${socket#@}" set-colors --override "active_border_color=#$theme_accent" >/dev/null 2>&1; then
applied=$((applied + 1))
fi
done < <(ss -xl 2>/dev/null | grep -oE '@mykitty[^[:space:]]*' | sort -u)
@@ -264,15 +851,31 @@ printf 'active_border_color #%s\n' "$accent_border_hex" >>"$kitty_dir/current-th
fi
fi
+# Anything else on this machine that wants to know. Runs after every generator
+# above, so a hook sees the finished state rather than a half-applied one, and
+# a broken hook cannot cost you a theme change -- panama-hook reports and steps
+# over. See bin/panama-hook.
+#
+# It used to sit INSIDE the kitty branch's if-condition, which meant it fired
+# in the middle of writing kitty's theme file and not at all on a machine
+# without a kitty theme to write.
+"${PANAMA_PATH:-$HOME/.local/share/Panama}/bin/panama-hook" theme-set "$scheme" "$accent" || true
+
# Every target reports what actually happened. A helper that says only
# "kitty: applied" while silently skipping three other applications is how a
# half-applied theme goes unnoticed.
jq -cn \
--arg scheme "$scheme" \
+ --arg theme "${theme_id:-}" \
--arg kitty "$status_kitty" \
--arg gtk "$status_gtk" \
+ --arg gtkcss "$status_gtkcss" \
--arg btop "$status_btop" \
--arg tmux "$status_tmux" \
--arg hyprlock "$status_hyprlock" \
--arg adwaita "$status_adwaita" \
- '{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux, hyprlock: $hyprlock, adwaita: $adwaita}'
+ --arg vicinae "$status_vicinae" \
+ --arg firefox "$status_firefox" \
+ '{scheme: $scheme, theme: $theme, kitty: $kitty, gtk: $gtk, gtkcss: $gtkcss,
+ btop: $btop, tmux: $tmux, hyprlock: $hyprlock, adwaita: $adwaita,
+ vicinae: $vicinae, firefox: $firefox}'
diff --git a/config/dot/quickshell/scripts/panama-video-wallpaper b/config/dot/quickshell/scripts/panama-video-wallpaper
new file mode 100755
index 0000000..1d4fbfb
--- /dev/null
+++ b/config/dot/quickshell/scripts/panama-video-wallpaper
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# Video wallpaper support: discovery and still frames. Playback itself is
+# mpvpaper, launched and supervised by services/VideoWallpaper.qml — this
+# helper only answers questions a QML Process shouldn't shell-script inline.
+#
+# scan newest 60 videos under the folder, one path per line
+# frame