Give the desktop real themes, video wallpapers, and honest titlebars

Appearance now opens on Themes: light and dark side by side, each
remembering its own choice, over galleries of ten shipped themes —
Tokyo Moon and Day joined by Moon Rose, Catppuccin, Nord, Gruvbox and
Everforest in both modes. A theme is a complete palette: the catalog
lives in themes.json, Theme.qml reads every color token from the
active record, and one render pipeline carries it to kitty, tmux,
btop, GTK, Vicinae, Firefox's chrome, and the lock screen. The Theme
editor builds new ones from four wells — wheel, hex, or eyedropper —
with derived surfaces, a saturation slider, debounced fine-tune, and
effects that save with the theme. Custom edits finally keep GNOME's
accent, kitty's border, and hyprlock in sync.

Wallpapers can be video: mpvpaper per output, hardware-decoded, muted
and looped, supervised and respawned. Panama owns the pausing — games,
battery, and a bar pill for right now — because the compositor
rebuilds full-screen blur for every frame a video wallpaper draws.
The lock screen gets a still frame.

Titlebars stop lying. GNOME apps get close-only on your chosen side,
the maximize and double-click settings are gone, the Settings window
obeys the same rules, and its titlebar can be turned off entirely.
Typography becomes five labeled dropdowns instead of a wall of
samples.

Contracts updated and written throughout (165 now); per the redesign
workflow none were executed — the full sweep runs once at the end.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-23 23:39:04 -04:00
parent 7578348db1
commit cb7c09d208
68 changed files with 6115 additions and 1138 deletions
+60 -33
View File
@@ -1,36 +1,63 @@
@define-color accent_color rgba(192,153,255,0.8);
@define-color accent_bg_color rgba(130,170,255,0.8);
@define-color accent_fg_color #ffffff;
@define-color destructive_color #ff7b63;
@define-color destructive_bg_color #c01c28;
@define-color destructive_fg_color #ffffff;
@define-color success_color #8ff0a4;
@define-color success_bg_color #26a269;
@define-color success_fg_color #ffffff;
@define-color warning_color #f8e45c;
@define-color warning_bg_color #cd9309;
@define-color warning_fg_color rgba(0, 0, 0, 0.8);
@define-color error_color #ff7b63;
@define-color error_bg_color #c01c28;
@define-color error_fg_color #ffffff;
@define-color window_bg_color rgba(46,47,61,0.98);
@define-color window_fg_color #ffffff;
@define-color view_bg_color rgba(46,47,61,0.98);
@define-color view_fg_color #ffffff;
@define-color headerbar_bg_color rgba(46,47,61,0.98);
@define-color headerbar_fg_color #ffffff;
@define-color headerbar_border_color #ffffff;
@define-color headerbar_backdrop_color @window_bg_color;
@define-color headerbar_shade_color rgba(46,47,61,0.05);
@define-color card_bg_color rgba(180, 200, 255, 0.15);
@define-color card_fg_color #ffffff;
@define-color card_shade_color rgba(0, 0, 0, 0.36);
@define-color dialog_bg_color rgba(46,47,61,0.98);
@define-color dialog_fg_color #ffffff;
@define-color popover_bg_color rgba(46,47,61,0.98);
@define-color popover_fg_color #ffffff;
@define-color shade_color rgba(0, 0, 0, 0);
@define-color scrollbar_outline_color rgba(0, 0, 0, 0.5);
/* Panama regenerates the block below from the active theme's palette, and only
* the block: every rule outside the markers belongs to the vendored theme and
* is never touched. Edit the theme, not this file -- the next theme change
* overwrites whatever is between the markers. The values shipped here are Tokyo
* Moon, the default dark theme, so the file is valid before it has ever been
* rewritten. See quickshell/scripts/panama-theme-apps. */
/* PANAMA THEME BEGIN */
/* Generated from the "moon" theme by panama-theme-apps. Edit the
* theme, not this block -- the next theme change overwrites it. */
@define-color accent_bg_color #82aaff;
@define-color accent_color #65bcff;
@define-color accent_fg_color #1e2030;
@define-color destructive_bg_color #c53b53;
@define-color destructive_color #ff757f;
@define-color destructive_fg_color #c8d3f5;
@define-color success_bg_color #c3e88d;
@define-color success_color #c3e88d;
@define-color success_fg_color #1e2030;
@define-color warning_bg_color #ffc777;
@define-color warning_color #ffc777;
@define-color warning_fg_color #1e2030;
@define-color error_bg_color #c53b53;
@define-color error_color #ff757f;
@define-color error_fg_color #c8d3f5;
@define-color window_bg_color #2e2f3d;
@define-color window_fg_color #c8d3f5;
@define-color view_bg_color #222436;
@define-color view_fg_color #c8d3f5;
@define-color headerbar_bg_color #2e2f3d;
@define-color headerbar_fg_color #c8d3f5;
@define-color headerbar_border_color #3b4261;
@define-color headerbar_backdrop_color #1e2030;
@define-color headerbar_shade_color rgba(30, 32, 48, 0.08);
@define-color headerbar_darker_shade_color rgba(30, 32, 48, 0.9);
@define-color sidebar_bg_color #1e2030;
@define-color sidebar_fg_color #c8d3f5;
@define-color sidebar_backdrop_color #1e2030;
@define-color sidebar_shade_color rgba(59, 66, 97, 0.08);
@define-color sidebar_border_color rgba(59, 66, 97, 0.36);
@define-color secondary_sidebar_bg_color #1e2030;
@define-color secondary_sidebar_fg_color #c8d3f5;
@define-color secondary_sidebar_backdrop_color #1e2030;
@define-color secondary_sidebar_shade_color rgba(30, 32, 48, 0.25);
@define-color secondary_sidebar_border_color rgba(59, 66, 97, 0.36);
@define-color card_bg_color #2f334d;
@define-color card_fg_color #c8d3f5;
@define-color card_shade_color rgba(30, 32, 48, 0.25);
@define-color dialog_bg_color #21212f;
@define-color dialog_fg_color #c8d3f5;
@define-color popover_bg_color #21212f;
@define-color popover_fg_color #c8d3f5;
@define-color popover_shade_color rgba(30, 32, 48, 0.25);
@define-color thumbnail_bg_color #2f334d;
@define-color thumbnail_fg_color #c8d3f5;
@define-color shade_color rgba(30, 32, 48, 0.25);
@define-color scrollbar_outline_color rgba(30, 32, 48, 0.5);
/* PANAMA THEME END */
/* The GNOME named palette below is reference, not theme: applications ask for
* @red_3 by name and expect GNOME's red. It stays outside the block. */
@define-color blue_1 #99c1f1;
@define-color blue_2 #62a0ea;
@define-color blue_3 #3584e4;
Executable → Regular
+58 -47
View File
@@ -1,53 +1,64 @@
/* GTK NAMED COLORS ---------------- use responsibly! */
@define-color accent_bg_color RGB(130 170 255 / 80%);
@define-color destructive_bg_color #c01c28;
@define-color destructive_fg_color white;
@define-color success_bg_color #26a269;
@define-color success_fg_color white;
@define-color warning_bg_color #cd9309;
@define-color warning_fg_color RGB(0 0 0 / 80%);
@define-color error_bg_color #c01c28;
@define-color error_fg_color white;
@define-color accent_color oklab(from @accent_bg_color max(l, 0.85) a b);
@define-color destructive_color oklab(from @destructive_bg_color max(l, 0.85) a b);
@define-color success_color oklab(from @success_bg_color max(l, 0.85) a b);
@define-color warning_color oklab(from @warning_bg_color max(l, 0.85) a b);
@define-color error_color oklab(from @error_bg_color max(l, 0.85) a b);
/* Panama regenerates the block below from the active theme's palette, and only
* the block: every rule outside the markers belongs to the vendored theme and
* is never touched. Edit the theme, not this file -- the next theme change
* overwrites whatever is between the markers. The values shipped here are Tokyo
* Moon, the default dark theme, so the file is valid before it has ever been
* rewritten. See quickshell/scripts/panama-theme-apps. */
/* PANAMA THEME BEGIN */
/* Generated from the "moon" theme by panama-theme-apps. Edit the
* theme, not this block -- the next theme change overwrites it. */
@define-color accent_bg_color #82aaff;
@define-color accent_color #65bcff;
@define-color accent_fg_color #1e2030;
@define-color destructive_bg_color #c53b53;
@define-color destructive_color #ff757f;
@define-color destructive_fg_color #c8d3f5;
@define-color success_bg_color #c3e88d;
@define-color success_color #c3e88d;
@define-color success_fg_color #1e2030;
@define-color warning_bg_color #ffc777;
@define-color warning_color #ffc777;
@define-color warning_fg_color #1e2030;
@define-color error_bg_color #c53b53;
@define-color error_color #ff757f;
@define-color error_fg_color #c8d3f5;
@define-color window_bg_color #2e2f3d;
@define-color window_fg_color white;
@define-color view_bg_color @window_bg_color
@define-color view_fg_color white;
@define-color headerbar_bg_color @window_bg_color
@define-color headerbar_fg_color white;
@define-color headerbar_border_color white;
@define-color headerbar_backdrop_color @window_bg_color;
@define-color headerbar_shade_color RGB(46 47 61 / 8%);
@define-color headerbar_darker_shade_color RGB(0, 0, 6, 0.9);
@define-color window_fg_color #c8d3f5;
@define-color view_bg_color #222436;
@define-color view_fg_color #c8d3f5;
@define-color headerbar_bg_color #2e2f3d;
@define-color headerbar_fg_color #c8d3f5;
@define-color headerbar_border_color #3b4261;
@define-color headerbar_backdrop_color #1e2030;
@define-color headerbar_shade_color rgba(30, 32, 48, 0.08);
@define-color headerbar_darker_shade_color rgba(30, 32, 48, 0.9);
@define-color sidebar_bg_color #1e2030;
@define-color sidebar_fg_color #c8d3f5;
@define-color sidebar_backdrop_color #1e2030;
@define-color sidebar_shade_color rgba(59, 66, 97, 0.08);
@define-color sidebar_border_color rgba(59, 66, 97, 0.36);
@define-color secondary_sidebar_bg_color #1e2030;
@define-color secondary_sidebar_fg_color #c8d3f5;
@define-color secondary_sidebar_backdrop_color #1e2030;
@define-color secondary_sidebar_shade_color rgba(30, 32, 48, 0.25);
@define-color secondary_sidebar_border_color rgba(59, 66, 97, 0.36);
@define-color card_bg_color #2f334d;
@define-color card_fg_color #c8d3f5;
@define-color card_shade_color rgba(30, 32, 48, 0.25);
@define-color dialog_bg_color #21212f;
@define-color dialog_fg_color #c8d3f5;
@define-color popover_bg_color #21212f;
@define-color popover_fg_color #c8d3f5;
@define-color popover_shade_color rgba(30, 32, 48, 0.25);
@define-color thumbnail_bg_color #2f334d;
@define-color thumbnail_fg_color #c8d3f5;
@define-color shade_color rgba(30, 32, 48, 0.25);
@define-color scrollbar_outline_color rgba(30, 32, 48, 0.5);
/* PANAMA THEME END */
@define-color sidebar_bg_color #3c3c4b;
@define-color sidebar_fg_color white;
@define-color sidebar_backdrop_color #3c3c4b;
@define-color sidebar_shade_color RGB(60 60 75/8%);
@define-color sidebar_border_color RGB(75 75 90/36%);
@define-color secondary_sidebar_bg_color #3c3c4b;
@define-color secondary_sidebar_fg_color white;
@define-color secondary_sidebar_backdrop_color #3c3c4b;
@define-color secondary_sidebar_shade_color RGB(0 0 6/25%);
@define-color secondary_sidebar_border_color RGB(0 0 6/36%);
@define-color card_bg_color RGB(180 200 255/15%);
@define-color card_fg_color white;
@define-color card_shade_color RGB(46 47 61/8%);
@define-color dialog_bg_color @window_bg_color;
@define-color dialog_fg_color white;
@define-color popover_bg_color @window_bg_color;
@define-color popover_fg_color white;
@define-color popover_shade_color RGB(0 0 6/25%);
@define-color thumbnail_bg_color @window_bg_color;
@define-color thumbnail_fg_color white;
@define-color shade_color RGB(0 0 6/25%);
@define-color scrollbar_outline_color RGB(0 0 12/95%);
/* The GNOME named palette below is reference, not theme: applications ask for
* @red_3 by name and expect GNOME's red. It stays outside the block. */
@define-color blue_1 #99c1f1;
@define-color blue_2 #62a0ea;
@define-color blue_3 #3584e4;
Executable → Regular
+58 -47
View File
@@ -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;
+6 -3
View File
@@ -1,8 +1,11 @@
# Tokyo Night Moon — the dark theme Panama ships.
#
# Extracted from kitty.conf so the two schemes can be swapped. kitty.conf
# includes current-theme.conf, which Panama generates from the color scheme
# setting; that generated file is gitignored because it is machine state.
# Reference, not the source of the running colours. kitty.conf includes
# current-theme.conf, which panama-theme-apps RENDERS from the active theme's
# palette -- so any of the ten themes reaches the terminal, not only the two
# Tokyo Nights. This file used to be copied into that one; it is kept because
# it is a complete kitty theme somebody may want to include directly, and
# because it is what Moon is supposed to look like.
#
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
# remote control. Without it a scheme change would only reach terminals opened
@@ -1,40 +1,79 @@
// Headless theme editor, for accent-controls-contract.
//
// The two halves of the editor that write colour: the four wells
// (ThemeEditorWells + ColorWell), which are the fast path and own hex
// validation and the eyedropper, and the six HSV rows (AccentEditor), which
// are the fine-tune behind a disclosure.
//
// AccentEditor commits on a debounce rather than per move, so `adjust` returns
// what is *pending*, not what is stored -- the caller waits and reads `status`
// again. That is the behaviour under test as much as the colour itself: a
// slider that wrote on every move spent a whole drag in apply-and-verify round
// trips and left the desktop repainting behind the pointer.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import qs.modules.settings
import qs.services
ShellRoot {
Item {
width: 680
height: editor.implicitHeight
height: wells.implicitHeight + editor.implicitHeight
ThemeEditorWells {
id: wells
width: parent.width
}
AccentEditor {
id: editor
y: wells.implicitHeight
width: parent.width
}
ThemeProfilePicker {
width: parent.width
visible: false
}
AccentPicker {
width: parent.width
visible: false
}
}
IpcHandler {
target: "accent-controls-test"
function status(): string {
return JSON.stringify(ThemeProfiles.activeProfile);
return JSON.stringify({
id: ThemeProfiles.activeProfile.id,
name: ThemeProfiles.activeProfile.name,
scheme: ThemeProfiles.activeProfile.scheme,
accent: String(ThemeProfiles.activeProfile.accent),
secondary: String(ThemeProfiles.activeProfile.secondary),
shipped: ThemeProfiles.activeProfile.shipped === true,
accentName: String(DesktopPreferences.get("accentName")),
bg: String(ThemeProfiles.activePalette.bg),
fg: String(ThemeProfiles.activePalette.fg),
fgDim: String(ThemeProfiles.activePalette.fgDim),
pending: editor.pending !== null,
wellError: wells.lastError
});
}
// Moves one HSV slider. The commit is debounced, so the answer is the
// pair the sliders are showing, not the stored one.
function adjust(target: string, channel: string, ratio: real): string {
editor.changeChannel(target, channel, ratio);
return JSON.stringify({
shown: { accent: editor.shownAccent, secondary: editor.shownSecondary },
pending: editor.pending !== null,
stored: {
accent: String(ThemeProfiles.activeProfile.accent),
secondary: String(ThemeProfiles.activeProfile.secondary)
}
});
}
// The one path every well takes -- typed hex, colour wheel and
// eyedropper all end here, so validation cannot differ between them.
function well(which: string, hex: string): string {
wells.apply(which, hex);
return status();
}
}
@@ -971,6 +971,20 @@ Singleton {
key: "wallpaperPerMonitor", type: "json", def: ({}), group: "wallpaper", internal: true,
label: "Per-display backgrounds", detail: "Background assigned to each connected display"
},
// Video wallpapers play through mpvpaper (services/VideoWallpaper.qml);
// the active video rides wallpaperPath like any still, routed by
// extension. These two only shape discovery and the battery policy.
{
key: "videoWallpaperDir", type: "string", def: "Videos/Wallpapers", group: "wallpaper",
pattern: "^~?/?[A-Za-z0-9 ._/+@'-]{1,160}$",
label: "Video wallpaper folder",
detail: "Where the picker looks for videos. Relative to your home folder unless it starts with /"
},
{
key: "videoWallpaperPauseOnBattery", type: "bool", def: true, group: "wallpaper",
label: "Pause video wallpaper on battery",
detail: "Freezes on the current frame and resumes on wall power"
},
// ── Lock-screen appearance ─────────────────────────────────────────
// scripts/panama-lock validates these again before generating a state
@@ -1129,6 +1143,22 @@ Singleton {
detail: "Named custom colour schemes and accent pairs",
internal: true
},
// The remembered theme per scheme: flipping light/dark lands on the
// theme you last chose for that side, never a forced default.
{
key: "themeDark", type: "string", def: "moon", group: "appearance",
pattern: "^[a-z0-9][a-z0-9-]{0,63}$",
label: "Dark theme",
detail: "The theme applied while the desktop is dark",
internal: true
},
{
key: "themeLight", type: "string", def: "day", group: "appearance",
pattern: "^[a-z0-9][a-z0-9-]{0,63}$",
label: "Light theme",
detail: "The theme applied while the desktop is light",
internal: true
},
// ── Application themes ─────────────────────────────────────────────
// ColorScheme owns GTK's light/dark theme. These are the two theme
@@ -1248,20 +1278,18 @@ Singleton {
{ value: "right", label: "Right" }
]
},
// Panama's own windows (Settings) draw their own titlebar and follow
// the same rules as GNOME apps. Off is pure Hyprland: Super+Q closes,
// Super+drag moves, Escape still works.
{
key: "titlebarMaximizeButton", type: "bool", def: false, group: "titlebar",
label: "Maximize button",
detail: "Show a maximize button in application titlebars that support it"
},
{
key: "titlebarDoubleClick", type: "enum", def: "toggle-maximize", group: "titlebar",
label: "Double-click titlebar",
detail: "Choose what a double-click on an application titlebar does",
options: [
{ value: "toggle-maximize", label: "Toggle maximize" },
{ value: "none", label: "Do nothing" }
]
key: "panamaTitlebar", type: "bool", def: true, group: "titlebar",
label: "Titlebar on Panama windows",
detail: "Hide it and the window is pure Hyprland — Super+Q closes, Super+drag moves"
},
// titlebarMaximizeButton and titlebarDoubleClick used to live here.
// Removed on purpose: Hyprland has no minimize, maximize is noise in a
// tiler, and DesktopStyle now pushes a close-only button-layout and
// leaves GNOME's double-click default alone.
// ── Accessibility ───────────────────────────────────────────────────
// Backed by gsettings so GTK applications agree with the shell, and
+29 -26
View File
@@ -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
+217
View File
@@ -0,0 +1,217 @@
{
"comment": "The shipped theme catalog — the one source of truth for full palettes. services/ThemeCatalog.qml reads it with FileView, scripts read it with jq, contracts read it with fs. The 19 palette keys are exactly Theme.qml's color tokens; ansi's 16 feed generated terminal themes. moon and day must stay byte-identical to the shell's pre-theme literals: theme-catalog-contract pins that.",
"defaultDark": "moon",
"defaultLight": "day",
"themes": [
{
"id": "moon", "name": "Tokyo Moon", "scheme": "dark",
"accent": "#82aaff", "secondary": "#b172b0",
"palette": {
"bg": "#222436", "bgDark": "#1e2030", "bgHighlight": "#2f334d",
"bgPanel": "#2e2f3d", "bgPopover": "#21212f",
"fg": "#c8d3f5", "fgDim": "#828bb8", "fgMuted": "#636da6",
"gutter": "#3b4261", "accentAlt": "#65bcff",
"cyan": "#86e1fc", "teal": "#4fd6be", "green": "#c3e88d",
"yellow": "#ffc777", "orange": "#ff966c", "red": "#ff757f",
"redDeep": "#c53b53", "magenta": "#c099ff", "pink": "#fca7ea"
},
"ansi": {
"black": "#1b1d2b", "red": "#ff757f", "green": "#c3e88d",
"yellow": "#ffc777", "blue": "#82aaff", "magenta": "#c099ff",
"cyan": "#86e1fc", "white": "#828bb8",
"brightBlack": "#444a73", "brightRed": "#ff8d94", "brightGreen": "#c7fb6d",
"brightYellow": "#ffd8ab", "brightBlue": "#9ab8ff", "brightMagenta": "#caabff",
"brightCyan": "#b2ebff", "brightWhite": "#c8d3f5"
}
},
{
"id": "moon-rose", "name": "Moon Rose", "scheme": "dark",
"accent": "#ff757f", "secondary": "#c099ff",
"palette": {
"bg": "#222436", "bgDark": "#1e2030", "bgHighlight": "#2f334d",
"bgPanel": "#2e2f3d", "bgPopover": "#21212f",
"fg": "#c8d3f5", "fgDim": "#828bb8", "fgMuted": "#636da6",
"gutter": "#3b4261", "accentAlt": "#fca7ea",
"cyan": "#86e1fc", "teal": "#4fd6be", "green": "#c3e88d",
"yellow": "#ffc777", "orange": "#ff966c", "red": "#ff757f",
"redDeep": "#c53b53", "magenta": "#c099ff", "pink": "#fca7ea"
},
"ansi": {
"black": "#1b1d2b", "red": "#ff757f", "green": "#c3e88d",
"yellow": "#ffc777", "blue": "#82aaff", "magenta": "#c099ff",
"cyan": "#86e1fc", "white": "#828bb8",
"brightBlack": "#444a73", "brightRed": "#ff8d94", "brightGreen": "#c7fb6d",
"brightYellow": "#ffd8ab", "brightBlue": "#9ab8ff", "brightMagenta": "#caabff",
"brightCyan": "#b2ebff", "brightWhite": "#c8d3f5"
}
},
{
"id": "mocha", "name": "Catppuccin Mocha", "scheme": "dark",
"accent": "#cba6f7", "secondary": "#f5c2e7",
"palette": {
"bg": "#1e1e2e", "bgDark": "#181825", "bgHighlight": "#313244",
"bgPanel": "#27273a", "bgPopover": "#1b1b2a",
"fg": "#cdd6f4", "fgDim": "#9399b2", "fgMuted": "#7f849c",
"gutter": "#45475a", "accentAlt": "#89dceb",
"cyan": "#89dceb", "teal": "#94e2d5", "green": "#a6e3a1",
"yellow": "#f9e2af", "orange": "#fab387", "red": "#f38ba8",
"redDeep": "#b04a5a", "magenta": "#cba6f7", "pink": "#f5c2e7"
},
"ansi": {
"black": "#45475a", "red": "#f38ba8", "green": "#a6e3a1",
"yellow": "#f9e2af", "blue": "#89b4fa", "magenta": "#f5c2e7",
"cyan": "#94e2d5", "white": "#bac2de",
"brightBlack": "#585b70", "brightRed": "#f38ba8", "brightGreen": "#a6e3a1",
"brightYellow": "#f9e2af", "brightBlue": "#89b4fa", "brightMagenta": "#f5c2e7",
"brightCyan": "#94e2d5", "brightWhite": "#a6adc8"
}
},
{
"id": "nord", "name": "Nord", "scheme": "dark",
"accent": "#88c0d0", "secondary": "#81a1c1",
"palette": {
"bg": "#2e3440", "bgDark": "#272c36", "bgHighlight": "#3b4252",
"bgPanel": "#353c4a", "bgPopover": "#2a303c",
"fg": "#d8dee9", "fgDim": "#9aa4b8", "fgMuted": "#7b8598",
"gutter": "#4c566a", "accentAlt": "#8fbcbb",
"cyan": "#88c0d0", "teal": "#8fbcbb", "green": "#a3be8c",
"yellow": "#ebcb8b", "orange": "#d08770", "red": "#bf616a",
"redDeep": "#a54e57", "magenta": "#b48ead", "pink": "#c9a3c0"
},
"ansi": {
"black": "#3b4252", "red": "#bf616a", "green": "#a3be8c",
"yellow": "#ebcb8b", "blue": "#81a1c1", "magenta": "#b48ead",
"cyan": "#88c0d0", "white": "#e5e9f0",
"brightBlack": "#4c566a", "brightRed": "#bf616a", "brightGreen": "#a3be8c",
"brightYellow": "#ebcb8b", "brightBlue": "#81a1c1", "brightMagenta": "#b48ead",
"brightCyan": "#8fbcbb", "brightWhite": "#eceff4"
}
},
{
"id": "gruvbox", "name": "Gruvbox", "scheme": "dark",
"accent": "#fabd2f", "secondary": "#fe8019",
"palette": {
"bg": "#282828", "bgDark": "#1d2021", "bgHighlight": "#3c3836",
"bgPanel": "#32302f", "bgPopover": "#211f1e",
"fg": "#ebdbb2", "fgDim": "#a89984", "fgMuted": "#928374",
"gutter": "#504945", "accentAlt": "#83a598",
"cyan": "#83a598", "teal": "#8ec07c", "green": "#b8bb26",
"yellow": "#fabd2f", "orange": "#fe8019", "red": "#fb4934",
"redDeep": "#cc241d", "magenta": "#d3869b", "pink": "#dfa7be"
},
"ansi": {
"black": "#282828", "red": "#cc241d", "green": "#98971a",
"yellow": "#d79921", "blue": "#458588", "magenta": "#b16286",
"cyan": "#689d6a", "white": "#a89984",
"brightBlack": "#928374", "brightRed": "#fb4934", "brightGreen": "#b8bb26",
"brightYellow": "#fabd2f", "brightBlue": "#83a598", "brightMagenta": "#d3869b",
"brightCyan": "#8ec07c", "brightWhite": "#ebdbb2"
}
},
{
"id": "everforest", "name": "Everforest", "scheme": "dark",
"accent": "#a7c080", "secondary": "#e69875",
"palette": {
"bg": "#2d353b", "bgDark": "#272e33", "bgHighlight": "#3d484d",
"bgPanel": "#343f44", "bgPopover": "#293136",
"fg": "#d3c6aa", "fgDim": "#9da9a0", "fgMuted": "#7a8478",
"gutter": "#475258", "accentAlt": "#7fbbb3",
"cyan": "#7fbbb3", "teal": "#83c092", "green": "#a7c080",
"yellow": "#dbbc7f", "orange": "#e69875", "red": "#e67e80",
"redDeep": "#c05a5e", "magenta": "#d699b6", "pink": "#dfa7c1"
},
"ansi": {
"black": "#3d484d", "red": "#e67e80", "green": "#a7c080",
"yellow": "#dbbc7f", "blue": "#7fbbb3", "magenta": "#d699b6",
"cyan": "#83c092", "white": "#d3c6aa",
"brightBlack": "#475258", "brightRed": "#e67e80", "brightGreen": "#a7c080",
"brightYellow": "#dbbc7f", "brightBlue": "#7fbbb3", "brightMagenta": "#d699b6",
"brightCyan": "#83c092", "brightWhite": "#d3c6aa"
}
},
{
"id": "day", "name": "Tokyo Day", "scheme": "light",
"accent": "#2e7de9", "secondary": "#9854f1",
"palette": {
"bg": "#e1e2e7", "bgDark": "#d3d5de", "bgHighlight": "#c4c8da",
"bgPanel": "#d9dae3", "bgPopover": "#eaeaee",
"fg": "#3760bf", "fgDim": "#6172b0", "fgMuted": "#848cb5",
"gutter": "#a8aecb", "accentAlt": "#007197",
"cyan": "#007197", "teal": "#118c74", "green": "#587539",
"yellow": "#8c6c3e", "orange": "#b15c00", "red": "#f52a65",
"redDeep": "#c64343", "magenta": "#9854f1", "pink": "#d20065"
},
"ansi": {
"black": "#b4b5b9", "red": "#f52a65", "green": "#587539",
"yellow": "#8c6c3e", "blue": "#2e7de9", "magenta": "#9854f1",
"cyan": "#007197", "white": "#6172b0",
"brightBlack": "#a1a6c5", "brightRed": "#ff4774", "brightGreen": "#5c8524",
"brightYellow": "#a27629", "brightBlue": "#358aff", "brightMagenta": "#a463ff",
"brightCyan": "#007ea8", "brightWhite": "#3760bf"
}
},
{
"id": "latte", "name": "Catppuccin Latte", "scheme": "light",
"accent": "#8839ef", "secondary": "#ea76cb",
"palette": {
"bg": "#eff1f5", "bgDark": "#e6e9ef", "bgHighlight": "#ccd0da",
"bgPanel": "#e2e6ee", "bgPopover": "#f5f7fa",
"fg": "#4c4f69", "fgDim": "#6c6f85", "fgMuted": "#8c8fa1",
"gutter": "#acb0be", "accentAlt": "#209fb5",
"cyan": "#04a5e5", "teal": "#179299", "green": "#40a02b",
"yellow": "#df8e1d", "orange": "#fe640b", "red": "#d20f39",
"redDeep": "#e64553", "magenta": "#8839ef", "pink": "#ea76cb"
},
"ansi": {
"black": "#5c5f77", "red": "#d20f39", "green": "#40a02b",
"yellow": "#df8e1d", "blue": "#1e66f5", "magenta": "#ea76cb",
"cyan": "#179299", "white": "#acb0be",
"brightBlack": "#6c6f85", "brightRed": "#d20f39", "brightGreen": "#40a02b",
"brightYellow": "#df8e1d", "brightBlue": "#1e66f5", "brightMagenta": "#ea76cb",
"brightCyan": "#179299", "brightWhite": "#bcc0cc"
}
},
{
"id": "gruvbox-light", "name": "Gruvbox Light", "scheme": "light",
"accent": "#b57614", "secondary": "#af3a03",
"palette": {
"bg": "#fbf1c7", "bgDark": "#f2e5bc", "bgHighlight": "#ebdbb2",
"bgPanel": "#f0e6c0", "bgPopover": "#fdf6d8",
"fg": "#3c3836", "fgDim": "#665c54", "fgMuted": "#7c6f64",
"gutter": "#bdae93", "accentAlt": "#076678",
"cyan": "#076678", "teal": "#427b58", "green": "#79740e",
"yellow": "#b57614", "orange": "#af3a03", "red": "#9d0006",
"redDeep": "#cc241d", "magenta": "#8f3f71", "pink": "#b16286"
},
"ansi": {
"black": "#fbf1c7", "red": "#cc241d", "green": "#98971a",
"yellow": "#d79921", "blue": "#458588", "magenta": "#b16286",
"cyan": "#689d6a", "white": "#7c6f64",
"brightBlack": "#928374", "brightRed": "#9d0006", "brightGreen": "#79740e",
"brightYellow": "#b57614", "brightBlue": "#076678", "brightMagenta": "#8f3f71",
"brightCyan": "#427b58", "brightWhite": "#3c3836"
}
},
{
"id": "everforest-light", "name": "Everforest Light", "scheme": "light",
"accent": "#8da101", "secondary": "#f57d26",
"palette": {
"bg": "#fdf6e3", "bgDark": "#f4f0d9", "bgHighlight": "#e6e2cc",
"bgPanel": "#efebd4", "bgPopover": "#faf3dc",
"fg": "#5c6a72", "fgDim": "#829181", "fgMuted": "#939f91",
"gutter": "#a6b0a0", "accentAlt": "#3a94c5",
"cyan": "#3a94c5", "teal": "#35a77c", "green": "#8da101",
"yellow": "#dfa000", "orange": "#f57d26", "red": "#f85552",
"redDeep": "#e66868", "magenta": "#df69ba", "pink": "#df69ba"
},
"ansi": {
"black": "#5c6a72", "red": "#f85552", "green": "#8da101",
"yellow": "#dfa000", "blue": "#3a94c5", "magenta": "#df69ba",
"cyan": "#35a77c", "white": "#e0dcc7",
"brightBlack": "#829181", "brightRed": "#f85552", "brightGreen": "#8da101",
"brightYellow": "#dfa000", "brightBlue": "#3a94c5", "brightMagenta": "#df69ba",
"brightCyan": "#35a77c", "brightWhite": "#fdf6e3"
}
}
]
}
@@ -20,11 +20,49 @@ to change you can type it and never touch the sidebar.
## Appearance
Panama ships Tokyo Night Moon in a light and a dark variant, with eight
accent colours. Changing either repaints the shell, the terminal, GTK
applications, the lock screen and the system monitor at once.
The Appearance page opens on **Themes**. At the top are light and dark, side by
side, each showing the theme it will land on; below them are two galleries —
six dark themes and four light ones, including Catppuccin, Nord, Gruvbox and
Everforest alongside Panama's own Tokyo Night Moon and Day.
The wallpaper is per-monitor if you want it to be.
Light and dark remember separately. Pick Everforest for the dark side and
Catppuccin Latte for the light one, and flipping between them lands on each
rather than resetting to the shipped pair.
Choosing a theme repaints the shell, the terminal, GTK applications, the lock
screen and the system monitor at once — not just the shell's own windows.
### Build your own
The **Theme editor** tab is four colours: primary, secondary, background and
foreground. Each has a swatch, a hex field you can type into, a colour wheel
and an eyedropper for sampling anything on screen. Background and foreground
are not single colours — the panels, popovers, dividers and dimmed text are all
mixed from them, so moving the background moves the whole family with it.
Under that, a saturation slider for the whole palette at once, a fine-tune for
the exact hue of each end of the accent, and the effects — corners, blur,
shadows, glow and motion. Save it with a name and it appears in the galleries
above beside the shipped ones, effects included. Editing a shipped theme does
not damage it: it makes you a copy.
### Wallpapers
The wallpaper is per-monitor if you want it to be, and it can be a video.
Videos loop muted and decode on the GPU. They pause themselves while a game is
running, and on battery unless you say otherwise; while one is playing a pill
appears in the bar, and clicking it pauses immediately — for a call, a
recording, or just quiet. The lock screen shows a still frame from the video,
because it cannot play motion.
### Titlebars
There is no minimize button, on Panama's windows or on GNOME applications.
Hyprland has no minimize, so the button would do nothing; maximize means very
little in a tiler. You get close, on whichever side you prefer. Panama's own
titlebar can be turned off entirely, which leaves the Settings window bare:
`Super + Q` closes it, `Super + drag` moves it, `Escape` still works.
## Applications
@@ -118,6 +118,10 @@ PanelWindow {
anchors.verticalCenter: parent.verticalCenter
}
WallpaperIndicator {
anchors.verticalCenter: parent.verticalCenter
}
FocusIndicator {
anchors.verticalCenter: parent.verticalCenter
}
@@ -0,0 +1,40 @@
// Visible only while a video wallpaper is active: one click pauses or resumes
// it. Exists because a playing wallpaper has real costs the user may want to
// stop right now — a remote desktop session, a recording, or just quiet —
// without opening Settings. Same conditional pattern as ActivityIndicator.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Pill {
id: root
visible: VideoWallpaper.active
horizontalPadding: 10
onActivated: VideoWallpaper.togglePause()
onSecondaryActivated: ShellState.openSettings("appearance")
Accessible.name: VideoWallpaper.paused ? "Resume video wallpaper" : "Pause video wallpaper"
Text {
anchors.verticalCenter: parent.verticalCenter
text: VideoWallpaper.paused ? "\u{F040A}" : "\u{F03E4}" // play / pause
color: VideoWallpaper.paused ? Theme.fgDim : Theme.warn
font.family: Theme.fontMono
font.pixelSize: 13
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: VideoWallpaper.paused
? (VideoWallpaper.gamePaused ? "Paused for game"
: (VideoWallpaper.batteryPaused ? "Paused on battery" : "Paused"))
: "Wallpaper"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
@@ -1,10 +1,18 @@
// Advanced accent editing remains a labelled, keyboard-operable extension of
// the named fast path. Hue is always accompanied by saturation, value, a
// numeric readout, and the two-colour preview supplied by Appearance.
// Hue, saturation and value for both ends of the Prism accent.
//
// This is the fine-tune behind a disclosure, not the fast path: the four wells
// above cover what most edits are, and these six rows are for the last few
// degrees. Each row is a labelled, keyboard-operable slider with a numeric
// readout, because a hue ring alone tells someone with a colour vision
// deficiency nothing.
//
// Committed on a debounce rather than per move. Each move used to write BOTH
// accent preferences, so a single drag across the hue row spent the whole
// gesture in apply-and-verify round trips and left the desktop repainting
// behind the pointer. The sliders now track the drag locally and one commit
// lands once it stops.
import QtQuick
import Quickshell
import Quickshell.Io
import qs.config
import qs.services
import qs.widgets
@@ -16,13 +24,18 @@ Column {
width: parent ? parent.width : 620
spacing: 0
property string pickerTarget: "primary"
property string lastError: ""
// The pair being dragged; null means "nothing pending, show what is stored".
property var pending: null
readonly property var primaryHsv: ThemeProfileModel.hexToHsv(
ThemeProfiles.activeProfile.accent) || ({ h: 0, s: 0, v: 0 })
readonly property var secondaryHsv: ThemeProfileModel.hexToHsv(
ThemeProfiles.activeProfile.secondary) || ({ h: 0, s: 0, v: 0 })
readonly property string shownAccent: editor.pending
? editor.pending.accent : String(ThemeProfiles.activeProfile.accent)
readonly property string shownSecondary: editor.pending
? editor.pending.secondary : String(ThemeProfiles.activeProfile.secondary)
readonly property var primaryHsv: ThemeProfileModel.hexToHsv(editor.shownAccent)
|| ({ h: 0, s: 0, v: 0 })
readonly property var secondaryHsv: ThemeProfileModel.hexToHsv(editor.shownSecondary)
|| ({ h: 0, s: 0, v: 0 })
function changeChannel(target: string, channel: string, ratio: real): void {
const source = target === "primary" ? editor.primaryHsv : editor.secondaryHsv;
@@ -30,34 +43,17 @@ Column {
next[channel] = Math.round(Math.max(0, Math.min(1, ratio))
* (channel === "h" ? 360 : 100));
const changed = ThemeProfileModel.hsvToHex(next.h, next.s, next.v);
const primary = target === "primary" ? changed : ThemeProfiles.activeProfile.accent;
const secondary = target === "secondary" ? changed : ThemeProfiles.activeProfile.secondary;
ThemeProfiles.setAccentPair(primary, secondary);
}
function pick(target: string): void {
if (screenPicker.running)
if (!changed)
return;
editor.pickerTarget = target;
editor.lastError = "";
screenPicker.exec(["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]);
}
function acceptPicked(value: string): void {
const picked = String(value).trim().toLowerCase();
if (ThemeProfileModel.hexToHsv(picked) === null) {
editor.lastError = "The sampled colour was not valid.";
return;
}
const primary = editor.pickerTarget === "primary"
? picked : ThemeProfiles.activeProfile.accent;
const secondary = editor.pickerTarget === "secondary"
? picked : ThemeProfiles.activeProfile.secondary;
ThemeProfiles.setAccentPair(primary, secondary);
editor.pending = {
accent: target === "primary" ? changed : editor.shownAccent,
secondary: target === "secondary" ? changed : editor.shownSecondary
};
commitTimer.restart();
}
component HsvRow: SettingRow {
id: root
id: row
required property string target
required property string channel
@@ -77,19 +73,18 @@ Column {
activeFocusOnTab: true
Accessible.role: Accessible.Slider
Accessible.name: root.label
Accessible.description: Math.round(root.channelValue) + root.suffix
+ ", range 0 to " + root.channelMaximum
Accessible.name: row.label
Accessible.description: Math.round(row.channelValue) + row.suffix
+ ", range 0 to " + row.channelMaximum
Accessible.focusable: true
Accessible.focused: activeFocus
Accessible.onIncreaseAction: keyboardSlider.step(1)
Accessible.onDecreaseAction: keyboardSlider.step(-1)
function step(direction: int): void {
const increment = root.channel === "h" ? 1 : 1;
const value = Math.max(0, Math.min(root.channelMaximum,
root.channelValue + direction * increment));
editor.changeChannel(root.target, root.channel, value / root.channelMaximum);
const value = Math.max(0, Math.min(row.channelMaximum,
row.channelValue + direction));
editor.changeChannel(row.target, row.channel, value / row.channelMaximum);
}
Keys.onPressed: event => {
@@ -100,10 +95,10 @@ Column {
keyboardSlider.step(1);
event.accepted = true;
} else if (event.key === Qt.Key_Home) {
editor.changeChannel(root.target, root.channel, 0);
editor.changeChannel(row.target, row.channel, 0);
event.accepted = true;
} else if (event.key === Qt.Key_End) {
editor.changeChannel(root.target, root.channel, 1);
editor.changeChannel(row.target, row.channel, 1);
event.accepted = true;
}
}
@@ -117,13 +112,14 @@ Column {
radius: 9
color: "transparent"
border.width: keyboardSlider.activeFocus ? 2 : 1
border.color: keyboardSlider.activeFocus ? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.08)
border.color: keyboardSlider.activeFocus
? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.08)
ValueSlider {
anchors.fill: parent
anchors.margins: 4
value: root.channelMaximum > 0 ? root.channelValue / root.channelMaximum : 0
onMoved: ratio => editor.changeChannel(root.target, root.channel, ratio)
value: row.channelMaximum > 0 ? row.channelValue / row.channelMaximum : 0
onMoved: ratio => editor.changeChannel(row.target, row.channel, ratio)
}
}
@@ -133,7 +129,7 @@ Column {
anchors.verticalCenter: parent.verticalCenter
width: 56
horizontalAlignment: Text.AlignRight
text: Math.round(root.channelValue) + root.suffix
text: Math.round(row.channelValue) + row.suffix
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
@@ -165,54 +161,26 @@ Column {
HsvRow {
target: "secondary"; channel: "v"; channelValue: editor.secondaryHsv.v; channelMaximum: 100
label: "Secondary value"; detail: "Brightness from black to full colour"
}
SettingRow {
label: "Pick colour from screen"
detail: editor.lastError !== ""
? editor.lastError
: "Sample either end of the accent gradient with hyprpicker"
divider: false
controlWidth: 330
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
SettingsButton {
text: "Pick primary from screen"
enabled: !screenPicker.running
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: editor.pick("primary")
Keys.onReturnPressed: if (enabled) editor.pick("primary")
Keys.onSpacePressed: if (enabled) editor.pick("primary")
}
SettingsButton {
text: "Pick secondary from screen"
enabled: !screenPicker.running
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: editor.pick("secondary")
Keys.onReturnPressed: if (enabled) editor.pick("secondary")
Keys.onSpacePressed: if (enabled) editor.pick("secondary")
}
Timer {
id: commitTimer
interval: 200
onTriggered: {
if (editor.pending === null)
return;
ThemeProfiles.setAccentPair(editor.pending.accent, editor.pending.secondary);
releaseTimer.restart();
}
}
Process {
id: screenPicker
stdout: StdioCollector {
onStreamFinished: editor.acceptPicked(this.text)
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
editor.lastError = "Screen colour picking was cancelled or unavailable.";
}
// Hands the sliders back to the stored pair. If the write was refused they
// snap back to what is really in effect rather than showing a colour
// nothing accepted.
Timer {
id: releaseTimer
interval: 160
onTriggered: editor.pending = null
}
}
@@ -1,122 +0,0 @@
// Choosing the desktop accent.
//
// Each swatch is drawn as the GRADIENT it will actually produce, not a flat
// dot, because the gradient is the thing being chosen -- the focused window
// border, the bar hairline and every active state are the two colors meeting.
// A row of flat circles would misrepresent all of them.
//
// Named accents rather than a color wheel: each name carries a curated pair
// per scheme, so every choice stays legible in both light and dark. See
// config/Theme.qml for the palette and the reasoning.
import QtQuick
import qs.config
import qs.services
Flow {
id: root
spacing: 10
readonly property string current: ThemeProfiles.activeAccentName
// The schema's own option list, not Theme.accents directly -- two lists
// hand-kept in sync is how they drift. This is the same pattern
// ChoiceRow.qml uses for every other enum row.
readonly property var spec: PreferenceSchema.spec("accentName")
readonly property var options: root.spec && root.spec.options ? root.spec.options : []
Repeater {
// The schema's option order is the palette's order, so blue is first
// because it is what Panama ships.
model: root.options
Column {
id: entry
required property var modelData
readonly property string name: entry.modelData.value
readonly property var pair: Theme.accents[entry.name]
readonly property bool selected: entry.name === root.current
readonly property color start: Theme.dark ? entry.pair.dark : entry.pair.light
readonly property color end: Theme.dark ? entry.pair.darkSecondary : entry.pair.lightSecondary
spacing: 5
// The hit target is the whole swatch+label unit, not just the
// 46px circle: the label exists specifically so someone with a
// color vision deficiency can identify an accent without it, and
// a label that cannot itself be tapped defeats that.
HoverHandler {
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
if (!SystemSettings.commitPreference("accentName", entry.name))
console.warn("AccentPicker: commitPreference rejected accent", entry.name);
}
}
Rectangle {
id: swatch
width: 46
height: 46
radius: 23
anchors.horizontalCenter: parent.horizontalCenter
color: "transparent"
// The ring sits outside the gradient rather than over it, so a
// selected swatch still shows its true colors.
border.width: swatch.activeFocus || entry.selected ? 2 : 1
border.color: swatch.activeFocus
? Theme.accentSecondary
: (entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14))
activeFocusOnTab: true
Accessible.role: Accessible.Button
Accessible.name: entry.pair.label + " accent"
Accessible.description: String(entry.start) + " to " + String(entry.end)
Accessible.focusable: true
Accessible.focused: activeFocus
function choose(): void {
if (!ThemeProfiles.useCuratedAccent(entry.modelData))
console.warn("AccentPicker: accent was rejected", entry.modelData);
}
Keys.onReturnPressed: swatch.choose()
Keys.onSpacePressed: swatch.choose()
Rectangle {
anchors.fill: parent
anchors.margins: entry.selected ? 4 : 3
radius: width / 2
border.width: 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: entry.start }
GradientStop { position: 1.0; color: entry.end }
}
}
HoverHandler { cursorShape: Qt.PointingHandCursor }
TapHandler { onTapped: swatch.choose() }
}
// Always shown, not a tooltip. Telling swatches apart by color is
// exactly what someone with a color vision deficiency cannot do,
// and it is the reason the palette is named rather than freeform --
// hiding the names behind a hover would waste that.
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: entry.modelData.label
color: entry.selected ? Theme.fg : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: entry.selected ? Font.DemiBold : Font.Normal
}
}
}
}
@@ -19,16 +19,25 @@ SettingsPage {
property string expandedPicker: ""
// Which group of cards is on screen. Theme leads deliberately: light and
// Whether the accent's HSV fine-tune is open. Collapsed by default: it is
// six sliders that answer a question the four colour wells above have
// usually already answered.
property bool fineTuneOpen: false
// Which group of cards is on screen. Themes leads deliberately: light and
// dark is the control reached most often, and it used to be the third
// section down, below a wallpaper grid and the whole lock screen.
property string tab: "theme"
property string tab: "themes"
// Arriving from another page that named a section opens on it. Taken once
// rather than bound, so the tabs still work normally afterwards.
// rather than bound, so the tabs still work normally afterwards. The old
// `theme` id still resolves: deep links, IPC calls and muscle memory all
// hold it, and it means the same thing this tab does.
Component.onCompleted: {
const section = ShellState.takeSettingsSection();
if (section !== "")
if (section === "theme")
root.tab = "themes";
else if (section !== "")
root.tab = section;
}
@@ -59,7 +68,8 @@ SettingsPage {
SettingsTabs {
tabs: [
{ value: "theme", label: "Theme" },
{ value: "themes", label: "Themes" },
{ value: "editor", label: "Theme editor" },
{ value: "background", label: "Background" },
{ value: "type", label: "Typography" },
{ value: "windows", label: "Windows" },
@@ -110,6 +120,47 @@ SettingsPage {
}
}
SettingsCard {
visible: root.tab === "background"
title: "Video playback"
subtitle: VideoWallpaper.lastError !== ""
? VideoWallpaper.lastError
: "Videos loop muted and decode on the GPU. Considerate by default — a video wallpaper should never cost you a frame you care about."
TextEntryRow { setting: "videoWallpaperDir"; placeholder: "Videos/Wallpapers" }
ToggleRow { setting: "videoWallpaperPauseOnBattery" }
TextRow {
label: "During games and fullscreen"
detail: "Pauses automatically — not optional, and free"
value: "Automatic"
}
TextRow {
label: "Pause from the bar"
detail: "While a video plays, a pill sits in the bar — one click pauses it, for remote desktop or just quiet"
value: ""
}
TextRow {
label: "Lock screen"
detail: "Uses a still frame of the video — hyprlock cannot play motion"
value: "Still frame"
}
ActionRow {
label: "Look for new videos"
detail: VideoWallpaper.available
? VideoWallpaper.candidates.length + " video" + (VideoWallpaper.candidates.length === 1 ? "" : "s") + " found"
: "mpvpaper is not installed — run panama update to pick it up"
action: "Rescan"
divider: false
enabled: VideoWallpaper.available
onTriggered: VideoWallpaper.rescan()
}
}
SettingsCard {
visible: root.tab === "background"
title: "Lock screen"
@@ -129,45 +180,220 @@ SettingsPage {
ToggleRow { setting: "lockFadeOnEmpty"; divider: false }
}
SettingsCard {
visible: root.tab === "theme"
// Not "Color scheme" any more: the card holds the saved themes as well
// as the scheme, and a theme carries both ends of the accent with it.
title: "Theme"
subtitle: ColorScheme.lastError !== ""
? ColorScheme.lastError
: "Start with Moon, Moon Rose, or Day; saved themes capture the scheme and both ends of the Prism accent."
// ── Themes ──────────────────────────────────────────────────────────────
//
// The gallery is the page, not a card on it. Cards would put a heading and
// a border around each of two lists that are already visually separate, and
// the theme cards themselves are the only surfaces here that need edges.
ThemeProfilePicker {
ThemeModeHero {
visible: root.tab === "themes"
width: parent.width
}
Text {
visible: root.tab === "themes" && ThemeCatalog.lastError !== ""
width: parent.width
text: ThemeCatalog.lastError
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
ThemeGallery {
visible: root.tab === "themes"
width: parent.width
scheme: "dark"
heading: "Dark themes"
}
ThemeGallery {
visible: root.tab === "themes"
width: parent.width
scheme: "light"
heading: "Light themes"
}
// Dashed rather than solid, and the width of the page: this is a door out
// of the gallery, not an eleventh theme in it.
Rectangle {
id: buildLink
visible: root.tab === "themes"
width: parent.width
implicitHeight: buildCopy.implicitHeight + 30
radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.accent, buildHover.hovered ? 0.12 : 0.07)
border.width: buildLink.activeFocus ? 2 : 1
border.color: buildLink.activeFocus
? Theme.accentSecondary : Theme.alpha(Theme.accent, 0.35)
activeFocusOnTab: true
Accessible.role: Accessible.Button
Accessible.name: "Build your own theme"
Accessible.description: "Opens the theme editor"
Accessible.focusable: true
Accessible.focused: buildLink.activeFocus
Keys.onReturnPressed: root.tab = "editor"
Keys.onSpacePressed: root.tab = "editor"
Text {
id: buildGlyph
anchors.left: parent.left
anchors.leftMargin: 17
anchors.verticalCenter: parent.verticalCenter
text: "◐"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: 20
}
Column {
id: buildCopy
anchors.left: buildGlyph.right
anchors.leftMargin: 13
anchors.right: buildArrow.left
anchors.rightMargin: 13
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: "Build your own theme"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: "Primary, secondary, background, foreground, effects — every colour yours, saved as a theme that lives in the galleries above"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
Text {
id: buildArrow
anchors.right: parent.right
anchors.rightMargin: 17
anchors.verticalCenter: parent.verticalCenter
text: "→"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: 17
}
HoverHandler {
id: buildHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.tab = "editor"
}
}
// ── Theme editor ────────────────────────────────────────────────────────
SettingsCard {
id: editorCard
visible: root.tab === "editor"
title: "Your theme"
// The identity of what is being edited, always. Failures get their own
// line below rather than displacing it -- a subtitle that sometimes
// says which theme you are editing and sometimes says something went
// wrong answers neither question reliably.
subtitle: ThemeProfiles.activeProfile.shipped === true
? ThemeProfiles.activeProfile.name
+ " — editing it forks a copy you can name below"
: "Based on " + ThemeProfiles.activeProfile.name
+ " · every change previews live on the real desktop"
Text {
width: parent.width
visible: wells.lastError !== "" || ColorScheme.lastError !== ""
text: wells.lastError !== "" ? wells.lastError : ColorScheme.lastError
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
bottomPadding: 10
}
ThemeStartChips {
width: parent.width
bottomPadding: 13
}
SegmentRow {
label: "Scheme"
detail: "Flipping sides returns to the theme you last chose there"
options: [{ value: "dark", label: "Dark" }, { value: "light", label: "Light" }]
value: ThemeProfiles.scheme
onSelected: value => ThemeProfiles.setScheme(value)
}
Item { width: 1; height: 13 }
ThemeEditorWells {
id: wells
width: parent.width
}
// Drawn as the gradient each accent produces rather than a flat dot,
// because the gradient is what is being chosen.
AccentPicker {
Item { width: 1; height: 13 }
ThemeSaturationRow {
width: parent.width
}
ActionRow {
label: "Fine-tune"
detail: "Hue, saturation and value for each end of the accent"
action: root.fineTuneOpen ? "Close" : "Open"
onTriggered: root.fineTuneOpen = !root.fineTuneOpen
}
AccentEditor {
visible: root.fineTuneOpen
width: parent.width
}
ThemeSaveRow {
width: parent.width
}
}
// One selector for every font. The two always-open lists and the
// open-one-at-a-time disclosure both retire: a closed dropdown per row
// reads as five choices instead of a wall of samples, and each row names
// what it actually controls — the old tab never said which font was the
// shell's and which was the applications'.
SettingsCard {
visible: root.tab === "type"
title: "Shell typography"
subtitle: Fonts.lastError !== ""
? Fonts.lastError
: "Every piece of text in the shell. Samples are drawn in the font they name."
title: "Fonts"
subtitle: {
if (Fonts.lastError !== "")
return Fonts.lastError;
if (DesktopStyle.lastError !== "")
return DesktopStyle.lastError;
return "Each row names what it controls. The menu shows every family in its own face — type to filter.";
}
SliderRow { setting: "interfaceFontSize" }
TextRow {
label: "Interface font"
SettingRow {
label: "Interface"
detail: Fonts.interfaceMissing
? "Not installed on this machine — fontconfig is substituting something else"
: "Used for all shell text"
value: Fonts.interfaceFont
? "Not installed — fontconfig is substituting something else"
: "The shell — bar, dock, notifications, this window"
divider: false
controlWidth: 8
}
FontPicker {
@@ -178,12 +404,13 @@ SettingsPage {
onPicked: family => Fonts.setInterface(family)
}
TextRow {
label: "Icon font"
SettingRow {
label: "Icons"
detail: Fonts.iconMissing
? "Not installed — the shell's glyphs will not draw correctly"
: "Draws the shell's glyphs, so only Nerd Fonts are offered"
value: Fonts.iconFont
: "Glyphs only, never text — needs a Nerd Font"
divider: false
controlWidth: 8
}
FontPicker {
@@ -193,77 +420,68 @@ SettingsPage {
emptyText: "No Nerd Fonts installed"
onPicked: family => Fonts.setIcon(family)
}
}
SettingsCard {
visible: root.tab === "type"
title: "Application typography"
subtitle: DesktopStyle.lastError !== ""
? DesktopStyle.lastError
: "Fonts used by applications that follow the desktop defaults. Open one family at a time to keep the page calm."
ActionRow {
label: "Application font"
detail: DesktopStyle.applicationFont
action: root.expandedPicker === "application-font" ? "Close" : "Choose"
onTriggered: root.expandedPicker = root.expandedPicker === "application-font" ? "" : "application-font"
SettingRow {
label: "Application"
detail: "GTK and libadwaita apps that follow the desktop default"
divider: false
controlWidth: 8
}
FontPicker {
visible: root.expandedPicker === "application-font"
width: parent.width
families: Fonts.interfaceFonts
current: DesktopStyle.applicationFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No application fonts found"
onPicked: family => {
if (DesktopStyle.setApplicationFont(family))
root.expandedPicker = "";
}
onPicked: family => DesktopStyle.setApplicationFont(family)
}
SliderRow { setting: "applicationFontSize" }
ActionRow {
label: "Document font"
detail: DesktopStyle.documentFont
action: root.expandedPicker === "document-font" ? "Close" : "Choose"
onTriggered: root.expandedPicker = root.expandedPicker === "document-font" ? "" : "document-font"
SettingRow {
label: "Document"
detail: "Long-form reading, where applications ask for it"
divider: false
controlWidth: 8
}
FontPicker {
visible: root.expandedPicker === "document-font"
width: parent.width
families: Fonts.interfaceFonts
current: DesktopStyle.documentFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No document fonts found"
onPicked: family => {
if (DesktopStyle.setDocumentFont(family))
root.expandedPicker = "";
}
onPicked: family => DesktopStyle.setDocumentFont(family)
}
SliderRow { setting: "documentFontSize" }
ActionRow {
label: "Monospace font"
detail: DesktopStyle.monospaceFont
action: root.expandedPicker === "monospace-font" ? "Close" : "Choose"
onTriggered: root.expandedPicker = root.expandedPicker === "monospace-font" ? "" : "monospace-font"
SettingRow {
label: "Monospace"
detail: "Terminals and code, wherever fixed width is asked for"
divider: false
controlWidth: 8
}
FontPicker {
visible: root.expandedPicker === "monospace-font"
width: parent.width
families: Fonts.monospaceFonts
current: DesktopStyle.monospaceFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No monospace fonts found"
onPicked: family => {
if (DesktopStyle.setMonospaceFont(family))
root.expandedPicker = "";
}
onPicked: family => DesktopStyle.setMonospaceFont(family)
}
}
SettingsCard {
visible: root.tab === "type"
title: "Sizes"
subtitle: "The interface size moves the shell's whole scale; the rest are per role."
SliderRow { setting: "interfaceFontSize" }
SliderRow { setting: "applicationFontSize" }
SliderRow { setting: "documentFontSize" }
SliderRow { setting: "monospaceFontSize"; divider: false }
}
SettingsCard {
visible: root.tab === "type"
title: "Rendering"
SliderRow { setting: "monospaceFontSize" }
ChoiceRow { setting: "fontHinting" }
ChoiceRow { setting: "fontAntialiasing"; divider: false }
}
@@ -322,11 +540,10 @@ SettingsPage {
SettingsCard {
visible: root.tab === "windows"
title: "Titlebars"
subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
subtitle: "One rule for GNOME applications and Panama's own windows. There is no minimize or maximize toggle: Hyprland has no minimize, so the buttons would be lies."
ChoiceRow { setting: "titlebarButtonSide" }
ToggleRow { setting: "titlebarMaximizeButton" }
ChoiceRow { setting: "titlebarDoubleClick"; divider: false }
ToggleRow { setting: "panamaTitlebar" }
ChoiceRow { setting: "titlebarButtonSide"; divider: false }
}
SettingsCard {
@@ -344,10 +561,13 @@ SettingsPage {
SliderRow { setting: "fullscreenOpacity"; divider: false }
}
// Effects belong to the theme now: saving snapshots all ten values into the
// record, and applying a theme that carries them puts them back. They sit
// in the editor rather than beside the galleries for exactly that reason.
SettingsCard {
visible: root.tab === "theme"
visible: root.tab === "editor"
title: "Effects"
subtitle: "Each of these costs frame time. Turning one off is a legitimate way to buy it back while gaming."
subtitle: "Part of your theme — corners, blur, shadows, glow and motion save with it. Each costs frame time; turning one off is a legitimate way to buy it back while gaming."
ToggleRow { setting: "blurEnabled" }
SliderRow { setting: "blurSize" }
@@ -0,0 +1,221 @@
// One color of a theme, with the three honest ways to change it.
//
// The swatch is the value, large enough to judge against the card it sits on.
// Under it: the hex, typed and committed on Enter or when focus leaves --
// never per keystroke, because a half-typed "#82a" is a real color and would
// repaint the whole desktop on the way to the one you meant. A rejected value
// marks the field and snaps back rather than quietly keeping bad text.
//
// The wheel and the eyedropper are the other two, and all three are keyboard
// reachable: this component is the reason the old accent editor could not be
// operated without a mouse.
import QtQuick
import qs.config
Rectangle {
id: root
property string role: ""
property string swatchColor: "#000000"
property bool picking: false
// A hex the caller accepted. Rejected values never reach here.
signal committed(value: string)
signal wheelRequested
signal pickRequested
// Set while the field holds something that is not a #rrggbb.
property bool invalid: false
implicitHeight: body.implicitHeight + 22
radius: Theme.cardRadius
color: Theme.alpha(Theme.fg, 0.045)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.09)
function normalized(value: string): string {
const text = String(value).trim().toLowerCase();
const prefixed = text.startsWith("#") ? text : "#" + text;
return /^#[0-9a-f]{6}$/.test(prefixed) ? prefixed : "";
}
function commit(): void {
const hex = root.normalized(hexInput.text);
if (hex === "") {
root.invalid = true;
return;
}
root.invalid = false;
hexInput.text = hex;
if (hex !== String(root.swatchColor).toLowerCase())
root.committed(hex);
}
// An accepted change elsewhere -- a theme click, the eyedropper, the HSV
// sliders -- replaces what is shown, unless it would yank the field out
// from under someone mid-edit.
onSwatchColorChanged: if (!hexInput.activeFocus) {
hexInput.text = String(root.swatchColor).toLowerCase();
root.invalid = false;
}
// The wheel and eyedropper buttons: square, glyph-only, and each its own
// focus stop with a spoken name, because a 30px square with a ⌖ in it is
// otherwise unidentifiable to anything but sight.
component IconButton: Rectangle {
id: button
property string glyph: ""
property string title: ""
property bool enabled: true
signal triggered
width: 30
height: 30
radius: 8
opacity: button.enabled ? 1 : 0.45
color: hover.hovered && button.enabled
? Theme.alpha(Theme.fg, 0.16) : Theme.alpha(Theme.fg, 0.08)
border.width: button.activeFocus ? 2 : 1
border.color: button.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.1)
activeFocusOnTab: button.enabled
Accessible.role: Accessible.Button
Accessible.name: button.title
Accessible.focusable: true
Accessible.focused: button.activeFocus
Keys.onReturnPressed: if (button.enabled) button.triggered()
Keys.onSpacePressed: if (button.enabled) button.triggered()
Text {
anchors.centerIn: parent
text: button.glyph
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
HoverHandler {
id: hover
enabled: button.enabled
cursorShape: Qt.PointingHandCursor
}
TapHandler {
enabled: button.enabled
onTapped: button.triggered()
}
}
Column {
id: body
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 11
spacing: 7
Text {
text: root.role
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.capitalization: Font.AllUppercase
font.letterSpacing: 0.6
}
Rectangle {
width: parent.width
height: 44
radius: 9
color: root.swatchColor
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.18)
}
Item {
width: parent.width
height: 30
Rectangle {
id: hexField
anchors.left: parent.left
anchors.right: buttons.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
height: 30
radius: 8
color: Theme.alpha(Theme.fg, 0.07)
border.width: hexInput.activeFocus || root.invalid ? 2 : 1
border.color: root.invalid
? Theme.danger
: (hexInput.activeFocus
? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.12))
TextInput {
id: hexInput
anchors.fill: parent
anchors.leftMargin: 9
anchors.rightMargin: 9
activeFocusOnTab: true
verticalAlignment: TextInput.AlignVCenter
maximumLength: 7
text: String(root.swatchColor).toLowerCase()
color: root.invalid ? Theme.danger : Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.4)
selectedTextColor: Theme.fg
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeSmall
clip: true
Accessible.role: Accessible.EditableText
Accessible.name: root.role + " colour, hex"
onTextEdited: root.invalid = false
onAccepted: root.commit()
onActiveFocusChanged: {
if (hexInput.activeFocus)
return;
// Leaving with something unusable in the field reverts
// rather than leaving a lie on screen.
if (root.normalized(hexInput.text) === "") {
root.invalid = false;
hexInput.text = String(root.swatchColor).toLowerCase();
return;
}
root.commit();
}
}
}
Row {
id: buttons
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
IconButton {
glyph: "◐"
title: "Open the colour wheel for " + root.role
onTriggered: root.wheelRequested()
}
IconButton {
glyph: "⌖"
title: "Pick " + root.role + " from the screen"
enabled: !root.picking
onTriggered: root.pickRequested()
}
}
}
}
}
@@ -50,13 +50,13 @@ Rectangle {
// Stands in for the wallpaper. Static: an animated gradient here would
// repaint forever behind a settings page.
// Follows the color scheme. A preview that stays dark while the shell
// around it is light does not read as "your desktop" -- it reads as a
// screenshot of someone else's.
// Derived from the active theme's palette rather than fixed hexes, so the
// preview re-grounds itself when a theme is chosen -- a Gruvbox desktop
// whose preview stayed Tokyo-blue would read as someone else's.
gradient: Gradient {
orientation: Gradient.Vertical
GradientStop { position: 0.0; color: Theme.dark ? "#2b3050" : "#b9c0dd" }
GradientStop { position: 1.0; color: Theme.dark ? "#241f33" : "#cbbcd4" }
GradientStop { position: 0.0; color: Theme.mix(Theme.bgHighlight, Theme.accent, 0.12) }
GradientStop { position: 1.0; color: Theme.mix(Theme.bgDark, Theme.accentSecondary, 0.10) }
}
// The bar, so the preview reads as this desktop and not a generic one.
@@ -209,7 +209,8 @@ Rectangle {
shadowEnabled: true
shadowColor: win.focused && root.glowOn
? Theme.alpha(Theme.accent, 0.5)
: Theme.alpha(Theme.dark ? "#15161e" : "#6172b0", Theme.dark ? 0.85 : 0.45)
: Theme.alpha(Theme.dark ? Theme.mix(Theme.bgDark, "#000000", 0.35) : Theme.fgDim,
Theme.dark ? 0.85 : 0.45)
shadowBlur: win.focused && root.glowOn
? Math.min(1.0, root.px(root.glowRange) / 12)
: Math.min(1.0, root.px(root.shadowRange) / 24)
@@ -1,8 +1,13 @@
// Choosing a font family.
//
// Each candidate is rendered IN the font it names. A list of family names set
// in the current font tells you nothing about what you are choosing, and the
// whole point of picking a typeface is seeing it.
// A closed dropdown: the button shows the current family rendered in its own
// face, and opening it reveals a search field and the matches — each drawn IN
// the font it names, because a list of family names set in the current font
// tells you nothing about what you are choosing.
//
// Closed by default on purpose. This used to be an always-open search-and-list
// that put twenty-four sample rows on the Typography tab before you asked for
// any of them.
import QtQuick
import qs.config
@@ -16,6 +21,7 @@ Column {
property var families: []
property string current: ""
property string emptyText: "No fonts found"
property bool open: false
signal picked(string family)
@@ -31,50 +37,122 @@ Column {
return list.slice(0, 12);
}
SearchField {
id: filter
width: parent.width
placeholder: "Search installed fonts"
function choose(family: string): void {
root.picked(family);
root.open = false;
filter.text = "";
}
Repeater {
model: root.matches
// ── The closed state: one button ────────────────────────────────────────
SettingRow {
id: candidate
Rectangle {
id: closedButton
required property var modelData
required property int index
width: parent.width
height: 38
radius: 10
color: buttonMouse.containsMouse || root.open || activeFocus
? Theme.alpha(Theme.fg, 0.09)
: Theme.alpha(Theme.fg, 0.055)
border.width: root.open || activeFocus ? 2 : 1
border.color: root.open || activeFocus
? Theme.alpha(Theme.accent, 0.55)
: Theme.alpha(Theme.fg, 0.07)
activeFocusOnTab: true
readonly property bool selected: candidate.modelData === root.current
Accessible.role: Accessible.ComboBox
Accessible.name: (root.current !== "" ? root.current : "Choose a font")
Keys.onReturnPressed: root.open = !root.open
Keys.onSpacePressed: root.open = !root.open
label: candidate.modelData
detail: candidate.selected ? "Currently in use" : ""
controlWidth: 210
divider: candidate.index < root.matches.length - 1
activatable: !candidate.selected
onActivated: root.picked(candidate.modelData)
Text {
anchors.left: parent.left
anchors.leftMargin: 13
anchors.right: chevron.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: root.current !== "" ? root.current : "Choose a font"
elide: Text.ElideRight
// The current family draws itself — the closed state is a sample.
font.family: root.current !== "" ? root.current : Theme.fontFamily
font.pixelSize: Theme.fontSize
color: root.current !== "" ? Theme.fg : Theme.fgMuted
}
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 200
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
// The sample is drawn in the candidate family, which is the
// entire reason this is a list rather than a text field.
text: "Handgloves 0123"
font.family: candidate.modelData
font.pixelSize: Theme.fontSize + 1
color: candidate.selected ? Theme.accent : Theme.fgDim
}
Text {
id: chevron
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: root.open ? "▴" : "▾"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 11
}
MouseArea {
id: buttonMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.open = !root.open
}
}
SettingRow {
Item { width: 1; height: root.open ? 8 : 0 }
// ── The open state: search plus matches ─────────────────────────────────
Column {
width: parent.width
visible: root.matches.length === 0
label: root.emptyText
divider: false
visible: root.open
spacing: 0
SearchField {
id: filter
width: parent.width
placeholder: "Search installed fonts"
}
Repeater {
model: root.open ? root.matches : []
SettingRow {
id: candidate
required property var modelData
required property int index
readonly property bool selected: candidate.modelData === root.current
label: candidate.modelData
detail: candidate.selected ? "Currently in use" : ""
controlWidth: 210
divider: candidate.index < root.matches.length - 1
activatable: !candidate.selected
onActivated: root.choose(candidate.modelData)
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 200
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
// The sample is drawn in the candidate family, which is the
// entire reason this is a list rather than a text field.
text: "Handgloves 0123"
font.family: candidate.modelData
font.pixelSize: Theme.fontSize + 1
color: candidate.selected ? Theme.accent : Theme.fgDim
}
}
}
SettingRow {
width: parent.width
visible: root.matches.length === 0
label: root.emptyText
divider: false
}
}
}
@@ -78,6 +78,73 @@ The final card is the ownership boundary. Network configuration and the exact
Users, Sharing, Color profiles, and Digital wellbeing handoffs open GNOME
Settings because Fedora's system services own those areas.
## Appearance
Six tabs, in the order the questions are actually asked: **Themes**, **Theme
editor**, **Background**, **Typography**, **Windows**, **Shell**. Themes leads
because light and dark is the control reached most often, and it used to be the
third section down, under a wallpaper grid and the whole lock screen. The old
`theme` section id still resolves to it, so deep links and IPC calls keep
working.
### Themes
`config/themes.json` is the catalog: ten shipped themes, each a full palette of
nineteen tokens plus sixteen ANSI colors. `services/ThemeCatalog.qml` reads it
with a `FileView` and carries Moon and Day as an embedded fallback, so the
shell renders correctly for the instant before the file loads and forever on a
machine where it is missing.
The tab is two galleries — dark and light — rather than a card each. Selection
is **per mode**: `themeDark` and `themeLight` remember what you chose on each
side, so flipping light and dark lands on your theme for that side rather than
resetting to the shipped defaults.
`services/ThemeProfiles.qml` resolves the active record; `Theme.qml` reads
every one of its nineteen color tokens from `ThemeProfiles.activePalette`.
There are no palette ternaries left in `Theme.qml`, which is what makes a
tenth theme a data change rather than a code change.
### Theme editor
Four wells — primary, secondary, background, foreground — a saturation slider,
an effects card, and the six HSV rows demoted to a fine-tune behind a
disclosure. Background and foreground are never stored alone: the five surfaces
and two text tints are mixed from them, so changing the ground moves the whole
family instead of leaving twelve tokens pointing at the old one.
Every route that writes color — typed hex, the colour wheel, the eyedropper,
the HSV sliders — ends in `ThemeProfiles.commitActive`, which also recomputes
`accentName` as the nearest curated accent. That is what keeps GNOME's accent
enum, kitty's border and the lock screen from going stale after a custom edit.
Editing a shipped theme forks it into a named custom carrying the whole
palette; saving snapshots the palette, the terminal colors, and all ten effect
values into the record.
The HSV rows commit on a **debounce**, not per move. Writing on every move
meant one drag across the hue row spent the whole gesture in apply-and-verify
round trips with the desktop repainting behind the pointer.
### Backgrounds, still and moving
Stills go through hyprpaper. Videos go through mpvpaper, supervised by
`services/VideoWallpaper.qml`, and Panama owns the pause policy rather than the
compositor: the video pauses whenever a game runs, on battery if the preference
says so, and whenever the bar pill is clicked — over mpv's JSON IPC socket, so
resuming does not restart the clip. hyprpaper's service is stopped while a
video plays, because both claim the background layer and stacking within a
layer is creation order. The lock screen gets a cached still frame; hyprlock
cannot play motion, and pretending otherwise would show a black screen.
### The honest titlebar
There is no minimize or maximize control, and no setting for one. Hyprland has
no minimize — it receives the request and does nothing with it — and maximize
is noise in a tiler, so `DesktopStyle` pushes a close-only GNOME button layout
and Panama's own Settings titlebar shows one button. `panamaTitlebar` turns
that bar off entirely, leaving the window pure Hyprland: Super+Q closes,
Super+drag moves, Escape still works.
## Adding a setting
One schema entry. That is the whole job.
@@ -141,13 +208,15 @@ or a copied default. Additions to this table require a concrete discoverability
reason and an update to `tests/quickshell/settings-ownership-contract`.
Window border color follows the same ownership rule. The inactive border is a
**scheme-relative role** owned by `ColorScheme.qml`: it changes only to retain
neutral contrast in light and dark modes. The focused Prism border is the
**accent role**, driven by the chosen `accentName` and also owned by
`ColorScheme.qml`: each accent carries a separate pair for light and dark, so
`ColorScheme.qml` restates the focused border alongside the inactive one on
every scheme change, rather than leaving a scheme flip to erase a
user-selected accent.
neutral contrast role — a **scheme-relative role** in the sense that it exists
to stay legible on either ground — owned by `ColorScheme.qml`, and it is drawn
from the active theme's `gutter` rather than from a hardcoded pair. The two
literals that used to live there were correct for two of the ten shipped themes
and for no custom one. The focused Prism border is the **accent role**, driven
by the chosen `accentName` and also owned by `ColorScheme.qml`: each accent
carries a separate pair for light and dark. Both roles are restated on a scheme
change, an accent change, and a theme change, rather than leaving a flip to
erase a user-selected accent or a theme switch to leave the border behind.
## The rows
@@ -40,18 +40,30 @@ Rectangle {
onPageRequested: page => ShellState.openSettings(page)
}
// The one titlebar Panama draws, so it follows Panama's own titlebar
// rules: the close button sits on the configured side, there is no
// minimize (Hyprland has no minimize — the button this bar used to show
// was a lie), and turning the titlebar off leaves the window pure
// Hyprland: Super+Q closes, Super+drag moves, Escape still works.
Rectangle {
id: titlebar
readonly property bool shown: DesktopPreferences.get("panamaTitlebar") !== false
readonly property bool buttonsLeft: DesktopPreferences.get("titlebarButtonSide") === "left"
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: 48
height: shown ? 48 : 0
visible: shown
color: Theme.alpha(Theme.bgDark, 0.97)
border.width: 0
Text {
anchors.left: parent.left
anchors.left: titlebar.buttonsLeft ? undefined : parent.left
anchors.right: titlebar.buttonsLeft ? parent.right : undefined
anchors.leftMargin: 18
anchors.rightMargin: 18
anchors.verticalCenter: parent.verticalCenter
text: "Settings"
color: Theme.fg
@@ -60,24 +72,42 @@ Rectangle {
font.weight: Font.DemiBold
}
Row {
anchors.right: parent.right
Rectangle {
id: closeButton
anchors.left: titlebar.buttonsLeft ? parent.left : undefined
anchors.right: titlebar.buttonsLeft ? undefined : parent.right
anchors.leftMargin: 12
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
spacing: 7
width: 28
height: 28
radius: 9
color: closeMouse.containsMouse || activeFocus
? Theme.alpha(Theme.danger, 0.17) : Theme.alpha(Theme.fg, 0)
border.width: activeFocus ? 2 : 0
border.color: Theme.alpha(Theme.danger, 0.6)
activeFocusOnTab: true
Rectangle {
width: 28; height: 28; radius: 9
color: minMouse.containsMouse ? Theme.alpha(Theme.fg, 0.12) : Theme.alpha(Theme.fg, 0)
Text { anchors.centerIn: parent; text: "—"; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
MouseArea { id: minMouse; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: if (root.hostWindow) root.hostWindow.minimized = true }
Accessible.role: Accessible.Button
Accessible.name: "Close Settings"
Keys.onReturnPressed: ShellState.closeSettings()
Keys.onSpacePressed: ShellState.closeSettings()
Text {
anchors.centerIn: parent
text: "×"
color: closeMouse.containsMouse || closeButton.activeFocus ? Theme.danger : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: 18
}
Rectangle {
width: 28; height: 28; radius: 9
color: closeMouse.containsMouse ? Theme.alpha(Theme.danger, 0.17) : Theme.alpha(Theme.fg, 0)
Text { anchors.centerIn: parent; text: "×"; color: closeMouse.containsMouse ? Theme.danger : Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: 18 }
MouseArea { id: closeMouse; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onClicked: ShellState.closeSettings() }
MouseArea {
id: closeMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: ShellState.closeSettings()
}
}
@@ -0,0 +1,207 @@
// One theme, drawn as the desktop it produces.
//
// A row of flat swatches would name a theme's colors without showing what any
// of them does. So the card is a miniature of the real thing: the ground, a
// window sitting on it wearing the accent as a border, the two text weights
// the shell actually reads with, and only then the four remaining colors as
// dots. Clicking applies it immediately -- there is no preview mode here,
// because the whole shell is the preview.
import QtQuick
import qs.config
import qs.services
Rectangle {
id: root
// A catalog record or a saved profile -- the same shape either way.
required property var theme
signal chosen
signal removed
// A custom profile saved before palettes existed carries none of its own
// and inherits its scheme's default, exactly as ThemeProfiles resolves it.
readonly property var palette: root.theme.palette
?? ThemeCatalog.defaultPalette(root.theme.scheme)
readonly property bool active: root.theme.id === ThemeProfiles.activeId
readonly property bool shipped: root.theme.shipped === true
readonly property bool isDefault: root.theme.id === (root.theme.scheme === "light"
? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark)
implicitHeight: preview.height + caption.height + 4
radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.fg, 0.04)
border.width: 2
border.color: root.activeFocus
? Theme.accentSecondary
: (root.active ? Theme.accent : Theme.alpha(Theme.fg, 0.12))
clip: true
activeFocusOnTab: true
Accessible.role: Accessible.Button
Accessible.name: root.theme.name
Accessible.description: (root.theme.scheme === "light" ? "Light theme" : "Dark theme")
+ (root.active ? ", selected" : "")
Accessible.focusable: true
Accessible.focused: root.activeFocus
Keys.onReturnPressed: root.chosen()
Keys.onSpacePressed: root.chosen()
Rectangle {
id: preview
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 2
height: 84
color: root.palette.bg
border.width: 0
Rectangle {
anchors.fill: parent
anchors.margins: 11
radius: 7
color: root.palette.bgPanel
border.width: 2
border.color: root.theme.accent
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 8
spacing: 7
Rectangle {
width: parent.width * 0.64
height: 4
radius: 2
color: root.palette.fg
border.width: 0
}
Row {
spacing: 4
Repeater {
model: [root.theme.accent, root.theme.secondary,
root.palette.fgDim, root.palette.fgMuted]
Rectangle {
required property var modelData
width: 9
height: 9
radius: 5
color: modelData
border.width: 0
}
}
}
}
}
}
Item {
id: caption
anchors.left: parent.left
anchors.right: parent.right
anchors.top: preview.bottom
anchors.leftMargin: 12
anchors.rightMargin: 10
height: 30
Text {
id: name
anchors.left: parent.left
anchors.right: marks.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
text: root.theme.name + (root.isDefault ? " · today's default" : "")
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Row {
id: marks
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.active
text: "✓"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
// Only saved themes can be removed, and the affordance is a
// separate focus stop so Tab never lands on "delete" when the
// person meant "select".
Rectangle {
id: remove
anchors.verticalCenter: parent.verticalCenter
visible: !root.shipped
width: 22
height: 22
radius: 7
color: removeHover.hovered
? Theme.alpha(Theme.danger, 0.22)
: Theme.alpha(Theme.fg, 0.07)
border.width: remove.activeFocus ? 2 : 1
border.color: remove.activeFocus
? Theme.danger : Theme.alpha(Theme.fg, 0.08)
activeFocusOnTab: visible
Accessible.role: Accessible.Button
Accessible.name: "Delete " + root.theme.name
Accessible.focusable: true
Accessible.focused: remove.activeFocus
Keys.onReturnPressed: root.removed()
Keys.onSpacePressed: root.removed()
Text {
anchors.centerIn: parent
text: "✕"
color: Theme.danger
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
HoverHandler {
id: removeHover
cursorShape: Qt.PointingHandCursor
}
// An exclusive grab, so the delete press does not also reach
// the card's own tap handler underneath and select the theme
// on its way out.
TapHandler {
gesturePolicy: TapHandler.ReleaseWithinBounds
onTapped: root.removed()
}
}
}
}
HoverHandler {
id: cardHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.chosen()
}
}
@@ -0,0 +1,119 @@
// The four colors a theme is actually made of.
//
// Primary and Secondary are the two ends of the Prism accent. Background and
// Foreground are the ground and the text, and neither is stored alone: the
// surfaces (bgDark, bgPanel, bgHighlight, bgPopover, gutter) and the two text
// tints (fgDim, fgMuted) are mixed from them, so changing the ground moves the
// whole family with it rather than leaving twelve tokens pointing at the old
// one.
//
// One eyedropper process and one colour wheel are shared by all four wells --
// the target is remembered rather than duplicated four times.
import QtQuick
import QtQuick.Dialogs
import Quickshell
import Quickshell.Io
import qs.config
import qs.services
Grid {
id: root
width: parent ? parent.width : 620
columns: root.width < 520 ? 1 : 2
spacing: 12
// Which well the wheel or the eyedropper is currently answering.
property string target: "primary"
property string lastError: ""
readonly property real cellWidth: root.columns > 1
? (root.width - root.spacing) / 2 : root.width
function currentColor(which: string): string {
if (which === "primary")
return String(ThemeProfiles.activeProfile.accent);
if (which === "secondary")
return String(ThemeProfiles.activeProfile.secondary);
if (which === "background")
return String(ThemeProfiles.activePalette.bg);
return String(ThemeProfiles.activePalette.fg);
}
// Every route through this component ends here, so the wheel, the
// eyedropper and the typed hex cannot drift apart.
function apply(which: string, hex: string): void {
const value = String(hex).trim().toLowerCase();
if (!/^#[0-9a-f]{6}$/.test(value)) {
root.lastError = "That is not a colour this theme can use.";
return;
}
root.lastError = "";
if (which === "primary")
ThemeProfiles.setAccentPair(value, ThemeProfiles.activeProfile.secondary);
else if (which === "secondary")
ThemeProfiles.setAccentPair(ThemeProfiles.activeProfile.accent, value);
else if (which === "background")
ThemeProfiles.setGroundColors(value, ThemeProfiles.activePalette.fg);
else
ThemeProfiles.setGroundColors(ThemeProfiles.activePalette.bg, value);
}
function pick(which: string): void {
if (screenPicker.running)
return;
root.target = which;
root.lastError = "";
screenPicker.exec(["hyprpicker", "--format=hex", "--lowercase-hex", "--quiet", "--no-fancy"]);
}
function openWheel(which: string): void {
root.target = which;
wheel.selectedColor = root.currentColor(which);
wheel.open();
}
Repeater {
model: [
{ key: "primary", role: "Primary" },
{ key: "secondary", role: "Secondary" },
{ key: "background", role: "Background" },
{ key: "foreground", role: "Foreground" },
]
ColorWell {
required property var modelData
width: root.cellWidth
role: modelData.role
swatchColor: root.currentColor(modelData.key)
picking: screenPicker.running
onCommitted: value => root.apply(modelData.key, value)
onWheelRequested: root.openWheel(modelData.key)
onPickRequested: root.pick(modelData.key)
}
}
ColorDialog {
id: wheel
title: "Choose a colour"
// Qt hands back a color value, not a string; toString() on it is
// "#aarrggbb" when there is alpha, so the channels are read directly.
onAccepted: root.apply(root.target, Qt.rgba(
wheel.selectedColor.r, wheel.selectedColor.g,
wheel.selectedColor.b, 1).toString())
}
Process {
id: screenPicker
stdout: StdioCollector {
onStreamFinished: root.apply(root.target, this.text)
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "Screen colour picking was cancelled or unavailable.";
}
}
}
@@ -0,0 +1,56 @@
// Every theme for one scheme, shipped and saved together.
//
// Saved themes are not a second-class list below the shipped ones: a theme you
// made is a theme, and once it is in the gallery it is chosen the same way. The
// grid drops to two columns on a narrowly tiled window, because a third card at
// 900px is narrower than its own caption.
import QtQuick
import qs.config
import qs.services
Column {
id: root
required property string scheme
property string heading: ""
width: parent ? parent.width : 620
spacing: 10
readonly property var themes: ThemeProfiles.profiles.filter(
profile => profile.scheme === root.scheme)
readonly property int columns: root.width < 640 ? 2 : 3
readonly property real cellWidth: root.columns > 0
? (root.width - (root.columns - 1) * 12) / root.columns : 200
Text {
text: root.heading
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.capitalization: Font.AllUppercase
font.letterSpacing: 0.7
}
Grid {
width: parent.width
columns: root.columns
spacing: 12
Repeater {
model: root.themes
ThemeCard {
required property var modelData
width: root.cellWidth
theme: modelData
onChosen: ThemeProfiles.selectProfile(modelData.id)
onRemoved: ThemeProfiles.deleteProfile(modelData.id)
}
}
}
}
@@ -0,0 +1,186 @@
// Light or dark, as two desktops rather than two words.
//
// This is the control reached most often in the whole of Appearance, so it
// leads the category and it is drawn, not labelled: each card is the scheme's
// remembered theme -- yours, not the shipped default -- rendered as a bar, a
// focused window and the text on it. Choosing a side returns to whatever theme
// you last chose for that side; it never resets you to Moon or Day.
import QtQuick
import qs.config
import qs.services
Row {
id: root
width: parent ? parent.width : 620
spacing: 14
// The record the scheme would land on, which is the honest thing to show.
// themeForScheme already falls back to the catalog default when the
// remembered id no longer exists.
function themeFor(scheme: string): var {
const id = ThemeProfiles.themeForScheme(scheme);
const record = ThemeCatalog.byId(id)
?? ThemeProfiles.customProfiles.find(profile => profile.id === id);
if (record)
return record;
const fallback = ThemeCatalog.byId(scheme === "light"
? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark);
return fallback ?? { scheme: scheme, accent: Theme.accent,
secondary: Theme.accentSecondary, palette: ThemeCatalog.defaultPalette(scheme) };
}
Repeater {
model: [
{ scheme: "dark", label: "Dark" },
{ scheme: "light", label: "Light" },
]
Rectangle {
id: card
required property var modelData
readonly property var theme: root.themeFor(card.modelData.scheme)
readonly property var palette: card.theme.palette
?? ThemeCatalog.defaultPalette(card.modelData.scheme)
readonly property bool selected: ThemeProfiles.scheme === card.modelData.scheme
width: (root.width - root.spacing) / 2
implicitHeight: scene.height + caption.height + 4
radius: Theme.cardRadius + 4
color: Theme.alpha(Theme.fg, 0.04)
border.width: 2
border.color: card.activeFocus
? Theme.accentSecondary
: (card.selected ? Theme.accent : Theme.alpha(Theme.fg, 0.13))
clip: true
activeFocusOnTab: true
Accessible.role: Accessible.Button
Accessible.name: card.modelData.label + " mode"
Accessible.description: card.theme.name
? card.theme.name + (card.selected ? ", selected" : "")
: ""
Accessible.focusable: true
Accessible.focused: card.activeFocus
function choose(): void {
ThemeProfiles.setScheme(card.modelData.scheme);
}
Keys.onReturnPressed: card.choose()
Keys.onSpacePressed: card.choose()
Rectangle {
id: scene
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 2
height: 110
color: card.palette.bg
border.width: 0
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 15
spacing: 10
// The bar, edge to edge on the real desktop and so drawn
// wide and short here.
Rectangle {
width: parent.width * 0.6
height: 8
radius: 4
color: card.palette.bgDark
border.width: 1
border.color: card.palette.bgHighlight
}
Rectangle {
width: parent.width
height: 56
radius: 8
color: card.palette.bgPanel
border.width: 2
border.color: card.theme.accent
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 9
spacing: 6
Rectangle {
width: parent.width * 0.6
height: 5
radius: 3
color: card.palette.fg
border.width: 0
}
Rectangle {
width: parent.width * 0.42
height: 5
radius: 3
color: card.palette.fgDim
border.width: 0
}
}
}
}
}
Item {
id: caption
anchors.left: parent.left
anchors.right: parent.right
anchors.top: scene.bottom
anchors.leftMargin: 14
anchors.rightMargin: 12
height: 34
Text {
anchors.left: parent.left
anchors.right: tick.left
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
text: card.modelData.label
+ (card.theme.name ? " · " + card.theme.name : "")
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
id: tick
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: card.selected
text: "✓"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
}
HoverHandler {
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: card.choose()
}
}
}
}
@@ -1,123 +0,0 @@
// Named themes are the first layer of Appearance: shipped profiles stay
// immutable, while saved profiles can be selected or removed in place.
import QtQuick
import qs.config
import qs.services
Column {
id: root
width: parent ? parent.width : 620
spacing: 0
Repeater {
model: ThemeProfiles.profiles
SettingRow {
id: profileRow
required property var modelData
label: profileRow.modelData.name
detail: (profileRow.modelData.scheme === "light" ? "Light" : "Dark")
+ " · " + profileRow.modelData.accent + " → " + profileRow.modelData.secondary
controlWidth: profileRow.modelData.shipped ? 88 : 170
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
SettingsButton {
id: useButton
text: profileRow.modelData.id === ThemeProfiles.activeProfile.id ? "Selected" : "Use"
enabled: profileRow.modelData.id !== ThemeProfiles.activeProfile.id
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : (tone === "accent" ? 0 : 1)
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: ThemeProfiles.selectProfile(profileRow.modelData.id)
Keys.onReturnPressed: if (enabled) ThemeProfiles.selectProfile(profileRow.modelData.id)
Keys.onSpacePressed: if (enabled) ThemeProfiles.selectProfile(profileRow.modelData.id)
}
SettingsButton {
id: deleteButton
visible: !profileRow.modelData.shipped
text: "Delete"
activeFocusOnTab: visible
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.danger : Theme.alpha(Theme.fg, 0.08)
onClicked: ThemeProfiles.deleteProfile(profileRow.modelData.id)
Keys.onReturnPressed: ThemeProfiles.deleteProfile(profileRow.modelData.id)
Keys.onSpacePressed: ThemeProfiles.deleteProfile(profileRow.modelData.id)
}
}
}
}
SettingRow {
label: "Save current theme"
detail: "Stores this scheme and accent pair under a unique name"
divider: false
controlWidth: 292
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
Rectangle {
width: 196
height: 31
radius: 8
color: Theme.alpha(Theme.fg, 0.05)
border.width: nameInput.activeFocus ? 2 : 1
border.color: nameInput.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12)
TextInput {
id: nameInput
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
activeFocusOnTab: true
verticalAlignment: TextInput.AlignVCenter
maximumLength: 40
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.35)
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
Accessible.name: "Theme profile name"
Text {
anchors.verticalCenter: parent.verticalCenter
visible: nameInput.text === "" && !nameInput.activeFocus
text: "Theme name"
color: Theme.fgMuted
font: nameInput.font
}
onAccepted: saveButton.save()
}
}
SettingsButton {
id: saveButton
text: "Save"
tone: "accent"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 0
border.color: activeFocus ? Theme.fg : "transparent"
function save(): void {
if (ThemeProfiles.saveProfile(nameInput.text))
nameInput.text = "";
}
onClicked: save()
Keys.onReturnPressed: save()
Keys.onSpacePressed: save()
}
}
}
}
@@ -0,0 +1,191 @@
// Saturation, as a nudge rather than a stored number.
//
// There is no saturation preference: `setSaturation` re-saturates the palette
// that is in effect right now, which makes it compounding -- committing 1.5
// twice really is 2.25. So the row keeps the reading it has shown you and
// commits the RATIO between the old reading and the new one, which makes the
// number on screen an honest total rather than a lever that lies the second
// time it is pulled. Selecting a different theme resets the reading to neutral,
// because the new palette is a new ground.
//
// The commit happens on release only. A drag through 0.6 → 0.9 → 1.4 would
// otherwise rewrite all nineteen tokens at every pixel, and each rewrite is
// lossy: HSV saturation cannot be recovered once it has been rounded to a hex.
import QtQuick
import qs.config
import qs.services
SettingRow {
id: root
label: "Saturation"
detail: "Shifts every derived surface and text tone together"
controlWidth: 280
// 1.0 is the palette as it stands. Range 0 2, shown as 0 200%.
property real shown: 1
property real committed: 1
readonly property real maximum: 2
// A theme change replaces the ground this row was measuring against.
readonly property string activeId: ThemeProfiles.activeId
onActiveIdChanged: {
root.shown = 1;
root.committed = 1;
}
function apply(): void {
if (Math.abs(root.shown - root.committed) < 0.005)
return;
// Dividing by the last reading turns an absolute slider into the
// relative factor the service actually takes.
const factor = root.committed > 0.02 ? root.shown / root.committed : root.maximum;
if (ThemeProfiles.setSaturation(Math.max(0, Math.min(root.maximum, factor))))
root.committed = root.shown;
else
root.shown = root.committed;
}
function moveTo(ratio: real): void {
root.shown = Math.round(Math.max(0, Math.min(1, ratio)) * root.maximum * 100) / 100;
}
function step(direction: int): void {
root.moveTo((root.shown + direction * 0.05) / root.maximum);
root.apply();
}
Item {
id: control
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 270
height: 32
activeFocusOnTab: true
Accessible.role: Accessible.Slider
Accessible.name: root.label
Accessible.description: Math.round(root.shown * 100) + " percent, range 0 to 200"
Accessible.focusable: true
Accessible.focused: control.activeFocus
Accessible.onIncreaseAction: root.step(1)
Accessible.onDecreaseAction: root.step(-1)
Keys.onPressed: event => {
if (event.key === Qt.Key_Left || event.key === Qt.Key_Down) {
root.step(-1);
event.accepted = true;
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_Up) {
root.step(1);
event.accepted = true;
} else if (event.key === Qt.Key_Home) {
root.moveTo(0);
root.apply();
event.accepted = true;
} else if (event.key === Qt.Key_End) {
root.moveTo(1);
root.apply();
event.accepted = true;
}
}
Rectangle {
id: frame
anchors.left: parent.left
anchors.right: readout.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
height: 24
radius: 9
color: "transparent"
border.width: control.activeFocus ? 2 : 1
border.color: control.activeFocus
? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.08)
// Written out rather than reusing ValueSlider: this row needs the
// release, and ValueSlider reports only movement.
Rectangle {
id: track
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 4
height: 10
radius: 5
color: Theme.alpha(Theme.fg, 0.12)
border.width: 0
Rectangle {
id: fill
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width * Math.max(0, Math.min(1, root.shown / root.maximum))
radius: parent.radius
border.width: 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.accent }
GradientStop { position: 1.0; color: Theme.accentSecondary }
}
}
Rectangle {
width: 16
height: 16
radius: 8
border.width: 0
color: Theme.fg
anchors.verticalCenter: parent.verticalCenter
x: Math.max(0, Math.min(parent.width - width, fill.width - width / 2))
visible: drag.containsMouse || drag.pressed
}
MouseArea {
id: drag
anchors.fill: parent
anchors.margins: -8
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
function ratioAt(mouseX: real): real {
return (mouseX - 8) / track.width;
}
onPressed: event => root.moveTo(drag.ratioAt(event.x))
onPositionChanged: event => {
if (drag.pressed)
root.moveTo(drag.ratioAt(event.x));
}
onReleased: root.apply()
onWheel: event => {
root.moveTo((root.shown + (event.angleDelta.y > 0 ? 0.05 : -0.05))
/ root.maximum);
root.apply();
}
}
}
}
Text {
id: readout
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 56
horizontalAlignment: Text.AlignRight
text: Math.round(root.shown * 100) + "%"
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
}
}
@@ -0,0 +1,105 @@
// Naming what you built.
//
// Saving snapshots the palette, the terminal colours and the ten effect values
// as they stand, so a saved theme restores the whole look rather than two
// accent hexes. Delete appears only for a saved theme, because a shipped one
// cannot be removed -- it is read from the catalog every launch.
import QtQuick
import qs.config
import qs.services
SettingRow {
id: root
readonly property bool custom: ThemeProfiles.activeProfile.shipped !== true
label: "Save as"
detail: root.custom
? "Saving again under a new name keeps both"
: "Editing a shipped theme forks it; give the fork a name to keep it"
divider: false
controlWidth: root.custom ? 380 : 292
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
Rectangle {
width: 196
height: 31
radius: 8
color: Theme.alpha(Theme.fg, 0.05)
border.width: nameInput.activeFocus ? 2 : 1
border.color: nameInput.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12)
TextInput {
id: nameInput
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
activeFocusOnTab: true
verticalAlignment: TextInput.AlignVCenter
maximumLength: 40
color: Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.35)
selectedTextColor: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
Accessible.role: Accessible.EditableText
Accessible.name: "Theme name"
Text {
anchors.verticalCenter: parent.verticalCenter
visible: nameInput.text === "" && !nameInput.activeFocus
text: "Theme name"
color: Theme.fgMuted
font: nameInput.font
}
onAccepted: saveButton.save()
}
}
SettingsButton {
id: saveButton
text: "Save theme"
tone: "accent"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 0
border.color: activeFocus ? Theme.fg : "transparent"
function save(): void {
if (ThemeProfiles.saveProfile(nameInput.text))
nameInput.text = "";
}
onClicked: saveButton.save()
Keys.onReturnPressed: saveButton.save()
Keys.onSpacePressed: saveButton.save()
}
SettingsButton {
id: deleteButton
visible: root.custom
text: "Delete"
tone: "danger"
activeFocusOnTab: visible
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.danger : Theme.alpha(Theme.danger, 0.45)
function remove(): void {
ThemeProfiles.deleteProfile(ThemeProfiles.activeId);
}
onClicked: deleteButton.remove()
Keys.onReturnPressed: deleteButton.remove()
Keys.onSpacePressed: deleteButton.remove()
}
}
}
@@ -0,0 +1,98 @@
// Where the theme you are editing came from.
//
// Editing a shipped theme forks it into a saved copy rather than changing what
// ships, so "start from" is the honest name for this row: picking a chip
// abandons the current edits and begins again from that theme. Only themes of
// the current scheme are offered -- a light theme cannot be the starting point
// for a dark one without also flipping the desktop underneath the editor.
import QtQuick
import qs.config
import qs.services
Flow {
id: root
width: parent ? parent.width : 620
spacing: 8
readonly property var themes: ThemeProfiles.profiles.filter(
profile => profile.scheme === ThemeProfiles.scheme)
Repeater {
model: root.themes
Rectangle {
id: chip
required property var modelData
readonly property bool active: chip.modelData.id === ThemeProfiles.activeId
implicitWidth: caption.implicitWidth + dot.width + 26
implicitHeight: 31
radius: Theme.pillRadius
color: chip.active
? Theme.alpha(Theme.accent, 0.12)
: Theme.alpha(Theme.fg, chipHover.hovered ? 0.12 : 0.06)
border.width: chip.activeFocus || chip.active ? 2 : 1
border.color: chip.activeFocus
? Theme.accentSecondary
: (chip.active ? Theme.accent : Theme.alpha(Theme.fg, 0.1))
activeFocusOnTab: true
Accessible.role: Accessible.Button
Accessible.name: "Start from " + chip.modelData.name
Accessible.focusable: true
Accessible.focused: chip.activeFocus
function choose(): void {
ThemeProfiles.selectProfile(chip.modelData.id);
}
Keys.onReturnPressed: chip.choose()
Keys.onSpacePressed: chip.choose()
// The gradient, not a flat dot: the pair is what the chip stands
// for, exactly as on the accent swatches it replaces.
Rectangle {
id: dot
anchors.left: parent.left
anchors.leftMargin: 7
anchors.verticalCenter: parent.verticalCenter
width: 17
height: 17
radius: 9
border.width: 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: chip.modelData.accent }
GradientStop { position: 1.0; color: chip.modelData.secondary }
}
}
Text {
id: caption
anchors.left: dot.right
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: chip.modelData.name
color: chip.active ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: chip.active ? Font.DemiBold : Font.Medium
}
HoverHandler {
id: chipHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: chip.choose()
}
}
}
}
@@ -34,16 +34,24 @@ Item {
property var setAssignmentAction: function(output, path) { Wallpaper.setAssignment(output, path); }
readonly property int collapsedRows: 2
readonly property var shown: root.expanded
? Wallpaper.available
: Wallpaper.available.slice(0, root.columns * root.collapsedRows)
// Videos join the grid only in single mode — slideshow and per-display
// are hyprpaper's modes and stills-only.
readonly property var catalog: root.mode === "single"
? Wallpaper.available.concat(VideoWallpaper.candidates)
: Wallpaper.available
readonly property int hidden: Wallpaper.available.length - root.shown.length
readonly property var shown: root.expanded
? root.catalog
: root.catalog.slice(0, root.columns * root.collapsedRows)
readonly property int hidden: root.catalog.length - root.shown.length
readonly property int columns: Math.max(2, Math.floor(width / 190))
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
function isCurrent(path: string): bool {
if (VideoWallpaper.active)
return VideoWallpaper.path === path;
return root.activeByOutput[root.selectedOutput] === path;
}
@@ -80,6 +88,7 @@ Item {
required property var modelData
readonly property bool current: root.isCurrent(tile.modelData)
readonly property bool video: VideoWallpaper.isVideo(tile.modelData)
readonly property bool member: root.mode === "slideshow" && root.selected(tile.modelData)
width: root.cellWidth
@@ -93,7 +102,8 @@ Item {
id: thumbnail
anchors.fill: parent
source: "file://" + tile.modelData
visible: !tile.video
source: tile.video ? "" : "file://" + tile.modelData
fillMode: Image.PreserveAspectCrop
asynchronous: true
// Decode to roughly the size actually drawn. Without this a
@@ -120,6 +130,48 @@ Item {
}
}
// A video tile draws a play badge instead of a thumbnail —
// decoding a frame per tile is exactly the cost this feature
// is designed to avoid paying casually.
Rectangle {
anchors.fill: parent
visible: tile.video
radius: parent.radius
gradient: Gradient {
GradientStop { position: 0; color: Theme.alpha(Theme.accent, 0.14) }
GradientStop { position: 1; color: Theme.alpha(Theme.bgDark, 0.9) }
}
Text {
anchors.centerIn: parent
text: "\u{F040A}"
color: Theme.alpha(Theme.fg, 0.8)
font.family: Theme.fontMono
font.pixelSize: 26
}
Rectangle {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 7
width: videoBadge.implicitWidth + 14
height: 20
radius: Theme.pillRadius
color: Theme.alpha(Theme.bgDark, 0.75)
Text {
id: videoBadge
anchors.centerIn: parent
text: "VIDEO"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: 9
font.weight: Font.DemiBold
font.letterSpacing: 0.5
}
}
}
Text {
anchors.centerIn: parent
visible: thumbnail.status === Image.Loading
@@ -83,7 +83,13 @@ PrivacyPage 1.0 PrivacyPage.qml
RegionPage 1.0 RegionPage.qml
SearchPicker 1.0 SearchPicker.qml
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
AccentPicker 1.0 AccentPicker.qml
TextFieldRow 1.0 TextFieldRow.qml
AccentEditor 1.0 AccentEditor.qml
ThemeProfilePicker 1.0 ThemeProfilePicker.qml
ThemeCard 1.0 ThemeCard.qml
ThemeGallery 1.0 ThemeGallery.qml
ThemeModeHero 1.0 ThemeModeHero.qml
ColorWell 1.0 ColorWell.qml
ThemeEditorWells 1.0 ThemeEditorWells.qml
ThemeSaturationRow 1.0 ThemeSaturationRow.qml
ThemeStartChips 1.0 ThemeStartChips.qml
ThemeSaveRow 1.0 ThemeSaveRow.qml
+25 -2
View File
@@ -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),
}
+46 -31
View File
@@ -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
+208 -1
View File
@@ -1,12 +1,14 @@
#!/usr/bin/env bash
# The accent palette, for the shell scripts that need it.
# The accent palette and the theme resolver, for the shell scripts that need
# them.
#
# Sourced, not run:
#
# source "$(dirname "$0")/panama-palette"
# accent_hex orchid dark # -> c099ff
# hex_to_rgb c099ff # -> 192, 153, 255
# eval "$(theme_vars "$(resolve_theme dark)")" # -> pal_bg, ansi_red, ...
#
# Why this exists: the same eight accents were written out by hand in five
# places -- Theme.qml through ThemeProfileModel.js, hypr/looks.lua,
@@ -79,3 +81,208 @@ hex_to_rgb() {
local hex="${1#\#}"
printf '%d, %d, %d' "0x${hex:0:2}" "0x${hex:2:2}" "0x${hex:4:2}"
}
# ── Themes ───────────────────────────────────────────────────────────────────
#
# An accent is one colour; a THEME is the whole palette plus that accent pair.
# Shipped themes live in config/themes.json, custom ones in settings.json's
# themeProfiles array, and the two shell generators that render application
# configuration -- panama-theme-apps and panama-lock -- both have to resolve
# the same active theme from those two files. Doing that twice is how the
# hyprlock colours came to be written out in three places; the resolution
# lives here for the same reason accent_hex does.
# The catalog lives beside the shell config, resolved from this script's own
# location so it is correct wherever the repository is checked out.
PANAMA_THEMES="${PANAMA_THEMES:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)/config/themes.json}"
# Read at call time rather than baked in at source time, so a caller (or a
# test) that sets XDG_CONFIG_HOME after sourcing still gets the right file.
panama_settings_file() {
printf '%s' "${PANAMA_SETTINGS:-${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json}"
}
# The complete theme record for one scheme, as compact JSON:
#
# { id, scheme, accent, secondary, palette: {19 keys}, ansi: {16 keys} }
#
# Resolution order, per scheme: the per-mode preference (themeDark/themeLight),
# then themeProfileId when it names a theme of that scheme, then the catalog's
# default for the scheme. A record found in either place wins only where it
# actually carries a value -- palette and ansi are MERGED over the scheme
# default's, so a custom theme saved with only an accent (every profile stored
# before themes existed) still resolves to a complete palette rather than to
# nothing.
resolve_theme() {
local scheme="${1:-dark}" settings settings_json='{}' catalog_json='{}'
[[ "$scheme" == "light" ]] || scheme="dark"
settings="$(panama_settings_file)"
if [[ -r "$settings" ]] && jq -e 'type == "object"' "$settings" >/dev/null 2>&1; then
settings_json="$(cat "$settings")"
fi
if [[ -r "$PANAMA_THEMES" ]] && jq -e 'type == "object"' "$PANAMA_THEMES" >/dev/null 2>&1; then
catalog_json="$(cat "$PANAMA_THEMES")"
fi
jq -cn --arg scheme "$scheme" --argjson settings "$settings_json" \
--argjson catalog "$catalog_json" '
def shipped: ($catalog.themes // []) | map(select(type == "object"));
def customs: ($settings.themeProfiles // []) | map(select(type == "object"));
def defaultId($s): if $s == "light" then ($catalog.defaultLight // "day")
else ($catalog.defaultDark // "moon") end;
def byId($id): (shipped | map(select(.id == $id)) | first)
// (customs | map(select(.id == $id)) | first);
def forScheme($id): byId($id) | select(. != null and ((.scheme // "dark") == $scheme));
(byId(defaultId($scheme)) // {}) as $base
| ( forScheme($settings[if $scheme == "light" then "themeLight" else "themeDark" end] // "")
// forScheme($settings.themeProfileId // "")
// $base ) as $theme
| { id: ($theme.id // defaultId($scheme)),
scheme: $scheme,
accent: ($theme.accent // $base.accent // ""),
secondary: ($theme.secondary // $base.secondary // ""),
palette: (($base.palette // {}) + ($theme.palette // {})),
ansi: (($base.ansi // {}) + ($theme.ansi // {})) }
' 2>/dev/null \
|| printf '{"id":"","scheme":"%s","accent":"","secondary":"","palette":{},"ansi":{}}' "$scheme"
}
# A theme record as eval-able shell assignments, so a generator reads a colour
# as "$pal_bg" rather than paying a jq invocation per token:
#
# eval "$(theme_vars "$(resolve_theme dark)")"
# printf 'background #%s\n' "$pal_bg"
#
# The optional prefix lets one script hold two themes at once -- Firefox needs
# both schemes in a single stylesheet so the browser can flip on its own.
#
# Every value is validated to six lowercase hex digits before it is emitted and
# anything else becomes the empty string, so the eval can never run something
# a settings file said. A caller must therefore treat "" as "not themed" rather
# than substituting it into a config.
theme_vars() {
local json="${1:-\{\}}" prefix="${2:-}"
jq -r --arg p "$prefix" '
def hex: if type == "string"
and (ascii_downcase | ltrimstr("#") | test("^[0-9a-f]{6}$"))
then (ascii_downcase | ltrimstr("#")) else "" end;
((.palette // {}) | to_entries[]
| select(.key | test("^[A-Za-z][A-Za-z0-9]*$"))
| "\($p)pal_\(.key)=\(.value | hex)"),
((.ansi // {}) | to_entries[]
| select(.key | test("^[A-Za-z][A-Za-z0-9]*$"))
| "\($p)ansi_\(.key)=\(.value | hex)"),
"\($p)theme_accent=\(.accent | hex)",
"\($p)theme_secondary=\(.secondary | hex)",
"\($p)theme_id=\(((.id // "") | if test("^[A-Za-z0-9._-]+$") then . else "" end))"
' <<<"$json" 2>/dev/null || true
}
# First non-empty of the two. Written out because `[[ -n $a ]] && printf $a`
# returns 1 when it is empty, which `set -e` treats as a failure.
hex_or() {
if [[ -n "${1:-}" ]]; then printf '%s' "$1"; else printf '%s' "${2:-}"; fi
}
# Blend two colours: mix_hex <base> <tint> <percent of tint>. Used to derive
# the in-between surfaces (a selected row, a grid tile) that no palette token
# names but every application theme wants.
mix_hex() {
local base="${1#\#}" tint="${2#\#}" percent="${3:-50}" offset out=""
local from to blended
for offset in 0 2 4; do
from=$((16#${base:offset:2}))
to=$((16#${tint:offset:2}))
blended=$(( (from * (100 - percent) + to * percent) / 100 ))
out+="$(printf '%02x' "$blended")"
done
printf '%s' "$out"
}
# Perceived luminance, 0-255. Not the arithmetic mean: green reads far brighter
# than blue at the same value, and the mean puts white text on a yellow accent.
luma_hex() {
local hex="${1#\#}" red green blue
red=$((16#${hex:0:2})); green=$((16#${hex:2:2})); blue=$((16#${hex:4:2}))
printf '%d' $(( (red * 299 + green * 587 + blue * 114) / 1000 ))
}
# Whether dark text belongs on top of this colour.
is_light_hex() {
(( $(luma_hex "$1") >= 140 ))
}
# The colour to draw text in on top of a filled surface -- an accent button, a
# selected row. Takes two of the theme's own colours rather than black and
# white, so the result keeps the theme's cast.
#
# The two candidates are compared by their own luminance rather than trusted to
# arrive in a fixed order. Passing "the dark one" first only works on a dark
# theme: on a light one the foreground IS the dark colour, which is how a light
# theme ended up with blue label text on a blue button.
on_hex() {
local surface="${1:-}" first="${2:-}" second="${3:-}" first_luma second_luma
[[ -n "$first" ]] || { printf '%s' "$second"; return; }
[[ -n "$second" && -n "$surface" ]] || { printf '%s' "$first"; return; }
first_luma="$(luma_hex "$first")"
second_luma="$(luma_hex "$second")"
if is_light_hex "$surface"; then
(( first_luma <= second_luma )) && printf '%s' "$first" || printf '%s' "$second"
else
(( first_luma >= second_luma )) && printf '%s' "$first" || printf '%s' "$second"
fi
}
# The lock screen, rendered from a theme into hyprlock's config language.
#
# This lives here rather than in panama-theme-apps because two other callers
# need the same eight substitutions: setup/scripts/link-dotfiles seeds the
# fallback config at install time, and panama-lock renders the generated one.
# All three used to carry their own table of literal rgba triples, and the copy
# most likely to be missed was the lock screen's -- which fails silently, with
# the machine locked in last season's colour and nothing to say why.
#
# hyprlock takes rgba(r, g, b, a) in DECIMAL rather than hex, which is why the
# template carries "R, G, B" triples. The two _HEX placeholders are the
# exception: they sit inside Pango markup, which wants ##rrggbb.
#
# render_hyprlock <dark|light> <template> <destination> [accent-name]
#
# Returns non-zero without touching the destination when the template is
# unreadable or the theme resolves without a palette -- a half-substituted
# config is not a parse error to hyprlock, it is a set of colours it quietly
# ignores in favour of its own bright grey defaults.
render_hyprlock() {
local scheme="${1:-dark}" template="${2:-}" destination="${3:-}" accent_name="${4:-blue}"
[[ -r "$template" && -n "$destination" ]] || return 1
(
set -euo pipefail
eval "$(theme_vars "$(resolve_theme "$scheme")")"
local accent
accent="$(hex_or "${theme_accent:-}" "$(accent_hex "$accent_name" "$scheme")")"
for value in "${pal_fg:-}" "${pal_fgMuted:-}" "${pal_red:-}" "${pal_bg:-}" \
"${pal_bgPanel:-}" "$accent"; do
[[ -n "$value" ]] || exit 1
done
# Written atomically: a lock triggered mid-write would otherwise read a
# truncated config and fall back to hyprlock's own defaults, which is a
# bright grey screen with none of this desktop's identity.
local temporary="$destination.tmp.$$"
sed -e "s/@FG@/$(hex_to_rgb "$pal_fg")/g" \
-e "s/@MUTED@/$(hex_to_rgb "$pal_fgMuted")/g" \
-e "s/@ACCENT@/$(hex_to_rgb "$accent")/g" \
-e "s/@ERROR@/$(hex_to_rgb "$pal_red")/g" \
-e "s/@BG@/$(hex_to_rgb "$pal_bg")/g" \
-e "s/@FIELD@/$(hex_to_rgb "$pal_bgPanel")/g" \
-e "s/@MUTED_HEX@/$pal_fgMuted/g" \
-e "s/@ERROR_HEX@/$pal_red/g" \
"$template" >"$temporary" 2>/dev/null \
&& mv "$temporary" "$destination" 2>/dev/null \
|| { rm -f "$temporary" 2>/dev/null || true; exit 1; }
)
}
+718 -115
View File
@@ -1,38 +1,52 @@
#!/usr/bin/env bash
# Propagates the color scheme to applications that do not read the desktop
# Propagates the active THEME to applications that do not read the desktop
# portal.
#
# Most modern applications DO follow org.freedesktop.portal.Settings and need
# nothing from us: GTK4/libadwaita, Qt6, Chromium and Electron all read
# org.freedesktop.appearance color-scheme, which xdg-desktop-portal-gtk serves
# from gsettings. Panama sets that, so those follow automatically.
# nothing from us for light-or-dark: GTK4/libadwaita, Qt6, Chromium and
# Electron all read org.freedesktop.appearance color-scheme, which
# xdg-desktop-portal-gtk serves from gsettings. Panama sets that, so those
# follow automatically.
#
# Terminals are the notable exception -- they predate the standard and carry
# their own palettes. kitty is handled here.
# A scheme is not a theme, though. The portal can say "dark"; it cannot say
# "Catppuccin Mocha". Everything below is an application that carries its own
# palette, and every one of them used to be handed a copy of a Tokyo Night file
# -- which is why choosing any other theme restyled the shell and nothing else.
# They are now RENDERED from the active theme's palette instead:
#
# GTK3 is the other one. Under GNOME, gnome-settings-daemon publishes the theme
# over XSETTINGS; under Hyprland nothing does, so ~/.config/gtk-3.0/settings.ini
# is authoritative for GTK3 applications. Pinned to dark, it contradicted the
# scheme in light mode, so it is generated from a template here instead.
# kitty current-theme.conf, plus a live `set-colors` over its socket
# tmux current-theme.conf, re-sourced into running servers
# btop a generated panama.theme, named by btop.conf's color_theme
# hyprlock the eight placeholders in hyprlock.conf.template
# GTK 3/4 the @define-color block in gtk.css, between the markers
# Vicinae panama-dark.toml / panama-light.toml in its data directory
# Firefox an Edge-Frfox override sheet, both schemes in one file
#
# panama-theme-apps dark|light [accent]
#
# The two arguments are the desktop's scheme and its nearest curated accent
# name. They remain the interface because that is what ColorScheme.qml knows
# and what GNOME's accent-color enum needs -- but the palette no longer comes
# from them. This script resolves the active theme itself, from settings.json
# and config/themes.json, through scripts/panama-palette. panama-lock resolves
# it exactly the same way, from the same function.
#
# kitty gets it twice: the generated include file so terminals opened later
# start correct, and a live `set-colors` over its control socket so terminals
# already open change now. Without the second, a scheme change appears to do
# already open change now. Without the second, a theme change appears to do
# nothing until you open a new window.
#
# kitty's active_border_color and hyprlock's focus ring are also the accent
# role, not just the scheme. The lookup lives in scripts/panama-palette,
# which reads config/palette.json -- the one place the eight accents are
# written down for everything outside QML.
# The tokyonight-* files under kitty/themes, tmux/themes and btop/themes are
# still shipped -- a user may name one directly -- but nothing copies them any
# more.
set -euo pipefail
# The accents, from the one file that holds them.
# The accents and the theme resolver, from the one file that holds them.
# shellcheck source=./panama-palette
source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/panama-palette"
script_dir="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
source "$script_dir/panama-palette"
scheme="${1:-dark}"
case "$scheme" in
@@ -50,87 +64,153 @@ case "$accent" in
*) accent=blue ;;
esac
# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by
# hand -- there is no shared source between QML and a shell script), primary
# hue per scheme only: both consumers below draw a flat color, not the
# two-stop gradient the window border does.
config_home="${XDG_CONFIG_HOME:-$HOME/.config}"
data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
# hyprlock wants "R, G, B" decimal, not hex.
# ── The theme ────────────────────────────────────────────────────────────────
# The active one for everything that renders a single palette, and BOTH schemes
# for Firefox -- its stylesheet carries light-dark() pairs so the browser flips
# on its own, without this script being re-run.
eval "$(theme_vars "$(resolve_theme "$scheme")")"
eval "$(theme_vars "$(resolve_theme dark)" dark_)"
eval "$(theme_vars "$(resolve_theme light)" light_)"
accent_border_hex="$(accent_hex "$accent" "$scheme")"
# The accent pair falls back to the curated accent named on the command line.
# A stored custom theme is allowed to carry an accent and no palette; one that
# carries neither is still a real selection and should not render an empty
# colour into a config file.
theme_accent="$(hex_or "${theme_accent:-}" "$(accent_hex "$accent" "$scheme")")"
theme_secondary="$(hex_or "${theme_secondary:-}" "$(accent_secondary_hex "$accent" "$scheme")")"
dark_theme_accent="$(hex_or "${dark_theme_accent:-}" "$(accent_hex "$accent" dark)")"
light_theme_accent="$(hex_or "${light_theme_accent:-}" "$(accent_hex "$accent" light)")"
# Whether there is enough of a palette to render from at all. An unreadable
# themes.json is the only way to get here, and the honest answer is then to
# leave every application's existing colours alone rather than to write half a
# theme over them.
palette_ok=true
for token in bg bgDark bgHighlight bgPanel bgPopover fg fgDim fgMuted gutter \
accentAlt cyan teal green yellow orange red redDeep magenta pink; do
name="pal_$token"
[[ -n "${!name:-}" ]] || palette_ok=false
done
ansi_ok=true
for token in black red green yellow blue magenta cyan white \
brightBlack brightRed brightGreen brightYellow brightBlue \
brightMagenta brightCyan brightWhite; do
name="ansi_$token"
[[ -n "${!name:-}" ]] || ansi_ok=false
done
both_schemes_ok=true
for token in bg bgDark bgHighlight bgPanel bgPopover fg fgDim fgMuted gutter red; do
for side in dark light; do
name="${side}_pal_$token"
[[ -n "${!name:-}" ]] || both_schemes_ok=false
done
done
# GTK and Firefox want rgba(), not hex, wherever a colour carries transparency.
rgba_of() {
printf 'rgba(%s, %s)' "$(hex_to_rgb "$1")" "$2"
}
# Text on a filled accent, in this theme's own grounds rather than in black and
# white: a pastel accent takes the theme's darkest surface, a saturated one
# takes its lightest text.
accent_fg="$(on_hex "$theme_accent" "${pal_bgDark:-}" "${pal_fg:-}")"
# Replace one file in place, but only when the result actually differs, and say
# which happened: "written", "unchanged", or "failed". Several of these targets
# are tracked files reached through the dotfile symlinks, and rewriting them
# with identical bytes on every accent sample would show up as a dirty working
# tree for no change at all.
#
# A word rather than an exit status because every caller is under `set -e`,
# where a helper that reports by returning 2 aborts the script instead.
replace_if_changed() {
local destination="$1" temporary="$2"
if [[ -e "$destination" ]] && cmp -s "$temporary" "$destination"; then
rm -f "$temporary"
printf 'unchanged'
return 0
fi
if mv "$temporary" "$destination" 2>/dev/null; then
printf 'written'
else
rm -f "$temporary"
printf 'failed'
fi
}
# ── hyprlock ─────────────────────────────────────────────────────────────────
# The lock screen. hyprlock is launched fresh on every lock (`pidof hyprlock ||
# hyprlock`), so it reads this file each time and needs no restart.
#
# It takes rgba(r, g, b, a) in DECIMAL, not hex, so the palette is expressed as
# "R, G, B" triples here rather than the hex used everywhere else. The two
# _HEX values are the exception: they sit inside Pango markup, where hyprlock
# wants ##rrggbb.
lock_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hypr"
# The substitutions themselves live in panama-palette's render_hyprlock: this
# script, panama-lock and setup/scripts/link-dotfiles all need them, and when
# each carried its own copy the lock screen was the one that silently kept the
# old colours.
lock_dir="$config_home/hypr"
lock_template="$lock_dir/hyprlock.conf.template"
if [[ "$scheme" == "light" ]]; then
lock_fg="55, 96, 191" # #3760bf
lock_muted="97, 114, 176" # #6172b0
lock_error="245, 42, 101" # #f52a65
lock_bg="225, 226, 231" # #e1e2e7
lock_field="208, 213, 227" # #d0d5e3
lock_muted_hex="6172b0"
lock_error_hex="f52a65"
else
lock_fg="200, 211, 245" # #c8d3f5
lock_muted="130, 139, 184" # #828bb8
lock_error="255, 117, 127" # #ff757f
lock_bg="34, 36, 54" # #222436
lock_field="46, 47, 61" # #2e2f3d
lock_muted_hex="828bb8"
lock_error_hex="ff757f"
fi
# The focus ring is the accent role, not a scheme-relative one -- follows
# accentName the same way scripts/panama-lock's own generator does.
lock_accent="$(hex_to_rgb "$accent_border_hex")"
status_hyprlock="skipped"
if [[ -r "$lock_template" ]]; then
# Written atomically: a lock triggered mid-write would otherwise read a
# truncated config and fall back to hyprlock's own defaults, which is a
# bright gray screen with none of this desktop's identity.
if sed -e "s/@FG@/$lock_fg/g" \
-e "s/@MUTED@/$lock_muted/g" \
-e "s/@ACCENT@/$lock_accent/g" \
-e "s/@ERROR@/$lock_error/g" \
-e "s/@BG@/$lock_bg/g" \
-e "s/@FIELD@/$lock_field/g" \
-e "s/@MUTED_HEX@/$lock_muted_hex/g" \
-e "s/@ERROR_HEX@/$lock_error_hex/g" \
"$lock_template" >"$lock_dir/hyprlock.conf.tmp" 2>/dev/null \
&& mv "$lock_dir/hyprlock.conf.tmp" "$lock_dir/hyprlock.conf" 2>/dev/null; then
if render_hyprlock "$scheme" "$lock_template" "$lock_dir/hyprlock.conf" "$accent"; then
status_hyprlock="written"
else
rm -f "$lock_dir/hyprlock.conf.tmp"
status_hyprlock="failed"
fi
fi
# ── tmux ─────────────────────────────────────────────────────────────────────
# Generated like kitty's: tmux.conf sources current-theme.conf, and that file is
# machine state rather than configuration. Running servers are re-sourced so an
# open session changes now instead of at next launch -- tmux applies a
# source-file to every attached client immediately.
tmux_dir="${XDG_CONFIG_HOME:-$HOME/.config}/tmux"
tmux_theme="$tmux_dir/themes/tokyonight-moon.conf"
[[ "$scheme" == "light" ]] && tmux_theme="$tmux_dir/themes/tokyonight-day.conf"
# tmux.conf sources current-theme.conf, and that file is machine state rather
# than configuration. Running servers are re-sourced so an open session changes
# now instead of at next launch -- tmux applies a source-file to every attached
# client immediately.
tmux_dir="$config_home/tmux"
render_tmux() {
printf '# Generated by Panama from the "%s" theme. Do not edit.\n' "${theme_id:-custom}"
printf '#\n# tmux.conf sources this file; panama-theme-apps rewrites it on every theme\n'
printf '# change. The tokyonight-* files beside it are shipped for anyone who wants\n'
printf '# to source one directly, and are no longer copied here.\n\n'
printf 'set -g mode-style "fg=#%s,bg=#%s"\n\n' "$theme_accent" "$pal_gutter"
printf 'set -g message-style "fg=#%s,bg=#%s"\n' "$theme_accent" "$pal_gutter"
printf 'set -g message-command-style "fg=#%s,bg=#%s"\n\n' "$theme_accent" "$pal_gutter"
printf 'set -g pane-border-style "fg=#%s"\n' "$theme_accent"
printf 'set -g pane-active-border-style "fg=#%s"\n\n' "$theme_secondary"
printf 'set -g status-style "fg=#%s,bg=#%s"\n' "$theme_secondary" "$pal_gutter"
printf 'set -g status-bg "#%s"\n\n' "$pal_bg"
printf 'set -g status-left "#[fg=#%s,bg=#%s,bold] #S #[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]"\n' \
"$ansi_black" "$theme_secondary" "$ansi_black" "$theme_secondary"
printf 'set -g status-right "#[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]#[fg=#%s,bg=#%s] #{prefix_highlight} #[fg=#%s,bg=#%s]#[fg=#%s,bg=#%s] %%Y/%%m/%%d %%I:%%M %%p #[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]#[fg=#%s,bg=#%s,bold,italics] #h "\n\n' \
"$ansi_black" "$theme_accent" "$theme_secondary" "$pal_gutter" \
"$pal_gutter" "$pal_gutter" "$theme_secondary" "$pal_gutter" \
"$theme_accent" "$pal_gutter" "$ansi_black" "$theme_secondary"
printf 'setw -g window-status-format "#[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]#[fg=#%s,bg=#%s] #I #W #F #[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]"\n' \
"$ansi_black" "$pal_gutter" "$theme_accent" "$pal_gutter" "$ansi_black" "$pal_gutter"
printf 'setw -g window-status-current-format "#[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]#[fg=#%s,bg=#%s,bold] #I #W #F #[fg=#%s,bg=#%s,nobold,nounderscore,noitalics]"\n\n' \
"$ansi_black" "$pal_gutter" "$theme_secondary" "$pal_gutter" "$pal_gutter" "$pal_gutter"
printf 'setw -g window-status-separator ""\n'
}
status_tmux="skipped"
if [[ -r "$tmux_theme" ]]; then
if cp "$tmux_theme" "$tmux_dir/current-theme.conf.tmp" 2>/dev/null \
&& mv "$tmux_dir/current-theme.conf.tmp" "$tmux_dir/current-theme.conf" 2>/dev/null; then
status_tmux="written"
if [[ -d "$tmux_dir" ]] && [[ "$palette_ok" == true ]] && [[ "$ansi_ok" == true ]]; then
if render_tmux >"$tmux_dir/current-theme.conf.tmp" 2>/dev/null; then
status_tmux="$(replace_if_changed "$tmux_dir/current-theme.conf" "$tmux_dir/current-theme.conf.tmp")"
# Only if a server is actually running; `tmux source-file` would
# otherwise start one just to theme it.
if command -v tmux >/dev/null 2>&1 && tmux has-session 2>/dev/null; then
if [[ "$status_tmux" != "failed" ]] \
&& command -v tmux >/dev/null 2>&1 && tmux has-session 2>/dev/null; then
tmux source-file "$tmux_dir/current-theme.conf" 2>/dev/null \
&& status_tmux="applied to running sessions"
fi
@@ -141,31 +221,98 @@ if [[ -r "$tmux_theme" ]]; then
fi
# ── btop ─────────────────────────────────────────────────────────────────────
# Only the color_theme line is rewritten, in place. btop OWNS btop.conf -- it
# rewrites the whole file on exit -- so the config is not symlinked into Panama
# and not replaced wholesale here; just this one value is edited, and btop keeps
# it on the next write.
# The theme is generated into btop's own themes directory and btop.conf is
# pointed at it by name. Only the color_theme line is rewritten, in place: btop
# OWNS btop.conf -- it rewrites the whole file on exit -- so the config is not
# symlinked into Panama and not replaced wholesale here.
#
# btop reads its theme once at startup, so a running instance keeps the old
# colors until it is restarted. That is acceptable for a monitor you open when
# you want it, and forcing a restart would kill a process the user is watching.
btop_conf="${XDG_CONFIG_HOME:-$HOME/.config}/btop/btop.conf"
btop_theme="tokyonight-moon"
[[ "$scheme" == "light" ]] && btop_theme="tokyonight-day"
btop_conf="$config_home/btop/btop.conf"
btop_theme_dir="$config_home/btop/themes"
render_btop() {
printf '# Generated by Panama from the "%s" theme. Do not edit.\n' "${theme_id:-custom}"
printf '#\n# The *_start/_mid/_end triples are gradients btop draws meters with: low,\n'
printf '# middle, and high. They run cool -> warm -> hot so a saturated resource is\n'
printf '# obvious at a glance without reading the number.\n\n'
printf 'theme[main_bg]="#%s"\n' "$pal_bg"
printf 'theme[main_fg]="#%s"\n' "$pal_fg"
printf 'theme[title]="#%s"\n' "$pal_fg"
printf 'theme[hi_fg]="#%s"\n' "$theme_accent"
printf 'theme[selected_bg]="#%s"\n' "$pal_gutter"
printf 'theme[selected_fg]="#%s"\n' "$theme_accent"
printf 'theme[inactive_fg]="#%s"\n' "$pal_fgMuted"
printf 'theme[proc_misc]="#%s"\n\n' "$pal_green"
# The four boxes share one hairline, a step brighter than the gutter so the
# frame reads as structure rather than as content.
local box; box="$(mix_hex "$pal_gutter" "$pal_fgDim" 30)"
printf 'theme[cpu_box]="#%s"\n' "$box"
printf 'theme[mem_box]="#%s"\n' "$box"
printf 'theme[net_box]="#%s"\n' "$box"
printf 'theme[proc_box]="#%s"\n' "$box"
printf 'theme[div_line]="#%s"\n\n' "$pal_gutter"
printf 'theme[temp_start]="#%s"\n' "$theme_accent"
printf 'theme[temp_mid]="#%s"\n' "$pal_yellow"
printf 'theme[temp_end]="#%s"\n\n' "$pal_red"
printf 'theme[cpu_start]="#%s"\n' "$theme_accent"
printf 'theme[cpu_mid]="#%s"\n' "$pal_magenta"
printf 'theme[cpu_end]="#%s"\n\n' "$pal_red"
printf 'theme[free_start]="#%s"\n' "$pal_gutter"
printf 'theme[free_mid]="#%s"\n' "$pal_accentAlt"
printf 'theme[free_end]="#%s"\n\n' "$pal_cyan"
printf 'theme[cached_start]="#%s"\n' "$pal_cyan"
printf 'theme[cached_mid]="#%s"\n' "$theme_accent"
printf 'theme[cached_end]="#%s"\n\n' "$pal_magenta"
printf 'theme[available_start]="#%s"\n' "$pal_yellow"
printf 'theme[available_mid]="#%s"\n' "$pal_orange"
printf 'theme[available_end]="#%s"\n\n' "$pal_red"
printf 'theme[used_start]="#%s"\n' "$pal_green"
printf 'theme[used_mid]="#%s"\n' "$pal_yellow"
printf 'theme[used_end]="#%s"\n\n' "$pal_red"
printf 'theme[download_start]="#%s"\n' "$pal_gutter"
printf 'theme[download_mid]="#%s"\n' "$theme_accent"
printf 'theme[download_end]="#%s"\n\n' "$pal_cyan"
printf 'theme[upload_start]="#%s"\n' "$pal_gutter"
printf 'theme[upload_mid]="#%s"\n' "$pal_magenta"
printf 'theme[upload_end]="#%s"\n' "$pal_pink"
}
status_btop="skipped"
if [[ -w "$btop_conf" ]]; then
if sed -i "s|^color_theme *=.*|color_theme = \"$btop_theme\"|" "$btop_conf" 2>/dev/null; then
status_btop="written"
if [[ "$palette_ok" == true ]] && mkdir -p "$btop_theme_dir" 2>/dev/null; then
if render_btop >"$btop_theme_dir/panama.theme.tmp" 2>/dev/null; then
status_btop="$(replace_if_changed "$btop_theme_dir/panama.theme" "$btop_theme_dir/panama.theme.tmp")"
else
rm -f "$btop_theme_dir/panama.theme.tmp"
status_btop="failed"
fi
if [[ "$status_btop" != "failed" && -w "$btop_conf" ]]; then
sed -i 's|^color_theme *=.*|color_theme = "panama"|' "$btop_conf" 2>/dev/null \
|| status_btop="failed"
fi
fi
# ── GTK ──────────────────────────────────────────────────────────────────────
# ── GTK settings.ini ─────────────────────────────────────────────────────────
# adw-gtk3, not Adwaita: no Adwaita GTK theme is installed on Fedora 44, and
# naming a theme that does not exist makes GTK fall back to its light default --
# which made dark mode silently produce light windows.
#
# GTK3 is the reason this file is generated at all. Under GNOME,
# gnome-settings-daemon publishes the theme over XSETTINGS; under Hyprland
# nothing does, so ~/.config/gtk-3.0/settings.ini is authoritative for GTK3
# applications.
if [[ "$scheme" == "light" ]]; then
gtk_theme="adw-gtk3"
prefer_dark=0
@@ -176,7 +323,7 @@ fi
status_gtk="skipped"
for gtk_version in 3.0 4.0; do
gtk_dir="${XDG_CONFIG_HOME:-$HOME/.config}/gtk-$gtk_version"
gtk_dir="$config_home/gtk-$gtk_version"
template="$gtk_dir/settings.ini.template"
[[ -r "$template" ]] || continue
@@ -192,6 +339,138 @@ for gtk_version in 3.0 4.0; do
fi
done
# ── GTK gtk.css ──────────────────────────────────────────────────────────────
# settings.ini names a theme; these are the colours that theme is overridden
# with. libadwaita reads @define-color from the user stylesheet, which is how
# Nautilus, Papers and every other GTK application end up wearing the Panama
# palette rather than adw-gtk3's stock greys.
#
# Only the marked block is rewritten. Everything else in these files is the
# structural CSS of a vendored theme -- a thousand lines of rounding, padding
# and window controls that has nothing to do with colour, and that regenerating
# the file wholesale would destroy.
#
# All three files get the ACTIVE theme rather than one being pinned light and
# another dark. gtk-4.0/gtk.css was a byte-for-byte copy of the dark one, so a
# light desktop drew dark GTK4 windows; rendering the active theme into each is
# what actually fixes that, whichever of the two GTK decides to read.
gtk_css_block() {
local shade sidebar_line
shade="$(rgba_of "$pal_bgDark" 0.25)"
sidebar_line="$(rgba_of "$pal_gutter" 0.36)"
printf '/* Generated from the "%s" theme by panama-theme-apps. Edit the\n' "${theme_id:-custom}"
printf ' * theme, not this block -- the next theme change overwrites it. */\n'
printf '@define-color accent_bg_color #%s;\n' "$theme_accent"
printf '@define-color accent_color #%s;\n' "$pal_accentAlt"
printf '@define-color accent_fg_color #%s;\n' "$accent_fg"
printf '@define-color destructive_bg_color #%s;\n' "$pal_redDeep"
printf '@define-color destructive_color #%s;\n' "$pal_red"
printf '@define-color destructive_fg_color #%s;\n' "$(on_hex "$pal_redDeep" "$pal_bgDark" "$pal_fg")"
printf '@define-color success_bg_color #%s;\n' "$pal_green"
printf '@define-color success_color #%s;\n' "$pal_green"
printf '@define-color success_fg_color #%s;\n' "$(on_hex "$pal_green" "$pal_bgDark" "$pal_fg")"
printf '@define-color warning_bg_color #%s;\n' "$pal_yellow"
printf '@define-color warning_color #%s;\n' "$pal_yellow"
printf '@define-color warning_fg_color #%s;\n' "$(on_hex "$pal_yellow" "$pal_bgDark" "$pal_fg")"
printf '@define-color error_bg_color #%s;\n' "$pal_redDeep"
printf '@define-color error_color #%s;\n' "$pal_red"
printf '@define-color error_fg_color #%s;\n' "$(on_hex "$pal_redDeep" "$pal_bgDark" "$pal_fg")"
printf '@define-color window_bg_color #%s;\n' "$pal_bgPanel"
printf '@define-color window_fg_color #%s;\n' "$pal_fg"
printf '@define-color view_bg_color #%s;\n' "$pal_bg"
printf '@define-color view_fg_color #%s;\n' "$pal_fg"
printf '@define-color headerbar_bg_color #%s;\n' "$pal_bgPanel"
printf '@define-color headerbar_fg_color #%s;\n' "$pal_fg"
printf '@define-color headerbar_border_color #%s;\n' "$pal_gutter"
printf '@define-color headerbar_backdrop_color #%s;\n' "$pal_bgDark"
printf '@define-color headerbar_shade_color %s;\n' "$(rgba_of "$pal_bgDark" 0.08)"
printf '@define-color headerbar_darker_shade_color %s;\n' "$(rgba_of "$pal_bgDark" 0.9)"
printf '@define-color sidebar_bg_color #%s;\n' "$pal_bgDark"
printf '@define-color sidebar_fg_color #%s;\n' "$pal_fg"
printf '@define-color sidebar_backdrop_color #%s;\n' "$pal_bgDark"
printf '@define-color sidebar_shade_color %s;\n' "$(rgba_of "$pal_gutter" 0.08)"
printf '@define-color sidebar_border_color %s;\n' "$sidebar_line"
printf '@define-color secondary_sidebar_bg_color #%s;\n' "$pal_bgDark"
printf '@define-color secondary_sidebar_fg_color #%s;\n' "$pal_fg"
printf '@define-color secondary_sidebar_backdrop_color #%s;\n' "$pal_bgDark"
printf '@define-color secondary_sidebar_shade_color %s;\n' "$shade"
printf '@define-color secondary_sidebar_border_color %s;\n' "$sidebar_line"
printf '@define-color card_bg_color #%s;\n' "$pal_bgHighlight"
printf '@define-color card_fg_color #%s;\n' "$pal_fg"
printf '@define-color card_shade_color %s;\n' "$shade"
printf '@define-color dialog_bg_color #%s;\n' "$pal_bgPopover"
printf '@define-color dialog_fg_color #%s;\n' "$pal_fg"
printf '@define-color popover_bg_color #%s;\n' "$pal_bgPopover"
printf '@define-color popover_fg_color #%s;\n' "$pal_fg"
printf '@define-color popover_shade_color %s;\n' "$shade"
printf '@define-color thumbnail_bg_color #%s;\n' "$pal_bgHighlight"
printf '@define-color thumbnail_fg_color #%s;\n' "$pal_fg"
printf '@define-color shade_color %s;\n' "$shade"
printf '@define-color scrollbar_outline_color %s;\n' "$(rgba_of "$pal_bgDark" 0.5)"
}
# Substitutes the block between the markers, leaving every other byte alone.
# The block is handed over in a file rather than through `awk -v`, which
# processes backslash escapes in the value it is given.
write_gtk_block() {
local target="$1" block_file="$2" temporary="$1.tmp"
if ! grep -q 'PANAMA THEME BEGIN' "$target" 2>/dev/null \
|| ! grep -q 'PANAMA THEME END' "$target" 2>/dev/null; then
printf 'no marker'
return 0
fi
awk -v block="$block_file" '
index($0, "PANAMA THEME BEGIN") {
print
while ((getline line < block) > 0) print line
close(block)
skipping = 1
next
}
index($0, "PANAMA THEME END") { skipping = 0; print; next }
!skipping { print }
' "$target" >"$temporary" 2>/dev/null || true
if [[ ! -s "$temporary" ]]; then
rm -f "$temporary"
printf 'failed'
return 0
fi
replace_if_changed "$target" "$temporary"
}
status_gtkcss="skipped"
if [[ "$palette_ok" == true ]]; then
gtk_block_file="$(mktemp "${TMPDIR:-/tmp}/panama-gtk-block.XXXXXX")"
if gtk_css_block >"$gtk_block_file" 2>/dev/null; then
for gtk_css in "$config_home/gtk-3.0/gtk.css" \
"$config_home/gtk-4.0/gtk.css" \
"$config_home/gtk-4.0/gtk-dark.css"; do
[[ -w "$gtk_css" ]] || continue
gtk_css_result="$(write_gtk_block "$gtk_css" "$gtk_block_file")"
# The worst outcome across the three files is the one worth
# reporting: two written and one missing its markers is not
# "written".
case "$gtk_css_result" in
failed) status_gtkcss="failed" ;;
"no marker") [[ "$status_gtkcss" == "failed" ]] || status_gtkcss="no marker" ;;
unchanged) [[ "$status_gtkcss" == "skipped" ]] && status_gtkcss="unchanged" || true ;;
written) [[ "$status_gtkcss" == "skipped" || "$status_gtkcss" == "unchanged" ]] \
&& status_gtkcss="written" || true ;;
esac
done
else
status_gtkcss="failed"
fi
rm -f "$gtk_block_file"
fi
# ── libadwaita accent ────────────────────────────────────────────────────────
# Files, Papers, Loupe and every other libadwaita application read their accent
# from the Settings portal, not from anything Panama draws, so without this they
@@ -199,10 +478,9 @@ done
#
# GNOME's accent-color is a FIXED enum of nine names -- there is no hex here to
# match, only a nearest member to pick. Which one each Panama accent maps to is
# recorded in config/palette.json alongside its colours, for the same reason
# accent lookup once was: there was no shared source between QML and shell.
# There is now -- config/palette.json -- and palette-contract fails when the
# QML table and it disagree.
# recorded in config/palette.json alongside its colours, and this is the one
# place the command line's accent NAME is still the input rather than the
# theme's own hex: an enum cannot take a colour.
#
# The portal must route Settings to the gnome backend for this to reach
# anything; xdg-desktop-portal-gtk cannot serve accent-color at all. See
@@ -222,41 +500,350 @@ if command -v gsettings >/dev/null 2>&1; then
fi
fi
kitty_dir="${XDG_CONFIG_HOME:-$HOME/.config}/kitty"
theme_file="$kitty_dir/themes/tokyonight-moon.conf"
[[ "$scheme" == "light" ]] && theme_file="$kitty_dir/themes/tokyonight-day.conf"
# ── Vicinae ──────────────────────────────────────────────────────────────────
# The launcher selects a theme per system appearance, so both schemes are
# rendered every time and vicinae.json names them permanently. They are written
# straight into Vicinae's data directory rather than into the repository's
# themes folder: that folder is symlinked into ~/.local/share/vicinae/themes,
# and a generated file inside it would be a machine's colours showing up as a
# change to the repository.
vicinae_dir="$data_home/vicinae/themes"
render_vicinae() {
(
set -euo pipefail
eval "$(theme_vars "$(resolve_theme "$1")")"
local variant="$1"
local core_accent core_secondary selection secondary_selection tile on_accent
core_accent="$(hex_or "${theme_accent:-}" "$(accent_hex "$2" "$1")")"
core_secondary="$(hex_or "${theme_secondary:-}" "$(accent_secondary_hex "$2" "$1")")"
on_accent="$(on_hex "$core_accent" "$pal_bgDark" "$pal_fg")"
# The two halves of the Prism selection, mixed into the highlight
# surface rather than named as tokens -- no palette key describes
# "a row that is selected".
selection="$(mix_hex "$pal_bgHighlight" "$core_accent" 30)"
secondary_selection="$(mix_hex "$pal_bgHighlight" "$core_secondary" 30)"
tile="$(mix_hex "$pal_bg" "$pal_bgHighlight" 50)"
printf '# Generated by Panama from the "%s" theme. Do not edit.\n' "${theme_id:-custom}"
printf '# panama-theme-apps rewrites this file on every theme change.\n\n'
printf '[meta]\nversion = 1\n'
printf 'name = "Panama %s"\n' "$variant"
printf 'description = "The active Panama theme, rendered for Vicinae."\n'
printf 'variant = "%s"\n\n' "$variant"
printf '[colors.core]\n'
printf 'background = "#%s"\n' "$pal_bg"
printf 'foreground = "#%s"\n' "$pal_fg"
printf 'secondary_background = "#%s"\n' "$pal_bgDark"
printf 'border = "#%s"\n' "$pal_gutter"
printf 'accent = "#%s"\n' "$core_accent"
printf 'accent_foreground = "#%s"\n\n' "$on_accent"
printf '[colors.accents]\n'
# The terminal blue, not accentAlt: on a light theme accentAlt is often
# the same cyan the next line already names.
printf 'blue = "#%s"\n' "$(hex_or "${ansi_blue:-}" "$pal_accentAlt")"
printf 'green = "#%s"\n' "$pal_green"
printf 'magenta = "#%s"\n' "$pal_magenta"
printf 'orange = "#%s"\n' "$pal_orange"
printf 'purple = "#%s"\n' "$pal_pink"
printf 'red = "#%s"\n' "$pal_red"
printf 'yellow = "#%s"\n' "$pal_yellow"
printf 'cyan = "#%s"\n\n' "$pal_cyan"
printf '[colors.main_window]\n'
printf 'border = "#%s"\n' "$pal_gutter"
printf 'footer = { background = "colors.core.secondary_background" }\n\n'
printf '[colors.settings_window]\n'
printf 'border = "#%s"\n\n' "$pal_gutter"
printf '[colors.shortcut]\n'
printf 'border = "colors.core.border"\n\n'
printf '[colors.text]\n'
printf 'default = "colors.core.foreground"\n'
printf 'muted = "#%s"\n' "$pal_fgDim"
printf 'danger = "#%s"\n' "$pal_red"
printf 'success = "#%s"\n' "$pal_green"
printf 'placeholder = "#%s"\n' "$pal_fgMuted"
printf 'selection = { background = "#%s", foreground = "#%s" }\n\n' "$core_accent" "$on_accent"
printf '[colors.text.links]\n'
printf 'default = "#%s"\n' "$core_accent"
printf 'visited = "#%s"\n\n' "$pal_magenta"
printf '[colors.input]\n'
printf 'border = "#%s"\n' "$pal_gutter"
printf 'border_focus = "#%s"\n' "$core_accent"
printf 'border_error = "#%s"\n\n' "$pal_red"
printf '[colors.button.primary]\n'
printf 'background = "#%s"\n' "$pal_bgHighlight"
printf 'foreground = "#%s"\n' "$pal_fg"
printf 'hover = { background = "#%s" }\n' "$pal_gutter"
printf 'focus = { outline = "colors.core.accent" }\n\n'
printf '[colors.list.item.hover]\n'
printf 'foreground = "#%s"\n' "$pal_fg"
printf 'secondary_foreground = "#%s"\n\n' "$pal_fgDim"
printf '[colors.list.item.selection]\n'
printf 'background = "#%s"\n' "$selection"
printf 'foreground = "#%s"\n' "$pal_fg"
printf 'secondary_background = "#%s"\n' "$secondary_selection"
printf 'secondary_foreground = "#%s"\n\n' "$pal_fg"
printf '[colors.grid.item]\n'
printf 'background = "#%s"\n' "$tile"
printf 'hover = { outline = "#%s" }\n' "$core_accent"
printf 'selection = { outline = "#%s" }\n\n' "$core_secondary"
printf '[colors.scrollbars]\n'
printf 'background = "#%s"\n\n' "$pal_gutter"
printf '[colors.loading]\n'
printf 'bar = "#%s"\n' "$core_accent"
printf 'spinner = "#%s"\n' "$pal_fg"
)
}
status_vicinae="skipped"
if [[ "$both_schemes_ok" == true ]] && mkdir -p "$vicinae_dir" 2>/dev/null; then
status_vicinae="written"
for vicinae_scheme in dark light; do
target="$vicinae_dir/panama-$vicinae_scheme.toml"
if render_vicinae "$vicinae_scheme" "$accent" >"$target.tmp" 2>/dev/null; then
[[ "$(replace_if_changed "$target" "$target.tmp")" == "failed" ]] \
&& status_vicinae="failed" || true
else
rm -f "$target.tmp"
status_vicinae="failed"
fi
done
fi
# ── Firefox ──────────────────────────────────────────────────────────────────
# Edge-Frfox, the vendored chrome theme, takes its colours from GTK system
# colours on Linux -- AccentColor, -moz-dialog, Field. Those follow the portal,
# which knows light from dark and nothing about which theme is on. So Firefox
# was the one surface that stayed grey-blue whatever Panama was wearing.
#
# This file is generated beside custom.css and imported by it, rather than being
# custom.css itself: custom.css is the seam a person edits, and regenerating it
# on every theme change would eat their overrides. It carries BOTH schemes as
# light-dark() pairs, so Firefox flips with the portal without this script
# running again -- and so a private window, which forces its own dark scheme,
# is themed too.
#
# Chrome only. about: pages are styled from userContent.css, which is an
# upstream file and does not import this one.
firefox_chrome="${PANAMA_FIREFOX_CHROME:-$(cd "$script_dir/../../../.." 2>/dev/null && pwd)/config/firefox/chrome}"
render_firefox() {
# light-dark() pairs, built from the two resolved palettes. Firefox picks
# the half that matches the chrome's own color-scheme.
pair() {
local light="light_pal_$1" dark="dark_pal_$1"
printf 'light-dark(#%s, #%s)' "${!light}" "${!dark}"
}
local accents on_light on_dark pressed
accents="light-dark(#$light_theme_accent, #$dark_theme_accent)"
on_light="$(on_hex "$light_theme_accent" "$light_pal_bgDark" "$light_pal_fg")"
on_dark="$(on_hex "$dark_theme_accent" "$dark_pal_bgDark" "$dark_pal_fg")"
# A pressed button has to read as a step past hover, and no palette token
# sits between the gutter and the text -- so it is mixed.
pressed="light-dark(#$(mix_hex "$light_pal_gutter" "$light_pal_fg" 15), #$(mix_hex "$dark_pal_gutter" "$dark_pal_fg" 15))"
printf '/* Generated by panama-theme-apps. Do not edit -- custom.css imports this,\n'
printf ' * and the next theme change overwrites it. Local Firefox tweaks belong in\n'
printf ' * custom.css, which is never regenerated.\n'
printf ' *\n'
printf ' * Both schemes are here as light-dark() pairs so the browser follows the\n'
printf ' * portal on its own. :root:root out-specifies Edge-Frfox colors.css, which\n'
printf ' * uses :root:not([lwtheme]) -- a plain :root would lose the tie. */\n\n'
printf ':root:root:not([lwtheme]),\n'
printf ':root:root[privatebrowsingmode="temporary"] {\n'
printf ' /* Toolbars */\n'
printf ' --lwt-accent-color: %s !important;\n' "$(pair bg)"
printf ' --lwt-accent-color-inactive: %s !important;\n' "$(pair bgDark)"
printf ' --lwt-text-color: %s !important;\n' "$(pair fg)"
printf ' --lwt-text-color-inactive: %s !important;\n' "$(pair fgDim)"
printf ' --toolbar-bgcolor: %s !important;\n' "$(pair bgPanel)"
printf ' --toolbar-non-lwt-bgcolor: var(--toolbar-bgcolor) !important;\n'
printf ' --toolbar-color: %s !important;\n' "$(pair fg)"
printf ' --toolbarbutton-icon-fill: var(--toolbar-color) !important;\n'
printf ' --toolbarbutton-icon-fill-attention: %s !important;\n' "$accents"
printf ' --chrome-content-separator-color: %s !important;\n' "$(pair gutter)"
printf ' --newtab-background-color: %s !important;\n' "$(pair bg)"
printf ' /* Tabs */\n'
printf ' --tab-selected-bgcolor: var(--toolbar-bgcolor) !important;\n'
printf ' --tab-selected-textcolor: %s !important;\n' "$(pair fg)"
printf ' --tab-selected-textcolor-inactive: %s !important;\n' "$(pair fgDim)"
printf ' --tab-attention-icon-color: %s !important;\n' "$accents"
printf ' /* URL bar */\n'
printf ' --toolbar-field-background-color: %s !important;\n' "$(pair bgHighlight)"
printf ' --toolbar-field-color: %s !important;\n' "$(pair fg)"
printf ' --toolbar-field-border-color: transparent !important;\n'
printf ' --toolbar-field-focus-background-color: %s !important;\n' "$(pair bgHighlight)"
printf ' --toolbar-field-focus-color: %s !important;\n' "$(pair fg)"
printf ' --toolbar-field-focus-border-color: %s !important;\n' "$accents"
printf ' --urlbar-box-bgcolor: %s !important;\n' "$(pair bgPopover)"
printf ' --urlbar-box-hover-bgcolor: %s !important;\n' "$(pair bgHighlight)"
printf ' --urlbar-box-active-bgcolor: %s !important;\n' "$(pair gutter)"
printf ' --urlbar-box-focus-bgcolor: var(--urlbar-box-hover-bgcolor) !important;\n'
printf ' --link-color: %s !important;\n' "$accents"
printf ' --uc-urlbarView-accent-color: %s !important;\n' "$accents"
printf ' --urlbarView-highlight-background: %s !important;\n' "$(pair bgHighlight)"
printf ' --urlbarView-highlight-color: %s !important;\n' "$(pair fg)"
printf ' --urlbarView-hover-background: %s !important;\n' "$(pair gutter)"
printf ' --urlbarView-separator-color: %s !important;\n' "$(pair gutter)"
printf ' /* Menus and panels */\n'
printf ' --arrowpanel-background: %s !important;\n' "$(pair bgPopover)"
printf ' --arrowpanel-color: %s !important;\n' "$(pair fg)"
printf ' --arrowpanel-border-color: %s !important;\n' "$(pair gutter)"
printf ' --panel-separator-color: %s !important;\n' "$(pair gutter)"
printf ' --panel-description-color: %s !important;\n' "$(pair fgDim)"
printf ' --panel-disabled-color: %s !important;\n' "$(pair fgMuted)"
printf ' --panel-item-hover-bgcolor: %s !important;\n' "$(pair bgHighlight)"
printf ' --panel-item-active-bgcolor: %s !important;\n' "$(pair gutter)"
printf ' /* Buttons */\n'
printf ' --button-primary-bgcolor: %s !important;\n' "$accents"
printf ' --button-primary-color: light-dark(#%s, #%s) !important;\n' "$on_light" "$on_dark"
printf ' --button-bgcolor: %s !important;\n' "$(pair bgHighlight)"
printf ' --button-color: %s !important;\n' "$(pair fg)"
printf ' --button-hover-bgcolor: %s !important;\n' "$(pair gutter)"
printf ' --button-active-bgcolor: %s !important;\n' "$pressed"
printf ' /* Inputs */\n'
printf ' --focus-outline-color: %s !important;\n' "$accents"
printf ' --input-bgcolor: %s !important;\n' "$(pair bgPopover)"
printf ' --input-color: %s !important;\n' "$(pair fg)"
printf ' --input-border-color: %s !important;\n' "$(pair gutter)"
printf ' --error-text-color: %s !important;\n' "$(pair red)"
printf ' --input-error-border-color: %s !important;\n' "$(pair red)"
printf ' /* Sidebar */\n'
printf ' --sidebar-background-color: %s !important;\n' "$(pair bgPanel)"
printf ' --sidebar-text-color: %s !important;\n' "$(pair fg)"
printf ' --sidebar-border-color: %s !important;\n' "$(pair gutter)"
# Chrome-level dialogs -- the bookmark editor, the print sheet. Edge-Frfox
# points these at the panel colours; the in-content names are what the
# dialog itself reads.
printf '\n &[dialogroot] {\n'
printf ' --in-content-page-background: %s !important;\n' "$(pair bgPopover)"
printf ' --in-content-page-color: %s !important;\n' "$(pair fg)"
printf ' --in-content-accent-color: %s !important;\n' "$accents"
printf ' --in-content-primary-button-background: %s !important;\n' "$accents"
printf ' --in-content-primary-button-text-color: light-dark(#%s, #%s) !important;\n' \
"$on_light" "$on_dark"
printf ' --in-content-button-background: %s !important;\n' "$(pair bgHighlight)"
printf ' --in-content-button-text-color: %s !important;\n' "$(pair fg)"
printf ' --in-content-box-background: %s !important;\n' "$(pair bg)"
printf ' --in-content-box-border-color: %s !important;\n' "$(pair gutter)"
printf ' }\n'
printf '}\n'
}
status_firefox="skipped"
if [[ "$both_schemes_ok" == true && -d "$firefox_chrome" && -w "$firefox_chrome" ]]; then
firefox_target="$firefox_chrome/panama-theme.css"
if render_firefox >"$firefox_target.tmp" 2>/dev/null; then
status_firefox="$(replace_if_changed "$firefox_target" "$firefox_target.tmp")"
else
rm -f "$firefox_target.tmp"
status_firefox="failed"
fi
fi
# ── kitty ────────────────────────────────────────────────────────────────────
# kitty.conf ends with `include current-theme.conf`; that file is machine state
# and is rendered here rather than copied from a per-scheme file, so any theme
# reaches the terminal rather than only the two Tokyo Nights.
kitty_dir="$config_home/kitty"
kitty_theme="$kitty_dir/current-theme.conf"
render_kitty() {
printf '# Generated by Panama from the "%s" theme. Do not edit.\n' "${theme_id:-custom}"
printf '#\n# kitty.conf includes this file; panama-theme-apps rewrites it on every theme\n'
printf '# change and pushes the same colours to running terminals over the kitty\n'
printf '# control socket. The tokyonight-* files beside it are shipped for anyone who\n'
printf '# wants to include one directly, and are no longer copied here.\n\n'
printf 'foreground #%s\n' "$pal_fg"
printf 'background #%s\n' "$pal_bg"
# The classic terminal inversion: selected text wears the foreground as its
# ground. Derived rather than named -- no palette token is "selection".
printf 'selection_foreground #%s\n' "$pal_bg"
printf 'selection_background #%s\n' "$pal_fg"
printf 'cursor #%s\n' "$pal_fg"
printf 'cursor_text_color #%s\n' "$pal_bg"
printf 'url_color #%s\n\n' "$pal_teal"
# The focused border is the accent role; the unfocused one is the gutter,
# the palette's neutral contrast surface.
printf 'active_border_color #%s\n' "$theme_accent"
printf 'inactive_border_color #%s\n\n' "$pal_gutter"
printf 'tab_bar_background #%s\n' "$pal_bg"
printf 'active_tab_foreground #%s\n' "$pal_bgDark"
printf 'active_tab_background #%s\n' "$theme_accent"
printf 'inactive_tab_foreground #%s\n' "$pal_fgDim"
printf 'inactive_tab_background #%s\n\n' "$pal_bgHighlight"
printf 'color0 #%s\n' "$ansi_black"
printf 'color8 #%s\n' "$ansi_brightBlack"
printf 'color1 #%s\n' "$ansi_red"
printf 'color9 #%s\n' "$ansi_brightRed"
printf 'color2 #%s\n' "$ansi_green"
printf 'color10 #%s\n' "$ansi_brightGreen"
printf 'color3 #%s\n' "$ansi_yellow"
printf 'color11 #%s\n' "$ansi_brightYellow"
printf 'color4 #%s\n' "$ansi_blue"
printf 'color12 #%s\n' "$ansi_brightBlue"
printf 'color5 #%s\n' "$ansi_magenta"
printf 'color13 #%s\n' "$ansi_brightMagenta"
printf 'color6 #%s\n' "$ansi_cyan"
printf 'color14 #%s\n' "$ansi_brightCyan"
printf 'color7 #%s\n' "$ansi_white"
printf 'color15 #%s\n' "$ansi_brightWhite"
# 16 and 17 are the extended slots tokyonight uses for orange and a deep
# red; some tools (delta, bat) reference them.
printf 'color16 #%s\n' "$pal_orange"
printf 'color17 #%s\n' "$pal_redDeep"
}
status_kitty="skipped"
if [[ -r "$theme_file" ]]; then
if [[ -d "$kitty_dir" ]] && [[ "$palette_ok" == true ]] && [[ "$ansi_ok" == true ]]; then
# Written atomically: kitty may read this while a new window is starting.
# The accent override is appended after the copied theme rather than
# edited into the tracked theme file: kitty applies settings top to
# bottom, so a later active_border_color line wins, and this file is
# itself generated -- the tracked per-scheme theme stays scheme-only.
if cp "$theme_file" "$kitty_dir/current-theme.conf.tmp" 2>/dev/null \
&& # Anything else on this machine that wants to know. Runs after every generator
# above, so a hook sees the finished state rather than a half-applied one, and
# a broken hook cannot cost you a theme change -- panama-hook reports and steps
# over. See bin/panama-hook.
"${PANAMA_PATH:-$HOME/.local/share/Panama}/bin/panama-hook" theme-set "$scheme" "$accent" || true
printf 'active_border_color #%s\n' "$accent_border_hex" >>"$kitty_dir/current-theme.conf.tmp" \
&& mv "$kitty_dir/current-theme.conf.tmp" "$kitty_dir/current-theme.conf" 2>/dev/null; then
status_kitty="written"
if render_kitty >"$kitty_theme.tmp" 2>/dev/null; then
status_kitty="$(replace_if_changed "$kitty_theme" "$kitty_theme.tmp")"
else
rm -f "$kitty_dir/current-theme.conf.tmp"
rm -f "$kitty_theme.tmp"
status_kitty="failed"
fi
# Live-apply to running terminals. kitty appends its PID to the socket name
# from listen_on, so there is one socket per instance -- "unix:@mykitty"
# alone reaches nothing, which is exactly how this looked like remote
# control being disabled when it was not.
if command -v kitty >/dev/null 2>&1 && command -v ss >/dev/null 2>&1; then
if [[ "$status_kitty" != "failed" ]] \
&& command -v kitty >/dev/null 2>&1 && command -v ss >/dev/null 2>&1; then
applied=0
while read -r socket; do
[[ -n "$socket" ]] || continue
if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1 \
&& kitty @ --to "unix:@${socket#@}" set-colors --override "active_border_color=#$accent_border_hex" >/dev/null 2>&1; then
if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$kitty_theme" >/dev/null 2>&1 \
&& kitty @ --to "unix:@${socket#@}" set-colors --override "active_border_color=#$theme_accent" >/dev/null 2>&1; then
applied=$((applied + 1))
fi
done < <(ss -xl 2>/dev/null | grep -oE '@mykitty[^[:space:]]*' | sort -u)
@@ -264,15 +851,31 @@ printf 'active_border_color #%s\n' "$accent_border_hex" >>"$kitty_dir/current-th
fi
fi
# Anything else on this machine that wants to know. Runs after every generator
# above, so a hook sees the finished state rather than a half-applied one, and
# a broken hook cannot cost you a theme change -- panama-hook reports and steps
# over. See bin/panama-hook.
#
# It used to sit INSIDE the kitty branch's if-condition, which meant it fired
# in the middle of writing kitty's theme file and not at all on a machine
# without a kitty theme to write.
"${PANAMA_PATH:-$HOME/.local/share/Panama}/bin/panama-hook" theme-set "$scheme" "$accent" || true
# Every target reports what actually happened. A helper that says only
# "kitty: applied" while silently skipping three other applications is how a
# half-applied theme goes unnoticed.
jq -cn \
--arg scheme "$scheme" \
--arg theme "${theme_id:-}" \
--arg kitty "$status_kitty" \
--arg gtk "$status_gtk" \
--arg gtkcss "$status_gtkcss" \
--arg btop "$status_btop" \
--arg tmux "$status_tmux" \
--arg hyprlock "$status_hyprlock" \
--arg adwaita "$status_adwaita" \
'{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux, hyprlock: $hyprlock, adwaita: $adwaita}'
--arg vicinae "$status_vicinae" \
--arg firefox "$status_firefox" \
'{scheme: $scheme, theme: $theme, kitty: $kitty, gtk: $gtk, gtkcss: $gtkcss,
btop: $btop, tmux: $tmux, hyprlock: $hyprlock, adwaita: $adwaita,
vicinae: $vicinae, firefox: $firefox}'
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Video wallpaper support: discovery and still frames. Playback itself is
# mpvpaper, launched and supervised by services/VideoWallpaper.qml — this
# helper only answers questions a QML Process shouldn't shell-script inline.
#
# scan <dir> newest 60 videos under the folder, one path per line
# frame <video> <out.png> a single still frame, for the lock screen
#
# The scan mirrors panama-wallpaper-scan's discipline exactly: two levels deep,
# newest first, a hard cap so a dumping-ground folder cannot flood the picker.
set -euo pipefail
command="${1:-}"
case "$command" in
scan)
dir="${2:-}"
[ -d "$dir" ] || exit 0
find "$dir" -maxdepth 2 -type f \
\( -iname '*.mp4' -o -iname '*.mkv' -o -iname '*.webm' \) \
-printf '%T@ %p\n' 2>/dev/null | sort -rn | cut -d' ' -f2- | awk 'NR <= 60'
;;
frame)
video="${2:-}"
out="${3:-}"
[ -f "$video" ] && [ -n "$out" ] || { echo "frame: bad arguments" >&2; exit 2; }
# A second in: the first frame of a loop is often a fade from black.
ffmpeg -hide_banner -loglevel error -ss 1 -i "$video" \
-frames:v 1 -update 1 -y "$out.tmp.png" </dev/null
mv "$out.tmp.png" "$out"
;;
*)
echo "usage: panama-video-wallpaper scan <dir> | frame <video> <out.png>" >&2
exit 2
;;
esac
+82 -37
View File
@@ -1,17 +1,25 @@
pragma Singleton
// Keeps everything that draws a window agreeing about light or dark.
// Keeps everything that draws a window agreeing about the active theme.
//
// The shell repaints itself from Theme.qml the moment the preference changes,
// because every token there is a binding. Two other things draw on this desktop
// and do not read Panama's store:
// The shell repaints itself from Theme.qml the moment a preference changes,
// because every token there is a binding. Three other things draw on this
// desktop and do not read Panama's store:
//
// GTK applications read gsettings color-scheme and gtk-theme
// the compositor draws window borders and shadows
// everything else carries its own palette file, rendered by
// scripts/panama-theme-apps
//
// A toolbar or a window border still wearing the other scheme is more jarring
// than either scheme on its own, which is why this exists rather than the
// setting simply being a Theme property.
// A toolbar or a window border still wearing the previous theme is more jarring
// than any theme on its own, which is why this exists rather than the setting
// simply being a Theme property.
//
// It watches three things, not one: the scheme, the nearest curated accent
// NAME (which is all GNOME's enum can take), and the active theme's palette.
// The third is the reason editing a custom theme's background reaches kitty --
// the id does not change across an edit, and the accent name often does not
// change across a theme selection either.
import Quickshell
import Quickshell.Io
@@ -24,32 +32,54 @@ Singleton {
readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
// A neutral contrast role, not an accent. The focused Prism border belongs
// to the visual theme and must remain untouched when this role changes.
readonly property string inactiveBorderDark: "rgba(3b426199)"
readonly property string inactiveBorderLight: "rgba(a8aecb99)"
readonly property string inactiveBorder: root.dark ? root.inactiveBorderDark : root.inactiveBorderLight
// The focused border follows the chosen accent. `ee` is the shipped alpha
// for the Prism gradient; Theme owns which colors, this owns the form the
// compositor wants them in.
function hyprColor(value: var): string {
// Theme owns which colors; this owns the form the compositor wants them in.
// The alphas are the shipped ones for each border role.
function hyprColor(value: var, alpha: string): string {
// Qt gives "#rrggbb" -- or "#aarrggbb" if the color ever carries an
// alpha channel. slice(-6) keeps the trailing rrggbb either way;
// slice(0, 6) would instead grab "aarrgg" out of an 8-digit string and
// call it RGB. Hyprland wants rgba(rrggbbaa).
return "rgba(" + String(value).replace("#", "").slice(-6) + "ee)";
return "rgba(" + String(value).replace("#", "").slice(-6) + alpha + ")";
}
readonly property string accentBorderStart: root.hyprColor(Theme.accent)
readonly property string accentBorderEnd: root.hyprColor(Theme.accentSecondary)
// A neutral contrast role, not an accent. The focused Prism border below
// belongs to the accent and must remain untouched when this role changes.
//
// It is the active theme's gutter -- the palette's own neutral surface --
// rather than the two Tokyo Night literals that used to be written here.
// Those were right for exactly two of the ten shipped themes and for no
// custom one.
readonly property string inactiveBorder: root.hyprColor(Theme.gutter, "99")
// The focused border follows the chosen accent.
readonly property string accentBorderStart: root.hyprColor(Theme.accent, "ee")
readonly property string accentBorderEnd: root.hyprColor(Theme.accentSecondary, "ee")
property string lastError: ""
// A cheap fingerprint of what the render pipeline actually renders from.
//
// The theme id alone is not enough: editing a custom theme's background
// keeps the same id, and the shell would repaint from the binding while
// kitty, GTK and the lock screen kept the old colours -- the change would
// look like it had only half landed. The values are in the key too, so any
// edit to the active palette is a change worth pushing.
readonly property string palettePrint: {
const palette = ThemeProfiles.activePalette;
if (!palette)
return "";
return Object.keys(palette).sort().map(token => palette[token]).join(",");
}
// What the last push actually sent, so apply() can tell an accent-only
// change apart from a scheme change and skip the steps that do not depend
// on whichever did not move. Their starting values do not matter: the
// first apply() always runs with force set, which ignores both.
// change apart from a scheme or theme change and skip the steps that do
// not depend on whichever did not move. Their starting values do not
// matter: the first apply() always runs with force set, which ignores all
// of them.
property bool appliedDark: false
property string appliedAccentName: ""
property string appliedThemeId: ""
property string appliedPalettePrint: ""
// Applied one command at a time: Process runs a single command, and several
// of these are separate programs.
@@ -119,10 +149,19 @@ Singleton {
root.lastError = "";
const accentName = DesktopPreferences.get("accentName") || "blue";
const themeId = ThemeProfiles.activeId;
const palettePrint = root.palettePrint;
const schemeChanged = force || root.dark !== root.appliedDark;
const accentChanged = force || accentName !== root.appliedAccentName;
// A theme change is a palette change: selecting another theme, or
// editing the active one. accentName is the NEAREST CURATED name, so
// it can hold still across both -- Moon to Moon Rose keeps "blue" and
// moves every other colour.
const themeChanged = force || themeId !== root.appliedThemeId
|| palettePrint !== root.appliedPalettePrint;
if (!schemeChanged && !accentChanged)
if (!schemeChanged && !accentChanged && !themeChanged)
return;
const commands = [];
@@ -147,17 +186,21 @@ Singleton {
commands.push(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme]);
commands.push(["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]);
}
if (schemeChanged || themeChanged) {
// Unfocused window borders need scheme-relative contrast, and stay
// this service's to own. The FOCUSED border below is the accent
// role and belongs to the theme -- see modules/settings/README.md.
// Unlike that role, this one does not depend on the accent, so an
// accent-only change never needs to restate it.
//
// It is drawn from the palette's gutter, so it moves with the THEME
// as well as with the scheme; an accent-only change still never
// needs to restate it.
commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
}
if (schemeChanged || accentChanged) {
if (schemeChanged || accentChanged || themeChanged) {
// Each accent carries a separate pair for light and dark, so this
// runs on either kind of change: a scheme flip restates the same
// accent's other pair, and an accent change restates the same
@@ -178,23 +221,25 @@ Singleton {
`hl.config({ general = { col = { active_border = ${activeBorder} } } })`]);
// Applications that predate org.freedesktop.appearance and carry
// their own palettes -- terminals, chiefly. Portal readers (GTK4,
// Qt6, Chromium, Electron) already have their SCHEME from the
// gsettings write above.
// their own palettes -- terminals, GTK's css, the lock screen, the
// launcher, the browser chrome. Portal readers (GTK4, Qt6, Chromium,
// Electron) already have their SCHEME from the gsettings write
// above, but no portal can tell them which THEME.
//
// Their ACCENT does not come from here: GNOME's accent-color is a
// fixed enum of nine names rather than a color, so the mapping from
// our eight lives in panama-theme-apps beside the other per-
// application translations. The accent is passed through for that,
// and because kitty's border and the hyprlock fallback template are
// the accent role too -- so this still has to run on an accent-only
// change, even though the scheme-relative work it also does is then
// redundant.
// The script resolves the palette itself, from settings.json and
// the catalog, so the two arguments here are only what it cannot
// read: nothing, and the nearest curated accent NAME, which GNOME's
// accent-color enum needs because it takes a name rather than a
// color. It runs on a theme change even when neither scheme nor
// accentName moved -- that is what selecting Nord over Moon looks
// like from here.
commands.push([root.appThemePath, root.dark ? "dark" : "light", accentName]);
}
root.appliedDark = root.dark;
root.appliedAccentName = accentName;
root.appliedThemeId = themeId;
root.appliedPalettePrint = palettePrint;
root.enqueue(commands);
}
@@ -174,15 +174,17 @@ Singleton {
return `${DesktopPreferences.get(familyKey)} ${DesktopPreferences.get(sizeKey)}`;
}
// Close only, on the configured side. Minimize would be a lie (Hyprland
// has no minimize) and maximize is noise in a tiler, so neither is
// offered — not as a button, not as a setting.
function buttonLayout(): string {
const side = DesktopPreferences.get("titlebarButtonSide");
const maximize = DesktopPreferences.get("titlebarMaximizeButton") === true;
// Tokens are fixed. Only their side and whether maximize is present
// vary, so preference data can never become command syntax.
// Tokens are fixed. Only their side varies, so preference data can
// never become command syntax.
if (side === "left")
return (maximize ? "close,maximize" : "close") + ":appmenu";
return "appmenu:" + (maximize ? "maximize,close" : "close");
return "close:appmenu";
return "appmenu:close";
}
function setting(schema: string, key: string, value: var): var {
@@ -207,8 +209,6 @@ Singleton {
root.setting("org.gnome.desktop.interface", "gtk-enable-primary-paste",
DesktopPreferences.get("middleClickPaste")),
root.setting("org.gnome.desktop.wm.preferences", "button-layout", root.buttonLayout()),
root.setting("org.gnome.desktop.wm.preferences", "action-double-click-titlebar",
DesktopPreferences.get("titlebarDoubleClick")),
// The portal republishes this as org.freedesktop.appearance
// contrast, which is what libadwaita reads -- so this reaches GTK4
// applications without any high-contrast theme being installed.
@@ -153,6 +153,16 @@ Singleton {
{ label: "Monitor position", detail: "Set where each display sits in the desktop", page: "displays" },
{ label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" },
{ label: "Theme profiles", detail: "Switch between Moon, Moon Rose, Day, and saved themes", page: "appearance" },
{ label: "Themes", detail: "Light and dark mode, and the theme for each", page: "appearance" },
{ label: "Dark mode", detail: "Flip the desktop to your dark theme", page: "appearance" },
{ label: "Light mode", detail: "Flip the desktop to your light theme", page: "appearance" },
{ label: "Theme editor", detail: "Build your own theme — colors, saturation, and effects", page: "appearance" },
{ label: "Catppuccin", detail: "Mocha and Latte, in the theme galleries", page: "appearance" },
{ label: "Nord", detail: "The arctic dark theme, in the gallery", page: "appearance" },
{ label: "Gruvbox", detail: "Dark and light, in the theme galleries", page: "appearance" },
{ label: "Everforest", detail: "Dark and light, in the theme galleries", page: "appearance" },
{ label: "Tokyo Night", detail: "Moon and Day, the shipped defaults", page: "appearance" },
{ label: "Video wallpaper", detail: "A looping video as the desktop background", page: "appearance" },
{ label: "Advanced accent", detail: "Adjust primary and secondary hue, saturation, and value", page: "appearance" },
{ label: "Pick colour from screen", detail: "Sample an accent colour with hyprpicker", page: "appearance" }
]
@@ -0,0 +1,163 @@
pragma Singleton
// The shipped theme catalog, read from config/themes.json — full palettes for
// every theme Panama ships, one file shared with the scripts (jq) and the
// Node halves of contracts (fs). QML reads it here with FileView rather than
// keeping a duplicated JS table: unlike palette.json's CURATED twin, nothing
// in this file needs to run under Node.
//
// The embedded fallback below carries only the two default themes (Moon and
// Day) so the shell renders correctly for the instant before the file loads —
// and forever if it is missing or malformed. theme-catalog-contract pins the
// fallback byte-identical to the catalog's moon and day records.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
// [{ id, name, scheme, accent, secondary, palette: {19 tokens}, ansi: {16} }]
property var themes: root.fallbackThemes
property string defaultDark: "moon"
property string defaultLight: "day"
property bool loaded: false
property string lastError: ""
function byId(id: string): var {
return root.themes.find(theme => theme.id === id) ?? null;
}
// The palette every resolution falls back to: the default theme for the
// scheme. Always complete, even before the file loads.
function defaultPalette(scheme: string): var {
const id = scheme === "light" ? root.defaultLight : root.defaultDark;
const theme = root.byId(id) ?? root.fallbackThemes[scheme === "light" ? 1 : 0];
return theme.palette;
}
function themesFor(scheme: string): var {
return root.themes.filter(theme => theme.scheme === scheme);
}
// A record is only accepted with every palette key present and valid, so
// nothing downstream ever needs a per-token fallback.
readonly property var paletteKeys: ["bg", "bgDark", "bgHighlight", "bgPanel",
"bgPopover", "fg", "fgDim", "fgMuted", "gutter", "accentAlt",
"cyan", "teal", "green", "yellow", "orange", "red", "redDeep",
"magenta", "pink"]
readonly property var ansiKeys: ["black", "red", "green", "yellow", "blue",
"magenta", "cyan", "white", "brightBlack", "brightRed", "brightGreen",
"brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite"]
function validHex(value: var): bool {
return typeof value === "string" && /^#[0-9a-f]{6}$/.test(value);
}
function normalizeTheme(entry: var): var {
if (!entry || typeof entry !== "object")
return null;
const id = String(entry.id ?? "");
const name = String(entry.name ?? "");
const scheme = String(entry.scheme ?? "");
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id) || name === ""
|| (scheme !== "dark" && scheme !== "light"))
return null;
if (!root.validHex(entry.accent) || !root.validHex(entry.secondary))
return null;
const palette = {};
for (const key of root.paletteKeys) {
const value = entry.palette?.[key];
if (!root.validHex(value))
return null;
palette[key] = value;
}
const ansi = {};
for (const key of root.ansiKeys) {
const value = entry.ansi?.[key];
if (!root.validHex(value))
return null;
ansi[key] = value;
}
return {
id: id, name: name, scheme: scheme, shipped: true,
accent: entry.accent, secondary: entry.secondary,
palette: palette, ansi: ansi
};
}
function consume(text: string): void {
try {
const parsed = JSON.parse(text);
const themes = (parsed.themes ?? [])
.map(entry => root.normalizeTheme(entry))
.filter(theme => theme !== null);
if (themes.length === 0)
throw new Error("no valid themes");
root.themes = themes;
root.defaultDark = String(parsed.defaultDark ?? "moon");
root.defaultLight = String(parsed.defaultLight ?? "day");
root.loaded = true;
root.lastError = "";
} catch (error) {
root.lastError = "The theme catalog could not be read; using the built-in themes.";
}
}
FileView {
id: catalogFile
path: Quickshell.shellDir + "/config/themes.json"
printErrors: false
onLoaded: root.consume(this.text())
onLoadFailed: root.lastError =
"The theme catalog is missing; using the built-in themes."
}
// Byte-identical to the catalog's moon and day records — the shell's
// pre-theme literals, pinned by theme-catalog-contract.
readonly property var fallbackThemes: [
{
id: "moon", name: "Tokyo Moon", scheme: "dark", shipped: true,
accent: "#82aaff", secondary: "#b172b0",
palette: {
bg: "#222436", bgDark: "#1e2030", bgHighlight: "#2f334d",
bgPanel: "#2e2f3d", bgPopover: "#21212f",
fg: "#c8d3f5", fgDim: "#828bb8", fgMuted: "#636da6",
gutter: "#3b4261", accentAlt: "#65bcff",
cyan: "#86e1fc", teal: "#4fd6be", green: "#c3e88d",
yellow: "#ffc777", orange: "#ff966c", red: "#ff757f",
redDeep: "#c53b53", magenta: "#c099ff", pink: "#fca7ea"
},
ansi: {
black: "#1b1d2b", red: "#ff757f", green: "#c3e88d",
yellow: "#ffc777", blue: "#82aaff", magenta: "#c099ff",
cyan: "#86e1fc", white: "#828bb8",
brightBlack: "#444a73", brightRed: "#ff8d94", brightGreen: "#c7fb6d",
brightYellow: "#ffd8ab", brightBlue: "#9ab8ff", brightMagenta: "#caabff",
brightCyan: "#b2ebff", brightWhite: "#c8d3f5"
}
},
{
id: "day", name: "Tokyo Day", scheme: "light", shipped: true,
accent: "#2e7de9", secondary: "#9854f1",
palette: {
bg: "#e1e2e7", bgDark: "#d3d5de", bgHighlight: "#c4c8da",
bgPanel: "#d9dae3", bgPopover: "#eaeaee",
fg: "#3760bf", fgDim: "#6172b0", fgMuted: "#848cb5",
gutter: "#a8aecb", accentAlt: "#007197",
cyan: "#007197", teal: "#118c74", green: "#587539",
yellow: "#8c6c3e", orange: "#b15c00", red: "#f52a65",
redDeep: "#c64343", magenta: "#9854f1", pink: "#d20065"
},
ansi: {
black: "#b4b5b9", red: "#f52a65", green: "#587539",
yellow: "#8c6c3e", blue: "#2e7de9", magenta: "#9854f1",
cyan: "#007197", white: "#6172b0",
brightBlack: "#a1a6c5", brightRed: "#ff4774", brightGreen: "#5c8524",
brightYellow: "#a27629", brightBlue: "#358aff", brightMagenta: "#a463ff",
brightCyan: "#007ea8", brightWhite: "#3760bf"
}
}
]
}
@@ -1,5 +1,34 @@
var MAX_NAME_LENGTH = 40;
// The token sets a theme record may carry beyond its accent pair. PALETTE_KEYS
// mirrors Theme.qml's color tokens; ANSI_KEYS feed generated terminal themes;
// EFFECT_SPEC mirrors the ten effects schema keys so a custom theme can
// snapshot them. All three are pinned by theme-catalog-contract against
// config/themes.json.
var PALETTE_KEYS = ["bg", "bgDark", "bgHighlight", "bgPanel", "bgPopover",
"fg", "fgDim", "fgMuted", "gutter", "accentAlt",
"cyan", "teal", "green", "yellow", "orange", "red", "redDeep",
"magenta", "pink"];
var ANSI_KEYS = ["black", "red", "green", "yellow", "blue", "magenta", "cyan",
"white", "brightBlack", "brightRed", "brightGreen", "brightYellow",
"brightBlue", "brightMagenta", "brightCyan", "brightWhite"];
var EFFECT_SPEC = {
blurEnabled: { kind: "bool" },
blurSize: { kind: "int", min: 1, max: 20 },
blurPasses: { kind: "int", min: 1, max: 5 },
shadowEnabled: { kind: "bool" },
shadowRange: { kind: "int", min: 0, max: 60 },
shadowSharp: { kind: "bool" },
shadowRenderPower: { kind: "int", min: 1, max: 4 },
glowEnabled: { kind: "bool" },
glowRange: { kind: "int", min: 0, max: 30 },
animationsEnabled: { kind: "bool" }
};
// The minimal built-in shipped list. The full catalog (config/themes.json,
// via ThemeCatalog) is passed into every function that takes a `shipped`
// argument; this fallback keeps the model usable headless and before the
// catalog loads.
var SHIPPED = [
{
id: "moon",
@@ -85,8 +114,53 @@ var CURATED = {
}
};
function copyColorMap(value, keys) {
if (!value || typeof value !== "object")
return null;
var result = {};
for (var index = 0; index < keys.length; index++) {
var color = normalizedColor(value[keys[index]]);
if (!color)
return null;
result[keys[index]] = color;
}
return result;
}
// A palette or ansi block is all-or-nothing: a record either carries every
// key valid, or the field is dropped and resolution falls back to the
// scheme's default theme. Effects are per-key: invalid entries are dropped.
function normalizePalette(value) { return copyColorMap(value, PALETTE_KEYS); }
function normalizeAnsi(value) { return copyColorMap(value, ANSI_KEYS); }
function normalizeEffects(value) {
if (!value || typeof value !== "object")
return null;
var result = {};
var any = false;
Object.keys(EFFECT_SPEC).forEach(function(key) {
var spec = EFFECT_SPEC[key];
var raw = value[key];
if (raw === undefined)
return;
if (spec.kind === "bool") {
if (typeof raw !== "boolean")
return;
result[key] = raw;
any = true;
return;
}
var number = Number(raw);
if (!isFinite(number))
return;
result[key] = Math.round(clamp(number, spec.min, spec.max));
any = true;
});
return any ? result : null;
}
function copyProfile(profile) {
return {
var result = {
id: profile.id,
name: profile.name,
scheme: profile.scheme,
@@ -94,10 +168,24 @@ function copyProfile(profile) {
secondary: profile.secondary,
shipped: profile.shipped === true
};
var palette = normalizePalette(profile.palette);
if (palette)
result.palette = palette;
var ansi = normalizeAnsi(profile.ansi);
if (ansi)
result.ansi = ansi;
var effects = normalizeEffects(profile.effects);
if (effects)
result.effects = effects;
return result;
}
function shippedProfiles() {
return SHIPPED.map(copyProfile);
function shippedList(shipped) {
return Array.isArray(shipped) && shipped.length > 0 ? shipped : SHIPPED;
}
function shippedProfiles(shipped) {
return shippedList(shipped).map(copyProfile);
}
function curatedAccents() {
@@ -130,7 +218,7 @@ function normalizeStoredProfile(value) {
|| !isScheme(value.scheme) || !accent || !secondary)
return null;
return {
var result = {
id: id,
name: name,
scheme: value.scheme,
@@ -138,15 +226,25 @@ function normalizeStoredProfile(value) {
secondary: secondary,
shipped: false
};
var palette = normalizePalette(value.palette);
if (palette)
result.palette = palette;
var ansi = normalizeAnsi(value.ansi);
if (ansi)
result.ansi = ansi;
var effects = normalizeEffects(value.effects);
if (effects)
result.effects = effects;
return result;
}
function validCustomProfiles(values) {
function validCustomProfiles(values, shipped) {
if (!Array.isArray(values))
return [];
var ids = {};
var names = {};
SHIPPED.forEach(function(profile) {
shippedList(shipped).forEach(function(profile) {
ids[profile.id] = true;
names[profile.name.toLowerCase()] = true;
});
@@ -166,8 +264,8 @@ function validCustomProfiles(values) {
return result;
}
function profileCatalog(values) {
return shippedProfiles().concat(validCustomProfiles(values));
function profileCatalog(values, shipped) {
return shippedProfiles(shipped).concat(validCustomProfiles(values, shipped));
}
function boundedName(value) {
@@ -218,9 +316,9 @@ function uniqueId(name, profiles) {
return base.slice(0, 52) + "-10000";
}
function createCustomProfile(values, input) {
var customs = validCustomProfiles(values);
var catalog = shippedProfiles().concat(customs);
function createCustomProfile(values, input, shipped) {
var customs = validCustomProfiles(values, shipped);
var catalog = shippedProfiles(shipped).concat(customs);
var scheme = input && input.scheme;
var accent = normalizedColor(input && input.accent);
var secondary = normalizedColor(input && input.secondary);
@@ -236,11 +334,20 @@ function createCustomProfile(values, input) {
secondary: secondary,
shipped: false
};
var palette = normalizePalette(input && input.palette);
if (palette)
profile.palette = palette;
var ansi = normalizeAnsi(input && input.ansi);
if (ansi)
profile.ansi = ansi;
var effects = normalizeEffects(input && input.effects);
if (effects)
profile.effects = effects;
return { profiles: customs.concat([profile]), profile: profile };
}
function findProfile(values, id) {
var catalog = profileCatalog(values);
function findProfile(values, id, shipped) {
var catalog = profileCatalog(values, shipped);
for (var index = 0; index < catalog.length; index++) {
if (catalog[index].id === id)
return catalog[index];
@@ -248,26 +355,32 @@ function findProfile(values, id) {
return null;
}
function editProfile(values, selected, changes) {
var customs = validCustomProfiles(values);
function editProfile(values, selected, changes, shipped) {
var customs = validCustomProfiles(values, shipped);
if (!selected || typeof selected !== "object")
return { profiles: customs, profile: null };
var accent = normalizedColor(changes && changes.accent !== undefined
? changes.accent : selected.accent);
var secondary = normalizedColor(changes && changes.secondary !== undefined
? changes.secondary : selected.secondary);
var scheme = changes && changes.scheme !== undefined ? changes.scheme : selected.scheme;
function pick(field, fallback) {
return changes && changes[field] !== undefined ? changes[field] : fallback;
}
var accent = normalizedColor(pick("accent", selected.accent));
var secondary = normalizedColor(pick("secondary", selected.secondary));
var scheme = pick("scheme", selected.scheme);
if (!isScheme(scheme) || !accent || !secondary)
return { profiles: customs, profile: null };
if (selected.shipped === true) {
// Editing a shipped theme forks it into a custom, carrying the whole
// palette so the fork looks identical until the edit lands.
return createCustomProfile(customs, {
name: selected.name + " custom",
scheme: scheme,
accent: accent,
secondary: secondary
});
secondary: secondary,
palette: pick("palette", selected.palette),
ansi: pick("ansi", selected.ansi),
effects: pick("effects", selected.effects)
}, shipped);
}
var stored = normalizeStoredProfile(selected);
@@ -279,6 +392,21 @@ function editProfile(values, selected, changes) {
accent: accent,
secondary: secondary
});
var palette = normalizePalette(pick("palette", stored.palette));
if (palette)
updated.palette = palette;
else
delete updated.palette;
var ansi = normalizeAnsi(pick("ansi", stored.ansi));
if (ansi)
updated.ansi = ansi;
else
delete updated.ansi;
var effects = normalizeEffects(pick("effects", stored.effects));
if (effects)
updated.effects = effects;
else
delete updated.effects;
var found = false;
var next = customs.map(function(profile) {
if (profile.id !== updated.id)
@@ -291,10 +419,11 @@ function editProfile(values, selected, changes) {
return { profiles: next, profile: updated };
}
function deleteProfile(values, id) {
var customs = validCustomProfiles(values);
for (var shippedIndex = 0; shippedIndex < SHIPPED.length; shippedIndex++) {
if (SHIPPED[shippedIndex].id === id)
function deleteProfile(values, id, shipped) {
var customs = validCustomProfiles(values, shipped);
var fixed = shippedList(shipped);
for (var shippedIndex = 0; shippedIndex < fixed.length; shippedIndex++) {
if (fixed[shippedIndex].id === id)
return { profiles: customs, removed: false };
}
var next = customs.filter(function(profile) { return profile.id !== id; });
@@ -308,17 +437,131 @@ function curatedPair(name, scheme) {
return { accent: entry.dark, secondary: entry.darkSecondary };
}
function matchingShippedProfile(scheme, accent, secondary) {
function matchingShippedProfile(scheme, accent, secondary, shipped) {
var first = normalizedColor(accent);
var second = normalizedColor(secondary);
for (var index = 0; index < SHIPPED.length; index++) {
var profile = SHIPPED[index];
var fixed = shippedList(shipped);
for (var index = 0; index < fixed.length; index++) {
var profile = fixed[index];
if (profile.scheme === scheme && profile.accent === first && profile.secondary === second)
return copyProfile(profile);
}
return null;
}
// The nearest curated accent to an arbitrary pair, by hue distance of the
// primary. This is what keeps `accentName` — and through it GNOME's
// accent-color enum, kitty's border, and the lock screen — in sync when a
// theme or a custom color is chosen instead of a curated swatch.
function nearestCuratedName(scheme, accent) {
var target = hexToHsv(accent);
if (!target)
return "blue";
var bestName = "blue";
var bestDistance = Infinity;
Object.keys(CURATED).forEach(function(name) {
var pair = curatedPair(name, isScheme(scheme) ? scheme : "dark");
var candidate = hexToHsv(pair.accent);
if (!candidate)
return;
var delta = Math.abs(candidate.h - target.h);
var hueDistance = Math.min(delta, 360 - delta);
// A desaturated accent should map to slate rather than whichever hue
// its noise happens to lean toward.
var distance = target.s < 12
? (name === "slate" ? 0 : 1000 + hueDistance)
: hueDistance + Math.abs(candidate.s - target.s) * 0.15;
if (distance < bestDistance) {
bestDistance = distance;
bestName = name;
}
});
return bestName;
}
// ── Palette derivation, for the editor ──────────────────────────────────────
function mixHex(first, second, ratio) {
var a = normalizedColor(first);
var b = normalizedColor(second);
var r = clamp(ratio, 0, 1);
if (!a || !b)
return a || b;
function channel(offset) {
var from = parseInt(a.slice(offset, offset + 2), 16);
var to = parseInt(b.slice(offset, offset + 2), 16);
return channelHex(from + (to - from) * r);
}
return "#" + channel(1) + channel(3) + channel(5);
}
// A full palette from the four editor wells plus a base theme. Background
// derives the surface family, foreground derives the text tints, and every
// other token is inherited from the base so a small edit stays a small edit.
function derivePalette(base, input) {
var origin = normalizePalette(base) || null;
var bg = normalizedColor(input && input.bg) || (origin ? origin.bg : null);
var fg = normalizedColor(input && input.fg) || (origin ? origin.fg : null);
var accent = normalizedColor(input && input.accent);
var scheme = isScheme(input && input.scheme) ? input.scheme : "dark";
if (!bg || !fg)
return origin;
var dark = scheme === "dark";
var result = origin ? Object.assign({}, origin) : {};
result.bg = bg;
result.bgDark = mixHex(bg, "#000000", dark ? 0.10 : 0.06);
result.bgPopover = dark ? mixHex(bg, "#000000", 0.06) : mixHex(bg, "#ffffff", 0.35);
result.bgPanel = dark ? mixHex(bg, fg, 0.05) : mixHex(bg, "#000000", 0.03);
result.bgHighlight = mixHex(bg, fg, dark ? 0.11 : 0.14);
result.gutter = mixHex(bg, fg, dark ? 0.20 : 0.35);
result.fg = fg;
result.fgDim = mixHex(fg, bg, 0.32);
result.fgMuted = mixHex(fg, bg, 0.47);
if (accent)
result.accentAlt = mixHex(accent, dark ? "#ffffff" : "#000000", 0.18);
return normalizePalette(result);
}
// Scale the saturation of every token together. factor 1.0 is neutral;
// backgrounds move at quarter strength so the ground stays a ground.
function resaturatePalette(palette, factor) {
var origin = normalizePalette(palette);
if (!origin)
return null;
var scale = clamp(factor, 0, 2);
var result = {};
PALETTE_KEYS.forEach(function(key) {
var hsv = hexToHsv(origin[key]);
var strength = key.indexOf("bg") === 0 || key === "gutter" ? 0.25 : 1;
var next = clamp(hsv.s * (1 + (scale - 1) * strength), 0, 100);
result[key] = hsvToHex(hsv.h, next, hsv.v);
});
return result;
}
// Terminal colors for a custom theme that has none of its own: semantic
// tokens map straight onto ANSI, brights lift value a step.
function deriveAnsi(palette, accent) {
var origin = normalizePalette(palette);
if (!origin)
return null;
var blue = normalizedColor(accent) || origin.accentAlt;
function brighten(color) {
var hsv = hexToHsv(color);
return hsvToHex(hsv.h, Math.max(0, hsv.s - 8), Math.min(100, hsv.v + 12));
}
return normalizeAnsi({
black: origin.bgHighlight, red: origin.red, green: origin.green,
yellow: origin.yellow, blue: blue, magenta: origin.magenta,
cyan: origin.cyan, white: origin.fgDim,
brightBlack: origin.gutter, brightRed: brighten(origin.red),
brightGreen: brighten(origin.green), brightYellow: brighten(origin.yellow),
brightBlue: brighten(blue), brightMagenta: brighten(origin.magenta),
brightCyan: brighten(origin.cyan), brightWhite: origin.fg
});
}
function curatedNameForProfile(profile) {
if (!profile || !isScheme(profile.scheme))
return "";
@@ -402,6 +645,9 @@ function hexToHsv(value) {
if (typeof module !== "undefined") {
module.exports = {
MAX_NAME_LENGTH: MAX_NAME_LENGTH,
PALETTE_KEYS: PALETTE_KEYS,
ANSI_KEYS: ANSI_KEYS,
EFFECT_SPEC: EFFECT_SPEC,
shippedProfiles: shippedProfiles,
curatedAccents: curatedAccents,
validCustomProfiles: validCustomProfiles,
@@ -413,6 +659,14 @@ if (typeof module !== "undefined") {
curatedPair: curatedPair,
matchingShippedProfile: matchingShippedProfile,
curatedNameForProfile: curatedNameForProfile,
nearestCuratedName: nearestCuratedName,
normalizePalette: normalizePalette,
normalizeAnsi: normalizeAnsi,
normalizeEffects: normalizeEffects,
mixHex: mixHex,
derivePalette: derivePalette,
resaturatePalette: resaturatePalette,
deriveAnsi: deriveAnsi,
hsvToHex: hsvToHex,
hexToHsv: hexToHsv
};
+120 -25
View File
@@ -1,5 +1,16 @@
pragma Singleton
// The theme resolver. Shipped themes come from ThemeCatalog (config/themes.json),
// customs from the themeProfiles preference; the active theme is one record with
// a full palette, resolved here for Theme.qml and the render pipeline.
//
// Selection is per-mode: themeDark and themeLight remember the chosen theme for
// each scheme, so flipping light/dark lands on *your* theme for that side —
// never a forced reset to the defaults. Every commit path ends in commitActive,
// which also keeps accentName (GNOME's enum, kitty's border, the lock screen)
// pointed at the nearest curated accent — the desync those surfaces used to
// suffer after a custom edit is structural now, not a convention.
import Quickshell
import QtQuick
import qs.config
@@ -8,94 +19,178 @@ import "ThemeProfileModel.js" as ThemeProfileModel
Singleton {
id: root
readonly property var shippedThemes: ThemeCatalog.themes
readonly property var customProfiles: ThemeProfileModel.validCustomProfiles(
DesktopPreferences.get("themeProfiles"))
DesktopPreferences.get("themeProfiles"), root.shippedThemes)
readonly property var curatedAccents: ThemeProfileModel.curatedAccents()
readonly property var profiles: ThemeProfileModel.profileCatalog(root.customProfiles)
readonly property string activeId: DesktopPreferences.get("themeProfileId") || "moon"
readonly property var profiles: ThemeProfileModel.profileCatalog(
root.customProfiles, root.shippedThemes)
readonly property string scheme: DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark"
readonly property string activeId: DesktopPreferences.get("themeProfileId") || ThemeCatalog.defaultDark
readonly property var activeProfile: ThemeProfileModel.findProfile(
root.customProfiles, root.activeId) || ThemeProfileModel.shippedProfiles()[0]
root.customProfiles, root.activeId, root.shippedThemes)
|| ThemeProfileModel.shippedProfiles(root.shippedThemes)[0]
readonly property string activeAccentName: ThemeProfileModel.curatedNameForProfile(
root.activeProfile)
// Always complete: the record's own palette, or the scheme default's.
readonly property var activePalette: root.activeProfile.palette
?? ThemeCatalog.defaultPalette(root.activeProfile.scheme)
readonly property var activeAnsi: root.activeProfile.ansi
?? (ThemeCatalog.byId(root.activeProfile.scheme === "light"
? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark)?.ansi ?? null)
property bool reconciling: false
// The remembered theme for a scheme, falling back to the catalog default.
function themeForScheme(target: string): string {
const key = target === "light" ? "themeLight" : "themeDark";
const fallback = target === "light" ? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark;
const stored = String(DesktopPreferences.get(key) || "");
return ThemeProfileModel.findProfile(root.customProfiles, stored, root.shippedThemes)
? stored : fallback;
}
function commitActive(profile: var): bool {
if (!profile)
return false;
// Scheme first: if another control changed it, the reconciliation hook
// may briefly select Day/Moon before this final stable profile id lands.
// may briefly select the scheme default before this final id lands.
const schemeAccepted = SystemSettings.commitPreference("colorScheme", profile.scheme);
const profileAccepted = SystemSettings.commitPreference("themeProfileId", profile.id);
SystemSettings.commitPreference(
profile.scheme === "light" ? "themeLight" : "themeDark", profile.id);
SystemSettings.commitPreference("accentName",
ThemeProfileModel.nearestCuratedName(profile.scheme, profile.accent));
root.applyProfileEffects(profile);
return schemeAccepted && profileAccepted;
}
// A theme that carries an effects snapshot restores it; themes without one
// (every shipped theme) leave the user's effects alone.
function applyProfileEffects(profile: var): void {
const effects = profile.effects ?? null;
if (!effects)
return;
for (const key of Object.keys(effects))
SystemSettings.commitPreference(key, effects[key]);
}
function selectProfile(id: string): bool {
return root.commitActive(ThemeProfileModel.findProfile(root.customProfiles, id));
return root.commitActive(ThemeProfileModel.findProfile(
root.customProfiles, id, root.shippedThemes));
}
// The Themes tab's light/dark hero: land on the remembered theme for the
// requested scheme.
function setScheme(target: string): bool {
return root.selectProfile(root.themeForScheme(target === "light" ? "light" : "dark"));
}
// Editing a shipped profile creates a saved custom copy. Editing a custom
// profile updates only that record. ThemeProfileModel enforces both rules,
// leaving this singleton responsible only for validated preference routing.
function setAccentPair(accent: string, secondary: string): bool {
const result = ThemeProfileModel.editProfile(root.customProfiles, root.activeProfile, {
accent: accent,
secondary: secondary
});
function updateActive(changes: var): bool {
const result = ThemeProfileModel.editProfile(
root.customProfiles, root.activeProfile, changes, root.shippedThemes);
if (!result.profile)
return false;
if (!SystemSettings.commitPreference("themeProfiles", result.profiles))
return false;
return SystemSettings.commitPreference("themeProfileId", result.profile.id);
return root.commitActive(result.profile);
}
function setAccentPair(accent: string, secondary: string): bool {
return root.updateActive({ accent: accent, secondary: secondary });
}
// The editor's Background/Foreground wells: derive the surface and text
// families from the two ground colors, keep everything else.
function setGroundColors(bg: string, fg: string): bool {
const palette = ThemeProfileModel.derivePalette(root.activePalette, {
scheme: root.activeProfile.scheme,
bg: bg,
fg: fg,
accent: root.activeProfile.accent
});
if (!palette)
return false;
return root.updateActive({
palette: palette,
ansi: root.activeProfile.ansi
?? ThemeProfileModel.deriveAnsi(palette, root.activeProfile.accent)
});
}
// The editor's saturation slider. factor 1.0 is neutral.
function setSaturation(factor: real): bool {
const palette = ThemeProfileModel.resaturatePalette(root.activePalette, factor);
if (!palette)
return false;
return root.updateActive({ palette: palette });
}
// Saving snapshots the whole look: palette, terminal colors, and the ten
// effect values as they stand right now.
function saveProfile(name: string): bool {
const effects = {};
for (const key of Object.keys(ThemeProfileModel.EFFECT_SPEC))
effects[key] = DesktopPreferences.get(key);
const result = ThemeProfileModel.createCustomProfile(root.customProfiles, {
name: name,
scheme: root.activeProfile.scheme,
accent: root.activeProfile.accent,
secondary: root.activeProfile.secondary
});
secondary: root.activeProfile.secondary,
palette: root.activePalette,
ansi: root.activeAnsi,
effects: effects
}, root.shippedThemes);
if (!result.profile)
return false;
if (!SystemSettings.commitPreference("themeProfiles", result.profiles))
return false;
return SystemSettings.commitPreference("themeProfileId", result.profile.id);
return root.commitActive(result.profile);
}
function deleteProfile(id: string): bool {
const result = ThemeProfileModel.deleteProfile(root.customProfiles, id);
const result = ThemeProfileModel.deleteProfile(
root.customProfiles, id, root.shippedThemes);
if (!result.removed)
return false;
if (!SystemSettings.commitPreference("themeProfiles", result.profiles))
return false;
if (root.activeId === id)
return root.selectProfile(DesktopPreferences.get("colorScheme") === "light" ? "day" : "moon");
return root.setScheme(root.scheme);
return true;
}
function useCuratedAccent(name: string): bool {
const scheme = DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark";
const pair = ThemeProfileModel.curatedPair(name, scheme);
// Accepts the curated name or the AccentPicker's option object — the
// picker used to hand the object to a string parameter and silently fall
// back to blue.
function useCuratedAccent(entry: var): bool {
const name = typeof entry === "string" ? entry : String(entry?.value ?? entry?.name ?? "");
const pair = ThemeProfileModel.curatedPair(name, root.scheme);
const shipped = ThemeProfileModel.matchingShippedProfile(
scheme, pair.accent, pair.secondary);
root.scheme, pair.accent, pair.secondary, root.shippedThemes);
const accepted = shipped
? root.commitActive(shipped)
: root.setAccentPair(pair.accent, pair.secondary);
if (accepted)
if (accepted && ThemeProfileModel.curatedAccents()[name])
SystemSettings.commitPreference("accentName", name);
return accepted;
}
// A scheme flip from anywhere (the hero, IPC, a synced settings file)
// lands on the remembered theme for that scheme — never a forced default.
function reconcileScheme(): void {
if (root.reconciling)
return;
const scheme = DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark";
if (root.activeProfile.scheme === scheme)
if (root.activeProfile.scheme === root.scheme)
return;
root.reconciling = true;
SystemSettings.commitPreference("themeProfileId", scheme === "light" ? "day" : "moon");
SystemSettings.commitPreference("themeProfileId", root.themeForScheme(root.scheme));
root.reconciling = false;
}
@@ -0,0 +1,244 @@
pragma Singleton
// Video wallpapers, played by mpvpaper — one supervised process per output,
// hardware-decoded, muted, looped. Panama owns the pause policy, because the
// compositor cannot be trusted with it: Hyprland rebuilds the full-screen blur
// chain on every wallpaper frame while any blur-enabled layer exists, and an
// occluded wallpaper keeps compositing unless solitary mode holds — which a
// single toast breaks. So the wallpaper pauses itself whenever a game runs,
// whenever the machine is on battery (if the preference says so), and whenever
// the bar pill is clicked, over mpv's JSON IPC socket.
//
// hyprpaper and mpvpaper both claim the background layer and stacking within a
// layer is creation order — a race. So while a video is active hyprpaper's
// service is stopped, and stopping the video starts it again; Wallpaper.qml
// reapplies the still policy once it returns.
//
// mpvpaper 1.9 (Terra) is the floor: it carries the libmpv fence-leak
// workaround. Known upstream sharp edges — a hotplug segfault and a
// mitigated-not-fixed leak — are why the processes are supervised and simply
// respawned rather than trusted.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-video-wallpaper"
property bool available: false // mpvpaper on PATH, checked once
property bool scanned: false
property var candidates: [] // newest-60 videos in videoWallpaperDir
property string path: "" // the active video, "" when inactive
readonly property bool active: root.path !== ""
property string lastError: ""
// Pause reasons, each independent; the video plays only when none holds.
property bool manuallyPaused: false
readonly property bool gamePaused: FocusModes.gameRunning
readonly property bool batteryPaused: DesktopPreferences.get("videoWallpaperPauseOnBattery") === true
&& Battery.available && !Battery.acOnline
readonly property bool paused: root.manuallyPaused || root.gamePaused || root.batteryPaused
readonly property string frameDir: Quickshell.env("XDG_STATE_HOME") !== ""
? Quickshell.env("XDG_STATE_HOME") + "/panama"
: Quickshell.env("HOME") + "/.local/state/panama"
// The lock screen cannot play video; panama-lock uses this still instead.
readonly property string framePath: root.frameDir + "/video-wallpaper-frame.png"
readonly property var outputs: Quickshell.screens.map(screen => screen.name)
function videoDir(): string {
const stored = String(DesktopPreferences.get("videoWallpaperDir") || "Videos/Wallpapers");
if (stored.startsWith("/"))
return stored;
if (stored.startsWith("~/"))
return Quickshell.env("HOME") + stored.slice(1);
return Quickshell.env("HOME") + "/" + stored;
}
function isVideo(candidate: string): bool {
return /\.(mp4|mkv|webm)$/i.test(String(candidate));
}
function socketFor(output: string): string {
return Quickshell.env("XDG_RUNTIME_DIR") + "/panama-wallpaper-" + output + ".sock";
}
// ── Discovery ───────────────────────────────────────────────────────────
function rescan(): void {
if (!scanProc.running) {
scanProc.command = [root.helperPath, "scan", root.videoDir()];
scanProc.running = true;
}
}
Process {
id: scanProc
stdout: StdioCollector {
onStreamFinished: {
root.candidates = this.text.split("\n").filter(line => line !== "");
root.scanned = true;
}
}
}
Process {
id: probeProc
command: ["sh", "-c", "command -v mpvpaper >/dev/null && echo yes || echo no"]
running: true
stdout: StdioCollector {
onStreamFinished: root.available = this.text.trim() === "yes"
}
}
// ── Playback lifecycle ──────────────────────────────────────────────────
// One mpvpaper per output. `-p -a FULL` is belt-and-braces auto-pause;
// the real pausing happens over the IPC socket below. `hwdec=vaapi` is
// named explicitly so a silent software-decode fallback cannot hide.
function mpvpaperCommand(output: string): var {
return ["mpvpaper", "-f", "-o",
"hwdec=vaapi profile=fast no-audio loop-file=inf panscan=1.0 "
+ "input-ipc-server=" + root.socketFor(output),
"-p", "-a", "FULL",
output, root.path];
}
function start(video: string): bool {
if (!root.available) {
root.lastError = "mpvpaper is not installed — run panama update to pick it up.";
return false;
}
if (!root.isVideo(video))
return false;
root.lastError = "";
root.path = video;
root.manuallyPaused = false;
hyprpaperControl.command = ["systemctl", "--user", "stop", "hyprpaper.service"];
hyprpaperControl.running = true;
frameProc.command = ["sh", "-c",
"mkdir -p '" + root.frameDir + "' && exec '" + root.helperPath
+ "' frame \"$1\" \"$2\"", "frame", video, root.framePath];
frameProc.running = true;
playerRespawn.restart();
return true;
}
function stop(): void {
if (!root.active)
return;
root.path = "";
root.manuallyPaused = false;
playerRespawn.stop();
for (const player of root.players)
player.running = false;
hyprpaperControl.command = ["systemctl", "--user", "start", "hyprpaper.service"];
hyprpaperControl.running = true;
Qt.callLater(() => Wallpaper.refreshActive());
}
// Players are created per current output set each (re)spawn, so hotplug
// lands on the same path as a crash: kill what runs, spawn fresh.
property var players: []
Timer {
id: playerRespawn
interval: 400
onTriggered: root.spawnPlayers()
}
function spawnPlayers(): void {
for (const player of root.players)
player.running = false;
const spawned = [];
for (const output of root.outputs) {
const player = playerComponent.createObject(root, { output: output });
player.command = root.mpvpaperCommand(output);
player.running = true;
spawned.push(player);
}
root.players = spawned;
pauseSync.restart();
}
Component {
id: playerComponent
Process {
id: player
property string output: ""
onExited: {
// A dead player while a video is meant to be active is a
// crash (mpvpaper has a known hotplug segfault): respawn the
// whole set after a beat rather than reasoning per-output.
if (root.active && !playerRespawn.running)
playerRespawn.restart();
}
}
}
onOutputsChanged: if (root.active) playerRespawn.restart()
Process {
id: hyprpaperControl
}
Process {
id: frameProc
}
// ── Pause, over mpv's JSON IPC ──────────────────────────────────────────
onPausedChanged: pauseSync.restart()
// The sockets appear a moment after mpvpaper starts; a short retry beats
// ordering ceremony. Each sync writes the current desired state to every
// output's socket and disconnects.
Timer {
id: pauseSync
interval: 600
onTriggered: root.pushPauseState()
}
function pushPauseState(): void {
if (!root.active)
return;
for (const output of root.outputs) {
const socket = pauseSocketComponent.createObject(root, {
socketPath: root.socketFor(output),
payload: JSON.stringify({ command: ["set_property", "pause", root.paused] }) + "\n"
});
socket.connected = true;
}
}
Component {
id: pauseSocketComponent
Socket {
id: pauseSocket
property string payload: ""
path: socketPath
property string socketPath: ""
onConnectionStateChanged: {
if (connected) {
write(payload);
flush();
Qt.callLater(() => { pauseSocket.connected = false; pauseSocket.destroy(); });
}
}
onError: pauseSocket.destroy()
}
}
function togglePause(): void {
root.manuallyPaused = !root.manuallyPaused;
}
Component.onCompleted: root.rescan()
}
+64 -3
View File
@@ -126,8 +126,16 @@ Singleton {
id: restore
interval: 1500
onTriggered: {
if (root.startupRestoreEnabled)
root.applyCurrentPolicy(false);
if (!root.startupRestoreEnabled)
return;
// A stored video wallpaper restores through mpvpaper; handing its
// path to the hyprpaper transaction would only fail validation.
if (DesktopPreferences.get("wallpaperMode") === "single"
&& VideoWallpaper.isVideo(root.configured)) {
VideoWallpaper.start(root.configured);
return;
}
root.applyCurrentPolicy(false);
}
}
@@ -197,7 +205,10 @@ Singleton {
function currentPolicy(): var {
return {
mode: DesktopPreferences.get("wallpaperMode") ?? "single",
globalPath: root.configured === "" ? root.shippedPath : root.configured,
// A stored video path cannot serve as a hyprpaper still; policies
// built while one is stored fall back to the shipped image.
globalPath: root.configured === "" || VideoWallpaper.isVideo(root.configured)
? root.shippedPath : root.configured,
collection: DesktopPreferences.get("wallpaperSlideshowPaths") ?? [],
intervalMinutes: DesktopPreferences.get("wallpaperIntervalMinutes") ?? 30,
shuffle: DesktopPreferences.get("wallpaperShuffle") !== false,
@@ -335,6 +346,10 @@ Singleton {
}
function applyCurrentPolicy(persist: bool): bool {
// While mpvpaper holds the background, hyprpaper has nothing to apply
// — hotplug is VideoWallpaper's problem and it respawns per output.
if (VideoWallpaper.active)
return true;
return root.applyPolicy(root.currentPolicy(), persist === true, false);
}
@@ -374,6 +389,19 @@ Singleton {
}
function setSingle(path: string): bool {
// A video routes to mpvpaper instead of hyprpaper; it still rides
// wallpaperPath so restore, backup, and the picker's "current" ring
// treat both kinds the same.
if (VideoWallpaper.isVideo(path)) {
if (!VideoWallpaper.start(path)) {
root.lastError = VideoWallpaper.lastError;
return false;
}
root.lastError = "";
DesktopPreferences.set("wallpaperMode", "single");
DesktopPreferences.set("wallpaperPath", path);
return true;
}
const effectivePath = path === "" ? root.shippedPath : path;
const allowed = root.candidates([]);
if (!WallpaperPolicy.validPath(effectivePath, allowed)) {
@@ -384,20 +412,53 @@ Singleton {
policy.mode = "single";
policy.globalPath = effectivePath;
policy.slideshowPath = effectivePath;
// Returning from a video: stop mpvpaper first, then give hyprpaper's
// service a beat to come back before the transaction talks to it.
if (VideoWallpaper.active) {
VideoWallpaper.stop();
root.pendingStillPolicy = policy;
stillAfterVideo.restart();
return true;
}
return root.applyPolicy(policy, true, false);
}
property var pendingStillPolicy: null
Timer {
id: stillAfterVideo
interval: 900
onTriggered: {
if (root.pendingStillPolicy) {
root.applyPolicy(root.pendingStillPolicy, true, false);
root.pendingStillPolicy = null;
}
}
}
function setMode(mode: string): bool {
if (!["single", "slideshow", "per-monitor"].includes(mode))
return false;
const policy = root.currentPolicy();
policy.mode = mode;
// Slideshow and per-display are hyprpaper's modes; a stored video path
// cannot serve as their global still, so fall back to the shipped one.
if (VideoWallpaper.isVideo(policy.globalPath)) {
policy.globalPath = root.shippedPath;
policy.slideshowPath = root.shippedPath;
}
if (mode === "slideshow") {
const collection = WallpaperPolicy.validCollection(
policy.collection, root.candidates([]));
if (!collection.includes(policy.slideshowPath))
policy.slideshowPath = collection[0] || policy.globalPath;
}
if (VideoWallpaper.active) {
VideoWallpaper.stop();
root.pendingStillPolicy = policy;
stillAfterVideo.restart();
return true;
}
return root.applyPolicy(policy, true, false);
}
+5 -2
View File
@@ -1,7 +1,10 @@
# Tokyo Night Moon for tmux -- the palette Panama ships.
#
# Extracted from tmux.conf so the two schemes can be swapped. tmux.conf sources
# current-theme.conf, which panama-theme-apps generates from one of these.
# Reference, not the source of the running colours. tmux.conf sources
# current-theme.conf, which panama-theme-apps RENDERS from the active theme's
# palette -- so any of the ten themes reaches tmux, not only the two Tokyo
# Nights. This file used to be copied into that one; it is kept because it is a
# complete tmux theme somebody may want to source directly.
#
# Every color here was already in tmux.conf; this file is that block verbatim.
+7 -4
View File
@@ -18,10 +18,13 @@ bind C-Space send-prefix
# Set status bar
#set -g status-bg pink
# Tokyo Night Moon color palette
# Colors live in themes/ and are generated into current-theme.conf by
# panama-theme-apps, which regenerates it whenever the desktop color scheme
# changes. -q so a fresh checkout without the generated file still starts.
# The active Panama theme.
# current-theme.conf is RENDERED from that theme's palette by
# panama-theme-apps, which rewrites it whenever the theme changes. The
# tokyonight-* files in themes/ are shipped for anyone who wants to source one
# directly; nothing copies them here any more, because a copied file only ever
# gave tmux the two Tokyo Nights and none of the other themes.
# -q so a fresh checkout without the generated file still starts.
source-file -q "~/.config/tmux/current-theme.conf"
# Increase scrollback buffer
+13 -7
View File
@@ -6,18 +6,24 @@
// and emoji-copy extensions, so its clipboard and emoji views are bound
// directly in hypr/keybinds.lua.
// Vicinae 0.26 selects a theme per system appearance, and now there is a
// theme for each: the launcher follows Panama's light/dark setting instead of
// staying dark on a light desktop. Both are authored here rather than using
// Vicinae's bundled Tokyo Night, which ships Night and Storm but not the Moon
// and Day variants the rest of this desktop uses.
// Vicinae 0.26 selects a theme per system appearance, and there is a theme
// for each: the launcher follows Panama's light/dark setting instead of
// staying dark on a light desktop.
//
// Both are GENERATED, by quickshell/scripts/panama-theme-apps, from whichever
// Panama theme is chosen for that scheme -- so the launcher wears Catppuccin
// when the desktop does, rather than being pinned to the two Tokyo Nights the
// way the authored themes/ files were. They are written straight into
// ~/.local/share/vicinae/themes, beside the symlinks to the authored ones: a
// generated file inside the repository's themes folder would show up as a
// change to the repository every time the theme moved.
"theme": {
"light": {
"name": "tokyonight-day",
"name": "panama-light",
"icon_theme": "auto"
},
"dark": {
"name": "tokyonight-moon",
"name": "panama-dark",
"icon_theme": "auto"
}
},