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