Give the desktop real themes, video wallpapers, and honest titlebars
Appearance now opens on Themes: light and dark side by side, each remembering its own choice, over galleries of ten shipped themes — Tokyo Moon and Day joined by Moon Rose, Catppuccin, Nord, Gruvbox and Everforest in both modes. A theme is a complete palette: the catalog lives in themes.json, Theme.qml reads every color token from the active record, and one render pipeline carries it to kitty, tmux, btop, GTK, Vicinae, Firefox's chrome, and the lock screen. The Theme editor builds new ones from four wells — wheel, hex, or eyedropper — with derived surfaces, a saturation slider, debounced fine-tune, and effects that save with the theme. Custom edits finally keep GNOME's accent, kitty's border, and hyprlock in sync. Wallpapers can be video: mpvpaper per output, hardware-decoded, muted and looped, supervised and respawned. Panama owns the pausing — games, battery, and a bar pill for right now — because the compositor rebuilds full-screen blur for every frame a video wallpaper draws. The lock screen gets a still frame. Titlebars stop lying. GNOME apps get close-only on your chosen side, the maximize and double-click settings are gone, the Settings window obeys the same rules, and its titlebar can be turned off entirely. Typography becomes five labeled dropdowns instead of a wall of samples. Contracts updated and written throughout (165 now); per the redesign workflow none were executed — the full sweep runs once at the end. Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+60
-33
@@ -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;
|
||||
|
||||
Executable → Regular
+58
-47
@@ -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;
|
||||
|
||||
Executable → Regular
+58
-47
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -118,6 +118,10 @@ PanelWindow {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
WallpaperIndicator {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
FocusIndicator {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <base> <tint> <percent of tint>. 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 <dark|light> <template> <destination> [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; }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}'
|
||||
|
||||
+37
@@ -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 <dir> newest 60 videos under the folder, one path per line
|
||||
# frame <video> <out.png> a single still frame, for the lock screen
|
||||
#
|
||||
# The scan mirrors panama-wallpaper-scan's discipline exactly: two levels deep,
|
||||
# newest first, a hard cap so a dumping-ground folder cannot flood the picker.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
command="${1:-}"
|
||||
|
||||
case "$command" in
|
||||
scan)
|
||||
dir="${2:-}"
|
||||
[ -d "$dir" ] || exit 0
|
||||
find "$dir" -maxdepth 2 -type f \
|
||||
\( -iname '*.mp4' -o -iname '*.mkv' -o -iname '*.webm' \) \
|
||||
-printf '%T@ %p\n' 2>/dev/null | sort -rn | cut -d' ' -f2- | awk 'NR <= 60'
|
||||
;;
|
||||
frame)
|
||||
video="${2:-}"
|
||||
out="${3:-}"
|
||||
[ -f "$video" ] && [ -n "$out" ] || { echo "frame: bad arguments" >&2; exit 2; }
|
||||
# A second in: the first frame of a loop is often a fade from black.
|
||||
ffmpeg -hide_banner -loglevel error -ss 1 -i "$video" \
|
||||
-frames:v 1 -update 1 -y "$out.tmp.png" </dev/null
|
||||
mv "$out.tmp.png" "$out"
|
||||
;;
|
||||
*)
|
||||
echo "usage: panama-video-wallpaper scan <dir> | frame <video> <out.png>" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -1,17 +1,25 @@
|
||||
pragma Singleton
|
||||
|
||||
// Keeps everything that draws a window agreeing about light or dark.
|
||||
// Keeps everything that draws a window agreeing about the active theme.
|
||||
//
|
||||
// The shell repaints itself from Theme.qml the moment the preference changes,
|
||||
// because every token there is a binding. Two other things draw on this desktop
|
||||
// and do not read Panama's store:
|
||||
// The shell repaints itself from Theme.qml the moment a preference changes,
|
||||
// because every token there is a binding. Three other things draw on this
|
||||
// desktop and do not read Panama's store:
|
||||
//
|
||||
// GTK applications read gsettings color-scheme and gtk-theme
|
||||
// the compositor draws window borders and shadows
|
||||
// everything else carries its own palette file, rendered by
|
||||
// scripts/panama-theme-apps
|
||||
//
|
||||
// A toolbar or a window border still wearing the other scheme is more jarring
|
||||
// than either scheme on its own, which is why this exists rather than the
|
||||
// setting simply being a Theme property.
|
||||
// A toolbar or a window border still wearing the previous theme is more jarring
|
||||
// than any theme on its own, which is why this exists rather than the setting
|
||||
// simply being a Theme property.
|
||||
//
|
||||
// It watches three things, not one: the scheme, the nearest curated accent
|
||||
// NAME (which is all GNOME's enum can take), and the active theme's palette.
|
||||
// The third is the reason editing a custom theme's background reaches kitty --
|
||||
// the id does not change across an edit, and the accent name often does not
|
||||
// change across a theme selection either.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
@@ -24,32 +32,54 @@ Singleton {
|
||||
readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
|
||||
|
||||
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
|
||||
// A neutral contrast role, not an accent. The focused Prism border belongs
|
||||
// to the visual theme and must remain untouched when this role changes.
|
||||
readonly property string inactiveBorderDark: "rgba(3b426199)"
|
||||
readonly property string inactiveBorderLight: "rgba(a8aecb99)"
|
||||
readonly property string inactiveBorder: root.dark ? root.inactiveBorderDark : root.inactiveBorderLight
|
||||
|
||||
// The focused border follows the chosen accent. `ee` is the shipped alpha
|
||||
// for the Prism gradient; Theme owns which colors, this owns the form the
|
||||
// compositor wants them in.
|
||||
function hyprColor(value: var): string {
|
||||
// Theme owns which colors; this owns the form the compositor wants them in.
|
||||
// The alphas are the shipped ones for each border role.
|
||||
function hyprColor(value: var, alpha: string): string {
|
||||
// Qt gives "#rrggbb" -- or "#aarrggbb" if the color ever carries an
|
||||
// alpha channel. slice(-6) keeps the trailing rrggbb either way;
|
||||
// slice(0, 6) would instead grab "aarrgg" out of an 8-digit string and
|
||||
// call it RGB. Hyprland wants rgba(rrggbbaa).
|
||||
return "rgba(" + String(value).replace("#", "").slice(-6) + "ee)";
|
||||
return "rgba(" + String(value).replace("#", "").slice(-6) + alpha + ")";
|
||||
}
|
||||
readonly property string accentBorderStart: root.hyprColor(Theme.accent)
|
||||
readonly property string accentBorderEnd: root.hyprColor(Theme.accentSecondary)
|
||||
|
||||
// A neutral contrast role, not an accent. The focused Prism border below
|
||||
// belongs to the accent and must remain untouched when this role changes.
|
||||
//
|
||||
// It is the active theme's gutter -- the palette's own neutral surface --
|
||||
// rather than the two Tokyo Night literals that used to be written here.
|
||||
// Those were right for exactly two of the ten shipped themes and for no
|
||||
// custom one.
|
||||
readonly property string inactiveBorder: root.hyprColor(Theme.gutter, "99")
|
||||
|
||||
// The focused border follows the chosen accent.
|
||||
readonly property string accentBorderStart: root.hyprColor(Theme.accent, "ee")
|
||||
readonly property string accentBorderEnd: root.hyprColor(Theme.accentSecondary, "ee")
|
||||
property string lastError: ""
|
||||
|
||||
// A cheap fingerprint of what the render pipeline actually renders from.
|
||||
//
|
||||
// The theme id alone is not enough: editing a custom theme's background
|
||||
// keeps the same id, and the shell would repaint from the binding while
|
||||
// kitty, GTK and the lock screen kept the old colours -- the change would
|
||||
// look like it had only half landed. The values are in the key too, so any
|
||||
// edit to the active palette is a change worth pushing.
|
||||
readonly property string palettePrint: {
|
||||
const palette = ThemeProfiles.activePalette;
|
||||
if (!palette)
|
||||
return "";
|
||||
return Object.keys(palette).sort().map(token => palette[token]).join(",");
|
||||
}
|
||||
|
||||
// What the last push actually sent, so apply() can tell an accent-only
|
||||
// change apart from a scheme change and skip the steps that do not depend
|
||||
// on whichever did not move. Their starting values do not matter: the
|
||||
// first apply() always runs with force set, which ignores both.
|
||||
// change apart from a scheme or theme change and skip the steps that do
|
||||
// not depend on whichever did not move. Their starting values do not
|
||||
// matter: the first apply() always runs with force set, which ignores all
|
||||
// of them.
|
||||
property bool appliedDark: false
|
||||
property string appliedAccentName: ""
|
||||
property string appliedThemeId: ""
|
||||
property string appliedPalettePrint: ""
|
||||
|
||||
// Applied one command at a time: Process runs a single command, and several
|
||||
// of these are separate programs.
|
||||
@@ -119,10 +149,19 @@ Singleton {
|
||||
root.lastError = "";
|
||||
|
||||
const accentName = DesktopPreferences.get("accentName") || "blue";
|
||||
const themeId = ThemeProfiles.activeId;
|
||||
const palettePrint = root.palettePrint;
|
||||
|
||||
const schemeChanged = force || root.dark !== root.appliedDark;
|
||||
const accentChanged = force || accentName !== root.appliedAccentName;
|
||||
// A theme change is a palette change: selecting another theme, or
|
||||
// editing the active one. accentName is the NEAREST CURATED name, so
|
||||
// it can hold still across both -- Moon to Moon Rose keeps "blue" and
|
||||
// moves every other colour.
|
||||
const themeChanged = force || themeId !== root.appliedThemeId
|
||||
|| palettePrint !== root.appliedPalettePrint;
|
||||
|
||||
if (!schemeChanged && !accentChanged)
|
||||
if (!schemeChanged && !accentChanged && !themeChanged)
|
||||
return;
|
||||
|
||||
const commands = [];
|
||||
@@ -147,17 +186,21 @@ Singleton {
|
||||
|
||||
commands.push(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme]);
|
||||
commands.push(["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]);
|
||||
}
|
||||
|
||||
if (schemeChanged || themeChanged) {
|
||||
// Unfocused window borders need scheme-relative contrast, and stay
|
||||
// this service's to own. The FOCUSED border below is the accent
|
||||
// role and belongs to the theme -- see modules/settings/README.md.
|
||||
// Unlike that role, this one does not depend on the accent, so an
|
||||
// accent-only change never needs to restate it.
|
||||
//
|
||||
// It is drawn from the palette's gutter, so it moves with the THEME
|
||||
// as well as with the scheme; an accent-only change still never
|
||||
// needs to restate it.
|
||||
commands.push(["hyprctl", "eval",
|
||||
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
|
||||
}
|
||||
|
||||
if (schemeChanged || accentChanged) {
|
||||
if (schemeChanged || accentChanged || themeChanged) {
|
||||
// Each accent carries a separate pair for light and dark, so this
|
||||
// runs on either kind of change: a scheme flip restates the same
|
||||
// accent's other pair, and an accent change restates the same
|
||||
@@ -178,23 +221,25 @@ Singleton {
|
||||
`hl.config({ general = { col = { active_border = ${activeBorder} } } })`]);
|
||||
|
||||
// Applications that predate org.freedesktop.appearance and carry
|
||||
// their own palettes -- terminals, chiefly. Portal readers (GTK4,
|
||||
// Qt6, Chromium, Electron) already have their SCHEME from the
|
||||
// gsettings write above.
|
||||
// their own palettes -- terminals, GTK's css, the lock screen, the
|
||||
// launcher, the browser chrome. Portal readers (GTK4, Qt6, Chromium,
|
||||
// Electron) already have their SCHEME from the gsettings write
|
||||
// above, but no portal can tell them which THEME.
|
||||
//
|
||||
// Their ACCENT does not come from here: GNOME's accent-color is a
|
||||
// fixed enum of nine names rather than a color, so the mapping from
|
||||
// our eight lives in panama-theme-apps beside the other per-
|
||||
// application translations. The accent is passed through for that,
|
||||
// and because kitty's border and the hyprlock fallback template are
|
||||
// the accent role too -- so this still has to run on an accent-only
|
||||
// change, even though the scheme-relative work it also does is then
|
||||
// redundant.
|
||||
// The script resolves the palette itself, from settings.json and
|
||||
// the catalog, so the two arguments here are only what it cannot
|
||||
// read: nothing, and the nearest curated accent NAME, which GNOME's
|
||||
// accent-color enum needs because it takes a name rather than a
|
||||
// color. It runs on a theme change even when neither scheme nor
|
||||
// accentName moved -- that is what selecting Nord over Moon looks
|
||||
// like from here.
|
||||
commands.push([root.appThemePath, root.dark ? "dark" : "light", accentName]);
|
||||
}
|
||||
|
||||
root.appliedDark = root.dark;
|
||||
root.appliedAccentName = accentName;
|
||||
root.appliedThemeId = themeId;
|
||||
root.appliedPalettePrint = palettePrint;
|
||||
|
||||
root.enqueue(commands);
|
||||
}
|
||||
|
||||
@@ -174,15 +174,17 @@ Singleton {
|
||||
return `${DesktopPreferences.get(familyKey)} ${DesktopPreferences.get(sizeKey)}`;
|
||||
}
|
||||
|
||||
// Close only, on the configured side. Minimize would be a lie (Hyprland
|
||||
// has no minimize) and maximize is noise in a tiler, so neither is
|
||||
// offered — not as a button, not as a setting.
|
||||
function buttonLayout(): string {
|
||||
const side = DesktopPreferences.get("titlebarButtonSide");
|
||||
const maximize = DesktopPreferences.get("titlebarMaximizeButton") === true;
|
||||
|
||||
// Tokens are fixed. Only their side and whether maximize is present
|
||||
// vary, so preference data can never become command syntax.
|
||||
// Tokens are fixed. Only their side varies, so preference data can
|
||||
// never become command syntax.
|
||||
if (side === "left")
|
||||
return (maximize ? "close,maximize" : "close") + ":appmenu";
|
||||
return "appmenu:" + (maximize ? "maximize,close" : "close");
|
||||
return "close:appmenu";
|
||||
return "appmenu:close";
|
||||
}
|
||||
|
||||
function setting(schema: string, key: string, value: var): var {
|
||||
@@ -207,8 +209,6 @@ Singleton {
|
||||
root.setting("org.gnome.desktop.interface", "gtk-enable-primary-paste",
|
||||
DesktopPreferences.get("middleClickPaste")),
|
||||
root.setting("org.gnome.desktop.wm.preferences", "button-layout", root.buttonLayout()),
|
||||
root.setting("org.gnome.desktop.wm.preferences", "action-double-click-titlebar",
|
||||
DesktopPreferences.get("titlebarDoubleClick")),
|
||||
// The portal republishes this as org.freedesktop.appearance
|
||||
// contrast, which is what libadwaita reads -- so this reaches GTK4
|
||||
// applications without any high-contrast theme being installed.
|
||||
|
||||
@@ -153,6 +153,16 @@ Singleton {
|
||||
{ label: "Monitor position", detail: "Set where each display sits in the desktop", page: "displays" },
|
||||
{ label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" },
|
||||
{ label: "Theme profiles", detail: "Switch between Moon, Moon Rose, Day, and saved themes", page: "appearance" },
|
||||
{ label: "Themes", detail: "Light and dark mode, and the theme for each", page: "appearance" },
|
||||
{ label: "Dark mode", detail: "Flip the desktop to your dark theme", page: "appearance" },
|
||||
{ label: "Light mode", detail: "Flip the desktop to your light theme", page: "appearance" },
|
||||
{ label: "Theme editor", detail: "Build your own theme — colors, saturation, and effects", page: "appearance" },
|
||||
{ label: "Catppuccin", detail: "Mocha and Latte, in the theme galleries", page: "appearance" },
|
||||
{ label: "Nord", detail: "The arctic dark theme, in the gallery", page: "appearance" },
|
||||
{ label: "Gruvbox", detail: "Dark and light, in the theme galleries", page: "appearance" },
|
||||
{ label: "Everforest", detail: "Dark and light, in the theme galleries", page: "appearance" },
|
||||
{ label: "Tokyo Night", detail: "Moon and Day, the shipped defaults", page: "appearance" },
|
||||
{ label: "Video wallpaper", detail: "A looping video as the desktop background", page: "appearance" },
|
||||
{ label: "Advanced accent", detail: "Adjust primary and secondary hue, saturation, and value", page: "appearance" },
|
||||
{ label: "Pick colour from screen", detail: "Sample an accent colour with hyprpicker", page: "appearance" }
|
||||
]
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
pragma Singleton
|
||||
|
||||
// The shipped theme catalog, read from config/themes.json — full palettes for
|
||||
// every theme Panama ships, one file shared with the scripts (jq) and the
|
||||
// Node halves of contracts (fs). QML reads it here with FileView rather than
|
||||
// keeping a duplicated JS table: unlike palette.json's CURATED twin, nothing
|
||||
// in this file needs to run under Node.
|
||||
//
|
||||
// The embedded fallback below carries only the two default themes (Moon and
|
||||
// Day) so the shell renders correctly for the instant before the file loads —
|
||||
// and forever if it is missing or malformed. theme-catalog-contract pins the
|
||||
// fallback byte-identical to the catalog's moon and day records.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// [{ id, name, scheme, accent, secondary, palette: {19 tokens}, ansi: {16} }]
|
||||
property var themes: root.fallbackThemes
|
||||
property string defaultDark: "moon"
|
||||
property string defaultLight: "day"
|
||||
property bool loaded: false
|
||||
property string lastError: ""
|
||||
|
||||
function byId(id: string): var {
|
||||
return root.themes.find(theme => theme.id === id) ?? null;
|
||||
}
|
||||
|
||||
// The palette every resolution falls back to: the default theme for the
|
||||
// scheme. Always complete, even before the file loads.
|
||||
function defaultPalette(scheme: string): var {
|
||||
const id = scheme === "light" ? root.defaultLight : root.defaultDark;
|
||||
const theme = root.byId(id) ?? root.fallbackThemes[scheme === "light" ? 1 : 0];
|
||||
return theme.palette;
|
||||
}
|
||||
|
||||
function themesFor(scheme: string): var {
|
||||
return root.themes.filter(theme => theme.scheme === scheme);
|
||||
}
|
||||
|
||||
// A record is only accepted with every palette key present and valid, so
|
||||
// nothing downstream ever needs a per-token fallback.
|
||||
readonly property var paletteKeys: ["bg", "bgDark", "bgHighlight", "bgPanel",
|
||||
"bgPopover", "fg", "fgDim", "fgMuted", "gutter", "accentAlt",
|
||||
"cyan", "teal", "green", "yellow", "orange", "red", "redDeep",
|
||||
"magenta", "pink"]
|
||||
readonly property var ansiKeys: ["black", "red", "green", "yellow", "blue",
|
||||
"magenta", "cyan", "white", "brightBlack", "brightRed", "brightGreen",
|
||||
"brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite"]
|
||||
|
||||
function validHex(value: var): bool {
|
||||
return typeof value === "string" && /^#[0-9a-f]{6}$/.test(value);
|
||||
}
|
||||
|
||||
function normalizeTheme(entry: var): var {
|
||||
if (!entry || typeof entry !== "object")
|
||||
return null;
|
||||
const id = String(entry.id ?? "");
|
||||
const name = String(entry.name ?? "");
|
||||
const scheme = String(entry.scheme ?? "");
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id) || name === ""
|
||||
|| (scheme !== "dark" && scheme !== "light"))
|
||||
return null;
|
||||
if (!root.validHex(entry.accent) || !root.validHex(entry.secondary))
|
||||
return null;
|
||||
const palette = {};
|
||||
for (const key of root.paletteKeys) {
|
||||
const value = entry.palette?.[key];
|
||||
if (!root.validHex(value))
|
||||
return null;
|
||||
palette[key] = value;
|
||||
}
|
||||
const ansi = {};
|
||||
for (const key of root.ansiKeys) {
|
||||
const value = entry.ansi?.[key];
|
||||
if (!root.validHex(value))
|
||||
return null;
|
||||
ansi[key] = value;
|
||||
}
|
||||
return {
|
||||
id: id, name: name, scheme: scheme, shipped: true,
|
||||
accent: entry.accent, secondary: entry.secondary,
|
||||
palette: palette, ansi: ansi
|
||||
};
|
||||
}
|
||||
|
||||
function consume(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
const themes = (parsed.themes ?? [])
|
||||
.map(entry => root.normalizeTheme(entry))
|
||||
.filter(theme => theme !== null);
|
||||
if (themes.length === 0)
|
||||
throw new Error("no valid themes");
|
||||
root.themes = themes;
|
||||
root.defaultDark = String(parsed.defaultDark ?? "moon");
|
||||
root.defaultLight = String(parsed.defaultLight ?? "day");
|
||||
root.loaded = true;
|
||||
root.lastError = "";
|
||||
} catch (error) {
|
||||
root.lastError = "The theme catalog could not be read; using the built-in themes.";
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: catalogFile
|
||||
path: Quickshell.shellDir + "/config/themes.json"
|
||||
printErrors: false
|
||||
onLoaded: root.consume(this.text())
|
||||
onLoadFailed: root.lastError =
|
||||
"The theme catalog is missing; using the built-in themes."
|
||||
}
|
||||
|
||||
// Byte-identical to the catalog's moon and day records — the shell's
|
||||
// pre-theme literals, pinned by theme-catalog-contract.
|
||||
readonly property var fallbackThemes: [
|
||||
{
|
||||
id: "moon", name: "Tokyo Moon", scheme: "dark", shipped: true,
|
||||
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: "day", name: "Tokyo Day", scheme: "light", shipped: true,
|
||||
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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,34 @@
|
||||
var MAX_NAME_LENGTH = 40;
|
||||
|
||||
// The token sets a theme record may carry beyond its accent pair. PALETTE_KEYS
|
||||
// mirrors Theme.qml's color tokens; ANSI_KEYS feed generated terminal themes;
|
||||
// EFFECT_SPEC mirrors the ten effects schema keys so a custom theme can
|
||||
// snapshot them. All three are pinned by theme-catalog-contract against
|
||||
// config/themes.json.
|
||||
var PALETTE_KEYS = ["bg", "bgDark", "bgHighlight", "bgPanel", "bgPopover",
|
||||
"fg", "fgDim", "fgMuted", "gutter", "accentAlt",
|
||||
"cyan", "teal", "green", "yellow", "orange", "red", "redDeep",
|
||||
"magenta", "pink"];
|
||||
var ANSI_KEYS = ["black", "red", "green", "yellow", "blue", "magenta", "cyan",
|
||||
"white", "brightBlack", "brightRed", "brightGreen", "brightYellow",
|
||||
"brightBlue", "brightMagenta", "brightCyan", "brightWhite"];
|
||||
var EFFECT_SPEC = {
|
||||
blurEnabled: { kind: "bool" },
|
||||
blurSize: { kind: "int", min: 1, max: 20 },
|
||||
blurPasses: { kind: "int", min: 1, max: 5 },
|
||||
shadowEnabled: { kind: "bool" },
|
||||
shadowRange: { kind: "int", min: 0, max: 60 },
|
||||
shadowSharp: { kind: "bool" },
|
||||
shadowRenderPower: { kind: "int", min: 1, max: 4 },
|
||||
glowEnabled: { kind: "bool" },
|
||||
glowRange: { kind: "int", min: 0, max: 30 },
|
||||
animationsEnabled: { kind: "bool" }
|
||||
};
|
||||
|
||||
// The minimal built-in shipped list. The full catalog (config/themes.json,
|
||||
// via ThemeCatalog) is passed into every function that takes a `shipped`
|
||||
// argument; this fallback keeps the model usable headless and before the
|
||||
// catalog loads.
|
||||
var SHIPPED = [
|
||||
{
|
||||
id: "moon",
|
||||
@@ -85,8 +114,53 @@ var CURATED = {
|
||||
}
|
||||
};
|
||||
|
||||
function copyColorMap(value, keys) {
|
||||
if (!value || typeof value !== "object")
|
||||
return null;
|
||||
var result = {};
|
||||
for (var index = 0; index < keys.length; index++) {
|
||||
var color = normalizedColor(value[keys[index]]);
|
||||
if (!color)
|
||||
return null;
|
||||
result[keys[index]] = color;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// A palette or ansi block is all-or-nothing: a record either carries every
|
||||
// key valid, or the field is dropped and resolution falls back to the
|
||||
// scheme's default theme. Effects are per-key: invalid entries are dropped.
|
||||
function normalizePalette(value) { return copyColorMap(value, PALETTE_KEYS); }
|
||||
function normalizeAnsi(value) { return copyColorMap(value, ANSI_KEYS); }
|
||||
|
||||
function normalizeEffects(value) {
|
||||
if (!value || typeof value !== "object")
|
||||
return null;
|
||||
var result = {};
|
||||
var any = false;
|
||||
Object.keys(EFFECT_SPEC).forEach(function(key) {
|
||||
var spec = EFFECT_SPEC[key];
|
||||
var raw = value[key];
|
||||
if (raw === undefined)
|
||||
return;
|
||||
if (spec.kind === "bool") {
|
||||
if (typeof raw !== "boolean")
|
||||
return;
|
||||
result[key] = raw;
|
||||
any = true;
|
||||
return;
|
||||
}
|
||||
var number = Number(raw);
|
||||
if (!isFinite(number))
|
||||
return;
|
||||
result[key] = Math.round(clamp(number, spec.min, spec.max));
|
||||
any = true;
|
||||
});
|
||||
return any ? result : null;
|
||||
}
|
||||
|
||||
function copyProfile(profile) {
|
||||
return {
|
||||
var result = {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
scheme: profile.scheme,
|
||||
@@ -94,10 +168,24 @@ function copyProfile(profile) {
|
||||
secondary: profile.secondary,
|
||||
shipped: profile.shipped === true
|
||||
};
|
||||
var palette = normalizePalette(profile.palette);
|
||||
if (palette)
|
||||
result.palette = palette;
|
||||
var ansi = normalizeAnsi(profile.ansi);
|
||||
if (ansi)
|
||||
result.ansi = ansi;
|
||||
var effects = normalizeEffects(profile.effects);
|
||||
if (effects)
|
||||
result.effects = effects;
|
||||
return result;
|
||||
}
|
||||
|
||||
function shippedProfiles() {
|
||||
return SHIPPED.map(copyProfile);
|
||||
function shippedList(shipped) {
|
||||
return Array.isArray(shipped) && shipped.length > 0 ? shipped : SHIPPED;
|
||||
}
|
||||
|
||||
function shippedProfiles(shipped) {
|
||||
return shippedList(shipped).map(copyProfile);
|
||||
}
|
||||
|
||||
function curatedAccents() {
|
||||
@@ -130,7 +218,7 @@ function normalizeStoredProfile(value) {
|
||||
|| !isScheme(value.scheme) || !accent || !secondary)
|
||||
return null;
|
||||
|
||||
return {
|
||||
var result = {
|
||||
id: id,
|
||||
name: name,
|
||||
scheme: value.scheme,
|
||||
@@ -138,15 +226,25 @@ function normalizeStoredProfile(value) {
|
||||
secondary: secondary,
|
||||
shipped: false
|
||||
};
|
||||
var palette = normalizePalette(value.palette);
|
||||
if (palette)
|
||||
result.palette = palette;
|
||||
var ansi = normalizeAnsi(value.ansi);
|
||||
if (ansi)
|
||||
result.ansi = ansi;
|
||||
var effects = normalizeEffects(value.effects);
|
||||
if (effects)
|
||||
result.effects = effects;
|
||||
return result;
|
||||
}
|
||||
|
||||
function validCustomProfiles(values) {
|
||||
function validCustomProfiles(values, shipped) {
|
||||
if (!Array.isArray(values))
|
||||
return [];
|
||||
|
||||
var ids = {};
|
||||
var names = {};
|
||||
SHIPPED.forEach(function(profile) {
|
||||
shippedList(shipped).forEach(function(profile) {
|
||||
ids[profile.id] = true;
|
||||
names[profile.name.toLowerCase()] = true;
|
||||
});
|
||||
@@ -166,8 +264,8 @@ function validCustomProfiles(values) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function profileCatalog(values) {
|
||||
return shippedProfiles().concat(validCustomProfiles(values));
|
||||
function profileCatalog(values, shipped) {
|
||||
return shippedProfiles(shipped).concat(validCustomProfiles(values, shipped));
|
||||
}
|
||||
|
||||
function boundedName(value) {
|
||||
@@ -218,9 +316,9 @@ function uniqueId(name, profiles) {
|
||||
return base.slice(0, 52) + "-10000";
|
||||
}
|
||||
|
||||
function createCustomProfile(values, input) {
|
||||
var customs = validCustomProfiles(values);
|
||||
var catalog = shippedProfiles().concat(customs);
|
||||
function createCustomProfile(values, input, shipped) {
|
||||
var customs = validCustomProfiles(values, shipped);
|
||||
var catalog = shippedProfiles(shipped).concat(customs);
|
||||
var scheme = input && input.scheme;
|
||||
var accent = normalizedColor(input && input.accent);
|
||||
var secondary = normalizedColor(input && input.secondary);
|
||||
@@ -236,11 +334,20 @@ function createCustomProfile(values, input) {
|
||||
secondary: secondary,
|
||||
shipped: false
|
||||
};
|
||||
var palette = normalizePalette(input && input.palette);
|
||||
if (palette)
|
||||
profile.palette = palette;
|
||||
var ansi = normalizeAnsi(input && input.ansi);
|
||||
if (ansi)
|
||||
profile.ansi = ansi;
|
||||
var effects = normalizeEffects(input && input.effects);
|
||||
if (effects)
|
||||
profile.effects = effects;
|
||||
return { profiles: customs.concat([profile]), profile: profile };
|
||||
}
|
||||
|
||||
function findProfile(values, id) {
|
||||
var catalog = profileCatalog(values);
|
||||
function findProfile(values, id, shipped) {
|
||||
var catalog = profileCatalog(values, shipped);
|
||||
for (var index = 0; index < catalog.length; index++) {
|
||||
if (catalog[index].id === id)
|
||||
return catalog[index];
|
||||
@@ -248,26 +355,32 @@ function findProfile(values, id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function editProfile(values, selected, changes) {
|
||||
var customs = validCustomProfiles(values);
|
||||
function editProfile(values, selected, changes, shipped) {
|
||||
var customs = validCustomProfiles(values, shipped);
|
||||
if (!selected || typeof selected !== "object")
|
||||
return { profiles: customs, profile: null };
|
||||
|
||||
var accent = normalizedColor(changes && changes.accent !== undefined
|
||||
? changes.accent : selected.accent);
|
||||
var secondary = normalizedColor(changes && changes.secondary !== undefined
|
||||
? changes.secondary : selected.secondary);
|
||||
var scheme = changes && changes.scheme !== undefined ? changes.scheme : selected.scheme;
|
||||
function pick(field, fallback) {
|
||||
return changes && changes[field] !== undefined ? changes[field] : fallback;
|
||||
}
|
||||
var accent = normalizedColor(pick("accent", selected.accent));
|
||||
var secondary = normalizedColor(pick("secondary", selected.secondary));
|
||||
var scheme = pick("scheme", selected.scheme);
|
||||
if (!isScheme(scheme) || !accent || !secondary)
|
||||
return { profiles: customs, profile: null };
|
||||
|
||||
if (selected.shipped === true) {
|
||||
// Editing a shipped theme forks it into a custom, carrying the whole
|
||||
// palette so the fork looks identical until the edit lands.
|
||||
return createCustomProfile(customs, {
|
||||
name: selected.name + " custom",
|
||||
scheme: scheme,
|
||||
accent: accent,
|
||||
secondary: secondary
|
||||
});
|
||||
secondary: secondary,
|
||||
palette: pick("palette", selected.palette),
|
||||
ansi: pick("ansi", selected.ansi),
|
||||
effects: pick("effects", selected.effects)
|
||||
}, shipped);
|
||||
}
|
||||
|
||||
var stored = normalizeStoredProfile(selected);
|
||||
@@ -279,6 +392,21 @@ function editProfile(values, selected, changes) {
|
||||
accent: accent,
|
||||
secondary: secondary
|
||||
});
|
||||
var palette = normalizePalette(pick("palette", stored.palette));
|
||||
if (palette)
|
||||
updated.palette = palette;
|
||||
else
|
||||
delete updated.palette;
|
||||
var ansi = normalizeAnsi(pick("ansi", stored.ansi));
|
||||
if (ansi)
|
||||
updated.ansi = ansi;
|
||||
else
|
||||
delete updated.ansi;
|
||||
var effects = normalizeEffects(pick("effects", stored.effects));
|
||||
if (effects)
|
||||
updated.effects = effects;
|
||||
else
|
||||
delete updated.effects;
|
||||
var found = false;
|
||||
var next = customs.map(function(profile) {
|
||||
if (profile.id !== updated.id)
|
||||
@@ -291,10 +419,11 @@ function editProfile(values, selected, changes) {
|
||||
return { profiles: next, profile: updated };
|
||||
}
|
||||
|
||||
function deleteProfile(values, id) {
|
||||
var customs = validCustomProfiles(values);
|
||||
for (var shippedIndex = 0; shippedIndex < SHIPPED.length; shippedIndex++) {
|
||||
if (SHIPPED[shippedIndex].id === id)
|
||||
function deleteProfile(values, id, shipped) {
|
||||
var customs = validCustomProfiles(values, shipped);
|
||||
var fixed = shippedList(shipped);
|
||||
for (var shippedIndex = 0; shippedIndex < fixed.length; shippedIndex++) {
|
||||
if (fixed[shippedIndex].id === id)
|
||||
return { profiles: customs, removed: false };
|
||||
}
|
||||
var next = customs.filter(function(profile) { return profile.id !== id; });
|
||||
@@ -308,17 +437,131 @@ function curatedPair(name, scheme) {
|
||||
return { accent: entry.dark, secondary: entry.darkSecondary };
|
||||
}
|
||||
|
||||
function matchingShippedProfile(scheme, accent, secondary) {
|
||||
function matchingShippedProfile(scheme, accent, secondary, shipped) {
|
||||
var first = normalizedColor(accent);
|
||||
var second = normalizedColor(secondary);
|
||||
for (var index = 0; index < SHIPPED.length; index++) {
|
||||
var profile = SHIPPED[index];
|
||||
var fixed = shippedList(shipped);
|
||||
for (var index = 0; index < fixed.length; index++) {
|
||||
var profile = fixed[index];
|
||||
if (profile.scheme === scheme && profile.accent === first && profile.secondary === second)
|
||||
return copyProfile(profile);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The nearest curated accent to an arbitrary pair, by hue distance of the
|
||||
// primary. This is what keeps `accentName` — and through it GNOME's
|
||||
// accent-color enum, kitty's border, and the lock screen — in sync when a
|
||||
// theme or a custom color is chosen instead of a curated swatch.
|
||||
function nearestCuratedName(scheme, accent) {
|
||||
var target = hexToHsv(accent);
|
||||
if (!target)
|
||||
return "blue";
|
||||
var bestName = "blue";
|
||||
var bestDistance = Infinity;
|
||||
Object.keys(CURATED).forEach(function(name) {
|
||||
var pair = curatedPair(name, isScheme(scheme) ? scheme : "dark");
|
||||
var candidate = hexToHsv(pair.accent);
|
||||
if (!candidate)
|
||||
return;
|
||||
var delta = Math.abs(candidate.h - target.h);
|
||||
var hueDistance = Math.min(delta, 360 - delta);
|
||||
// A desaturated accent should map to slate rather than whichever hue
|
||||
// its noise happens to lean toward.
|
||||
var distance = target.s < 12
|
||||
? (name === "slate" ? 0 : 1000 + hueDistance)
|
||||
: hueDistance + Math.abs(candidate.s - target.s) * 0.15;
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestName = name;
|
||||
}
|
||||
});
|
||||
return bestName;
|
||||
}
|
||||
|
||||
// ── Palette derivation, for the editor ──────────────────────────────────────
|
||||
|
||||
function mixHex(first, second, ratio) {
|
||||
var a = normalizedColor(first);
|
||||
var b = normalizedColor(second);
|
||||
var r = clamp(ratio, 0, 1);
|
||||
if (!a || !b)
|
||||
return a || b;
|
||||
function channel(offset) {
|
||||
var from = parseInt(a.slice(offset, offset + 2), 16);
|
||||
var to = parseInt(b.slice(offset, offset + 2), 16);
|
||||
return channelHex(from + (to - from) * r);
|
||||
}
|
||||
return "#" + channel(1) + channel(3) + channel(5);
|
||||
}
|
||||
|
||||
// A full palette from the four editor wells plus a base theme. Background
|
||||
// derives the surface family, foreground derives the text tints, and every
|
||||
// other token is inherited from the base so a small edit stays a small edit.
|
||||
function derivePalette(base, input) {
|
||||
var origin = normalizePalette(base) || null;
|
||||
var bg = normalizedColor(input && input.bg) || (origin ? origin.bg : null);
|
||||
var fg = normalizedColor(input && input.fg) || (origin ? origin.fg : null);
|
||||
var accent = normalizedColor(input && input.accent);
|
||||
var scheme = isScheme(input && input.scheme) ? input.scheme : "dark";
|
||||
if (!bg || !fg)
|
||||
return origin;
|
||||
|
||||
var dark = scheme === "dark";
|
||||
var result = origin ? Object.assign({}, origin) : {};
|
||||
result.bg = bg;
|
||||
result.bgDark = mixHex(bg, "#000000", dark ? 0.10 : 0.06);
|
||||
result.bgPopover = dark ? mixHex(bg, "#000000", 0.06) : mixHex(bg, "#ffffff", 0.35);
|
||||
result.bgPanel = dark ? mixHex(bg, fg, 0.05) : mixHex(bg, "#000000", 0.03);
|
||||
result.bgHighlight = mixHex(bg, fg, dark ? 0.11 : 0.14);
|
||||
result.gutter = mixHex(bg, fg, dark ? 0.20 : 0.35);
|
||||
result.fg = fg;
|
||||
result.fgDim = mixHex(fg, bg, 0.32);
|
||||
result.fgMuted = mixHex(fg, bg, 0.47);
|
||||
if (accent)
|
||||
result.accentAlt = mixHex(accent, dark ? "#ffffff" : "#000000", 0.18);
|
||||
return normalizePalette(result);
|
||||
}
|
||||
|
||||
// Scale the saturation of every token together. factor 1.0 is neutral;
|
||||
// backgrounds move at quarter strength so the ground stays a ground.
|
||||
function resaturatePalette(palette, factor) {
|
||||
var origin = normalizePalette(palette);
|
||||
if (!origin)
|
||||
return null;
|
||||
var scale = clamp(factor, 0, 2);
|
||||
var result = {};
|
||||
PALETTE_KEYS.forEach(function(key) {
|
||||
var hsv = hexToHsv(origin[key]);
|
||||
var strength = key.indexOf("bg") === 0 || key === "gutter" ? 0.25 : 1;
|
||||
var next = clamp(hsv.s * (1 + (scale - 1) * strength), 0, 100);
|
||||
result[key] = hsvToHex(hsv.h, next, hsv.v);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Terminal colors for a custom theme that has none of its own: semantic
|
||||
// tokens map straight onto ANSI, brights lift value a step.
|
||||
function deriveAnsi(palette, accent) {
|
||||
var origin = normalizePalette(palette);
|
||||
if (!origin)
|
||||
return null;
|
||||
var blue = normalizedColor(accent) || origin.accentAlt;
|
||||
function brighten(color) {
|
||||
var hsv = hexToHsv(color);
|
||||
return hsvToHex(hsv.h, Math.max(0, hsv.s - 8), Math.min(100, hsv.v + 12));
|
||||
}
|
||||
return normalizeAnsi({
|
||||
black: origin.bgHighlight, red: origin.red, green: origin.green,
|
||||
yellow: origin.yellow, blue: blue, magenta: origin.magenta,
|
||||
cyan: origin.cyan, white: origin.fgDim,
|
||||
brightBlack: origin.gutter, brightRed: brighten(origin.red),
|
||||
brightGreen: brighten(origin.green), brightYellow: brighten(origin.yellow),
|
||||
brightBlue: brighten(blue), brightMagenta: brighten(origin.magenta),
|
||||
brightCyan: brighten(origin.cyan), brightWhite: origin.fg
|
||||
});
|
||||
}
|
||||
|
||||
function curatedNameForProfile(profile) {
|
||||
if (!profile || !isScheme(profile.scheme))
|
||||
return "";
|
||||
@@ -402,6 +645,9 @@ function hexToHsv(value) {
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
MAX_NAME_LENGTH: MAX_NAME_LENGTH,
|
||||
PALETTE_KEYS: PALETTE_KEYS,
|
||||
ANSI_KEYS: ANSI_KEYS,
|
||||
EFFECT_SPEC: EFFECT_SPEC,
|
||||
shippedProfiles: shippedProfiles,
|
||||
curatedAccents: curatedAccents,
|
||||
validCustomProfiles: validCustomProfiles,
|
||||
@@ -413,6 +659,14 @@ if (typeof module !== "undefined") {
|
||||
curatedPair: curatedPair,
|
||||
matchingShippedProfile: matchingShippedProfile,
|
||||
curatedNameForProfile: curatedNameForProfile,
|
||||
nearestCuratedName: nearestCuratedName,
|
||||
normalizePalette: normalizePalette,
|
||||
normalizeAnsi: normalizeAnsi,
|
||||
normalizeEffects: normalizeEffects,
|
||||
mixHex: mixHex,
|
||||
derivePalette: derivePalette,
|
||||
resaturatePalette: resaturatePalette,
|
||||
deriveAnsi: deriveAnsi,
|
||||
hsvToHex: hsvToHex,
|
||||
hexToHsv: hexToHsv
|
||||
};
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
pragma Singleton
|
||||
|
||||
// The theme resolver. Shipped themes come from ThemeCatalog (config/themes.json),
|
||||
// customs from the themeProfiles preference; the active theme is one record with
|
||||
// a full palette, resolved here for Theme.qml and the render pipeline.
|
||||
//
|
||||
// Selection is per-mode: themeDark and themeLight remember the chosen theme for
|
||||
// each scheme, so flipping light/dark lands on *your* theme for that side —
|
||||
// never a forced reset to the defaults. Every commit path ends in commitActive,
|
||||
// which also keeps accentName (GNOME's enum, kitty's border, the lock screen)
|
||||
// pointed at the nearest curated accent — the desync those surfaces used to
|
||||
// suffer after a custom edit is structural now, not a convention.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
@@ -8,94 +19,178 @@ import "ThemeProfileModel.js" as ThemeProfileModel
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var shippedThemes: ThemeCatalog.themes
|
||||
readonly property var customProfiles: ThemeProfileModel.validCustomProfiles(
|
||||
DesktopPreferences.get("themeProfiles"))
|
||||
DesktopPreferences.get("themeProfiles"), root.shippedThemes)
|
||||
readonly property var curatedAccents: ThemeProfileModel.curatedAccents()
|
||||
readonly property var profiles: ThemeProfileModel.profileCatalog(root.customProfiles)
|
||||
readonly property string activeId: DesktopPreferences.get("themeProfileId") || "moon"
|
||||
readonly property var profiles: ThemeProfileModel.profileCatalog(
|
||||
root.customProfiles, root.shippedThemes)
|
||||
|
||||
readonly property string scheme: DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark"
|
||||
readonly property string activeId: DesktopPreferences.get("themeProfileId") || ThemeCatalog.defaultDark
|
||||
readonly property var activeProfile: ThemeProfileModel.findProfile(
|
||||
root.customProfiles, root.activeId) || ThemeProfileModel.shippedProfiles()[0]
|
||||
root.customProfiles, root.activeId, root.shippedThemes)
|
||||
|| ThemeProfileModel.shippedProfiles(root.shippedThemes)[0]
|
||||
readonly property string activeAccentName: ThemeProfileModel.curatedNameForProfile(
|
||||
root.activeProfile)
|
||||
|
||||
// Always complete: the record's own palette, or the scheme default's.
|
||||
readonly property var activePalette: root.activeProfile.palette
|
||||
?? ThemeCatalog.defaultPalette(root.activeProfile.scheme)
|
||||
readonly property var activeAnsi: root.activeProfile.ansi
|
||||
?? (ThemeCatalog.byId(root.activeProfile.scheme === "light"
|
||||
? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark)?.ansi ?? null)
|
||||
|
||||
property bool reconciling: false
|
||||
|
||||
// The remembered theme for a scheme, falling back to the catalog default.
|
||||
function themeForScheme(target: string): string {
|
||||
const key = target === "light" ? "themeLight" : "themeDark";
|
||||
const fallback = target === "light" ? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark;
|
||||
const stored = String(DesktopPreferences.get(key) || "");
|
||||
return ThemeProfileModel.findProfile(root.customProfiles, stored, root.shippedThemes)
|
||||
? stored : fallback;
|
||||
}
|
||||
|
||||
function commitActive(profile: var): bool {
|
||||
if (!profile)
|
||||
return false;
|
||||
|
||||
// Scheme first: if another control changed it, the reconciliation hook
|
||||
// may briefly select Day/Moon before this final stable profile id lands.
|
||||
// may briefly select the scheme default before this final id lands.
|
||||
const schemeAccepted = SystemSettings.commitPreference("colorScheme", profile.scheme);
|
||||
const profileAccepted = SystemSettings.commitPreference("themeProfileId", profile.id);
|
||||
SystemSettings.commitPreference(
|
||||
profile.scheme === "light" ? "themeLight" : "themeDark", profile.id);
|
||||
SystemSettings.commitPreference("accentName",
|
||||
ThemeProfileModel.nearestCuratedName(profile.scheme, profile.accent));
|
||||
root.applyProfileEffects(profile);
|
||||
return schemeAccepted && profileAccepted;
|
||||
}
|
||||
|
||||
// A theme that carries an effects snapshot restores it; themes without one
|
||||
// (every shipped theme) leave the user's effects alone.
|
||||
function applyProfileEffects(profile: var): void {
|
||||
const effects = profile.effects ?? null;
|
||||
if (!effects)
|
||||
return;
|
||||
for (const key of Object.keys(effects))
|
||||
SystemSettings.commitPreference(key, effects[key]);
|
||||
}
|
||||
|
||||
function selectProfile(id: string): bool {
|
||||
return root.commitActive(ThemeProfileModel.findProfile(root.customProfiles, id));
|
||||
return root.commitActive(ThemeProfileModel.findProfile(
|
||||
root.customProfiles, id, root.shippedThemes));
|
||||
}
|
||||
|
||||
// The Themes tab's light/dark hero: land on the remembered theme for the
|
||||
// requested scheme.
|
||||
function setScheme(target: string): bool {
|
||||
return root.selectProfile(root.themeForScheme(target === "light" ? "light" : "dark"));
|
||||
}
|
||||
|
||||
// Editing a shipped profile creates a saved custom copy. Editing a custom
|
||||
// profile updates only that record. ThemeProfileModel enforces both rules,
|
||||
// leaving this singleton responsible only for validated preference routing.
|
||||
function setAccentPair(accent: string, secondary: string): bool {
|
||||
const result = ThemeProfileModel.editProfile(root.customProfiles, root.activeProfile, {
|
||||
accent: accent,
|
||||
secondary: secondary
|
||||
});
|
||||
function updateActive(changes: var): bool {
|
||||
const result = ThemeProfileModel.editProfile(
|
||||
root.customProfiles, root.activeProfile, changes, root.shippedThemes);
|
||||
if (!result.profile)
|
||||
return false;
|
||||
if (!SystemSettings.commitPreference("themeProfiles", result.profiles))
|
||||
return false;
|
||||
return SystemSettings.commitPreference("themeProfileId", result.profile.id);
|
||||
return root.commitActive(result.profile);
|
||||
}
|
||||
|
||||
function setAccentPair(accent: string, secondary: string): bool {
|
||||
return root.updateActive({ accent: accent, secondary: secondary });
|
||||
}
|
||||
|
||||
// The editor's Background/Foreground wells: derive the surface and text
|
||||
// families from the two ground colors, keep everything else.
|
||||
function setGroundColors(bg: string, fg: string): bool {
|
||||
const palette = ThemeProfileModel.derivePalette(root.activePalette, {
|
||||
scheme: root.activeProfile.scheme,
|
||||
bg: bg,
|
||||
fg: fg,
|
||||
accent: root.activeProfile.accent
|
||||
});
|
||||
if (!palette)
|
||||
return false;
|
||||
return root.updateActive({
|
||||
palette: palette,
|
||||
ansi: root.activeProfile.ansi
|
||||
?? ThemeProfileModel.deriveAnsi(palette, root.activeProfile.accent)
|
||||
});
|
||||
}
|
||||
|
||||
// The editor's saturation slider. factor 1.0 is neutral.
|
||||
function setSaturation(factor: real): bool {
|
||||
const palette = ThemeProfileModel.resaturatePalette(root.activePalette, factor);
|
||||
if (!palette)
|
||||
return false;
|
||||
return root.updateActive({ palette: palette });
|
||||
}
|
||||
|
||||
// Saving snapshots the whole look: palette, terminal colors, and the ten
|
||||
// effect values as they stand right now.
|
||||
function saveProfile(name: string): bool {
|
||||
const effects = {};
|
||||
for (const key of Object.keys(ThemeProfileModel.EFFECT_SPEC))
|
||||
effects[key] = DesktopPreferences.get(key);
|
||||
const result = ThemeProfileModel.createCustomProfile(root.customProfiles, {
|
||||
name: name,
|
||||
scheme: root.activeProfile.scheme,
|
||||
accent: root.activeProfile.accent,
|
||||
secondary: root.activeProfile.secondary
|
||||
});
|
||||
secondary: root.activeProfile.secondary,
|
||||
palette: root.activePalette,
|
||||
ansi: root.activeAnsi,
|
||||
effects: effects
|
||||
}, root.shippedThemes);
|
||||
if (!result.profile)
|
||||
return false;
|
||||
if (!SystemSettings.commitPreference("themeProfiles", result.profiles))
|
||||
return false;
|
||||
return SystemSettings.commitPreference("themeProfileId", result.profile.id);
|
||||
return root.commitActive(result.profile);
|
||||
}
|
||||
|
||||
function deleteProfile(id: string): bool {
|
||||
const result = ThemeProfileModel.deleteProfile(root.customProfiles, id);
|
||||
const result = ThemeProfileModel.deleteProfile(
|
||||
root.customProfiles, id, root.shippedThemes);
|
||||
if (!result.removed)
|
||||
return false;
|
||||
if (!SystemSettings.commitPreference("themeProfiles", result.profiles))
|
||||
return false;
|
||||
if (root.activeId === id)
|
||||
return root.selectProfile(DesktopPreferences.get("colorScheme") === "light" ? "day" : "moon");
|
||||
return root.setScheme(root.scheme);
|
||||
return true;
|
||||
}
|
||||
|
||||
function useCuratedAccent(name: string): bool {
|
||||
const scheme = DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark";
|
||||
const pair = ThemeProfileModel.curatedPair(name, scheme);
|
||||
// Accepts the curated name or the AccentPicker's option object — the
|
||||
// picker used to hand the object to a string parameter and silently fall
|
||||
// back to blue.
|
||||
function useCuratedAccent(entry: var): bool {
|
||||
const name = typeof entry === "string" ? entry : String(entry?.value ?? entry?.name ?? "");
|
||||
const pair = ThemeProfileModel.curatedPair(name, root.scheme);
|
||||
const shipped = ThemeProfileModel.matchingShippedProfile(
|
||||
scheme, pair.accent, pair.secondary);
|
||||
root.scheme, pair.accent, pair.secondary, root.shippedThemes);
|
||||
const accepted = shipped
|
||||
? root.commitActive(shipped)
|
||||
: root.setAccentPair(pair.accent, pair.secondary);
|
||||
if (accepted)
|
||||
if (accepted && ThemeProfileModel.curatedAccents()[name])
|
||||
SystemSettings.commitPreference("accentName", name);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
// A scheme flip from anywhere (the hero, IPC, a synced settings file)
|
||||
// lands on the remembered theme for that scheme — never a forced default.
|
||||
function reconcileScheme(): void {
|
||||
if (root.reconciling)
|
||||
return;
|
||||
const scheme = DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark";
|
||||
if (root.activeProfile.scheme === scheme)
|
||||
if (root.activeProfile.scheme === root.scheme)
|
||||
return;
|
||||
root.reconciling = true;
|
||||
SystemSettings.commitPreference("themeProfileId", scheme === "light" ? "day" : "moon");
|
||||
SystemSettings.commitPreference("themeProfileId", root.themeForScheme(root.scheme));
|
||||
root.reconciling = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
pragma Singleton
|
||||
|
||||
// Video wallpapers, played by mpvpaper — one supervised process per output,
|
||||
// hardware-decoded, muted, looped. Panama owns the pause policy, because the
|
||||
// compositor cannot be trusted with it: Hyprland rebuilds the full-screen blur
|
||||
// chain on every wallpaper frame while any blur-enabled layer exists, and an
|
||||
// occluded wallpaper keeps compositing unless solitary mode holds — which a
|
||||
// single toast breaks. So the wallpaper pauses itself whenever a game runs,
|
||||
// whenever the machine is on battery (if the preference says so), and whenever
|
||||
// the bar pill is clicked, over mpv's JSON IPC socket.
|
||||
//
|
||||
// hyprpaper and mpvpaper both claim the background layer and stacking within a
|
||||
// layer is creation order — a race. So while a video is active hyprpaper's
|
||||
// service is stopped, and stopping the video starts it again; Wallpaper.qml
|
||||
// reapplies the still policy once it returns.
|
||||
//
|
||||
// mpvpaper 1.9 (Terra) is the floor: it carries the libmpv fence-leak
|
||||
// workaround. Known upstream sharp edges — a hotplug segfault and a
|
||||
// mitigated-not-fixed leak — are why the processes are supervised and simply
|
||||
// respawned rather than trusted.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-video-wallpaper"
|
||||
|
||||
property bool available: false // mpvpaper on PATH, checked once
|
||||
property bool scanned: false
|
||||
property var candidates: [] // newest-60 videos in videoWallpaperDir
|
||||
property string path: "" // the active video, "" when inactive
|
||||
readonly property bool active: root.path !== ""
|
||||
property string lastError: ""
|
||||
|
||||
// Pause reasons, each independent; the video plays only when none holds.
|
||||
property bool manuallyPaused: false
|
||||
readonly property bool gamePaused: FocusModes.gameRunning
|
||||
readonly property bool batteryPaused: DesktopPreferences.get("videoWallpaperPauseOnBattery") === true
|
||||
&& Battery.available && !Battery.acOnline
|
||||
readonly property bool paused: root.manuallyPaused || root.gamePaused || root.batteryPaused
|
||||
|
||||
readonly property string frameDir: Quickshell.env("XDG_STATE_HOME") !== ""
|
||||
? Quickshell.env("XDG_STATE_HOME") + "/panama"
|
||||
: Quickshell.env("HOME") + "/.local/state/panama"
|
||||
// The lock screen cannot play video; panama-lock uses this still instead.
|
||||
readonly property string framePath: root.frameDir + "/video-wallpaper-frame.png"
|
||||
|
||||
readonly property var outputs: Quickshell.screens.map(screen => screen.name)
|
||||
|
||||
function videoDir(): string {
|
||||
const stored = String(DesktopPreferences.get("videoWallpaperDir") || "Videos/Wallpapers");
|
||||
if (stored.startsWith("/"))
|
||||
return stored;
|
||||
if (stored.startsWith("~/"))
|
||||
return Quickshell.env("HOME") + stored.slice(1);
|
||||
return Quickshell.env("HOME") + "/" + stored;
|
||||
}
|
||||
|
||||
function isVideo(candidate: string): bool {
|
||||
return /\.(mp4|mkv|webm)$/i.test(String(candidate));
|
||||
}
|
||||
|
||||
function socketFor(output: string): string {
|
||||
return Quickshell.env("XDG_RUNTIME_DIR") + "/panama-wallpaper-" + output + ".sock";
|
||||
}
|
||||
|
||||
// ── Discovery ───────────────────────────────────────────────────────────
|
||||
|
||||
function rescan(): void {
|
||||
if (!scanProc.running) {
|
||||
scanProc.command = [root.helperPath, "scan", root.videoDir()];
|
||||
scanProc.running = true;
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: scanProc
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.candidates = this.text.split("\n").filter(line => line !== "");
|
||||
root.scanned = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: probeProc
|
||||
command: ["sh", "-c", "command -v mpvpaper >/dev/null && echo yes || echo no"]
|
||||
running: true
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.available = this.text.trim() === "yes"
|
||||
}
|
||||
}
|
||||
|
||||
// ── Playback lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
// One mpvpaper per output. `-p -a FULL` is belt-and-braces auto-pause;
|
||||
// the real pausing happens over the IPC socket below. `hwdec=vaapi` is
|
||||
// named explicitly so a silent software-decode fallback cannot hide.
|
||||
function mpvpaperCommand(output: string): var {
|
||||
return ["mpvpaper", "-f", "-o",
|
||||
"hwdec=vaapi profile=fast no-audio loop-file=inf panscan=1.0 "
|
||||
+ "input-ipc-server=" + root.socketFor(output),
|
||||
"-p", "-a", "FULL",
|
||||
output, root.path];
|
||||
}
|
||||
|
||||
function start(video: string): bool {
|
||||
if (!root.available) {
|
||||
root.lastError = "mpvpaper is not installed — run panama update to pick it up.";
|
||||
return false;
|
||||
}
|
||||
if (!root.isVideo(video))
|
||||
return false;
|
||||
root.lastError = "";
|
||||
root.path = video;
|
||||
root.manuallyPaused = false;
|
||||
hyprpaperControl.command = ["systemctl", "--user", "stop", "hyprpaper.service"];
|
||||
hyprpaperControl.running = true;
|
||||
frameProc.command = ["sh", "-c",
|
||||
"mkdir -p '" + root.frameDir + "' && exec '" + root.helperPath
|
||||
+ "' frame \"$1\" \"$2\"", "frame", video, root.framePath];
|
||||
frameProc.running = true;
|
||||
playerRespawn.restart();
|
||||
return true;
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (!root.active)
|
||||
return;
|
||||
root.path = "";
|
||||
root.manuallyPaused = false;
|
||||
playerRespawn.stop();
|
||||
for (const player of root.players)
|
||||
player.running = false;
|
||||
hyprpaperControl.command = ["systemctl", "--user", "start", "hyprpaper.service"];
|
||||
hyprpaperControl.running = true;
|
||||
Qt.callLater(() => Wallpaper.refreshActive());
|
||||
}
|
||||
|
||||
// Players are created per current output set each (re)spawn, so hotplug
|
||||
// lands on the same path as a crash: kill what runs, spawn fresh.
|
||||
property var players: []
|
||||
|
||||
Timer {
|
||||
id: playerRespawn
|
||||
interval: 400
|
||||
onTriggered: root.spawnPlayers()
|
||||
}
|
||||
|
||||
function spawnPlayers(): void {
|
||||
for (const player of root.players)
|
||||
player.running = false;
|
||||
const spawned = [];
|
||||
for (const output of root.outputs) {
|
||||
const player = playerComponent.createObject(root, { output: output });
|
||||
player.command = root.mpvpaperCommand(output);
|
||||
player.running = true;
|
||||
spawned.push(player);
|
||||
}
|
||||
root.players = spawned;
|
||||
pauseSync.restart();
|
||||
}
|
||||
|
||||
Component {
|
||||
id: playerComponent
|
||||
|
||||
Process {
|
||||
id: player
|
||||
property string output: ""
|
||||
onExited: {
|
||||
// A dead player while a video is meant to be active is a
|
||||
// crash (mpvpaper has a known hotplug segfault): respawn the
|
||||
// whole set after a beat rather than reasoning per-output.
|
||||
if (root.active && !playerRespawn.running)
|
||||
playerRespawn.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onOutputsChanged: if (root.active) playerRespawn.restart()
|
||||
|
||||
Process {
|
||||
id: hyprpaperControl
|
||||
}
|
||||
|
||||
Process {
|
||||
id: frameProc
|
||||
}
|
||||
|
||||
// ── Pause, over mpv's JSON IPC ──────────────────────────────────────────
|
||||
|
||||
onPausedChanged: pauseSync.restart()
|
||||
|
||||
// The sockets appear a moment after mpvpaper starts; a short retry beats
|
||||
// ordering ceremony. Each sync writes the current desired state to every
|
||||
// output's socket and disconnects.
|
||||
Timer {
|
||||
id: pauseSync
|
||||
interval: 600
|
||||
onTriggered: root.pushPauseState()
|
||||
}
|
||||
|
||||
function pushPauseState(): void {
|
||||
if (!root.active)
|
||||
return;
|
||||
for (const output of root.outputs) {
|
||||
const socket = pauseSocketComponent.createObject(root, {
|
||||
socketPath: root.socketFor(output),
|
||||
payload: JSON.stringify({ command: ["set_property", "pause", root.paused] }) + "\n"
|
||||
});
|
||||
socket.connected = true;
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: pauseSocketComponent
|
||||
|
||||
Socket {
|
||||
id: pauseSocket
|
||||
property string payload: ""
|
||||
path: socketPath
|
||||
property string socketPath: ""
|
||||
onConnectionStateChanged: {
|
||||
if (connected) {
|
||||
write(payload);
|
||||
flush();
|
||||
Qt.callLater(() => { pauseSocket.connected = false; pauseSocket.destroy(); });
|
||||
}
|
||||
}
|
||||
onError: pauseSocket.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
function togglePause(): void {
|
||||
root.manuallyPaused = !root.manuallyPaused;
|
||||
}
|
||||
|
||||
Component.onCompleted: root.rescan()
|
||||
}
|
||||
@@ -126,8 +126,16 @@ Singleton {
|
||||
id: restore
|
||||
interval: 1500
|
||||
onTriggered: {
|
||||
if (root.startupRestoreEnabled)
|
||||
root.applyCurrentPolicy(false);
|
||||
if (!root.startupRestoreEnabled)
|
||||
return;
|
||||
// A stored video wallpaper restores through mpvpaper; handing its
|
||||
// path to the hyprpaper transaction would only fail validation.
|
||||
if (DesktopPreferences.get("wallpaperMode") === "single"
|
||||
&& VideoWallpaper.isVideo(root.configured)) {
|
||||
VideoWallpaper.start(root.configured);
|
||||
return;
|
||||
}
|
||||
root.applyCurrentPolicy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +205,10 @@ Singleton {
|
||||
function currentPolicy(): var {
|
||||
return {
|
||||
mode: DesktopPreferences.get("wallpaperMode") ?? "single",
|
||||
globalPath: root.configured === "" ? root.shippedPath : root.configured,
|
||||
// A stored video path cannot serve as a hyprpaper still; policies
|
||||
// built while one is stored fall back to the shipped image.
|
||||
globalPath: root.configured === "" || VideoWallpaper.isVideo(root.configured)
|
||||
? root.shippedPath : root.configured,
|
||||
collection: DesktopPreferences.get("wallpaperSlideshowPaths") ?? [],
|
||||
intervalMinutes: DesktopPreferences.get("wallpaperIntervalMinutes") ?? 30,
|
||||
shuffle: DesktopPreferences.get("wallpaperShuffle") !== false,
|
||||
@@ -335,6 +346,10 @@ Singleton {
|
||||
}
|
||||
|
||||
function applyCurrentPolicy(persist: bool): bool {
|
||||
// While mpvpaper holds the background, hyprpaper has nothing to apply
|
||||
// — hotplug is VideoWallpaper's problem and it respawns per output.
|
||||
if (VideoWallpaper.active)
|
||||
return true;
|
||||
return root.applyPolicy(root.currentPolicy(), persist === true, false);
|
||||
}
|
||||
|
||||
@@ -374,6 +389,19 @@ Singleton {
|
||||
}
|
||||
|
||||
function setSingle(path: string): bool {
|
||||
// A video routes to mpvpaper instead of hyprpaper; it still rides
|
||||
// wallpaperPath so restore, backup, and the picker's "current" ring
|
||||
// treat both kinds the same.
|
||||
if (VideoWallpaper.isVideo(path)) {
|
||||
if (!VideoWallpaper.start(path)) {
|
||||
root.lastError = VideoWallpaper.lastError;
|
||||
return false;
|
||||
}
|
||||
root.lastError = "";
|
||||
DesktopPreferences.set("wallpaperMode", "single");
|
||||
DesktopPreferences.set("wallpaperPath", path);
|
||||
return true;
|
||||
}
|
||||
const effectivePath = path === "" ? root.shippedPath : path;
|
||||
const allowed = root.candidates([]);
|
||||
if (!WallpaperPolicy.validPath(effectivePath, allowed)) {
|
||||
@@ -384,20 +412,53 @@ Singleton {
|
||||
policy.mode = "single";
|
||||
policy.globalPath = effectivePath;
|
||||
policy.slideshowPath = effectivePath;
|
||||
// Returning from a video: stop mpvpaper first, then give hyprpaper's
|
||||
// service a beat to come back before the transaction talks to it.
|
||||
if (VideoWallpaper.active) {
|
||||
VideoWallpaper.stop();
|
||||
root.pendingStillPolicy = policy;
|
||||
stillAfterVideo.restart();
|
||||
return true;
|
||||
}
|
||||
return root.applyPolicy(policy, true, false);
|
||||
}
|
||||
|
||||
property var pendingStillPolicy: null
|
||||
|
||||
Timer {
|
||||
id: stillAfterVideo
|
||||
interval: 900
|
||||
onTriggered: {
|
||||
if (root.pendingStillPolicy) {
|
||||
root.applyPolicy(root.pendingStillPolicy, true, false);
|
||||
root.pendingStillPolicy = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setMode(mode: string): bool {
|
||||
if (!["single", "slideshow", "per-monitor"].includes(mode))
|
||||
return false;
|
||||
const policy = root.currentPolicy();
|
||||
policy.mode = mode;
|
||||
// Slideshow and per-display are hyprpaper's modes; a stored video path
|
||||
// cannot serve as their global still, so fall back to the shipped one.
|
||||
if (VideoWallpaper.isVideo(policy.globalPath)) {
|
||||
policy.globalPath = root.shippedPath;
|
||||
policy.slideshowPath = root.shippedPath;
|
||||
}
|
||||
if (mode === "slideshow") {
|
||||
const collection = WallpaperPolicy.validCollection(
|
||||
policy.collection, root.candidates([]));
|
||||
if (!collection.includes(policy.slideshowPath))
|
||||
policy.slideshowPath = collection[0] || policy.globalPath;
|
||||
}
|
||||
if (VideoWallpaper.active) {
|
||||
VideoWallpaper.stop();
|
||||
root.pendingStillPolicy = policy;
|
||||
stillAfterVideo.restart();
|
||||
return true;
|
||||
}
|
||||
return root.applyPolicy(policy, true, false);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# Tokyo Night Moon for tmux -- the palette Panama ships.
|
||||
#
|
||||
# Extracted from tmux.conf so the two schemes can be swapped. tmux.conf sources
|
||||
# current-theme.conf, which panama-theme-apps generates from one of these.
|
||||
# Reference, not the source of the running colours. tmux.conf sources
|
||||
# current-theme.conf, which panama-theme-apps RENDERS from the active theme's
|
||||
# palette -- so any of the ten themes reaches tmux, not only the two Tokyo
|
||||
# Nights. This file used to be copied into that one; it is kept because it is a
|
||||
# complete tmux theme somebody may want to source directly.
|
||||
#
|
||||
# Every color here was already in tmux.conf; this file is that block verbatim.
|
||||
|
||||
|
||||
@@ -18,10 +18,13 @@ bind C-Space send-prefix
|
||||
|
||||
# Set status bar
|
||||
#set -g status-bg pink
|
||||
# Tokyo Night Moon color palette
|
||||
# Colors live in themes/ and are generated into current-theme.conf by
|
||||
# panama-theme-apps, which regenerates it whenever the desktop color scheme
|
||||
# changes. -q so a fresh checkout without the generated file still starts.
|
||||
# The active Panama theme.
|
||||
# current-theme.conf is RENDERED from that theme's palette by
|
||||
# panama-theme-apps, which rewrites it whenever the theme changes. The
|
||||
# tokyonight-* files in themes/ are shipped for anyone who wants to source one
|
||||
# directly; nothing copies them here any more, because a copied file only ever
|
||||
# gave tmux the two Tokyo Nights and none of the other themes.
|
||||
# -q so a fresh checkout without the generated file still starts.
|
||||
source-file -q "~/.config/tmux/current-theme.conf"
|
||||
|
||||
# Increase scrollback buffer
|
||||
|
||||
@@ -6,18 +6,24 @@
|
||||
// and emoji-copy extensions, so its clipboard and emoji views are bound
|
||||
// directly in hypr/keybinds.lua.
|
||||
|
||||
// Vicinae 0.26 selects a theme per system appearance, and now there is a
|
||||
// theme for each: the launcher follows Panama's light/dark setting instead of
|
||||
// staying dark on a light desktop. Both are authored here rather than using
|
||||
// Vicinae's bundled Tokyo Night, which ships Night and Storm but not the Moon
|
||||
// and Day variants the rest of this desktop uses.
|
||||
// Vicinae 0.26 selects a theme per system appearance, and there is a theme
|
||||
// for each: the launcher follows Panama's light/dark setting instead of
|
||||
// staying dark on a light desktop.
|
||||
//
|
||||
// Both are GENERATED, by quickshell/scripts/panama-theme-apps, from whichever
|
||||
// Panama theme is chosen for that scheme -- so the launcher wears Catppuccin
|
||||
// when the desktop does, rather than being pinned to the two Tokyo Nights the
|
||||
// way the authored themes/ files were. They are written straight into
|
||||
// ~/.local/share/vicinae/themes, beside the symlinks to the authored ones: a
|
||||
// generated file inside the repository's themes folder would show up as a
|
||||
// change to the repository every time the theme moved.
|
||||
"theme": {
|
||||
"light": {
|
||||
"name": "tokyonight-day",
|
||||
"name": "panama-light",
|
||||
"icon_theme": "auto"
|
||||
},
|
||||
"dark": {
|
||||
"name": "tokyonight-moon",
|
||||
"name": "panama-dark",
|
||||
"icon_theme": "auto"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,5 +4,13 @@
|
||||
* without editing its files -- which keeps updating the theme a matter of
|
||||
* replacing the directory rather than merging into it.
|
||||
*
|
||||
* Deliberately empty. The import resolving to nothing is not an error in CSS,
|
||||
* but a file that exists is a seam somebody can find. */
|
||||
* panama-theme.css beside it is the active Panama theme, rendered by
|
||||
* quickshell/scripts/panama-theme-apps on every theme change and gitignored
|
||||
* because it is machine state. It is imported rather than written into this
|
||||
* file so that this file stays yours: regenerating it would eat whatever you
|
||||
* had added. Put your own rules below the import and they win over both.
|
||||
*
|
||||
* @import must come first in a stylesheet, so nothing goes above this line.
|
||||
* A missing panama-theme.css is not an error -- Firefox skips an import it
|
||||
* cannot resolve, and the chrome falls back to Edge-Frfox's system colours. */
|
||||
@import url("panama-theme.css");
|
||||
|
||||
+4
-3
@@ -4,7 +4,7 @@
|
||||
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
|
||||
after changing the schema; a contract fails when this copy is stale.
|
||||
|
||||
151 settings across 30 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
|
||||
152 settings across 30 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
|
||||
|
||||
## accessibility
|
||||
|
||||
@@ -281,8 +281,7 @@ Found on **Appearance**.
|
||||
| Setting | Default | What it does |
|
||||
|---|---|---|
|
||||
| **Button side**<br>`titlebarButtonSide` | right | Place application titlebar buttons on the left or right Choices: Left, Right. |
|
||||
| **Maximize button**<br>`titlebarMaximizeButton` | false | Show a maximize button in application titlebars that support it |
|
||||
| **Double-click titlebar**<br>`titlebarDoubleClick` | toggle-maximize | Choose what a double-click on an application titlebar does Choices: Toggle maximize, Do nothing. |
|
||||
| **Titlebar on Panama windows**<br>`panamaTitlebar` | true | Hide it and the window is pure Hyprland — Super+Q closes, Super+drag moves |
|
||||
|
||||
## touchpad
|
||||
|
||||
@@ -342,6 +341,8 @@ Found on **Appearance**.
|
||||
| **Wallpaper mode**<br>`wallpaperMode` | single | Use one image, rotate a collection, or choose per display Choices: Single, Slideshow, Per display. |
|
||||
| **Change background every**<br>`wallpaperIntervalMinutes` | 30 min | Time between slideshow images. Range 5–1440. |
|
||||
| **Shuffle**<br>`wallpaperShuffle` | true | Show every selected image before repeating |
|
||||
| **Video wallpaper folder**<br>`videoWallpaperDir` | Videos/Wallpapers | Where the picker looks for videos. Relative to your home folder unless it starts with / |
|
||||
| **Pause video wallpaper on battery**<br>`videoWallpaperPauseOnBattery` | true | Freezes on the current frame and resumes on wall power |
|
||||
|
||||
## weather
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# Appearance redesign — full themes, editor, video wallpapers, honest titlebars
|
||||
|
||||
Approved 2026-08-23 (consolidated interactive mock). Four workstreams; the theme system
|
||||
is the core. No test is executed until the whole settings redesign is done (see the
|
||||
test backlog spec); tests are written alongside.
|
||||
|
||||
## 1. The theme system
|
||||
|
||||
A **theme** is a complete palette plus an accent pair, bound to one scheme. Selecting a
|
||||
theme restyles the shell, kitty, GTK, the lock screen, Vicinae, Firefox's chrome, tmux,
|
||||
and btop. Ten ship; the editor makes more.
|
||||
|
||||
### The catalog — one source of truth
|
||||
|
||||
`config/dot/quickshell/config/themes.json` holds the shipped themes. Unlike
|
||||
`palette.json` (which stays as-is for the curated accent pairs), the catalog is **not**
|
||||
duplicated into a JS table: a new `services/ThemeCatalog.qml` singleton reads it with
|
||||
`FileView` + `JSON.parse` (the ManualPage pattern), scripts read it with `jq`, and the
|
||||
Node halves of contracts read the JSON directly with `fs`. `ThemeCatalog` embeds a
|
||||
minimal Moon/Day fallback so the shell renders correctly for the instant before the
|
||||
file loads (and forever if it is missing).
|
||||
|
||||
A theme record:
|
||||
|
||||
```json
|
||||
{ "id": "mocha", "name": "Catppuccin Mocha", "scheme": "dark",
|
||||
"accent": "#cba6f7", "secondary": "#f5c2e7",
|
||||
"palette": { "bg": "…", "bgDark": "…", "bgHighlight": "…", "bgPanel": "…",
|
||||
"bgPopover": "…", "fg": "…", "fgDim": "…", "fgMuted": "…", "gutter": "…",
|
||||
"accentAlt": "…", "cyan": "…", "teal": "…", "green": "…", "yellow": "…",
|
||||
"orange": "…", "red": "…", "redDeep": "…", "magenta": "…", "pink": "…" },
|
||||
"ansi": { "black": "…", "red": "…", "green": "…", "yellow": "…", "blue": "…",
|
||||
"magenta": "…", "cyan": "…", "white": "…", "brightBlack": "…", "…": "…",
|
||||
"brightWhite": "…" } }
|
||||
```
|
||||
|
||||
The nineteen palette keys are exactly `Theme.qml`'s color tokens. `ansi` (16 keys)
|
||||
exists so terminal themes can be generated rather than hand-kept per theme.
|
||||
|
||||
Shipped lineup: **Tokyo Moon** (default dark — today's exact values), **Moon Rose**
|
||||
(Moon's palette, rose accent), **Catppuccin Mocha**, **Nord**, **Gruvbox**,
|
||||
**Everforest**; **Tokyo Day** (default light — today's values), **Catppuccin Latte**,
|
||||
**Gruvbox Light**, **Everforest Light**.
|
||||
|
||||
### Records, selection, and scheme flips
|
||||
|
||||
`ThemeProfileModel.js` custom records extend to
|
||||
`{ id, name, scheme, accent, secondary, shipped, palette?, ansi?, effects? }` — both
|
||||
whitelists (`copyProfile`, `normalizeStoredProfile`) learn the new optional fields,
|
||||
each palette/ansi value hex-validated, `effects` validated against the ten effect keys.
|
||||
A stored custom without `palette` inherits its scheme's default theme palette
|
||||
(moon/day), which also grandfathers every existing saved profile.
|
||||
|
||||
Selection becomes per-mode: new internal prefs `themeDark` (default `"moon"`) and
|
||||
`themeLight` (default `"day"`). The Themes tab's light/dark hero writes `colorScheme`;
|
||||
`ThemeProfiles` resolves `activeId = colorScheme == "light" ? themeLight : themeDark`.
|
||||
`reconcileScheme`'s force-reset to moon/day dies — a scheme flip now lands on *your*
|
||||
chosen theme for that scheme, custom or shipped. Picking a theme card writes its id
|
||||
into the mode's key (and flips `colorScheme` when the card belongs to the other mode).
|
||||
|
||||
`accentName` desync bug dies with it: every commit path (theme select, curated swatch,
|
||||
hex, wheel, eyedropper, HSV) ends in one function that also recomputes `accentName` as
|
||||
the nearest curated accent (for GNOME's enum) — libadwaita, kitty borders, and the
|
||||
lock screen stop trailing the truth.
|
||||
|
||||
### Theme.qml
|
||||
|
||||
The 19 color ternaries and 7 alpha ternaries become lookups:
|
||||
`palette(token)` reads `ThemeProfiles.activeProfile.palette?.[token] ?? ThemeCatalog`
|
||||
default for the active scheme. Semantic aliases (`ok/warn/danger/urgent`), geometry,
|
||||
type, and motion tokens are untouched.
|
||||
|
||||
### Reach — the render pipeline
|
||||
|
||||
`panama-theme-apps` generalizes: it resolves the active theme itself (settings.json →
|
||||
`themeProfileId`/`themeDark`/`themeLight` → catalog or stored customs, all via `jq`)
|
||||
and renders from the palette instead of copying per-scheme files:
|
||||
|
||||
| target | today | becomes |
|
||||
|---|---|---|
|
||||
| kitty | copy tokyonight-{moon,day}.conf + append accent | render `current-theme.conf` from palette + ansi (template), live-apply via existing socket loop |
|
||||
| hyprlock | scheme literals duplicated in 3 scripts | all eight placeholders filled from the palette (the triplication dies) |
|
||||
| GTK settings.ini | template (scheme only) | unchanged |
|
||||
| GTK gtk.css (3.0/4.0) | static, `#82aaff` baked in, "light" file secretly dark | marker-delimited `@define-color` block regenerated from palette; structural CSS untouched; light file actually light |
|
||||
| libadwaita accent | gsettings enum | unchanged (nearest-curated mapping) |
|
||||
| Vicinae | two hardcoded TOMLs | render `panama-dark.toml` / `panama-light.toml` from palette; `vicinae.json` points at them |
|
||||
| Firefox | vendored Edge-Frfox palette, no Panama colors | render `chrome/custom.css` (the shipped override seam) mapping Edge-Frfox variables to the palette |
|
||||
| tmux | copy tokyonight files | render `current-theme.conf` from palette |
|
||||
| btop | sed the theme name | render a `panama.theme` from palette, point btop at it |
|
||||
| nvim | reads colorScheme itself | **unchanged this phase** — stays tokyonight moon/day; a per-theme colorscheme map is future work |
|
||||
|
||||
`bin/panama-hook theme-set` keeps firing (and moves out of the kitty `if`-condition it
|
||||
is accidentally inside, so it genuinely runs after all generators).
|
||||
|
||||
### Effects belong to themes
|
||||
|
||||
The Effects card moves from the theme tab to the **Theme editor**. Saving a custom
|
||||
theme snapshots the ten effect values into the record; applying a theme that carries
|
||||
`effects` commits them; shipped themes carry none and leave yours alone.
|
||||
|
||||
### The UI
|
||||
|
||||
`AppearancePage` tabs become **Themes | Theme editor | Background | Typography |
|
||||
Windows | Shell** (tab values `themes`, `editor`, plus existing).
|
||||
|
||||
**Themes tab** (the category's landing): the light/dark hero (two preview cards,
|
||||
writes `colorScheme`), a dark gallery and a light gallery of theme cards (shipped +
|
||||
customs for that scheme; custom cards get Delete), each card a mini palette preview;
|
||||
clicking applies live. A dashed "Build your own theme" card jumps to the editor tab.
|
||||
|
||||
**Theme editor tab**: start-from chips (any theme, or the active one), scheme segment,
|
||||
four color wells — Primary, Secondary, **Background**, **Foreground** — each with a
|
||||
hex field, a color wheel (Qt `ColorDialog`; if unavailable under Quickshell, a small
|
||||
in-shell HSV wheel popover), and the hyprpicker eyedropper. Background edits derive
|
||||
the surface tokens (bgDark/bgPanel/bgHighlight/bgPopover/gutter) by mixing;
|
||||
Foreground derives fgDim/fgMuted; a global **Saturation** slider re-saturates the
|
||||
derived palette. HSV fine-tune for the accent pair stays behind a disclosure (debounced
|
||||
now — today it commits two preferences per pixel of drag). Then the Effects card, then
|
||||
save-as / delete. Edits apply live immediately (the existing fork-to-custom model);
|
||||
there is no staged revert in this phase.
|
||||
|
||||
## 2. Typography
|
||||
|
||||
`FontPicker` is rebuilt as a closed dropdown: a button showing the current family in
|
||||
its own face, opening a popover with a search field and the filtered list (the current
|
||||
12-match cap stays). All five rows use it — Interface, Icons, Application, Document,
|
||||
Monospace — with details naming what each controls. The always-open lists and the
|
||||
ActionRow disclosure pattern for fonts both retire. Sizes, hinting, antialiasing,
|
||||
Icons & pointer unchanged.
|
||||
|
||||
## 3. Titlebars — close-only, everywhere
|
||||
|
||||
- Schema: `titlebarMaximizeButton` and `titlebarDoubleClick` are **deleted**. GNOME's
|
||||
`button-layout` push becomes close-only, honoring `titlebarButtonSide`
|
||||
(`close:appmenu` / `appmenu:close`); the `action-double-click-titlebar` push is
|
||||
dropped (GNOME's default stands). A migration note: removed keys vanish from
|
||||
settings.json on next write; DesktopStyle simply stops reading them.
|
||||
- New bool `panamaTitlebar` (group `titlebar`, default `true`): "Titlebar on Panama
|
||||
windows". Off = the Settings window renders no titlebar at all — pure Hyprland
|
||||
(Super+Q closes, Super+drag moves, Escape works); the sidebar and tab strip anchor
|
||||
to the window top.
|
||||
- When on, the Settings titlebar obeys `titlebarButtonSide`, shows **close only** (the
|
||||
minimize button was a lie — Hyprland has no minimize), and its close button gains
|
||||
keyboard focus + Accessible metadata (the only unreachable control in the app today).
|
||||
|
||||
## 4. Video wallpapers
|
||||
|
||||
**Engine: mpvpaper** (Terra packages 1.9, which carries the libmpv fence-leak
|
||||
workaround; Fedora/RPM Fusion don't ship it). One process per output,
|
||||
`hwdec=vaapi profile=fast no-audio loop-file=inf panscan=1.0` plus a per-output
|
||||
`input-ipc-server` socket under `$XDG_RUNTIME_DIR`. VAAPI verified on this machine:
|
||||
~0.18 core for 1440p30 h264 vs ~0.70 software. Rejected: in-shell QtMultimedia — a
|
||||
continuously animating in-process surface drives repaints across every shell window
|
||||
(Quickshell's own documented failure mode), and a decoder crash would take the whole
|
||||
shell down instead of a wallpaper.
|
||||
|
||||
Mechanics, owned by a new `services/VideoWallpaper.qml` + `scripts/panama-video-wallpaper`:
|
||||
- Videos come from `videoWallpaperDir` (default `Videos/Wallpapers`), scanned with the
|
||||
same newest-60 discipline (mp4/mkv/webm), rendered in the picker grid with a ▶ badge.
|
||||
- Selecting a video **stops `hyprpaper.service`** for the session (both fight for the
|
||||
background layer; stacking is a race) and starts supervised mpvpaper; returning to a
|
||||
still kills mpvpaper and restarts hyprpaper. The processes are watched and respawned
|
||||
(mpvpaper has an open hotplug-segfault issue), and restarted nightly as leak
|
||||
insurance.
|
||||
- **Pause is Panama's job, not the compositor's.** Research finding: Hyprland rebuilds
|
||||
the full-screen blur chain on every wallpaper frame while blur-enabled layers exist,
|
||||
and occluded wallpapers keep compositing except under solitary mode — which any
|
||||
overlay surface (a toast mid-game) breaks. So: pause via mpv JSON IPC
|
||||
(`Quickshell.Io.Socket`, no socat) on (a) the existing `panama-gaming` start/end
|
||||
hooks, (b) `solitary` polling from `hyprctl -j monitors` on socket2 events,
|
||||
(c) session lock and idle, (d) battery when `videoWallpaperPauseOnBattery` (default
|
||||
on), and (e) the bar pill — a `Pill` visible while a video wallpaper is active,
|
||||
click to pause/resume (remote desktop, or just quiet). mpvpaper's `-p -a FULL`
|
||||
auto-pause rides along as belt-and-braces only.
|
||||
- Lock screen: `panama-lock` uses a cached still frame (one-time ffmpeg frame grab on
|
||||
selection) when the wallpaper is a video.
|
||||
- A `panama-doctor` check verifies VAAPI actually engaged (the known silent
|
||||
software-decode failure reads as "mpvpaper is heavy").
|
||||
- Packages: `mpvpaper`, `mpv` added to `setup/packages/hyprland-packages`.
|
||||
- Schema: `videoWallpaperDir` (string), `videoWallpaperPauseOnBattery` (bool, default
|
||||
true); the active video path rides `wallpaperPath` (the picker treats stills and
|
||||
videos uniformly; `Wallpaper.qml` routes by extension).
|
||||
- The 120fps HEVC Canyons file gets flagged in UI copy (interval/fps advice), not
|
||||
special-cased.
|
||||
|
||||
## 5. Blast radius (themes workstream)
|
||||
|
||||
Contracts rewritten alongside (not run): `theme-profiles-contract` (new record
|
||||
fields), `accent-controls-contract` (editor rebuild), `palette-contract` (unchanged
|
||||
accents, plus themes.json shape checks), `adwaita-accent-contract` (nearest-curated
|
||||
mapping), `gtk-theme-contract` (generated css block), `lock-screen-theme-contract`
|
||||
(palette-fed placeholders), `desktop-style-contract` (titlebar keys removed),
|
||||
`settings-hardcoded-values-contract`, `wallpaper-*` (video), plus new
|
||||
`theme-catalog-contract` (themes.json shape: 10 themes, 19 palette keys, 16 ansi keys,
|
||||
valid hexes, unique ids, moon/day byte-matching Theme.qml's former literals).
|
||||
Generators: settings docs/commands unaffected (no new pages); SettingsSearch entries
|
||||
updated (Themes, Theme editor, video wallpaper terms; titlebar entries pruned).
|
||||
`docs/settings.md`, settings README, manual chapter updates ride along.
|
||||
|
||||
## Non-goals this phase
|
||||
|
||||
nvim per-theme colorschemes; ohmyposh/wofi theming; staged preview-and-revert in the
|
||||
editor; per-token palette overrides beyond the four wells + saturation; theme
|
||||
import/export.
|
||||
@@ -7,8 +7,9 @@ with Gabriel's go-ahead**, and failures get fixed then.
|
||||
|
||||
## The run
|
||||
|
||||
- `panama test` — the full suite (162 contracts at last count; the top-level
|
||||
README's count line must match the final number).
|
||||
- `panama test` — the full suite (165 contracts as of the phase 3 contracts
|
||||
wave; the top-level README's count line is set to match and is itself
|
||||
checked by `setup/readme-contract`).
|
||||
|
||||
## Known items to verify or investigate at the end
|
||||
|
||||
@@ -27,3 +28,60 @@ with Gabriel's go-ahead**, and failures get fixed then.
|
||||
|
||||
## Phase 3 (Appearance) — append below
|
||||
|
||||
The contracts wave is done. Everything below is written and **still to be
|
||||
RUN**. Nothing in this list has been executed against a live harness: the
|
||||
static halves were checked against the tree, but every harness run and the
|
||||
full-suite pass are deferred to the end-of-redesign sweep.
|
||||
|
||||
### New contracts (3)
|
||||
|
||||
| Contract | What it pins |
|
||||
|---|---|
|
||||
| `quickshell/theme-catalog-contract` | `config/themes.json` shape (10 themes, 6 dark / 4 light), every palette and ansi block accepted by the model's own validators, moon and day byte-identical to the shell's pre-theme literals, `ThemeCatalog.qml`'s embedded fallback carrying both, and `Theme.qml` holding no palette ternary. |
|
||||
| `quickshell/video-wallpaper-contract` | Three independent pause reasons (`FocusModes.gameRunning`, `Battery.acOnline` plus the preference, `manuallyPaused`) and pausing over mpv's JSON IPC; the mpvpaper argv (`hwdec=vaapi`, `no-audio`, `loop-file=inf`, `input-ipc-server=`); the hyprpaper stop/start choreography and `Wallpaper.refreshActive()`; `Wallpaper.qml`'s video routing branches; the helper's 60-file cap, depth-2 scan and mp4/mkv/webm filter; the still frame for the lock screen; `WallpaperIndicator` in `Bar.qml`'s right-hand row with its `visible` binding, spoken name and no animation; the two schema keys; the doctor check id. |
|
||||
| `quickshell/settings-titlebar-contract` | No minimize or maximize anywhere in the settings chrome (comments may say the words, code may not); `height: shown ? 48 : 0` so hiding collapses rather than leaving a hole; `buttonsLeft` side-awareness with exactly one anchor released per element; the close button's `activeFocusOnTab`, `Accessible.role`/`name`, Return/Space handlers and focus treatment, and that it is the only button; Escape declared on the shell rather than inside the bar; the `panamaTitlebar` schema entry and its Appearance row. |
|
||||
|
||||
### Updated contracts (6)
|
||||
|
||||
| Contract | What it now pins |
|
||||
|---|---|
|
||||
| `quickshell/accent-controls-contract` | Rewritten around the new editor: the nine surviving components and their qmldir lines, `AccentPicker`/`ThemeProfilePicker` staying deleted *and* unregistered, the four wells and their single `apply()` path, one hyprpicker invocation aimed by a remembered target, `ColorWell`'s hex validation committing on Enter and focus-out but never per keystroke, the six HSV labels, the debounce (the write lives in `commitTimer`, `changeChannel` writes nothing, `releaseTimer` hands the sliders back), `commitActive` recomputing `accentName` through `nearestCuratedName` with `selectProfile`/`updateActive` both routing into it, `Theme.qml` reading `ThemeProfiles.activePalette`, the Themes and Theme editor tabs, four search labels, and the no-animation ban extended over all nine components. Live half rewritten against the rebuilt harness. |
|
||||
| `quickshell/theme-profiles-contract` | Node half rebuilt for the optional-field model: `shippedProfiles()` with no argument still returns the three built-in fallbacks (back-compat), the ten catalog records survive with palettes and ansi and no effects, palette/ansi/effects survive a stored record with per-key effect clamping, an invalid palette drops the FIELD not the profile, the caller's shipped list owns the id and name space, `editProfile` passes untouched fields through and a shipped fork carries the whole palette, `nearestCuratedName` mappings (mauve→orchid, gruvbox-yellow→amber, grey→slate, per scheme), and `derivePalette`/`resaturatePalette`/`deriveAnsi`/`mixHex` producing palettes the validators accept. Live half now pins the per-mode selection flow: a light/dark flip returns to the theme chosen on that side, never a forced default. |
|
||||
| `quickshell/desktop-style-contract` | `titlebarMaximizeButton` and `titlebarDoubleClick` asserted **gone** from the schema and from `DesktopStyle`, along with `action-double-click-titlebar`; `panamaTitlebar` added (bool, def true, group titlebar); the button layout pinned close-only on both sides with `minimize`/`maximize` banned from the function body; the Appearance greps moved to Fonts/Sizes/Rendering with exactly five `FontPicker` rows and their five role labels, plus the honest-titlebar subtitle. |
|
||||
| `quickshell/settings-ownership-contract` | The ColorScheme block was pinning `inactiveBorderDark`/`inactiveBorderLight`, which no longer exist. It now pins the inactive border as the active theme's `gutter`, bans a regrown literal, pins the accent border roles through the new `hyprColor(value, alpha)` signature, requires both roles to be restated on a theme change, and cross-checks Hyprland's startup literals against moon's and day's gutters in `themes.json`. |
|
||||
| `quickshell/lock-screen-settings-contract` | The "between Background and Shell typography" ordering check named a card that no longer exists. It now pins Background → Video playback → Lock screen order, and that all three sit on the Background tab. |
|
||||
| `quickshell/settings-search-contract` | Seven fixed cases added for the new surface: themes, theme editor, dark mode, Catppuccin, Gruvbox, video wallpaper, titlebar. The schema-label sweep already covers the new keys automatically. |
|
||||
|
||||
### Verified against the new tree, no edit needed
|
||||
|
||||
- `quickshell/settings-hardcoded-values-contract` — its scope is `Settings.qml`,
|
||||
which the theme work did not touch; `Theme.qml`'s literals are covered by
|
||||
`theme-catalog-contract` instead.
|
||||
- `quickshell/wallpaper-service-contract` — already carries the video routing
|
||||
greps.
|
||||
- `quickshell/wallpaper-settings-contract`, `wallpaper-policy-contract`,
|
||||
`settings-nav-contract`, `settings-pages-contract`, `manual-contract`,
|
||||
`settings-docs-contract`, `qmldir-registration-contract`,
|
||||
`settings-sync-contract`, `settings-backup-contract` — checked, nothing stale.
|
||||
- `quickshell/panama-doctor-contract` — already lists `input.video-wallpaper`.
|
||||
|
||||
### Docs updated in the same wave
|
||||
|
||||
- `modules/settings/README.md` — new **Appearance** section (Themes, Theme
|
||||
editor, backgrounds still and moving, the honest titlebar); the border
|
||||
ownership paragraph corrected to the theme's gutter.
|
||||
- `manual/05-making-it-yours.md` — rewritten Appearance chapter: the two
|
||||
galleries, per-mode memory, the four-well editor, video wallpapers, and why
|
||||
there is no minimize.
|
||||
- Top-level `README.md` — contract count 162 → 165, confirmed by
|
||||
`setup/readme-contract`.
|
||||
|
||||
### Still open before the run
|
||||
|
||||
- The parallel pipeline wave's files (`gtk-theme-contract`,
|
||||
`lock-screen-theme-contract`, `palette-contract`, the two bridge tests) are
|
||||
not counted above. If that wave adds contracts, the README count line and the
|
||||
count in this file both need bumping again before the suite runs.
|
||||
- `AppearancePage.qml`'s `tab` property defaults to `"background"` while its
|
||||
own comment says Themes leads. Decide which is intended before the run;
|
||||
nothing currently pins it either way.
|
||||
|
||||
@@ -20,6 +20,10 @@ hyprland-uwsm
|
||||
hyprlock
|
||||
hyprpaper
|
||||
hyprpicker
|
||||
# Video wallpapers. mpvpaper comes from Terra (Fedora does not package it);
|
||||
# mpv is its hard requirement, named here so the dependency stays visible.
|
||||
mpv
|
||||
mpvpaper
|
||||
hyprpolkitagent
|
||||
hyprpwcenter
|
||||
hyprshutdown
|
||||
|
||||
@@ -111,11 +111,12 @@ for pkg in mako dunst SwayNotificationCenter; do
|
||||
fi
|
||||
done
|
||||
|
||||
# Select the launcher theme once vicinae exists.
|
||||
if exists vicinae; then
|
||||
log "Setting vicinae theme"
|
||||
vicinae theme set tokyonight-moon >/dev/null 2>&1 || true
|
||||
fi
|
||||
# The launcher theme is NOT set here any more. vicinae.json selects one theme
|
||||
# per system appearance -- panama-dark and panama-light, both rendered from the
|
||||
# active Panama theme by quickshell/scripts/panama-theme-apps -- and
|
||||
# `vicinae theme set` replaces that pair with a single fixed name. It would pin
|
||||
# the launcher to one palette for good, and it would do it by rewriting
|
||||
# vicinae.json, which is symlinked out of this repository.
|
||||
|
||||
# A machine with a battery gets managed idle timings from the start. The
|
||||
# shipped hypridle.conf deliberately never suspends -- correct for a desktop,
|
||||
|
||||
+12
-12
@@ -123,20 +123,20 @@ elif [ -r "$HYPRLOCK_TEMPLATE" ]; then
|
||||
lock_stored="$(jq -r '.colorScheme // "dark"' "$lock_prefs" 2>/dev/null || echo dark)"
|
||||
[ "$lock_stored" = "light" ] && lock_scheme="light"
|
||||
fi
|
||||
if [ "$lock_scheme" = "light" ]; then
|
||||
sed -e "s/@FG@/55, 96, 191/g" -e "s/@MUTED@/97, 114, 176/g" \
|
||||
-e "s/@ACCENT@/46, 125, 233/g" -e "s/@ERROR@/245, 42, 101/g" \
|
||||
-e "s/@BG@/225, 226, 231/g" -e "s/@FIELD@/208, 213, 227/g" \
|
||||
-e "s/@MUTED_HEX@/6172b0/g" -e "s/@ERROR_HEX@/f52a65/g" \
|
||||
"$HYPRLOCK_TEMPLATE" > "$HYPRLOCK_CONF"
|
||||
# Rendered by panama-palette's render_hyprlock, the same function
|
||||
# panama-theme-apps and panama-lock use. This block used to carry its own
|
||||
# table of literal rgba triples -- a third copy of the same eight colours --
|
||||
# and a new theme meant remembering to edit all three. The one that got
|
||||
# missed was always this one, because a wrong seed only shows up the first
|
||||
# time somebody locks a fresh machine.
|
||||
# shellcheck source=../../config/dot/quickshell/scripts/panama-palette
|
||||
source "$PANAMA_DOT/quickshell/scripts/panama-palette"
|
||||
lock_accent="$(jq -r '.accentName // "blue"' "$lock_prefs" 2>/dev/null || echo blue)"
|
||||
if render_hyprlock "$lock_scheme" "$HYPRLOCK_TEMPLATE" "$HYPRLOCK_CONF" "$lock_accent"; then
|
||||
log "Seeded hyprlock $lock_scheme theme → $HYPRLOCK_CONF"
|
||||
else
|
||||
sed -e "s/@FG@/200, 211, 245/g" -e "s/@MUTED@/130, 139, 184/g" \
|
||||
-e "s/@ACCENT@/130, 170, 255/g" -e "s/@ERROR@/255, 117, 127/g" \
|
||||
-e "s/@BG@/34, 36, 54/g" -e "s/@FIELD@/46, 47, 61/g" \
|
||||
-e "s/@MUTED_HEX@/828bb8/g" -e "s/@ERROR_HEX@/ff757f/g" \
|
||||
"$HYPRLOCK_TEMPLATE" > "$HYPRLOCK_CONF"
|
||||
log "Could not render the hyprlock theme; the first lock will use hyprlock's defaults"
|
||||
fi
|
||||
log "Seeded hyprlock $lock_scheme theme → $HYPRLOCK_CONF"
|
||||
fi
|
||||
|
||||
# btop reads themes from its own config directory, but OWNS btop.conf -- it
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The theme editor's colour controls.
|
||||
#
|
||||
# The editor used to be a curated swatch strip (AccentPicker), a profile
|
||||
# dropdown (ThemeProfilePicker) and six HSV sliders. It is now four colour
|
||||
# wells -- primary, secondary, background, foreground -- with the HSV rows
|
||||
# demoted to a fine-tune behind a disclosure. Both deleted components are
|
||||
# pinned as gone here, because a file left behind and still registered in the
|
||||
# qmldir is a component the next person wires back up.
|
||||
#
|
||||
# What must hold:
|
||||
#
|
||||
# 1. Every colour route ends in ONE function. Typed hex, the colour wheel and
|
||||
# the eyedropper all call ThemeEditorWells.apply(), so validation cannot
|
||||
# differ between them -- and every write ends in ThemeProfiles.commitActive,
|
||||
# which recomputes accentName. That is what stops GNOME's accent enum,
|
||||
# kitty's border and the lock screen going stale after a custom edit.
|
||||
# 2. Colour is never the only signal. Each well has a hex field, each button
|
||||
# a spoken name, each HSV row a numeric readout, and all of them take
|
||||
# keyboard focus. A hue ring alone tells someone with a colour vision
|
||||
# deficiency nothing.
|
||||
# 3. Writes are debounced. Committing per slider move meant a single drag
|
||||
# spent the whole gesture in apply-and-verify round trips with the desktop
|
||||
# repainting behind the pointer.
|
||||
# 4. Nothing here animates continuously. These are settings controls on a
|
||||
# high-refresh display; a shimmer costs real frame time forever.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
settings="$repo_dir/config/dot/quickshell/modules/settings"
|
||||
theme="$repo_dir/config/dot/quickshell/config/Theme.qml"
|
||||
profiles="$repo_dir/config/dot/quickshell/services/ThemeProfiles.qml"
|
||||
scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml"
|
||||
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
|
||||
|
||||
@@ -13,12 +40,75 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
for component in AccentPicker AccentEditor ThemeProfilePicker; do
|
||||
# ── The components that exist, and the two that must not ────────────────────
|
||||
for component in AccentEditor ColorWell ThemeEditorWells ThemeStartChips \
|
||||
ThemeSaturationRow ThemeSaveRow ThemeCard ThemeGallery ThemeModeHero; do
|
||||
[[ -f "$settings/$component.qml" ]] || fail "$component is missing"
|
||||
rg -Fq "$component 1.0 $component.qml" "$settings/qmldir" \
|
||||
|| fail "$component is not registered in the settings module"
|
||||
done
|
||||
|
||||
for retired in AccentPicker ThemeProfilePicker; do
|
||||
if [[ -f "$settings/$retired.qml" ]]; then
|
||||
fail "$retired was deleted in the redesign but the file is back"
|
||||
fi
|
||||
if rg -Fq "$retired" "$settings/qmldir"; then
|
||||
fail "$retired is still registered in the settings qmldir"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── The four wells are the fast path ────────────────────────────────────────
|
||||
for role in '"primary", role: "Primary"' '"secondary", role: "Secondary"' \
|
||||
'"background", role: "Background"' '"foreground", role: "Foreground"'; do
|
||||
rg -Fq "$role" "$settings/ThemeEditorWells.qml" \
|
||||
|| fail "the editor is missing the $role well"
|
||||
done
|
||||
rg -Fq 'function apply(which: string, hex: string)' "$settings/ThemeEditorWells.qml" \
|
||||
|| fail 'the wells no longer share one apply path'
|
||||
rg -Fq 'ThemeProfiles.setGroundColors(' "$settings/ThemeEditorWells.qml" \
|
||||
|| fail 'background and foreground do not derive the surface and text families'
|
||||
rg -Fq 'ThemeProfiles.setAccentPair(' "$settings/ThemeEditorWells.qml" \
|
||||
|| fail 'the accent wells do not write the accent pair'
|
||||
rg -Fq 'ThemeProfiles.activePalette' "$settings/ThemeEditorWells.qml" \
|
||||
|| fail 'the ground wells do not show the palette actually in effect'
|
||||
|
||||
# ── One eyedropper, one wheel, both aimed by a remembered target ────────────
|
||||
rg -Fq '["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]' \
|
||||
"$settings/ThemeEditorWells.qml" \
|
||||
|| fail 'screen picking does not use the validated hyprpicker hex invocation'
|
||||
[[ "$(rg -c 'hyprpicker' "$settings/ThemeEditorWells.qml")" == "1" ]] \
|
||||
|| fail 'the eyedropper is invoked from more than one place'
|
||||
rg -Fq 'root.target = which' "$settings/ThemeEditorWells.qml" \
|
||||
|| fail 'the eyedropper and wheel do not remember which well they answer'
|
||||
rg -Fq 'onStreamFinished: root.apply(root.target, this.text)' "$settings/ThemeEditorWells.qml" \
|
||||
|| fail 'the picked colour does not return through the shared apply path'
|
||||
rg -Fq 'root.apply(root.target, Qt.rgba(' "$settings/ThemeEditorWells.qml" \
|
||||
|| fail 'the colour wheel does not return through the shared apply path'
|
||||
for target in 'Open the colour wheel for " + root.role' 'Pick " + root.role + " from the screen'; do
|
||||
rg -Fq "$target" "$settings/ColorWell.qml" \
|
||||
|| fail "a glyph-only button has no spoken name: $target"
|
||||
done
|
||||
|
||||
# ── Hex validation lives in the well, and commits on intent ─────────────────
|
||||
rg -Fq '/^#[0-9a-f]{6}$/.test(prefixed)' "$settings/ColorWell.qml" \
|
||||
|| fail 'ColorWell does not validate typed hex'
|
||||
rg -Fq 'property bool invalid' "$settings/ColorWell.qml" \
|
||||
|| fail 'a rejected hex is not marked in the field'
|
||||
rg -Fq 'onAccepted: root.commit()' "$settings/ColorWell.qml" \
|
||||
|| fail 'a typed hex does not commit on Enter'
|
||||
rg -Fq 'onActiveFocusChanged:' "$settings/ColorWell.qml" \
|
||||
|| fail 'a typed hex does not commit when focus leaves'
|
||||
# Per keystroke would repaint the desktop on the way to the colour you meant:
|
||||
# "#82a" is a real colour.
|
||||
if rg -q 'onTextChanged:.*commit|onTextEdited:.*commit' "$settings/ColorWell.qml"; then
|
||||
fail 'the hex field commits per keystroke'
|
||||
fi
|
||||
rg -Fq 'Accessible.name: root.role + " colour, hex"' "$settings/ColorWell.qml" \
|
||||
|| fail 'the hex field is not programmatically labelled'
|
||||
rg -Fq 'activeFocusOnTab: true' "$settings/ColorWell.qml" \
|
||||
|| fail 'the hex field cannot receive keyboard focus'
|
||||
|
||||
# ── The six HSV rows, still labelled, still keyboard-operable ───────────────
|
||||
for label in \
|
||||
'Primary hue' 'Primary saturation' 'Primary value' \
|
||||
'Secondary hue' 'Secondary saturation' 'Secondary value'; do
|
||||
@@ -27,10 +117,10 @@ for label in \
|
||||
done
|
||||
|
||||
rg -Fq 'ValueSlider {' "$settings/AccentEditor.qml" \
|
||||
|| fail 'advanced accents do not reuse ValueSlider'
|
||||
|| fail 'the fine-tune does not reuse ValueSlider'
|
||||
rg -Fq 'Accessible.role: Accessible.Slider' "$settings/AccentEditor.qml" \
|
||||
|| fail 'HSV controls do not expose slider semantics'
|
||||
rg -Fq 'Accessible.name: root.label' "$settings/AccentEditor.qml" \
|
||||
rg -Fq 'Accessible.name: row.label' "$settings/AccentEditor.qml" \
|
||||
|| fail 'HSV controls are not programmatically labelled'
|
||||
rg -Fq 'Accessible.description:' "$settings/AccentEditor.qml" \
|
||||
|| fail 'HSV controls do not expose their numeric value as a non-hue signal'
|
||||
@@ -38,73 +128,92 @@ rg -Fq 'activeFocusOnTab: true' "$settings/AccentEditor.qml" \
|
||||
|| fail 'HSV controls cannot receive keyboard focus'
|
||||
rg -Fq 'Keys.onPressed:' "$settings/AccentEditor.qml" \
|
||||
|| fail 'HSV controls cannot be adjusted from the keyboard'
|
||||
rg -Fq 'activeFocus ? Theme.accentSecondary' "$settings/AccentEditor.qml" \
|
||||
rg -Fq 'border.width: keyboardSlider.activeFocus ? 2 : 1' "$settings/AccentEditor.qml" \
|
||||
|| fail 'HSV controls have no visible keyboard focus treatment'
|
||||
|
||||
rg -Fq '["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]' \
|
||||
"$settings/AccentEditor.qml" \
|
||||
|| fail 'screen picking does not use the validated hyprpicker hex invocation'
|
||||
for target in 'Pick primary from screen' 'Pick secondary from screen'; do
|
||||
rg -Fq "$target" "$settings/AccentEditor.qml" \
|
||||
|| fail "screen picker action is missing $target"
|
||||
done
|
||||
# ── Debounced, not per-move ─────────────────────────────────────────────────
|
||||
rg -Fq 'property var pending' "$settings/AccentEditor.qml" \
|
||||
|| fail 'the sliders no longer track the drag locally'
|
||||
rg -Fq 'commitTimer.restart();' "$settings/AccentEditor.qml" \
|
||||
|| fail 'a slider move does not restart the debounce'
|
||||
python3 - "$settings/AccentEditor.qml" <<'PY' || fail 'the HSV commit is no longer debounced through a timer'
|
||||
import re
|
||||
import sys
|
||||
|
||||
# The curated swatches come from the accentName schema rather than from
|
||||
# Object.keys(Theme.accents): the schema's option order is the palette's order,
|
||||
# and it is the same source every other enum row reads. What matters here is
|
||||
# unchanged -- the curated accents are still one click away, ahead of the
|
||||
# editor.
|
||||
rg -Fq 'PreferenceSchema.spec("accentName")' "$settings/AccentPicker.qml" \
|
||||
|| fail 'curated swatches are no longer sourced from the accent schema'
|
||||
rg -Fq 'model: root.options' "$settings/AccentPicker.qml" \
|
||||
|| fail 'curated swatches are no longer the fast path'
|
||||
rg -Fq 'ThemeProfiles.useCuratedAccent(entry.modelData)' "$settings/AccentPicker.qml" \
|
||||
|| fail 'curated swatches do not select a profile-backed accent'
|
||||
rg -Fq 'readonly property string current: ThemeProfiles.activeAccentName' "$settings/AccentPicker.qml" \
|
||||
|| fail 'swatch selection does not follow the active profile'
|
||||
rg -Fq 'Accessible.name: entry.pair.label + " accent"' "$settings/AccentPicker.qml" \
|
||||
|| fail 'swatches rely on hue without a programmatic name'
|
||||
rg -Fq 'activeFocusOnTab: true' "$settings/AccentPicker.qml" \
|
||||
|| fail 'swatches cannot receive keyboard focus'
|
||||
rg -Fq 'Keys.onReturnPressed:' "$settings/AccentPicker.qml" \
|
||||
|| fail 'swatches cannot be selected from the keyboard'
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
# The write must happen inside a Timer's onTriggered, never in changeChannel.
|
||||
commit = re.search(r'Timer\s*\{\s*id: commitTimer(.*?)\n \}', text, re.S)
|
||||
if not commit or 'ThemeProfiles.setAccentPair(' not in commit.group(1):
|
||||
raise SystemExit('the commit timer does not write the accent pair')
|
||||
change = re.search(r'function changeChannel\(.*?\n \}', text, re.S)
|
||||
if not change or 'ThemeProfiles.' in change.group(0):
|
||||
raise SystemExit('changeChannel writes directly instead of scheduling a commit')
|
||||
# A refused write must snap the sliders back to what is really in effect.
|
||||
if not re.search(r'Timer\s*\{\s*id: releaseTimer.*?editor\.pending = null', text, re.S):
|
||||
raise SystemExit('nothing hands the sliders back to the stored pair')
|
||||
PY
|
||||
|
||||
rg -Fq 'model: ThemeProfiles.profiles' "$settings/ThemeProfilePicker.qml" \
|
||||
|| fail 'profile picker does not show shipped and saved profiles'
|
||||
rg -Fq 'ThemeProfiles.selectProfile(' "$settings/ThemeProfilePicker.qml" \
|
||||
|| fail 'profile switching is not wired'
|
||||
rg -Fq 'ThemeProfiles.saveProfile(' "$settings/ThemeProfilePicker.qml" \
|
||||
|| fail 'profile saving is not wired'
|
||||
rg -Fq 'maximumLength: 40' "$settings/ThemeProfilePicker.qml" \
|
||||
|| fail 'profile names are not visibly bounded to the model limit'
|
||||
rg -Fq 'ThemeProfiles.deleteProfile(' "$settings/ThemeProfilePicker.qml" \
|
||||
|| fail 'custom profile deletion is not wired'
|
||||
rg -Fq 'activeFocusOnTab:' "$settings/ThemeProfilePicker.qml" \
|
||||
|| fail 'profile actions cannot receive keyboard focus'
|
||||
rg -Fq 'Keys.onReturnPressed:' "$settings/ThemeProfilePicker.qml" \
|
||||
|| fail 'profile actions cannot be triggered from the keyboard'
|
||||
# ── One commit path, and it keeps accentName in sync ────────────────────────
|
||||
rg -Fq 'ThemeProfileModel.nearestCuratedName(profile.scheme, profile.accent)' "$profiles" \
|
||||
|| fail 'commitActive does not recompute accentName from the chosen colour'
|
||||
python3 - "$profiles" <<'PY' || fail 'a write path bypasses commitActive'
|
||||
import re
|
||||
import sys
|
||||
|
||||
for component in ThemeProfilePicker AccentPicker AccentEditor; do
|
||||
text = re.sub(r"//.*", "", open(sys.argv[1], encoding="utf-8").read())
|
||||
commit = re.search(r'function commitActive\(.*?\n \}', text, re.S)
|
||||
if not commit:
|
||||
raise SystemExit('commitActive is gone')
|
||||
for fragment in (
|
||||
'SystemSettings.commitPreference("colorScheme"',
|
||||
'SystemSettings.commitPreference("themeProfileId"',
|
||||
'SystemSettings.commitPreference("accentName"',
|
||||
'root.applyProfileEffects(profile)',
|
||||
):
|
||||
if fragment not in commit.group(0):
|
||||
raise SystemExit(f'commitActive no longer does: {fragment}')
|
||||
# Everything that changes the active theme routes through it.
|
||||
for func in ('selectProfile', 'updateActive'):
|
||||
block = re.search(r'function ' + func + r'\(.*?\n \}', text, re.S)
|
||||
if not block or 'root.commitActive(' not in block.group(0):
|
||||
raise SystemExit(f'{func} does not end in commitActive')
|
||||
PY
|
||||
|
||||
rg -Fq 'ThemeProfiles.activePalette' "$theme" \
|
||||
|| fail 'Theme roles do not read the resolved palette'
|
||||
rg -Fq 'root.hyprColor(Theme.accent, ' "$scheme" \
|
||||
|| fail 'the focused border start does not follow Theme.accent'
|
||||
rg -Fq 'root.hyprColor(Theme.accentSecondary, ' "$scheme" \
|
||||
|| fail 'the focused border end does not follow Theme.accentSecondary'
|
||||
|
||||
# ── The editor is on the page, under its own tab ────────────────────────────
|
||||
for component in ThemeStartChips ThemeEditorWells ThemeSaturationRow \
|
||||
AccentEditor ThemeSaveRow ThemeModeHero ThemeGallery; do
|
||||
rg -Fq "$component {" "$settings/AppearancePage.qml" \
|
||||
|| fail "Appearance does not include $component"
|
||||
done
|
||||
|
||||
rg -Fq 'ThemeProfiles.activeProfile' "$theme" \
|
||||
|| fail 'Theme roles do not react to the selected profile'
|
||||
rg -Fq 'root.hyprColor(Theme.accent)' "$scheme" \
|
||||
|| fail 'the focused border start does not follow Theme.accent'
|
||||
rg -Fq 'root.hyprColor(Theme.accentSecondary)' "$scheme" \
|
||||
|| fail 'the focused border end does not follow Theme.accentSecondary'
|
||||
|
||||
for term in 'Theme profiles' 'Advanced accent' 'Pick colour from screen'; do
|
||||
rg -Fq "$term" "$search" || fail "Settings search is missing $term"
|
||||
for tab in '{ value: "themes", label: "Themes" }' '{ value: "editor", label: "Theme editor" }'; do
|
||||
rg -Fq "$tab" "$settings/AppearancePage.qml" \
|
||||
|| fail "Appearance is missing the tab $tab"
|
||||
done
|
||||
|
||||
# ── Findable by name ────────────────────────────────────────────────────────
|
||||
for term in 'Themes' 'Theme editor' 'Theme profiles' 'Advanced accent' \
|
||||
'Pick colour from screen'; do
|
||||
rg -Fq "\"$term\"" "$search" || fail "Settings search is missing $term"
|
||||
done
|
||||
|
||||
# ── No decorative or continuously repainting animation ──────────────────────
|
||||
if rg -n 'NumberAnimation|ColorAnimation|SequentialAnimation|ParallelAnimation|loops:[[:space:]]*Animation\.Infinite' \
|
||||
"$settings/AccentEditor.qml" "$settings/AccentPicker.qml" "$settings/ThemeProfilePicker.qml"; then
|
||||
"$settings/AccentEditor.qml" "$settings/ColorWell.qml" \
|
||||
"$settings/ThemeEditorWells.qml" "$settings/ThemeStartChips.qml" \
|
||||
"$settings/ThemeSaturationRow.qml" "$settings/ThemeSaveRow.qml" \
|
||||
"$settings/ThemeCard.qml" "$settings/ThemeGallery.qml" \
|
||||
"$settings/ThemeModeHero.qml"; then
|
||||
fail 'theme controls introduce continuously repainting or decorative animation'
|
||||
fi
|
||||
|
||||
# ── Live: the editor, headless ──────────────────────────────────────────────
|
||||
state_home="$(mktemp -d /tmp/panama-accent-controls.XXXXXX)"
|
||||
harness="$repo_dir/config/dot/quickshell/accent-controls-harness.qml"
|
||||
|
||||
@@ -125,15 +234,40 @@ for _ in $(seq 1 40); do
|
||||
sleep 0.1
|
||||
done
|
||||
qs_for_test ipc show 2>/dev/null | rg -q '^target accent-controls-test$' \
|
||||
|| fail 'headless AccentEditor harness did not start'
|
||||
|| fail 'headless theme editor harness did not start'
|
||||
|
||||
before="$(qs_for_test ipc call accent-controls-test status)"
|
||||
jq -e '.id == "moon" and .shipped == true' <<<"$before" >/dev/null \
|
||||
|| fail 'headless editor did not begin on Moon'
|
||||
after="$(qs_for_test ipc call accent-controls-test adjust primary h 0)"
|
||||
jq -e '.shipped == false and .accent != "#82aaff" and .secondary == "#b172b0"' \
|
||||
<<<"$after" >/dev/null \
|
||||
|| fail 'an HSV adjustment did not create a custom profile with the unchanged secondary colour'
|
||||
status() { qs_for_test ipc call accent-controls-test status; }
|
||||
|
||||
jq -e '.id == "moon" and .shipped == true and .accent == "#82aaff"
|
||||
and .accentName == "blue"' <<<"$(status)" >/dev/null \
|
||||
|| fail 'the headless editor did not begin on Moon'
|
||||
|
||||
# A slider move shows immediately and writes on a beat, not per move.
|
||||
moved="$(qs_for_test ipc call accent-controls-test adjust primary h 0)"
|
||||
jq -e '.pending == true and .shown.accent != "#82aaff"
|
||||
and .stored.accent == "#82aaff"' <<<"$moved" >/dev/null \
|
||||
|| fail 'an HSV move either did not show or wrote before the debounce elapsed'
|
||||
|
||||
sleep 0.8
|
||||
settled="$(status)"
|
||||
jq -e '.shipped == false and .accent != "#82aaff" and .secondary == "#b172b0"
|
||||
and .pending == false' <<<"$settled" >/dev/null \
|
||||
|| fail 'the debounced commit did not fork a custom profile with the unchanged secondary'
|
||||
|
||||
# The wells: a valid hex lands, and accentName follows it without being asked.
|
||||
jq -e '.accent == "#86e1fc" and .accentName == "teal" and .wellError == ""' \
|
||||
<<<"$(qs_for_test ipc call accent-controls-test well primary '#86e1fc')" >/dev/null \
|
||||
|| fail 'a typed primary hex did not land, or accentName did not follow it'
|
||||
|
||||
jq -e '.accent == "#86e1fc" and (.wellError | length) > 0' \
|
||||
<<<"$(qs_for_test ipc call accent-controls-test well primary 'not-a-colour')" >/dev/null \
|
||||
|| fail 'an unusable hex was accepted, or was refused without saying so'
|
||||
|
||||
# The ground wells move the whole surface and text family, not one token.
|
||||
ground="$(qs_for_test ipc call accent-controls-test well background '#101020')"
|
||||
jq -e '.bg == "#101020" and .fg == "#c8d3f5" and .fgDim != "#828bb8"' \
|
||||
<<<"$ground" >/dev/null \
|
||||
|| fail 'changing the background did not re-derive the surface and text family'
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
|
||||
@@ -110,11 +110,18 @@ expected = {
|
||||
"fontHinting": ("enum", '"slight"', "typography"),
|
||||
"fontAntialiasing": ("enum", '"rgba"', "typography"),
|
||||
"titlebarButtonSide": ("enum", '"right"', "titlebar"),
|
||||
"titlebarMaximizeButton": ("bool", "false", "titlebar"),
|
||||
"titlebarDoubleClick": ("enum", '"toggle-maximize"', "titlebar"),
|
||||
"panamaTitlebar": ("bool", "true", "titlebar"),
|
||||
"middleClickPaste": ("bool", "true", "pointer"),
|
||||
}
|
||||
|
||||
# Deleted on purpose. Hyprland has no minimize and maximize is noise in a
|
||||
# tiler, so neither button is offered -- and neither is a setting for it. A
|
||||
# schema entry that comes back is a control that writes a preference nothing
|
||||
# reads, which is worse than no control at all.
|
||||
for gone in ("titlebarMaximizeButton", "titlebarDoubleClick"):
|
||||
if re.search(r'key:\s*"' + gone + r'"', text):
|
||||
raise SystemExit(f"{gone} is back in the schema; it was removed on purpose")
|
||||
|
||||
def block_for(key: str) -> str:
|
||||
match = re.search(r"\{\s*\n\s*key:\s*\"" + re.escape(key) + r"\".*?\n\s{8}\}", text, re.S)
|
||||
if not match:
|
||||
@@ -134,7 +141,6 @@ enum_values = {
|
||||
"fontHinting": ["none", "slight", "medium", "full"],
|
||||
"fontAntialiasing": ["none", "grayscale", "rgba"],
|
||||
"titlebarButtonSide": ["left", "right"],
|
||||
"titlebarDoubleClick": ["toggle-maximize", "none"],
|
||||
}
|
||||
for key, values in enum_values.items():
|
||||
block = block_for(key)
|
||||
@@ -169,13 +175,39 @@ for needle in \
|
||||
'Fonts.interfaceFonts' \
|
||||
'Fonts.monospaceFonts' \
|
||||
'gtk-enable-primary-paste' \
|
||||
'button-layout' \
|
||||
'action-double-click-titlebar'; do
|
||||
'button-layout'; do
|
||||
rg -Fq "$needle" "$service" || fail "DesktopStyle is missing $needle"
|
||||
done
|
||||
! rg -q 'sh -c|bash -c' "$service" \
|
||||
|| fail 'DesktopStyle routes gsettings through a shell'
|
||||
|
||||
# ── The button layout is close-only, on either side ─────────────────────────
|
||||
# A minimize button on Hyprland is a button that does nothing, and maximize is
|
||||
# noise in a tiler. Neither token may reappear in the layout DesktopStyle
|
||||
# pushes, and the double-click action is left at GNOME's default rather than
|
||||
# being driven from a preference that no longer exists.
|
||||
python3 - "$service" <<'PY' || fail 'the titlebar button layout is no longer close-only'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
block = re.search(r'function buttonLayout\(\).*?\n \}', text, re.S)
|
||||
if not block:
|
||||
raise SystemExit("DesktopStyle no longer builds a button layout")
|
||||
body = block.group(0)
|
||||
if '"close:appmenu"' not in body or '"appmenu:close"' not in body:
|
||||
raise SystemExit("the layout is not close-only on both sides")
|
||||
for token in ("minimize", "maximize"):
|
||||
if token in body:
|
||||
raise SystemExit(f"the button layout offers {token}, which Hyprland cannot honour")
|
||||
if 'DesktopPreferences.get("titlebarButtonSide")' not in body:
|
||||
raise SystemExit("the button side is no longer read from the schema")
|
||||
|
||||
for gone in ("titlebarMaximizeButton", "titlebarDoubleClick", "action-double-click-titlebar"):
|
||||
if gone in text:
|
||||
raise SystemExit(f"DesktopStyle still reads or writes {gone}")
|
||||
PY
|
||||
|
||||
for group in typography themes titlebar; do
|
||||
rg -q "\"$group\": \"appearance\"" "$search" \
|
||||
|| fail "settings search does not route $group to Appearance"
|
||||
@@ -183,8 +215,15 @@ done
|
||||
rg -q '"pointer": "mouse"' "$search" \
|
||||
|| fail 'settings search no longer routes pointer controls to Mouse'
|
||||
|
||||
# Typography is one Fonts card of five dropdowns plus Sizes and Rendering.
|
||||
# The old split into "Shell typography" and "Application typography" is gone:
|
||||
# it never said which font was the shell's and which was the applications',
|
||||
# which was the one question the page existed to answer.
|
||||
for needle in \
|
||||
'title: "Application typography"' \
|
||||
'title: "Fonts"' \
|
||||
'title: "Sizes"' \
|
||||
'title: "Rendering"' \
|
||||
'setting: "interfaceFontSize"' \
|
||||
'setting: "applicationFontSize"' \
|
||||
'setting: "documentFontSize"' \
|
||||
'setting: "monospaceFontSize"' \
|
||||
@@ -193,14 +232,34 @@ for needle in \
|
||||
'title: "Icons & pointer"' \
|
||||
'DesktopStyle.cursorThemes' \
|
||||
'DesktopStyle.iconThemes' \
|
||||
'DesktopStyle.setApplicationFont(' \
|
||||
'DesktopStyle.setDocumentFont(' \
|
||||
'DesktopStyle.setMonospaceFont(' \
|
||||
'title: "Titlebars"' \
|
||||
'setting: "titlebarButtonSide"' \
|
||||
'setting: "titlebarMaximizeButton"' \
|
||||
'setting: "titlebarDoubleClick"'; do
|
||||
'setting: "panamaTitlebar"' \
|
||||
'setting: "titlebarButtonSide"'; do
|
||||
rg -Fq "$needle" "$appearance" || fail "Appearance is missing $needle"
|
||||
done
|
||||
! rg -q 'setting: "titlebarMinimize|GTK theme|Shell theme' "$appearance" \
|
||||
|| fail 'Appearance exposes an inert or separately-owned theme control'
|
||||
|
||||
# Five font rows, each naming what it controls, each a closed dropdown.
|
||||
[[ "$(rg -c 'FontPicker \{' "$appearance")" == "5" ]] \
|
||||
|| fail 'Appearance no longer offers exactly one FontPicker per font role'
|
||||
for role in 'label: "Interface"' 'label: "Icons"' 'label: "Application"' \
|
||||
'label: "Document"' 'label: "Monospace"'; do
|
||||
rg -Fq "$role" "$appearance" || fail "the Fonts card is missing $role"
|
||||
done
|
||||
|
||||
# The titlebar card must say why there is no minimize or maximize toggle,
|
||||
# rather than leaving the absence looking like an oversight.
|
||||
rg -Fq 'no minimize or maximize toggle' "$appearance" \
|
||||
|| fail 'the Titlebars card does not explain why it offers only close'
|
||||
for gone in 'setting: "titlebarMaximizeButton"' 'setting: "titlebarDoubleClick"' \
|
||||
'setting: "titlebarMinimize' 'GTK theme' 'Shell theme' \
|
||||
'title: "Shell typography"' 'title: "Application typography"'; do
|
||||
if rg -Fq "$gone" "$appearance"; then
|
||||
fail "Appearance exposes a retired, inert, or separately-owned control: $gone"
|
||||
fi
|
||||
done
|
||||
|
||||
rg -q 'setting: "middleClickPaste"' "$mouse" \
|
||||
|| fail 'Mouse does not expose middle-click paste'
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
# The failure is silent by construction, so it needs a test rather than a
|
||||
# comment. Checks the compositor-facing setting and the generated GTK config
|
||||
# agree, and that both name something real.
|
||||
#
|
||||
# It also covers the second half of GTK theming: settings.ini names a theme,
|
||||
# gtk.css overrides that theme's colours. Those colours used to be baked in --
|
||||
# #82aaff written out by hand, and the GTK4 "light" gtk.css a byte-for-byte
|
||||
# copy of the dark one, so a light desktop drew dark GTK4 windows. They are now
|
||||
# a marked block that panama-theme-apps rewrites from the active theme, with a
|
||||
# thousand lines of vendored structural CSS around it that must survive
|
||||
# untouched.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
@@ -85,4 +93,65 @@ for scheme in dark light; do
|
||||
done
|
||||
done
|
||||
|
||||
# ── The generated colour block ───────────────────────────────────────────────
|
||||
catalog="$repo_dir/config/dot/quickshell/config/themes.json"
|
||||
gtk_css_files=(gtk-3.0/gtk.css gtk-4.0/gtk.css gtk-4.0/gtk-dark.css)
|
||||
|
||||
for relative in "${gtk_css_files[@]}"; do
|
||||
tracked="$repo_dir/config/dot/$relative"
|
||||
[[ -r "$tracked" ]] || fail "$relative is missing"
|
||||
|
||||
grep -q 'PANAMA THEME BEGIN' "$tracked" \
|
||||
|| fail "$relative has no PANAMA THEME BEGIN marker, so the palette is never written into it and GTK keeps whatever colours the file shipped with"
|
||||
grep -q 'PANAMA THEME END' "$tracked" \
|
||||
|| fail "$relative has no PANAMA THEME END marker; the generator would rewrite the rest of the file"
|
||||
|
||||
# The shipped file has to be valid before it has ever been regenerated: a
|
||||
# fresh checkout renders GTK windows before the first theme change.
|
||||
grep -q '@define-color window_bg_color' "$tracked" \
|
||||
|| fail "$relative ships without a window_bg_color, so a fresh checkout draws GTK windows in adw-gtk3's stock greys"
|
||||
|
||||
cp "$tracked" "$fixture/$relative"
|
||||
done
|
||||
|
||||
mkdir -p "$fixture/panama"
|
||||
|
||||
# One dark theme and one light one, named through settings.json the way the
|
||||
# shell names them.
|
||||
for pair in "dark:$(jq -r .defaultDark "$catalog")" "light:$(jq -r .defaultLight "$catalog")"; do
|
||||
scheme="${pair%%:*}"
|
||||
theme_id="${pair#*:}"
|
||||
want_bg="$(jq -r --arg id "$theme_id" '.themes[] | select(.id == $id) | .palette.bg' "$catalog")"
|
||||
want_accent="$(jq -r --arg id "$theme_id" '.themes[] | select(.id == $id) | .accent' "$catalog")"
|
||||
|
||||
jq -n --arg id "$theme_id" --arg scheme "$scheme" \
|
||||
'{colorScheme: $scheme, themeProfileId: $id}' >"$fixture/panama/settings.json"
|
||||
XDG_CONFIG_HOME="$fixture" "$theme_apps" "$scheme" >/dev/null 2>&1
|
||||
|
||||
for relative in "${gtk_css_files[@]}"; do
|
||||
generated="$fixture/$relative"
|
||||
block="$(awk '/PANAMA THEME BEGIN/,/PANAMA THEME END/' "$generated")"
|
||||
|
||||
grep -qF "@define-color view_bg_color $want_bg;" <<<"$block" \
|
||||
|| fail "$relative did not take \"$theme_id\"'s background ($want_bg) for $scheme -- the block is not being regenerated from the theme"
|
||||
grep -qF "@define-color accent_bg_color $want_accent;" <<<"$block" \
|
||||
|| fail "$relative did not take \"$theme_id\"'s accent ($want_accent) for $scheme"
|
||||
|
||||
# Nothing outside the markers may move. That is the entire reason for
|
||||
# a marked block rather than a generated file.
|
||||
diff <(sed '/PANAMA THEME BEGIN/,/PANAMA THEME END/d' "$repo_dir/config/dot/$relative") \
|
||||
<(sed '/PANAMA THEME BEGIN/,/PANAMA THEME END/d' "$generated") >/dev/null \
|
||||
|| fail "$relative changed OUTSIDE the markers -- the vendored theme's structural CSS is being rewritten"
|
||||
done
|
||||
done
|
||||
|
||||
# The last render above was light, so this reads the light result: gtk-4.0's
|
||||
# gtk.css shipped as a byte-for-byte copy of the dark one, which is how a light
|
||||
# desktop kept drawing dark GTK4 windows.
|
||||
light_bg="$(sed -n 's/^@define-color view_bg_color #\([0-9a-fA-F]\{6\}\);.*/\1/p' \
|
||||
"$fixture/gtk-4.0/gtk.css" | head -1)"
|
||||
[[ -n "$light_bg" ]] || fail 'gtk-4.0/gtk.css has no view_bg_color after a light render'
|
||||
(( 16#${light_bg:0:2} > 128 )) \
|
||||
|| fail "gtk-4.0/gtk.css renders a DARK background (#$light_bg) in light mode -- the original bug"
|
||||
|
||||
printf 'gtk theme contract: PASS\n'
|
||||
|
||||
@@ -46,14 +46,30 @@ for key in lockBackgroundMode lockBlurLevel lockShowClock lockShowDate lockShowU
|
||||
|| fail "$key was duplicated onto Power or Privacy"
|
||||
done
|
||||
|
||||
python3 - "$appearance" <<'PY' || fail 'Lock screen card is not between Background and Shell typography'
|
||||
# The lock screen is the third card of the Background tab, after the still and
|
||||
# video wallpaper cards. It belongs there because it is a picture of the
|
||||
# desktop: the background is what it blurs, and the card above is what it
|
||||
# takes a still frame of.
|
||||
python3 - "$appearance" <<'PY' || fail 'the Lock screen card is not the last card of the Background tab'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
background = text.index('title: "Background"')
|
||||
lock = text.index('title: "Lock screen"')
|
||||
typography = text.index('title: "Shell typography"')
|
||||
raise SystemExit(0 if background < lock < typography else 1)
|
||||
order = ['title: "Background"', 'title: "Video playback"', 'title: "Lock screen"']
|
||||
positions = []
|
||||
for title in order:
|
||||
if title not in text:
|
||||
raise SystemExit(f"Appearance has no {title} card")
|
||||
positions.append(text.index(title))
|
||||
if positions != sorted(positions):
|
||||
raise SystemExit("the Background tab's cards are out of order")
|
||||
|
||||
# Each of the three is gated on the same tab, so none of them strands the
|
||||
# others on a page nobody opens.
|
||||
for card in re.findall(r'SettingsCard \{(.*?)\n \}', text, re.S):
|
||||
for title in order:
|
||||
if title in card and 'root.tab === "background"' not in card:
|
||||
raise SystemExit(f"the {title} card is not on the Background tab")
|
||||
PY
|
||||
|
||||
qs_for_harness() {
|
||||
|
||||
@@ -32,6 +32,33 @@ literal="$(grep -vE '^\s*#' "$template" | grep -oE 'rgba\([0-9]+, *[0-9]+, *[0-9
|
||||
[[ -z "$literal" ]] \
|
||||
|| fail "the template still contains hardcoded colors, which will not follow the scheme: $literal"
|
||||
|
||||
# ── The colours are written down once ────────────────────────────────────────
|
||||
# They used to be three copies of the same two tables: one here in
|
||||
# panama-theme-apps, one in panama-lock, one in setup/scripts/link-dotfiles.
|
||||
# The copy that got missed was always the seed, because a wrong seed only shows
|
||||
# up the first time somebody locks a fresh machine -- and by then they are
|
||||
# locked out of the machine they would fix it on.
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-palette"
|
||||
lock_script="$repo_dir/config/dot/quickshell/scripts/panama-lock"
|
||||
linker="$repo_dir/setup/scripts/link-dotfiles"
|
||||
|
||||
grep -q '^render_hyprlock()' "$helper" \
|
||||
|| fail 'panama-palette does not define render_hyprlock, so the eight substitutions have gone back to living per-caller'
|
||||
for consumer in "$theme_apps" "$linker"; do
|
||||
grep -q 'render_hyprlock' "$consumer" \
|
||||
|| fail "$(basename "$consumer") no longer fills the template through the shared renderer"
|
||||
done
|
||||
grep -q 'resolve_theme' "$lock_script" \
|
||||
|| fail 'panama-lock no longer resolves the theme through the shared resolver, so it has its own palette again'
|
||||
|
||||
# A quoted decimal triple in any of them is the table growing back.
|
||||
for consumer in "$theme_apps" "$lock_script" "$linker"; do
|
||||
restated="$(grep -vE '^\s*#' "$consumer" \
|
||||
| grep -oE "['\"][0-9]{1,3}, [0-9]{1,3}, [0-9]{1,3}['\"]" || true)"
|
||||
[[ -z "$restated" ]] \
|
||||
|| fail "$(basename "$consumer") carries hardcoded lock-screen colors again: $restated"
|
||||
done
|
||||
|
||||
fixture="$(mktemp -d /tmp/panama-lockscreen.XXXXXX)"
|
||||
trap 'rm -rf "$fixture"' EXIT
|
||||
mkdir -p "$fixture/hypr"
|
||||
@@ -90,4 +117,32 @@ light_hash="$(sha256sum "$fixture/hypr/hyprlock.conf" | cut -d' ' -f1)"
|
||||
[[ "$dark_hash" != "$light_hash" ]] \
|
||||
|| fail 'the light and dark lock screens are byte-identical, so the scheme is not being applied'
|
||||
|
||||
# ── A THEME reaches it, not only a scheme ────────────────────────────────────
|
||||
# The lock screen followed light and dark long before it followed themes:
|
||||
# choosing Catppuccin restyled the shell, the terminal and the launcher, and
|
||||
# left the lock screen in Tokyo Night.
|
||||
catalog="$repo_dir/config/dot/quickshell/config/themes.json"
|
||||
if [[ -r "$catalog" ]]; then
|
||||
default_dark="$(jq -r '.defaultDark' "$catalog")"
|
||||
# A theme whose BACKGROUND differs from the default's, not merely its id:
|
||||
# Moon Rose is Moon's palette with another accent, and asserting against it
|
||||
# would pass even if the generator ignored the theme entirely.
|
||||
other="$(jq -r --arg default "$default_dark" '
|
||||
(.themes[] | select(.id == $default) | .palette.bg) as $base
|
||||
| [.themes[] | select(.scheme == "dark" and .palette.bg != $base)][0].id' "$catalog")"
|
||||
|
||||
if [[ -n "$other" && "$other" != "null" ]]; then
|
||||
want="$(jq -r --arg id "$other" '.themes[] | select(.id == $id) | .palette.bg' "$catalog")"
|
||||
triple="$(printf '%d, %d, %d' "0x${want:1:2}" "0x${want:3:2}" "0x${want:5:2}")"
|
||||
|
||||
mkdir -p "$fixture/panama"
|
||||
jq -n --arg id "$other" '{colorScheme: "dark", themeProfileId: $id}' \
|
||||
>"$fixture/panama/settings.json"
|
||||
XDG_CONFIG_HOME="$fixture" "$theme_apps" dark >/dev/null 2>&1
|
||||
|
||||
grep -q "rgba($triple, 1.0)" "$fixture/hypr/hyprlock.conf" \
|
||||
|| fail "selecting the \"$other\" theme did not reach the lock screen: its background ($want) is nowhere in the generated config"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf 'lock screen theme contract: PASS\n'
|
||||
|
||||
@@ -89,6 +89,79 @@ done
|
||||
looks_accents="$(grep -cE '^\s+(orchid|teal|green|amber|orange|rose|slate)\s*=' "$looks" || true)"
|
||||
(( looks_accents == 0 )) || note "looks.lua has an accent table again ($looks_accents entries)"
|
||||
|
||||
# ── The same argument, one level up: the THEME resolver ──────────────────────
|
||||
#
|
||||
# An accent is one colour. A theme is the whole palette, and the two shell
|
||||
# generators both need the active one -- panama-theme-apps to render kitty,
|
||||
# GTK, btop, tmux, Vicinae and Firefox, panama-lock to render the lock screen.
|
||||
# They answered that question with their own tables of Tokyo Night literals
|
||||
# until the resolution moved into panama-palette beside accent_hex, for exactly
|
||||
# the reason recorded above. link-dotfiles is the third caller: it seeds the
|
||||
# lock screen at install time.
|
||||
|
||||
themes="$repo_dir/config/dot/quickshell/config/themes.json"
|
||||
linker="$repo_dir/setup/scripts/link-dotfiles"
|
||||
|
||||
grep -q 'themes.json' "$helper" || note 'the shell palette helper does not read themes.json, so nothing shared resolves a theme'
|
||||
for consumer in "$theme_apps" "$lock"; do
|
||||
grep -q 'resolve_theme' "$consumer" \
|
||||
|| note "$(basename "$consumer") does not resolve the active theme through the shared function"
|
||||
done
|
||||
grep -q 'render_hyprlock' "$linker" \
|
||||
|| note 'link-dotfiles seeds the lock screen without the shared renderer, so it carries its own colour table again'
|
||||
|
||||
# ── The resolver answers with a complete theme ───────────────────────────────
|
||||
|
||||
if [[ -r "$themes" ]] && jq -e . "$themes" >/dev/null 2>&1; then
|
||||
resolver_fixture="$(mktemp -d /tmp/panama-theme-resolve.XXXXXX)"
|
||||
trap 'rm -rf "$resolver_fixture"' EXIT
|
||||
mkdir -p "$resolver_fixture/panama"
|
||||
|
||||
while read -r id; do
|
||||
scheme="$(jq -r --arg id "$id" '.themes[] | select(.id == $id) | .scheme' "$themes")"
|
||||
jq -n --arg id "$id" '{themeProfileId: $id}' >"$resolver_fixture/panama/settings.json"
|
||||
resolved="$(XDG_CONFIG_HOME="$resolver_fixture" \
|
||||
bash -c "source '$helper'; resolve_theme '$scheme'")"
|
||||
|
||||
[[ "$(jq -r '.id' <<<"$resolved")" == "$id" ]] \
|
||||
|| note "the resolver does not find the shipped theme '$id'"
|
||||
# 19 palette tokens and 16 ansi keys, or a generator writes a config
|
||||
# with a colour of "" in it.
|
||||
[[ "$(jq -r '.palette | length' <<<"$resolved")" == "19" ]] \
|
||||
|| note "'$id' resolves with $(jq -r '.palette | length' <<<"$resolved") palette keys rather than 19"
|
||||
[[ "$(jq -r '.ansi | length' <<<"$resolved")" == "16" ]] \
|
||||
|| note "'$id' resolves with $(jq -r '.ansi | length' <<<"$resolved") ansi keys rather than 16"
|
||||
done < <(jq -r '.themes[].id' "$themes")
|
||||
|
||||
# A custom carrying only an accent -- which is every profile saved before
|
||||
# themes existed. It must keep its accent AND inherit a full palette.
|
||||
jq -n '{themeProfileId: "bare",
|
||||
themeProfiles: [{id: "bare", name: "Bare", scheme: "dark",
|
||||
accent: "#ff0088", secondary: "#00ffcc"}]}' \
|
||||
>"$resolver_fixture/panama/settings.json"
|
||||
bare="$(XDG_CONFIG_HOME="$resolver_fixture" bash -c "source '$helper'; resolve_theme dark")"
|
||||
[[ "$(jq -r '.accent' <<<"$bare")" == "#ff0088" ]] \
|
||||
|| note 'a custom theme without a palette loses its own accent'
|
||||
default_bg="$(jq -r --arg id "$(jq -r '.defaultDark' "$themes")" \
|
||||
'.themes[] | select(.id == $id) | .palette.bg' "$themes")"
|
||||
[[ "$(jq -r '.palette.bg' <<<"$bare")" == "$default_bg" ]] \
|
||||
|| note 'a custom theme without a palette does not inherit the scheme default, so every profile saved before themes existed resolves to no colours at all'
|
||||
|
||||
# An unknown id must land on the scheme default rather than on an empty
|
||||
# record: a settings file naming a deleted theme is a normal state.
|
||||
jq -n '{themeProfileId: "no-such-theme"}' >"$resolver_fixture/panama/settings.json"
|
||||
unknown="$(XDG_CONFIG_HOME="$resolver_fixture" bash -c "source '$helper'; resolve_theme light")"
|
||||
[[ "$(jq -r '.id' <<<"$unknown")" == "$(jq -r '.defaultLight' "$themes")" ]] \
|
||||
|| note "an unknown theme id resolves to '$(jq -r '.id' <<<"$unknown")' rather than to the light default"
|
||||
|
||||
# The per-mode keys are the selection; themeProfileId is the fallback.
|
||||
jq -n --arg dark "$(jq -r '[.themes[] | select(.scheme == "dark")][-1].id' "$themes")" \
|
||||
'{themeProfileId: "day", themeDark: $dark}' >"$resolver_fixture/panama/settings.json"
|
||||
per_mode="$(XDG_CONFIG_HOME="$resolver_fixture" bash -c "source '$helper'; resolve_theme dark")"
|
||||
[[ "$(jq -r '.id' <<<"$per_mode")" == "$(jq -r '[.themes[] | select(.scheme == "dark")][-1].id' "$themes")" ]] \
|
||||
|| note 'themeDark does not win over themeProfileId, so a scheme flip lands on the wrong theme'
|
||||
fi
|
||||
|
||||
# ── The helper resolves what the palette says ────────────────────────────────
|
||||
|
||||
while read -r name; do
|
||||
|
||||
@@ -128,7 +128,7 @@ run_doctor() {
|
||||
/usr/bin/python3 "$doctor" "$@"
|
||||
}
|
||||
|
||||
expected_order=$'desktop.hyprland\ndesktop.quickshell\ndesktop.notifications\ndesktop.portals\ndesktop.document-portal\ndesktop.portal-stability\ndesktop.hyprpaper\ndesktop.hypridle\ndesktop.hyprlock\ndesktop.vicinae\ninput.pipewire\ninput.clipboard\ninput.wallpaper\ninput.capture\ninput.ocr\ninput.brightness\nintegration.nextcloud\nintegration.rustdesk\nintegration.kdeconnect\nintegration.bluebubbles\nintegration.home-assistant\nintegration.calendar\npanama.updates\npanama.runtime-links\npanama.vicinae-commands\npanama.selected-terminal\npanama.selected-launcher\npanama.processes\npanama.caffeine'
|
||||
expected_order=$'desktop.hyprland\ndesktop.quickshell\ndesktop.notifications\ndesktop.portals\ndesktop.document-portal\ndesktop.portal-stability\ndesktop.hyprpaper\ndesktop.hypridle\ndesktop.hyprlock\ndesktop.vicinae\ninput.pipewire\ninput.clipboard\ninput.wallpaper\ninput.video-wallpaper\ninput.capture\ninput.ocr\ninput.brightness\nintegration.nextcloud\nintegration.rustdesk\nintegration.kdeconnect\nintegration.bluebubbles\nintegration.home-assistant\nintegration.calendar\npanama.updates\npanama.runtime-links\npanama.vicinae-commands\npanama.selected-terminal\npanama.selected-launcher\npanama.processes\npanama.caffeine'
|
||||
|
||||
assert_schema_and_redaction() {
|
||||
local snapshot="$1"
|
||||
|
||||
@@ -14,6 +14,7 @@ schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
|
||||
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
|
||||
scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml"
|
||||
looks="$repo_dir/config/dot/hypr/looks.lua"
|
||||
catalog="$repo_dir/config/dot/quickshell/config/themes.json"
|
||||
readme="$pages_dir/README.md"
|
||||
|
||||
fail() {
|
||||
@@ -109,32 +110,56 @@ for needle in \
|
||||
rg -Fq "$needle" "$readme" || fail "README is missing $needle"
|
||||
done
|
||||
|
||||
python3 - "$scheme" "$looks" <<'PY' \
|
||||
|| fail 'scheme-relative border ownership drifted'
|
||||
python3 - "$scheme" "$looks" "$catalog" <<'PY' \
|
||||
|| fail 'window border ownership drifted'
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
scheme = open(sys.argv[1], encoding="utf-8").read()
|
||||
looks = open(sys.argv[2], encoding="utf-8").read()
|
||||
themes = {t["id"]: t for t in json.load(open(sys.argv[3], encoding="utf-8"))["themes"]}
|
||||
|
||||
dark = re.search(r'property string inactiveBorderDark:\s*"([^"]+)"', scheme)
|
||||
light = re.search(r'property string inactiveBorderLight:\s*"([^"]+)"', scheme)
|
||||
effective = re.search(r'property string inactiveBorder:\s*root\.dark\s*\?\s*root\.inactiveBorderDark\s*:\s*root\.inactiveBorderLight', scheme)
|
||||
if not dark or not light or not effective:
|
||||
raise SystemExit("ColorScheme does not expose the two inactive-border roles")
|
||||
if dark.group(1) not in looks or light.group(1) not in looks:
|
||||
raise SystemExit("Hyprland startup values disagree with the live scheme roles")
|
||||
# Hyprland's own startup value has to stand alone with no settings file, so it
|
||||
# carries a literal pair -- but that pair is the two DEFAULT themes' gutters,
|
||||
# the same role ColorScheme restates the moment the shell is up. A drift here
|
||||
# is a visible flash of the wrong border on every login.
|
||||
for theme_id in ("moon", "day"):
|
||||
expected = "rgba(" + themes[theme_id]["palette"]["gutter"].lstrip("#") + "99)"
|
||||
if expected not in looks:
|
||||
raise SystemExit(
|
||||
f"Hyprland's startup inactive_border does not carry {theme_id}'s gutter ({expected})")
|
||||
|
||||
# The inactive border is a neutral contrast role, and it is now drawn from the
|
||||
# ACTIVE THEME's gutter rather than from two Tokyo Night literals. Those
|
||||
# literals were right for two of the ten shipped themes and for no custom one,
|
||||
# so a hardcoded pair here is the regression worth catching.
|
||||
inactive = re.search(
|
||||
r'property string inactiveBorder:\s*root\.hyprColor\(Theme\.gutter,', scheme)
|
||||
if not inactive:
|
||||
raise SystemExit("the inactive border is no longer the active theme's neutral role")
|
||||
if re.search(r'property string inactiveBorder(Dark|Light):\s*"#', scheme):
|
||||
raise SystemExit("ColorScheme has regrown a hardcoded inactive-border literal")
|
||||
if re.search(r'rgba\([0-9a-f]{8}\)', scheme):
|
||||
raise SystemExit("ColorScheme has regrown a literal Hyprland border colour")
|
||||
|
||||
without_comments = re.sub(r"//.*", "", scheme)
|
||||
|
||||
# The focused border is the accent role, and ColorScheme.qml owns it too:
|
||||
# each named accent carries a separate pair per scheme, so a scheme change
|
||||
# must restate the focused border, not just the neutral one, or a chosen
|
||||
# accent goes stale the moment light/dark flips.
|
||||
start = re.search(r'property string accentBorderStart:\s*root\.hyprColor\(Theme\.accent\)', scheme)
|
||||
end = re.search(r'property string accentBorderEnd:\s*root\.hyprColor\(Theme\.accentSecondary\)', scheme)
|
||||
# accent goes stale the moment light/dark flips. Both roles are also restated
|
||||
# when the THEME changes, since both now follow the resolved palette.
|
||||
start = re.search(r'property string accentBorderStart:\s*root\.hyprColor\(Theme\.accent,', scheme)
|
||||
end = re.search(r'property string accentBorderEnd:\s*root\.hyprColor\(Theme\.accentSecondary,', scheme)
|
||||
if not start or not end:
|
||||
raise SystemExit("ColorScheme does not derive the focused border from the chosen accent")
|
||||
if 'themeChanged' not in without_comments:
|
||||
raise SystemExit("ColorScheme does not restate its borders when the theme changes")
|
||||
if 'schemeChanged || themeChanged' not in without_comments:
|
||||
raise SystemExit("the inactive border is not restated on a theme change")
|
||||
if 'schemeChanged || accentChanged || themeChanged' not in without_comments:
|
||||
raise SystemExit("the focused border is not restated on a theme change")
|
||||
|
||||
# Written as a Lua TABLE, not a string: the string form of a Hyprland gradient
|
||||
# carries only one stop, so writing it that way is accepted and silently
|
||||
|
||||
@@ -68,6 +68,13 @@ per-display wallpaper|Per-display wallpaper|appearance
|
||||
arrange displays|Arrange displays|displays
|
||||
monitor position|Monitor position|displays
|
||||
primary display|Primary display|displays
|
||||
themes|Themes|appearance
|
||||
theme editor|Theme editor|appearance
|
||||
dark mode|Dark mode|appearance
|
||||
catppuccin|Catppuccin|appearance
|
||||
gruvbox|Gruvbox|appearance
|
||||
video wallpaper|Video wallpaper|appearance
|
||||
titlebar|Titlebar on Panama windows|appearance
|
||||
CASES
|
||||
|
||||
! rg -Fq 'Startup & Services' "$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" \
|
||||
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The one titlebar Panama draws.
|
||||
#
|
||||
# Settings is the only Panama window with a titlebar of its own, and it used to
|
||||
# show three buttons: minimize, maximize, close. Two of those were lies.
|
||||
# Hyprland has no minimize -- it receives the request and does nothing with it
|
||||
# -- and maximize is meaningless in a tiler. Pressing them looked exactly like
|
||||
# pressing a button that works.
|
||||
#
|
||||
# So the bar is close-only, it follows the same button-side preference GNOME
|
||||
# applications get, and it can be turned off entirely: with `panamaTitlebar`
|
||||
# false the window is pure Hyprland -- Super+Q closes it, Super+drag moves it,
|
||||
# and Escape still works.
|
||||
#
|
||||
# What must hold:
|
||||
#
|
||||
# 1. No minimize or maximize control anywhere in the settings chrome.
|
||||
# 2. The bar collapses to nothing rather than merely hiding, or the page
|
||||
# below keeps a 48px hole where the bar used to be.
|
||||
# 3. The title and the close button swap sides together.
|
||||
# 4. The close button is reachable and identifiable without a mouse or
|
||||
# sight: it is a "×" glyph and nothing else.
|
||||
# 5. Escape closes the window whether or not the bar is drawn, so turning
|
||||
# the titlebar off never traps somebody in a window with no visible way
|
||||
# out.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
shell_dir="$repo_dir/config/dot/quickshell"
|
||||
shell_ui="$shell_dir/modules/settings/SettingsShell.qml"
|
||||
window="$shell_dir/modules/settings/SettingsWindow.qml"
|
||||
appearance="$shell_dir/modules/settings/AppearancePage.qml"
|
||||
schema="$shell_dir/config/PreferenceSchema.qml"
|
||||
|
||||
fail() {
|
||||
printf 'settings titlebar contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for file in "$shell_ui" "$window" "$appearance" "$schema"; do
|
||||
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
|
||||
done
|
||||
|
||||
# ── 1. Nothing that cannot work ─────────────────────────────────────────────
|
||||
python3 - "$shell_ui" "$window" <<'PY' || fail 'the settings chrome offers a control Hyprland cannot honour'
|
||||
import re
|
||||
import sys
|
||||
|
||||
for path in sys.argv[1:]:
|
||||
text = open(path, encoding="utf-8").read()
|
||||
# Comments may say the words -- explaining why they are absent is the
|
||||
# point. Code may not.
|
||||
code = re.sub(r"//.*", "", text)
|
||||
for word in ("minimize", "minimise", "maximize", "maximise"):
|
||||
if re.search(word, code, re.I):
|
||||
raise SystemExit(f"{path.split('/')[-1]} still has a {word} control")
|
||||
PY
|
||||
|
||||
# ── 2. Off means gone, not merely invisible ─────────────────────────────────
|
||||
rg -Fq 'readonly property bool shown: DesktopPreferences.get("panamaTitlebar") !== false' "$shell_ui" \
|
||||
|| fail 'the titlebar does not follow the panamaTitlebar preference'
|
||||
rg -Fq 'height: shown ? 48 : 0' "$shell_ui" \
|
||||
|| fail 'the titlebar hides without collapsing, leaving a hole above the page'
|
||||
rg -Fq 'visible: shown' "$shell_ui" \
|
||||
|| fail 'the hidden titlebar is still rendered'
|
||||
# Everything below is anchored to the bar's bottom edge, so collapsing it is
|
||||
# what actually reclaims the space.
|
||||
[[ "$(rg -c 'anchors.top: titlebar.bottom' "$shell_ui")" -ge 3 ]] \
|
||||
|| fail 'the sidebar, tab strip and page no longer follow the titlebar edge'
|
||||
|
||||
# ── 3. Title and close button swap together ─────────────────────────────────
|
||||
rg -Fq 'readonly property bool buttonsLeft: DesktopPreferences.get("titlebarButtonSide") === "left"' "$shell_ui" \
|
||||
|| fail 'the titlebar does not follow the button-side preference'
|
||||
python3 - "$shell_ui" <<'PY' || fail 'the titlebar is not side-aware'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
pairs = re.findall(r'anchors\.(left|right): titlebar\.buttonsLeft \? (\S+) : (\S+)', text)
|
||||
if len(pairs) < 4:
|
||||
raise SystemExit("fewer than two side-aware elements; the title or the button is pinned")
|
||||
# For each element, exactly one edge is released and the other taken, in both
|
||||
# states -- binding both edges leaves the label squeezed into what is left.
|
||||
for index in range(0, len(pairs), 2):
|
||||
first, second = pairs[index], pairs[index + 1]
|
||||
if first[0] == second[0]:
|
||||
raise SystemExit("a side-aware element anchors the same edge twice")
|
||||
if "undefined" not in (first[1] + first[2]) or "undefined" not in (second[1] + second[2]):
|
||||
raise SystemExit("a side-aware element never releases an anchor")
|
||||
PY
|
||||
|
||||
# ── 4. The close button is reachable and identifiable ───────────────────────
|
||||
python3 - "$shell_ui" <<'PY' || fail 'the close button is mouse-and-sight only'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
block = re.search(r'Rectangle \{\s*\n\s*id: closeButton(.*?)\n \}\n', text, re.S)
|
||||
if not block:
|
||||
raise SystemExit("there is no close button")
|
||||
body = block.group(1)
|
||||
for needle in (
|
||||
"activeFocusOnTab: true",
|
||||
"Accessible.role: Accessible.Button",
|
||||
'Accessible.name: "Close Settings"',
|
||||
"Keys.onReturnPressed: ShellState.closeSettings()",
|
||||
"Keys.onSpacePressed: ShellState.closeSettings()",
|
||||
"onClicked: ShellState.closeSettings()",
|
||||
):
|
||||
if needle not in body:
|
||||
raise SystemExit(f"the close button is missing {needle}")
|
||||
# A focused button that looks identical to an unfocused one is not keyboard
|
||||
# operable in any useful sense.
|
||||
if "activeFocus" not in body:
|
||||
raise SystemExit("the close button has no visible focus treatment")
|
||||
PY
|
||||
|
||||
# It is the only button in the bar.
|
||||
[[ "$(rg -c 'Accessible.role: Accessible.Button' "$shell_ui")" == "1" ]] \
|
||||
|| fail 'the titlebar has grown a second button'
|
||||
|
||||
# ── 5. The window is still movable and still closable ───────────────────────
|
||||
rg -Fq 'startSystemMove()' "$shell_ui" \
|
||||
|| fail 'the titlebar cannot drag the window'
|
||||
python3 - "$shell_ui" <<'PY' || fail 'Escape no longer closes the window'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
shortcut = re.search(r'Shortcut \{(.*?)\}', text, re.S)
|
||||
if not shortcut or 'sequence: "Escape"' not in shortcut.group(1):
|
||||
raise SystemExit("there is no Escape shortcut")
|
||||
if 'ShellState.closeSettings()' not in shortcut.group(1):
|
||||
raise SystemExit("Escape does not close settings")
|
||||
# It is declared on the shell, not inside the titlebar, so hiding the bar
|
||||
# cannot take it away.
|
||||
if text.index("Shortcut {") < text.index("id: titlebar"):
|
||||
raise SystemExit("the Escape shortcut is declared before the shell content")
|
||||
titlebar = re.search(r'Rectangle \{\s*\n\s*id: titlebar(.*?)\n \}\n', text, re.S)
|
||||
if titlebar and "Shortcut" in titlebar.group(1):
|
||||
raise SystemExit("Escape is scoped inside the titlebar, so hiding the bar removes it")
|
||||
PY
|
||||
|
||||
# ── The preference exists and is reachable ──────────────────────────────────
|
||||
python3 - "$schema" <<'PY' || fail 'the panamaTitlebar schema entry is missing or malformed'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
block = re.search(r"\{\s*\n\s*key:\s*\"panamaTitlebar\".*?\n\s{8}\}", text, re.S)
|
||||
if not block:
|
||||
raise SystemExit("panamaTitlebar is not in the schema")
|
||||
body = block.group(0)
|
||||
for needle in ('type: "bool"', "def: true", 'group: "titlebar"'):
|
||||
if needle not in body:
|
||||
raise SystemExit(f"panamaTitlebar is missing {needle}")
|
||||
if "internal: true" in body:
|
||||
raise SystemExit("panamaTitlebar is internal, so nobody can turn it off")
|
||||
PY
|
||||
|
||||
rg -Fq 'setting: "panamaTitlebar"' "$appearance" \
|
||||
|| fail 'Appearance does not expose the titlebar toggle'
|
||||
rg -Fq 'Super+Q closes' "$schema" \
|
||||
|| fail 'the toggle does not say what happens once the titlebar is gone'
|
||||
|
||||
printf 'settings titlebar contract: PASS\n'
|
||||
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The theme catalog is the one source of truth for every shipped palette, and
|
||||
# three consumers read it three ways: ThemeCatalog.qml with FileView, the
|
||||
# render pipeline with jq, and this contract with Node. What breaks silently:
|
||||
#
|
||||
# - a theme missing a palette key renders as QML's "undefined" black
|
||||
# - moon/day drifting from the shell's pre-theme literals repaints every
|
||||
# machine that never chose a theme
|
||||
# - ThemeCatalog.qml's embedded fallback drifting from the catalog means the
|
||||
# shell renders differently for the instant before the file loads
|
||||
#
|
||||
# Nothing at runtime cross-checks any of that. This does, statically.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
shell_dir="$repo_dir/config/dot/quickshell"
|
||||
catalog="$shell_dir/config/themes.json"
|
||||
catalog_qml="$shell_dir/services/ThemeCatalog.qml"
|
||||
model="$shell_dir/services/ThemeProfileModel.js"
|
||||
|
||||
fail() {
|
||||
printf 'theme catalog contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -f "$catalog" ] || fail "themes.json is missing"
|
||||
[ -f "$catalog_qml" ] || fail "ThemeCatalog.qml is missing"
|
||||
|
||||
node - "$catalog" "$model" "$catalog_qml" <<'EOF'
|
||||
const fs = require("fs");
|
||||
const [catalogPath, modelPath, qmlPath] = process.argv.slice(2);
|
||||
const model = require(modelPath);
|
||||
const catalog = JSON.parse(fs.readFileSync(catalogPath, "utf8"));
|
||||
|
||||
const fail = message => { console.error("theme catalog contract: " + message); process.exit(1); };
|
||||
|
||||
// ── Shape ────────────────────────────────────────────────────────────────
|
||||
const themes = catalog.themes ?? [];
|
||||
if (themes.length !== 10)
|
||||
fail(`expected 10 shipped themes, found ${themes.length}`);
|
||||
const ids = new Set();
|
||||
for (const theme of themes) {
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(theme.id ?? ""))
|
||||
fail(`bad theme id: ${theme.id}`);
|
||||
if (ids.has(theme.id))
|
||||
fail(`duplicate theme id: ${theme.id}`);
|
||||
ids.add(theme.id);
|
||||
if (theme.scheme !== "dark" && theme.scheme !== "light")
|
||||
fail(`${theme.id}: bad scheme`);
|
||||
// The model's validators are the authority on key sets and hex shape —
|
||||
// if they reject a shipped theme, the shell would fall back silently.
|
||||
if (!model.normalizePalette(theme.palette))
|
||||
fail(`${theme.id}: palette rejected by ThemeProfileModel.normalizePalette`);
|
||||
if (!model.normalizeAnsi(theme.ansi))
|
||||
fail(`${theme.id}: ansi rejected by ThemeProfileModel.normalizeAnsi`);
|
||||
for (const color of [theme.accent, theme.secondary])
|
||||
if (!/^#[0-9a-f]{6}$/.test(color ?? ""))
|
||||
fail(`${theme.id}: bad accent pair`);
|
||||
}
|
||||
if (!ids.has(catalog.defaultDark) || !ids.has(catalog.defaultLight))
|
||||
fail("defaultDark/defaultLight name unknown themes");
|
||||
const darkCount = themes.filter(theme => theme.scheme === "dark").length;
|
||||
if (darkCount !== 6 || themes.length - darkCount !== 4)
|
||||
fail(`expected 6 dark and 4 light themes, found ${darkCount}/${themes.length - darkCount}`);
|
||||
|
||||
// ── moon and day are the shell's pre-theme literals ──────────────────────
|
||||
// These exact values were Theme.qml's ternaries before themes existed; a
|
||||
// machine that never chose a theme must keep rendering byte-identically.
|
||||
const pinned = {
|
||||
moon: { 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", accent: "#82aaff", secondary: "#b172b0" },
|
||||
day: { 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", accent: "#2e7de9", secondary: "#9854f1" }
|
||||
};
|
||||
for (const [id, expected] of Object.entries(pinned)) {
|
||||
const theme = themes.find(entry => entry.id === id);
|
||||
if (!theme)
|
||||
fail(`shipped theme ${id} is missing`);
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
const actual = key === "accent" || key === "secondary" ? theme[key] : theme.palette[key];
|
||||
if (actual !== value)
|
||||
fail(`${id}.${key} drifted: ${actual} (expected ${value})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── The QML fallback carries moon and day byte-identically ───────────────
|
||||
// ThemeCatalog.qml embeds the two defaults for the instant before FileView
|
||||
// loads; every pinned hex above must appear in the QML, and every hex in the
|
||||
// fallback block must exist somewhere in the catalog's moon/day records.
|
||||
const qml = fs.readFileSync(qmlPath, "utf8");
|
||||
for (const [id, expected] of Object.entries(pinned))
|
||||
for (const [key, value] of Object.entries(expected))
|
||||
if (!qml.includes(value))
|
||||
fail(`ThemeCatalog.qml fallback is missing ${id}.${key} = ${value}`);
|
||||
if (!/fallbackThemes/.test(qml))
|
||||
fail("ThemeCatalog.qml no longer declares fallbackThemes");
|
||||
|
||||
// ── The model and the catalog agree on the token sets ────────────────────
|
||||
const paletteKeys = Object.keys(themes[0].palette).sort();
|
||||
if (JSON.stringify(paletteKeys) !== JSON.stringify([...model.PALETTE_KEYS].sort()))
|
||||
fail("themes.json palette keys diverge from ThemeProfileModel.PALETTE_KEYS");
|
||||
const ansiKeys = Object.keys(themes[0].ansi).sort();
|
||||
if (JSON.stringify(ansiKeys) !== JSON.stringify([...model.ANSI_KEYS].sort()))
|
||||
fail("themes.json ansi keys diverge from ThemeProfileModel.ANSI_KEYS");
|
||||
|
||||
console.log(`theme catalog contract: ok (${themes.length} themes, ${model.PALETTE_KEYS.length} palette keys)`);
|
||||
EOF
|
||||
|
||||
# Theme.qml must read every palette token from the resolver, never a literal
|
||||
# ternary — a reintroduced literal would silently stop following themes.
|
||||
if grep -E 'readonly property color (bg|fg|gutter|cyan|teal|green|yellow|orange|red|magenta|pink)[A-Za-z]*:.*root\.dark \?' \
|
||||
"$shell_dir/config/Theme.qml" >/dev/null; then
|
||||
fail "Theme.qml has regrown a hardcoded palette ternary"
|
||||
fi
|
||||
grep -q 'ThemeProfiles.activePalette' "$shell_dir/config/Theme.qml" \
|
||||
|| fail "Theme.qml no longer reads ThemeProfiles.activePalette"
|
||||
|
||||
printf 'theme catalog contract: ok\n'
|
||||
@@ -1,94 +1,252 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The theme record and the selection flow.
|
||||
#
|
||||
# ThemeProfileModel.js is the whole of the record's grammar: what a theme is
|
||||
# made of, which parts are optional, what happens to a stored record that is
|
||||
# half wrong, and which curated accent an arbitrary colour is nearest to. All
|
||||
# of it is pure and runs under Node, so it is checked directly rather than
|
||||
# through the shell.
|
||||
#
|
||||
# Two things here are easy to break silently and expensive when broken:
|
||||
#
|
||||
# - `shippedProfiles()` with no argument is the pre-catalog fallback. The
|
||||
# shell renders with it for the instant before config/themes.json loads,
|
||||
# and forever on a machine where that file is missing. It must stay the
|
||||
# three built-in records, unchanged, even though every runtime caller now
|
||||
# passes the ten-theme catalog.
|
||||
# - A stored custom with one bad palette key must lose the FIELD, not the
|
||||
# profile. Dropping the profile would delete somebody's saved theme
|
||||
# because a single hex went wrong.
|
||||
#
|
||||
# The live half pins the selection flow: a light/dark flip lands on the theme
|
||||
# you last chose on that side, never a forced reset to Moon and Day.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
model="$repo_dir/config/dot/quickshell/services/ThemeProfileModel.js"
|
||||
catalog="$repo_dir/config/dot/quickshell/config/themes.json"
|
||||
|
||||
node - "$model" <<'JS'
|
||||
node - "$model" "$catalog" <<'JS'
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const model = require(process.argv[2])
|
||||
const catalog = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'))
|
||||
// ThemeCatalog.normalizeTheme() stamps `shipped: true` on every record before
|
||||
// handing the list to the model, so the fixture has to as well -- without it
|
||||
// the model would treat catalog themes as editable customs.
|
||||
const shippedCatalog = catalog.themes.map(theme => ({ ...theme, shipped: true }))
|
||||
|
||||
// ── Back-compat: no argument is still the three built-in fallbacks ──────────
|
||||
const shipped = model.shippedProfiles()
|
||||
assert.deepEqual(shipped, [
|
||||
{
|
||||
id: 'moon',
|
||||
name: 'Moon',
|
||||
scheme: 'dark',
|
||||
accent: '#82aaff',
|
||||
secondary: '#b172b0',
|
||||
shipped: true
|
||||
},
|
||||
{
|
||||
id: 'moon-rose',
|
||||
name: 'Moon Rose',
|
||||
scheme: 'dark',
|
||||
accent: '#ff757f',
|
||||
secondary: '#c099ff',
|
||||
shipped: true
|
||||
},
|
||||
{
|
||||
id: 'day',
|
||||
name: 'Day',
|
||||
scheme: 'light',
|
||||
accent: '#2e7de9',
|
||||
secondary: '#9854f1',
|
||||
shipped: true
|
||||
}
|
||||
{ id: 'moon', name: 'Moon', scheme: 'dark', accent: '#82aaff', secondary: '#b172b0', shipped: true },
|
||||
{ id: 'moon-rose', name: 'Moon Rose', scheme: 'dark', accent: '#ff757f', secondary: '#c099ff', shipped: true },
|
||||
{ id: 'day', name: 'Day', scheme: 'light', accent: '#2e7de9', secondary: '#9854f1', shipped: true }
|
||||
])
|
||||
|
||||
// ── The catalog's records survive the same copy, palettes included ──────────
|
||||
const full = model.shippedProfiles(shippedCatalog)
|
||||
assert.equal(full.length, 10)
|
||||
assert.deepEqual(full.map(p => p.id), shippedCatalog.map(t => t.id))
|
||||
for (const profile of full) {
|
||||
assert.ok(model.normalizePalette(profile.palette), `${profile.id} lost its palette`)
|
||||
assert.ok(model.normalizeAnsi(profile.ansi), `${profile.id} lost its ansi block`)
|
||||
// Shipped themes carry no effects snapshot, so applying one leaves the
|
||||
// user's blur, shadows and motion exactly where they were.
|
||||
assert.equal(profile.effects, undefined)
|
||||
assert.equal(profile.shipped, true)
|
||||
}
|
||||
|
||||
const moonPalette = shippedCatalog.find(t => t.id === 'moon').palette
|
||||
const moonAnsi = shippedCatalog.find(t => t.id === 'moon').ansi
|
||||
|
||||
// ── Optional fields survive a stored record ────────────────────────────────
|
||||
const storedInput = {
|
||||
id: 'custom-full', name: 'Full', scheme: 'dark',
|
||||
accent: '#86E1FC', secondary: '#82AAFF',
|
||||
palette: moonPalette, ansi: moonAnsi,
|
||||
effects: { blurEnabled: true, blurSize: 40, shadowSharp: 'yes', glowRange: 12 }
|
||||
}
|
||||
const stored = model.validCustomProfiles([storedInput], shippedCatalog)[0]
|
||||
assert.equal(stored.accent, '#86e1fc')
|
||||
assert.deepEqual(stored.palette, model.normalizePalette(moonPalette))
|
||||
assert.deepEqual(stored.ansi, model.normalizeAnsi(moonAnsi))
|
||||
// Effects are per-key: out of range clamps, wrong-typed drops, the rest lands.
|
||||
assert.deepEqual(stored.effects, { blurEnabled: true, blurSize: 20, glowRange: 12 })
|
||||
|
||||
// ── An invalid palette drops the FIELD, never the profile ───────────────────
|
||||
const partial = {
|
||||
...storedInput, id: 'custom-partial', name: 'Partial',
|
||||
palette: { bg: '#222436' }, ansi: 'not an object', effects: { blurSize: 'wide' }
|
||||
}
|
||||
const kept = model.validCustomProfiles([partial], shippedCatalog)
|
||||
assert.equal(kept.length, 1)
|
||||
assert.equal(kept[0].id, 'custom-partial')
|
||||
assert.equal(kept[0].palette, undefined)
|
||||
assert.equal(kept[0].ansi, undefined)
|
||||
assert.equal(kept[0].effects, undefined)
|
||||
assert.equal(kept[0].accent, '#86e1fc')
|
||||
|
||||
// ── The shipped list a caller passes owns the id and name space ─────────────
|
||||
const clash = model.createCustomProfile([], {
|
||||
name: 'Nord', scheme: 'dark', accent: '#88c0d0', secondary: '#81a1c1'
|
||||
}, shippedCatalog)
|
||||
assert.equal(clash.profile.name, 'Nord 2')
|
||||
assert.equal(clash.profile.id, 'custom-nord-2')
|
||||
const free = model.createCustomProfile([], {
|
||||
name: 'Nord', scheme: 'dark', accent: '#88c0d0', secondary: '#81a1c1'
|
||||
})
|
||||
assert.equal(free.profile.name, 'Nord')
|
||||
assert.equal(free.profile.id, 'custom-nord')
|
||||
// A stored custom colliding with a catalog name is dropped; the same record
|
||||
// against the three-entry fallback is kept, because there "Nord" is free.
|
||||
const impostor = {
|
||||
id: 'custom-nord', name: 'Nord', scheme: 'dark',
|
||||
accent: '#88c0d0', secondary: '#81a1c1', shipped: false
|
||||
}
|
||||
assert.equal(model.validCustomProfiles([impostor], shippedCatalog).length, 0)
|
||||
assert.equal(model.validCustomProfiles([impostor]).length, 1)
|
||||
|
||||
// ── createCustomProfile carries the optional fields through ─────────────────
|
||||
const rich = model.createCustomProfile([], {
|
||||
name: 'Rich', scheme: 'dark', accent: '#86e1fc', secondary: '#82aaff',
|
||||
palette: moonPalette, ansi: moonAnsi, effects: { animationsEnabled: false }
|
||||
}, shippedCatalog)
|
||||
assert.deepEqual(rich.profile.palette, model.normalizePalette(moonPalette))
|
||||
assert.deepEqual(rich.profile.ansi, model.normalizeAnsi(moonAnsi))
|
||||
assert.deepEqual(rich.profile.effects, { animationsEnabled: false })
|
||||
|
||||
// ── editProfile passes the fields through ───────────────────────────────────
|
||||
const recolored = model.resaturatePalette(moonPalette, 1.4)
|
||||
const editedCustom = model.editProfile(rich.profiles, rich.profile, {
|
||||
palette: recolored
|
||||
}, shippedCatalog)
|
||||
assert.equal(editedCustom.profile.id, rich.profile.id)
|
||||
assert.deepEqual(editedCustom.profile.palette, recolored)
|
||||
// Untouched fields are not collateral damage of a palette edit.
|
||||
assert.deepEqual(editedCustom.profile.ansi, model.normalizeAnsi(moonAnsi))
|
||||
assert.deepEqual(editedCustom.profile.effects, { animationsEnabled: false })
|
||||
assert.equal(editedCustom.profiles.length, 1)
|
||||
|
||||
// Forking a shipped theme carries its whole palette, so the fork looks
|
||||
// identical until the edit lands -- and leaves the original untouched.
|
||||
const nord = full.find(p => p.id === 'nord')
|
||||
const fork = model.editProfile([], nord, { accent: '#a3be8c', secondary: '#88c0d0' }, shippedCatalog)
|
||||
assert.equal(fork.profile.shipped, false)
|
||||
assert.equal(fork.profile.name, 'Nord custom')
|
||||
assert.equal(fork.profile.accent, '#a3be8c')
|
||||
assert.deepEqual(fork.profile.palette, model.normalizePalette(nord.palette))
|
||||
assert.deepEqual(fork.profile.ansi, model.normalizeAnsi(nord.ansi))
|
||||
assert.deepEqual(nord, full.find(p => p.id === 'nord'))
|
||||
|
||||
// ── nearestCuratedName is what keeps accentName in sync ─────────────────────
|
||||
// Nearest by hue, per scheme, because each curated name carries a different
|
||||
// pair on each side. This is the function that stops GNOME's accent enum,
|
||||
// kitty's border and the lock screen going stale after a custom edit.
|
||||
for (const [accent, expected] of [
|
||||
['#cba6f7', 'orchid'], // Catppuccin mauve
|
||||
['#fabd2f', 'amber'], // Gruvbox yellow
|
||||
['#808080', 'slate'], // a desaturated grey is slate, not a hue guess
|
||||
['#82aaff', 'blue'], // Moon
|
||||
['#a7c080', 'green'], // Everforest green
|
||||
['#88c0d0', 'teal'] // Nord frost
|
||||
])
|
||||
assert.equal(model.nearestCuratedName('dark', accent), expected, `dark ${accent}`)
|
||||
for (const [accent, expected] of [
|
||||
['#cba6f7', 'orchid'],
|
||||
['#fabd2f', 'amber'],
|
||||
['#808080', 'slate'],
|
||||
['#2e7de9', 'blue'], // Day
|
||||
['#a7c080', 'green'],
|
||||
['#88c0d0', 'teal']
|
||||
])
|
||||
assert.equal(model.nearestCuratedName('light', accent), expected, `light ${accent}`)
|
||||
assert.equal(model.nearestCuratedName('dark', 'not-a-colour'), 'blue')
|
||||
|
||||
// Every shipped theme resolves to a real curated name.
|
||||
for (const theme of full)
|
||||
assert.ok(model.curatedAccents()[model.nearestCuratedName(theme.scheme, theme.accent)],
|
||||
`${theme.id} has no curated accent`)
|
||||
|
||||
// ── Derivation produces palettes the validators accept ──────────────────────
|
||||
const derived = model.derivePalette(moonPalette, {
|
||||
scheme: 'dark', bg: '#101020', fg: '#e8e8f8', accent: '#86e1fc'
|
||||
})
|
||||
assert.ok(model.normalizePalette(derived))
|
||||
assert.equal(derived.bg, '#101020')
|
||||
assert.equal(derived.fg, '#e8e8f8')
|
||||
// The surfaces and text tints moved with the ground rather than staying behind.
|
||||
assert.notEqual(derived.bgDark, moonPalette.bgDark)
|
||||
assert.notEqual(derived.fgDim, moonPalette.fgDim)
|
||||
// Tokens the wells do not own are inherited untouched, so a small edit stays
|
||||
// a small edit.
|
||||
assert.equal(derived.green, moonPalette.green)
|
||||
assert.equal(model.derivePalette(null, { scheme: 'dark' }), null)
|
||||
|
||||
const saturated = model.resaturatePalette(moonPalette, 1.5)
|
||||
assert.ok(model.normalizePalette(saturated))
|
||||
// At zero the colour tokens go fully grey while the grounds keep most of their
|
||||
// tint -- backgrounds move at quarter strength so the ground stays a ground.
|
||||
const grey = model.resaturatePalette(moonPalette, 0)
|
||||
assert.ok(model.normalizePalette(grey))
|
||||
const channels = hex => [hex.slice(1, 3), hex.slice(3, 5), hex.slice(5, 7)]
|
||||
for (const key of ['green', 'red', 'magenta', 'accentAlt']) {
|
||||
const [r, g, b] = channels(grey[key])
|
||||
assert.ok(r === g && g === b, `${key} did not desaturate to grey: ${grey[key]}`)
|
||||
}
|
||||
const [bgR, bgG, bgB] = channels(grey.bg)
|
||||
assert.ok(!(bgR === bgG && bgG === bgB), 'the ground desaturated at full strength')
|
||||
// Neutral is a round trip through HSV, so it is near-identical rather than
|
||||
// byte-identical; what matters is that it stays a palette and stays in family.
|
||||
const neutral = model.resaturatePalette(moonPalette, 1)
|
||||
assert.ok(model.normalizePalette(neutral))
|
||||
for (const key of model.PALETTE_KEYS)
|
||||
for (let offset = 1; offset < 7; offset += 2)
|
||||
assert.ok(Math.abs(parseInt(neutral[key].slice(offset, offset + 2), 16)
|
||||
- parseInt(moonPalette[key].slice(offset, offset + 2), 16)) <= 3,
|
||||
`${key} drifted at neutral saturation: ${neutral[key]} vs ${moonPalette[key]}`)
|
||||
assert.equal(model.resaturatePalette({ bg: '#000000' }, 1.2), null)
|
||||
|
||||
const ansi = model.deriveAnsi(moonPalette, '#86e1fc')
|
||||
assert.ok(model.normalizeAnsi(ansi))
|
||||
assert.equal(ansi.blue, '#86e1fc')
|
||||
assert.equal(ansi.white, moonPalette.fgDim)
|
||||
assert.equal(ansi.brightWhite, moonPalette.fg)
|
||||
assert.equal(model.deriveAnsi({ bg: '#000000' }, '#86e1fc'), null)
|
||||
|
||||
assert.equal(model.mixHex('#000000', '#ffffff', 0.5), '#808080')
|
||||
assert.equal(model.mixHex('#000000', '#ffffff', 0), '#000000')
|
||||
assert.equal(model.mixHex('#000000', '#ffffff', 1), '#ffffff')
|
||||
|
||||
// ── The unchanged core still holds ──────────────────────────────────────────
|
||||
const first = model.createCustomProfile([], {
|
||||
name: ' Ocean ',
|
||||
scheme: 'dark',
|
||||
accent: '#86E1FC',
|
||||
secondary: '#82AAFF'
|
||||
name: ' Ocean ', scheme: 'dark', accent: '#86E1FC', secondary: '#82AAFF'
|
||||
})
|
||||
assert.deepEqual(first.profile, {
|
||||
id: 'custom-ocean',
|
||||
name: 'Ocean',
|
||||
scheme: 'dark',
|
||||
accent: '#86e1fc',
|
||||
secondary: '#82aaff',
|
||||
shipped: false
|
||||
id: 'custom-ocean', name: 'Ocean', scheme: 'dark',
|
||||
accent: '#86e1fc', secondary: '#82aaff', shipped: false
|
||||
})
|
||||
|
||||
const second = model.createCustomProfile(first.profiles, {
|
||||
name: 'ocean',
|
||||
scheme: 'light',
|
||||
accent: '#007197',
|
||||
secondary: '#2e7de9'
|
||||
name: 'ocean', scheme: 'light', accent: '#007197', secondary: '#2e7de9'
|
||||
})
|
||||
assert.equal(second.profile.name, 'ocean 2')
|
||||
assert.equal(second.profile.id, 'custom-ocean-2')
|
||||
|
||||
const bounded = model.createCustomProfile(second.profiles, {
|
||||
name: 'A theme name that is deliberately much longer than forty characters',
|
||||
scheme: 'dark',
|
||||
accent: '#c3e88d',
|
||||
secondary: '#86e1fc'
|
||||
scheme: 'dark', accent: '#c3e88d', secondary: '#86e1fc'
|
||||
})
|
||||
assert.equal(bounded.profile.name.length, 40)
|
||||
|
||||
const originalMoon = shipped[0]
|
||||
const edited = model.editProfile([], originalMoon, {
|
||||
accent: '#ffc777',
|
||||
secondary: '#ff966c'
|
||||
})
|
||||
assert.equal(edited.profile.shipped, false)
|
||||
assert.equal(edited.profile.name, 'Moon custom')
|
||||
assert.equal(edited.profiles.length, 1)
|
||||
assert.deepEqual(originalMoon, shipped[0])
|
||||
assert.equal(shipped[0].accent, '#82aaff')
|
||||
|
||||
const refusedDelete = model.deleteProfile(edited.profiles, 'moon')
|
||||
assert.equal(refusedDelete.removed, false)
|
||||
assert.deepEqual(refusedDelete.profiles, edited.profiles)
|
||||
assert.equal(model.deleteProfile(fork.profiles, 'nord', shippedCatalog).removed, false)
|
||||
assert.equal(model.deleteProfile(fork.profiles, fork.profile.id, shippedCatalog).removed, true)
|
||||
|
||||
assert.deepEqual(model.profileCatalog([
|
||||
edited.profile,
|
||||
first.profile,
|
||||
{ id: 'moon', name: 'Counterfeit', scheme: 'dark', accent: '#ffffff', secondary: '#ffffff', shipped: false },
|
||||
{ id: 'custom-bad', name: 'Bad', scheme: 'sepia', accent: '#ffffff', secondary: '#ffffff', shipped: false }
|
||||
]), [...shipped, edited.profile])
|
||||
], shippedCatalog), [...full, first.profile])
|
||||
|
||||
assert.equal(model.hsvToHex(0, 100, 100), '#ff0000')
|
||||
assert.equal(model.hsvToHex(120, 100, 100), '#00ff00')
|
||||
@@ -100,17 +258,17 @@ assert.deepEqual(model.hexToHsv('#82aaff'), { h: 221, s: 49, v: 100 })
|
||||
assert.equal(model.hexToHsv('not-a-colour'), null)
|
||||
assert.equal(model.curatedNameForProfile(shipped[0]), 'blue')
|
||||
assert.equal(model.curatedNameForProfile(shipped[1]), 'rose')
|
||||
assert.equal(model.matchingShippedProfile(
|
||||
'dark', '#ff757f', '#c099ff'
|
||||
).id, 'moon-rose')
|
||||
assert.equal(model.matchingShippedProfile(
|
||||
'light', '#2e7de9', '#9854f1'
|
||||
).id, 'day')
|
||||
assert.equal(model.matchingShippedProfile('dark', '#ff757f', '#c099ff', shippedCatalog).id, 'moon-rose')
|
||||
assert.equal(model.matchingShippedProfile('light', '#2e7de9', '#9854f1', shippedCatalog).id, 'day')
|
||||
assert.equal(model.curatedNameForProfile({
|
||||
id: 'custom-unmatched', name: 'Unmatched', scheme: 'dark',
|
||||
accent: '#123456', secondary: '#654321', shipped: false
|
||||
}), '')
|
||||
|
||||
assert.equal(model.PALETTE_KEYS.length, 19)
|
||||
assert.equal(model.ANSI_KEYS.length, 16)
|
||||
assert.equal(Object.keys(model.EFFECT_SPEC).length, 10)
|
||||
|
||||
console.log('theme profiles contract: PASS')
|
||||
JS
|
||||
|
||||
@@ -136,33 +294,59 @@ done
|
||||
qs_for_test ipc show 2>/dev/null | rg -q '^target theme-profiles-test$' \
|
||||
|| { printf 'theme profiles contract: test IPC target did not start\n' >&2; exit 1; }
|
||||
|
||||
status="$(qs_for_test ipc call theme-profiles-test status)"
|
||||
jq -e '.active.id == "moon" and (.profiles | length) == 3' <<<"$status" >/dev/null
|
||||
status() { qs_for_test ipc call theme-profiles-test status; }
|
||||
|
||||
# The catalog is live here, so the profile list is the ten shipped themes, not
|
||||
# the three-entry pre-load fallback.
|
||||
jq -e '.active.id == "moon" and (.profiles | length) == 10' <<<"$(status)" >/dev/null
|
||||
|
||||
# ── A scheme flip lands on the remembered theme for that side ───────────────
|
||||
# This is the whole point of themeDark/themeLight. Before them, flipping to
|
||||
# light forced Day and flipping back forced Moon, so choosing Everforest and
|
||||
# then turning on the light meant losing it.
|
||||
qs_for_test ipc call theme-profiles-test scheme light >/dev/null
|
||||
jq -e '.active.id == "day" and .active.scheme == "light"' <<<"$(status)" >/dev/null
|
||||
|
||||
qs_for_test ipc call theme-profiles-test select latte >/dev/null
|
||||
jq -e '.active.id == "latte" and .active.scheme == "light"' <<<"$(status)" >/dev/null
|
||||
|
||||
qs_for_test ipc call theme-profiles-test scheme dark >/dev/null
|
||||
jq -e '.active.id == "moon" and .active.scheme == "dark"' <<<"$(status)" >/dev/null
|
||||
|
||||
qs_for_test ipc call theme-profiles-test select nord >/dev/null
|
||||
jq -e '.active.id == "nord" and .active.scheme == "dark"' <<<"$(status)" >/dev/null
|
||||
|
||||
qs_for_test ipc call theme-profiles-test scheme light >/dev/null
|
||||
jq -e '.active.id == "day" and .active.scheme == "light"' \
|
||||
<<<"$(qs_for_test ipc call theme-profiles-test status)" >/dev/null
|
||||
jq -e '.active.id == "latte"' <<<"$(status)" >/dev/null \
|
||||
|| { printf 'theme profiles contract: light did not return to the theme chosen there\n' >&2; exit 1; }
|
||||
|
||||
qs_for_test ipc call theme-profiles-test select day >/dev/null
|
||||
jq -e '.active.id == "day" and .active.scheme == "light"' \
|
||||
<<<"$(qs_for_test ipc call theme-profiles-test status)" >/dev/null
|
||||
qs_for_test ipc call theme-profiles-test scheme dark >/dev/null
|
||||
jq -e '.active.id == "nord"' <<<"$(status)" >/dev/null \
|
||||
|| { printf 'theme profiles contract: dark did not return to the theme chosen there\n' >&2; exit 1; }
|
||||
|
||||
qs_for_test ipc call theme-profiles-test edit '#587539' '#007197' >/dev/null
|
||||
edited="$(qs_for_test ipc call theme-profiles-test status)"
|
||||
jq -e '.active.shipped == false and .active.accent == "#587539" and (.stored | length) == 1' \
|
||||
<<<"$edited" >/dev/null
|
||||
# ── Editing a shipped theme forks it, carrying its palette ──────────────────
|
||||
qs_for_test ipc call theme-profiles-test edit '#a3be8c' '#88c0d0' >/dev/null
|
||||
edited="$(status)"
|
||||
jq -e '.active.shipped == false and .active.accent == "#a3be8c"
|
||||
and .active.name == "Nord custom" and (.stored | length) == 1
|
||||
and (.active.palette | length) == 19' <<<"$edited" >/dev/null
|
||||
custom_id="$(jq -r '.active.id' <<<"$edited")"
|
||||
|
||||
qs_for_test ipc call theme-profiles-test save ' Forest ' >/dev/null
|
||||
saved="$(qs_for_test ipc call theme-profiles-test status)"
|
||||
jq -e '.active.name == "Forest" and (.stored | length) == 2' <<<"$saved" >/dev/null
|
||||
|
||||
[[ "$(qs_for_test ipc call theme-profiles-test remove moon)" == "false" ]]
|
||||
saved="$(status)"
|
||||
jq -e '.active.name == "Forest" and (.stored | length) == 2
|
||||
and (.active.effects | length) == 10' <<<"$saved" >/dev/null \
|
||||
|| { printf 'theme profiles contract: saving did not snapshot the effects\n' >&2; exit 1; }
|
||||
forest_id="$(jq -r '.active.id' <<<"$saved")"
|
||||
|
||||
# ── Shipped themes cannot be deleted; deleting the active custom falls back ─
|
||||
[[ "$(qs_for_test ipc call theme-profiles-test remove nord)" == "false" ]]
|
||||
[[ "$(qs_for_test ipc call theme-profiles-test remove "$forest_id")" == "true" ]]
|
||||
jq -e '.active.id == "day" and .active.shipped == true' \
|
||||
<<<"$(qs_for_test ipc call theme-profiles-test status)" >/dev/null
|
||||
# themeDark pointed at the deleted record, so resolution falls through to the
|
||||
# catalog default rather than leaving the desktop on a theme that is gone.
|
||||
jq -e '.active.id == "moon" and .active.shipped == true' <<<"$(status)" >/dev/null
|
||||
[[ "$(qs_for_test ipc call theme-profiles-test remove "$custom_id")" == "true" ]]
|
||||
jq -e '(.stored | length) == 0' <<<"$(status)" >/dev/null
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
|
||||
Executable
+217
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Video wallpapers.
|
||||
#
|
||||
# A looping video behind every window is the single most expensive thing a
|
||||
# desktop can draw, and none of the cost is visible: Hyprland rebuilds the
|
||||
# full-screen blur chain on every wallpaper frame while any blur-enabled layer
|
||||
# exists, and an occluded wallpaper keeps compositing unless solitary mode
|
||||
# holds -- which one toast breaks. So Panama owns the pause policy rather than
|
||||
# trusting the compositor with it, and this pins that policy in place.
|
||||
#
|
||||
# What must hold:
|
||||
#
|
||||
# 1. The video pauses for a game, on battery when asked to, and on demand
|
||||
# from the bar. Each reason is independent; the video plays only when
|
||||
# none of them holds.
|
||||
# 2. mpvpaper is told to decode on the GPU, stay muted, loop, and open an
|
||||
# IPC socket. A silent software-decode fallback would burn a core
|
||||
# forever with nothing on screen to explain it.
|
||||
# 3. hyprpaper's service is stopped while a video plays and started again
|
||||
# afterwards. Both claim the background layer and stacking within a layer
|
||||
# is creation order -- a race with no winner worth having.
|
||||
# 4. The picker cannot be flooded, and cannot be handed a file mpvpaper will
|
||||
# not play.
|
||||
# 5. The lock screen gets a still frame, because hyprlock cannot play motion.
|
||||
#
|
||||
# Static only: starting a real mpvpaper would take over the live desktop's
|
||||
# background.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
shell_dir="$repo_dir/config/dot/quickshell"
|
||||
service="$shell_dir/services/VideoWallpaper.qml"
|
||||
wallpaper="$shell_dir/services/Wallpaper.qml"
|
||||
helper="$shell_dir/scripts/panama-video-wallpaper"
|
||||
indicator="$shell_dir/modules/bar/WallpaperIndicator.qml"
|
||||
bar="$shell_dir/modules/bar/Bar.qml"
|
||||
picker="$shell_dir/modules/settings/WallpaperPicker.qml"
|
||||
appearance="$shell_dir/modules/settings/AppearancePage.qml"
|
||||
schema="$shell_dir/config/PreferenceSchema.qml"
|
||||
lock="$shell_dir/scripts/panama-lock"
|
||||
packages="$repo_dir/setup/packages/hyprland-packages"
|
||||
|
||||
fail() {
|
||||
printf 'video wallpaper contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for file in "$service" "$wallpaper" "$helper" "$indicator" "$bar" "$picker" \
|
||||
"$appearance" "$schema" "$lock" "$packages"; do
|
||||
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
|
||||
done
|
||||
[[ -x "$helper" ]] || fail 'panama-video-wallpaper is not executable'
|
||||
bash -n "$helper" || fail 'panama-video-wallpaper does not parse'
|
||||
|
||||
# ── 1. Three independent pause reasons ──────────────────────────────────────
|
||||
python3 - "$service" <<'PY' || fail 'the pause policy drifted'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
stripped = re.sub(r"//.*", "", text)
|
||||
|
||||
reasons = {
|
||||
"gamePaused": "FocusModes.gameRunning",
|
||||
"batteryPaused": 'DesktopPreferences.get("videoWallpaperPauseOnBattery")',
|
||||
}
|
||||
for prop, source in reasons.items():
|
||||
block = re.search(r'property bool ' + prop + r':(.*?)\n\s*(readonly )?property', stripped, re.S)
|
||||
if not block or source not in block.group(1):
|
||||
raise SystemExit(f"{prop} no longer follows {source}")
|
||||
|
||||
battery = re.search(r'property bool batteryPaused:(.*?)\n\s*(readonly )?property', stripped, re.S)
|
||||
for needle in ("Battery.available", "!Battery.acOnline"):
|
||||
if needle not in battery.group(1):
|
||||
raise SystemExit(f"the battery pause reason no longer checks {needle}")
|
||||
|
||||
if not re.search(r'property bool manuallyPaused: false', stripped):
|
||||
raise SystemExit("the bar pill's manual pause is gone")
|
||||
|
||||
combined = re.search(r'property bool paused:(.*?)\n', stripped)
|
||||
if not combined:
|
||||
raise SystemExit("there is no combined paused state")
|
||||
for reason in ("root.manuallyPaused", "root.gamePaused", "root.batteryPaused"):
|
||||
if reason not in combined.group(1):
|
||||
raise SystemExit(f"the combined paused state ignores {reason}")
|
||||
|
||||
# The pause reaches mpv over its JSON IPC socket rather than by killing and
|
||||
# respawning the player, which would restart the video from the first frame.
|
||||
if 'JSON.stringify({ command: ["set_property", "pause", root.paused] })' not in stripped:
|
||||
raise SystemExit("pausing no longer goes over mpv's JSON IPC")
|
||||
if 'onPausedChanged: pauseSync.restart()' not in stripped:
|
||||
raise SystemExit("a change of pause state does not push to the player")
|
||||
PY
|
||||
|
||||
# ── 2. What mpvpaper is actually told ───────────────────────────────────────
|
||||
for option in 'hwdec=vaapi' 'no-audio' 'loop-file=inf' 'input-ipc-server='; do
|
||||
rg -Fq "$option" "$service" || fail "the mpvpaper invocation is missing $option"
|
||||
done
|
||||
rg -Fq '"mpvpaper", "-f", "-o",' "$service" \
|
||||
|| fail 'the mpvpaper invocation is no longer a fixed argv'
|
||||
rg -Fq 'command -v mpvpaper' "$service" \
|
||||
|| fail 'the shell never checks whether mpvpaper is installed'
|
||||
rg -Fq 'mpvpaper' "$packages" \
|
||||
|| fail 'mpvpaper is not in the package list, so a fresh machine has no player'
|
||||
|
||||
# A dead player while a video is meant to be active is a crash -- mpvpaper has
|
||||
# a known hotplug segfault -- so the whole set respawns rather than being
|
||||
# reasoned about per output.
|
||||
rg -Fq 'playerRespawn.restart();' "$service" \
|
||||
|| fail 'a crashed player is not respawned'
|
||||
rg -Fq 'onOutputsChanged: if (root.active) playerRespawn.restart()' "$service" \
|
||||
|| fail 'a display hotplug does not respawn the players'
|
||||
|
||||
# ── 3. The hyprpaper handover ───────────────────────────────────────────────
|
||||
rg -Fq '["systemctl", "--user", "stop", "hyprpaper.service"]' "$service" \
|
||||
|| fail 'starting a video does not stop hyprpaper, so the two race for the layer'
|
||||
rg -Fq '["systemctl", "--user", "start", "hyprpaper.service"]' "$service" \
|
||||
|| fail 'stopping a video does not bring hyprpaper back'
|
||||
rg -Fq 'Wallpaper.refreshActive()' "$service" \
|
||||
|| fail 'the still wallpaper is not reapplied once hyprpaper returns'
|
||||
|
||||
# Wallpaper.qml routes a video away from the hyprpaper transaction, which would
|
||||
# only fail validation, and gives hyprpaper a beat to come back on the way out.
|
||||
for needle in 'VideoWallpaper.isVideo(path)' 'VideoWallpaper.isVideo(root.configured)' \
|
||||
'pendingStillPolicy' 'VideoWallpaper.start(' 'VideoWallpaper.stop()'; do
|
||||
rg -Fq "$needle" "$wallpaper" || fail "Wallpaper.qml lost its video routing: $needle"
|
||||
done
|
||||
|
||||
# ── 4. Discovery is bounded and typed ───────────────────────────────────────
|
||||
rg -Fq 'NR <= 60' "$helper" \
|
||||
|| fail 'the video scan is unbounded, so a dumping-ground folder floods the picker'
|
||||
rg -Fq -- '-maxdepth 2' "$helper" \
|
||||
|| fail 'the video scan is no longer bounded in depth'
|
||||
rg -Fq "\\( -iname '*.mp4' -o -iname '*.mkv' -o -iname '*.webm' \\)" "$helper" \
|
||||
|| fail 'the video scan accepts extensions mpvpaper may not play'
|
||||
rg -Fq '/\.(mp4|mkv|webm)$/i' "$service" \
|
||||
|| fail 'the shell and the helper disagree about what counts as a video'
|
||||
rg -Fq 'VideoWallpaper.isVideo(' "$picker" \
|
||||
|| fail 'the picker does not distinguish video tiles from image tiles'
|
||||
|
||||
# ── 5. The lock screen gets a still ─────────────────────────────────────────
|
||||
rg -Fq 'video-wallpaper-frame.png' "$service" \
|
||||
|| fail 'no still frame is cached for the lock screen'
|
||||
rg -Fq 'video-wallpaper-frame.png' "$lock" \
|
||||
|| fail 'the lock screen does not use the cached still frame'
|
||||
rg -Fq 'ffmpeg -hide_banner' "$helper" \
|
||||
|| fail 'the frame grab no longer runs ffmpeg quietly'
|
||||
rg -Fq '\.(mp4|mkv|webm)$' "$lock" \
|
||||
|| fail 'panama-lock does not recognise a video wallpaper'
|
||||
|
||||
# ── The bar pill ────────────────────────────────────────────────────────────
|
||||
rg -Fq 'visible: VideoWallpaper.active' "$indicator" \
|
||||
|| fail 'the bar pill is not conditional on a video actually playing'
|
||||
rg -Fq 'onActivated: VideoWallpaper.togglePause()' "$indicator" \
|
||||
|| fail 'clicking the bar pill does not pause the video'
|
||||
rg -Fq 'Accessible.name: VideoWallpaper.paused' "$indicator" \
|
||||
|| fail 'the bar pill has no spoken name, so it is a glyph and nothing else'
|
||||
rg -Fq 'Paused for game' "$indicator" \
|
||||
|| fail 'the pill does not say why the video paused itself'
|
||||
python3 - "$bar" <<'PY' || fail 'the wallpaper pill is not in the bar'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
if 'WallpaperIndicator {' not in text:
|
||||
raise SystemExit("Bar.qml never instantiates WallpaperIndicator")
|
||||
# It belongs in the right-hand status row, beside the other conditional pills.
|
||||
row = re.search(r'// ── Right ─+\n\s*Row \{(.*?)\n \}', text, re.S)
|
||||
if not row or 'WallpaperIndicator {' not in row.group(1):
|
||||
raise SystemExit("WallpaperIndicator is not in the bar's right-hand row")
|
||||
PY
|
||||
if rg -n 'NumberAnimation|ColorAnimation|SequentialAnimation|ParallelAnimation|loops:[[:space:]]*Animation\.Infinite' \
|
||||
"$indicator"; then
|
||||
fail 'the wallpaper pill animates continuously beside a video that already costs frames'
|
||||
fi
|
||||
|
||||
# ── Schema and settings surface ─────────────────────────────────────────────
|
||||
python3 - "$schema" <<'PY' || fail 'the video wallpaper schema entries are missing or malformed'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
expected = {
|
||||
"videoWallpaperDir": ("string", '"Videos/Wallpapers"', "wallpaper"),
|
||||
"videoWallpaperPauseOnBattery": ("bool", "true", "wallpaper"),
|
||||
}
|
||||
for key, (kind, default, group) in expected.items():
|
||||
block = re.search(r"\{\s*\n\s*key:\s*\"" + key + r"\".*?\n\s{8}\}", text, re.S)
|
||||
if not block:
|
||||
raise SystemExit(f"missing {key}")
|
||||
body = block.group(0)
|
||||
if not re.search(rf'type:\s*"{kind}"', body):
|
||||
raise SystemExit(f"{key} has wrong type")
|
||||
if not re.search(rf'def:\s*{re.escape(default)}', body):
|
||||
raise SystemExit(f"{key} has wrong default")
|
||||
if not re.search(rf'group:\s*"{group}"', body):
|
||||
raise SystemExit(f"{key} has wrong group")
|
||||
if 'internal: true' in body:
|
||||
raise SystemExit(f"{key} is marked internal, so nobody can find it")
|
||||
PY
|
||||
|
||||
for needle in 'title: "Video playback"' 'setting: "videoWallpaperDir"' \
|
||||
'setting: "videoWallpaperPauseOnBattery"' 'VideoWallpaper.rescan()'; do
|
||||
rg -Fq "$needle" "$appearance" || fail "Appearance is missing $needle"
|
||||
done
|
||||
# Honest about what it cannot do, rather than silently showing a black lock
|
||||
# screen when the wallpaper is a video.
|
||||
rg -Fq 'Still frame' "$appearance" \
|
||||
|| fail 'the settings page does not say the lock screen uses a still frame'
|
||||
|
||||
# ── The doctor knows about it ───────────────────────────────────────────────
|
||||
rg -Fq 'input.video-wallpaper' "$shell_dir/scripts/panama-doctor" \
|
||||
|| fail 'panama-doctor has no video wallpaper check'
|
||||
|
||||
printf 'video wallpaper contract: PASS\n'
|
||||
@@ -27,6 +27,13 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Video routing: a video path must branch to VideoWallpaper before hyprpaper
|
||||
# validation ever sees it, and policies built while one is stored must fall
|
||||
# back to the shipped still rather than hand hyprpaper a video.
|
||||
for needle in 'VideoWallpaper.isVideo(path)' 'VideoWallpaper.isVideo(root.configured)' 'pendingStillPolicy'; do
|
||||
grep -qF "$needle" "$service" || fail "Wallpaper.qml lost its video routing: $needle"
|
||||
done
|
||||
|
||||
for needle in 'property var activeByOutput' 'function applyPolicy' 'function setSingle'; do
|
||||
rg -Fq "$needle" "$service" || fail "Wallpaper service is missing $needle"
|
||||
done
|
||||
|
||||
Reference in New Issue
Block a user