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