Complete the Panama theme system
This commit is contained in:
@@ -93,6 +93,14 @@ Singleton {
|
||||
function onRevisionChanged(): void { coalesce.restart(); }
|
||||
}
|
||||
|
||||
// Profile selection and custom edits both update preferences, but binding
|
||||
// directly to the effective profile also documents the in-shell ownership
|
||||
// boundary and keeps this propagation reactive if that storage changes.
|
||||
Connections {
|
||||
target: ThemeProfiles
|
||||
function onActiveProfileChanged(): void { coalesce.restart(); }
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: coalesce
|
||||
interval: 250
|
||||
|
||||
@@ -136,7 +136,10 @@ Singleton {
|
||||
{ label: "Per-display wallpaper", detail: "Assign a different image to each connected display", page: "appearance" },
|
||||
{ label: "Arrange displays", detail: "Drag connected displays into their physical positions", page: "displays" },
|
||||
{ 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: "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: "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" }
|
||||
]
|
||||
|
||||
function pageFor(group: string): string {
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
var MAX_NAME_LENGTH = 40;
|
||||
|
||||
var SHIPPED = [
|
||||
{
|
||||
id: "moon",
|
||||
name: "Moon",
|
||||
scheme: "dark",
|
||||
accent: "#82aaff",
|
||||
secondary: "#b172b0",
|
||||
shipped: true
|
||||
},
|
||||
{
|
||||
id: "moon-rose",
|
||||
name: "Moon Rose",
|
||||
scheme: "dark",
|
||||
accent: "#ff757f",
|
||||
secondary: "#c099ff",
|
||||
shipped: true
|
||||
},
|
||||
{
|
||||
id: "day",
|
||||
name: "Day",
|
||||
scheme: "light",
|
||||
accent: "#2e7de9",
|
||||
secondary: "#9854f1",
|
||||
shipped: true
|
||||
}
|
||||
];
|
||||
|
||||
// The curated accents. `gnome` is the nearest member of GNOME's own
|
||||
// accent-color enum, which is a fixed list of nine we do not get to extend. It
|
||||
// is what libadwaita applications -- Files, Papers, Loupe -- are told to use, so
|
||||
// choosing an accent here recolors them too instead of leaving them in GNOME
|
||||
// blue. Nearest by hue, not by name: "rose" maps to red rather than pink
|
||||
// because it is the red role in this palette. adwaita-accent-contract reads
|
||||
// this table and fails when a member is missing or is not in GNOME's enum.
|
||||
var CURATED = {
|
||||
blue: {
|
||||
dark: "#82aaff", darkSecondary: "#b172b0",
|
||||
light: "#2e7de9", lightSecondary: "#9854f1",
|
||||
label: "Prism blue",
|
||||
gnome: "blue"
|
||||
},
|
||||
orchid: {
|
||||
dark: "#c099ff", darkSecondary: "#fca7ea",
|
||||
light: "#7847bd", lightSecondary: "#9854f1",
|
||||
label: "Orchid",
|
||||
gnome: "purple"
|
||||
},
|
||||
teal: {
|
||||
dark: "#86e1fc", darkSecondary: "#82aaff",
|
||||
light: "#007197", lightSecondary: "#2e7de9",
|
||||
label: "Teal",
|
||||
gnome: "teal"
|
||||
},
|
||||
green: {
|
||||
dark: "#c3e88d", darkSecondary: "#86e1fc",
|
||||
light: "#587539", lightSecondary: "#007197",
|
||||
label: "Green",
|
||||
gnome: "green"
|
||||
},
|
||||
amber: {
|
||||
dark: "#ffc777", darkSecondary: "#ff966c",
|
||||
light: "#8c6c3e", lightSecondary: "#b15c00",
|
||||
label: "Amber",
|
||||
gnome: "yellow"
|
||||
},
|
||||
orange: {
|
||||
dark: "#ff966c", darkSecondary: "#ff757f",
|
||||
light: "#b15c00", lightSecondary: "#c64343",
|
||||
label: "Orange",
|
||||
gnome: "orange"
|
||||
},
|
||||
rose: {
|
||||
dark: "#ff757f", darkSecondary: "#c099ff",
|
||||
light: "#f52a65", lightSecondary: "#9854f1",
|
||||
label: "Rose",
|
||||
gnome: "red"
|
||||
},
|
||||
slate: {
|
||||
dark: "#828bb8", darkSecondary: "#82aaff",
|
||||
light: "#6172b0", lightSecondary: "#2e7de9",
|
||||
label: "Slate",
|
||||
gnome: "slate"
|
||||
}
|
||||
};
|
||||
|
||||
function copyProfile(profile) {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
scheme: profile.scheme,
|
||||
accent: profile.accent,
|
||||
secondary: profile.secondary,
|
||||
shipped: profile.shipped === true
|
||||
};
|
||||
}
|
||||
|
||||
function shippedProfiles() {
|
||||
return SHIPPED.map(copyProfile);
|
||||
}
|
||||
|
||||
function curatedAccents() {
|
||||
var result = {};
|
||||
Object.keys(CURATED).forEach(function(name) {
|
||||
result[name] = Object.assign({}, CURATED[name]);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function isScheme(value) {
|
||||
return value === "dark" || value === "light";
|
||||
}
|
||||
|
||||
function normalizedColor(value) {
|
||||
var color = String(value || "").trim().toLowerCase();
|
||||
return /^#[0-9a-f]{6}$/.test(color) ? color : null;
|
||||
}
|
||||
|
||||
function normalizeStoredProfile(value) {
|
||||
if (!value || typeof value !== "object" || value.shipped === true)
|
||||
return null;
|
||||
|
||||
var id = String(value.id || "").trim();
|
||||
var name = String(value.name || "").trim();
|
||||
var accent = normalizedColor(value.accent);
|
||||
var secondary = normalizedColor(value.secondary);
|
||||
if (!/^custom-[a-z0-9][a-z0-9-]{0,56}$/.test(id)
|
||||
|| name.length === 0 || name.length > MAX_NAME_LENGTH
|
||||
|| !isScheme(value.scheme) || !accent || !secondary)
|
||||
return null;
|
||||
|
||||
return {
|
||||
id: id,
|
||||
name: name,
|
||||
scheme: value.scheme,
|
||||
accent: accent,
|
||||
secondary: secondary,
|
||||
shipped: false
|
||||
};
|
||||
}
|
||||
|
||||
function validCustomProfiles(values) {
|
||||
if (!Array.isArray(values))
|
||||
return [];
|
||||
|
||||
var ids = {};
|
||||
var names = {};
|
||||
SHIPPED.forEach(function(profile) {
|
||||
ids[profile.id] = true;
|
||||
names[profile.name.toLowerCase()] = true;
|
||||
});
|
||||
|
||||
var result = [];
|
||||
values.forEach(function(value) {
|
||||
var profile = normalizeStoredProfile(value);
|
||||
if (!profile)
|
||||
return;
|
||||
var foldedName = profile.name.toLowerCase();
|
||||
if (ids[profile.id] || names[foldedName])
|
||||
return;
|
||||
ids[profile.id] = true;
|
||||
names[foldedName] = true;
|
||||
result.push(profile);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function profileCatalog(values) {
|
||||
return shippedProfiles().concat(validCustomProfiles(values));
|
||||
}
|
||||
|
||||
function boundedName(value) {
|
||||
var name = String(value || "").trim();
|
||||
if (!name)
|
||||
name = "Custom theme";
|
||||
return name.slice(0, MAX_NAME_LENGTH);
|
||||
}
|
||||
|
||||
function uniqueName(value, profiles) {
|
||||
var requested = boundedName(value);
|
||||
var names = {};
|
||||
profiles.forEach(function(profile) {
|
||||
names[profile.name.toLowerCase()] = true;
|
||||
});
|
||||
if (!names[requested.toLowerCase()])
|
||||
return requested;
|
||||
|
||||
for (var suffix = 2; suffix < 10000; suffix++) {
|
||||
var ending = " " + suffix;
|
||||
var candidate = requested.slice(0, MAX_NAME_LENGTH - ending.length) + ending;
|
||||
if (!names[candidate.toLowerCase()])
|
||||
return candidate;
|
||||
}
|
||||
return requested.slice(0, MAX_NAME_LENGTH - 6) + " 10000";
|
||||
}
|
||||
|
||||
function slug(value) {
|
||||
var result = String(value || "").toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48)
|
||||
.replace(/-+$/g, "");
|
||||
return result || "theme";
|
||||
}
|
||||
|
||||
function uniqueId(name, profiles) {
|
||||
var base = "custom-" + slug(name);
|
||||
var ids = {};
|
||||
profiles.forEach(function(profile) { ids[profile.id] = true; });
|
||||
if (!ids[base])
|
||||
return base;
|
||||
for (var suffix = 2; suffix < 10000; suffix++) {
|
||||
var candidate = base.slice(0, 57 - String(suffix).length) + "-" + suffix;
|
||||
if (!ids[candidate])
|
||||
return candidate;
|
||||
}
|
||||
return base.slice(0, 52) + "-10000";
|
||||
}
|
||||
|
||||
function createCustomProfile(values, input) {
|
||||
var customs = validCustomProfiles(values);
|
||||
var catalog = shippedProfiles().concat(customs);
|
||||
var scheme = input && input.scheme;
|
||||
var accent = normalizedColor(input && input.accent);
|
||||
var secondary = normalizedColor(input && input.secondary);
|
||||
if (!isScheme(scheme) || !accent || !secondary)
|
||||
return { profiles: customs, profile: null };
|
||||
|
||||
var name = uniqueName(input && input.name, catalog);
|
||||
var profile = {
|
||||
id: uniqueId(name, catalog),
|
||||
name: name,
|
||||
scheme: scheme,
|
||||
accent: accent,
|
||||
secondary: secondary,
|
||||
shipped: false
|
||||
};
|
||||
return { profiles: customs.concat([profile]), profile: profile };
|
||||
}
|
||||
|
||||
function findProfile(values, id) {
|
||||
var catalog = profileCatalog(values);
|
||||
for (var index = 0; index < catalog.length; index++) {
|
||||
if (catalog[index].id === id)
|
||||
return catalog[index];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function editProfile(values, selected, changes) {
|
||||
var customs = validCustomProfiles(values);
|
||||
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;
|
||||
if (!isScheme(scheme) || !accent || !secondary)
|
||||
return { profiles: customs, profile: null };
|
||||
|
||||
if (selected.shipped === true) {
|
||||
return createCustomProfile(customs, {
|
||||
name: selected.name + " custom",
|
||||
scheme: scheme,
|
||||
accent: accent,
|
||||
secondary: secondary
|
||||
});
|
||||
}
|
||||
|
||||
var stored = normalizeStoredProfile(selected);
|
||||
if (!stored)
|
||||
return { profiles: customs, profile: null };
|
||||
|
||||
var updated = Object.assign({}, stored, {
|
||||
scheme: scheme,
|
||||
accent: accent,
|
||||
secondary: secondary
|
||||
});
|
||||
var found = false;
|
||||
var next = customs.map(function(profile) {
|
||||
if (profile.id !== updated.id)
|
||||
return profile;
|
||||
found = true;
|
||||
return updated;
|
||||
});
|
||||
if (!found)
|
||||
next.push(updated);
|
||||
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)
|
||||
return { profiles: customs, removed: false };
|
||||
}
|
||||
var next = customs.filter(function(profile) { return profile.id !== id; });
|
||||
return { profiles: next, removed: next.length !== customs.length };
|
||||
}
|
||||
|
||||
function curatedPair(name, scheme) {
|
||||
var entry = CURATED[name] || CURATED.blue;
|
||||
if (scheme === "light")
|
||||
return { accent: entry.light, secondary: entry.lightSecondary };
|
||||
return { accent: entry.dark, secondary: entry.darkSecondary };
|
||||
}
|
||||
|
||||
function matchingShippedProfile(scheme, accent, secondary) {
|
||||
var first = normalizedColor(accent);
|
||||
var second = normalizedColor(secondary);
|
||||
for (var index = 0; index < SHIPPED.length; index++) {
|
||||
var profile = SHIPPED[index];
|
||||
if (profile.scheme === scheme && profile.accent === first && profile.secondary === second)
|
||||
return copyProfile(profile);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function curatedNameForProfile(profile) {
|
||||
if (!profile || !isScheme(profile.scheme))
|
||||
return "";
|
||||
var accent = normalizedColor(profile.accent);
|
||||
var secondary = normalizedColor(profile.secondary);
|
||||
var names = Object.keys(CURATED);
|
||||
for (var index = 0; index < names.length; index++) {
|
||||
var name = names[index];
|
||||
var pair = curatedPair(name, profile.scheme);
|
||||
if (pair.accent === accent && pair.secondary === secondary)
|
||||
return name;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function clamp(value, minimum, maximum) {
|
||||
return Math.max(minimum, Math.min(maximum, Number(value)));
|
||||
}
|
||||
|
||||
function channelHex(value) {
|
||||
var text = Math.round(value).toString(16);
|
||||
return text.length < 2 ? "0" + text : text;
|
||||
}
|
||||
|
||||
function hsvToHex(hue, saturation, value) {
|
||||
var h = Number(hue);
|
||||
var s = Number(saturation);
|
||||
var v = Number(value);
|
||||
if (!isFinite(h) || !isFinite(s) || !isFinite(v))
|
||||
return null;
|
||||
h = ((h % 360) + 360) % 360;
|
||||
s = clamp(s, 0, 100) / 100;
|
||||
v = clamp(v, 0, 100) / 100;
|
||||
|
||||
var chroma = v * s;
|
||||
var section = h / 60;
|
||||
var x = chroma * (1 - Math.abs(section % 2 - 1));
|
||||
var red = 0;
|
||||
var green = 0;
|
||||
var blue = 0;
|
||||
if (section < 1) { red = chroma; green = x; }
|
||||
else if (section < 2) { red = x; green = chroma; }
|
||||
else if (section < 3) { green = chroma; blue = x; }
|
||||
else if (section < 4) { green = x; blue = chroma; }
|
||||
else if (section < 5) { red = x; blue = chroma; }
|
||||
else { red = chroma; blue = x; }
|
||||
var match = v - chroma;
|
||||
return "#" + channelHex((red + match) * 255)
|
||||
+ channelHex((green + match) * 255)
|
||||
+ channelHex((blue + match) * 255);
|
||||
}
|
||||
|
||||
function hexToHsv(value) {
|
||||
var color = normalizedColor(value);
|
||||
if (!color)
|
||||
return null;
|
||||
var red = parseInt(color.slice(1, 3), 16) / 255;
|
||||
var green = parseInt(color.slice(3, 5), 16) / 255;
|
||||
var blue = parseInt(color.slice(5, 7), 16) / 255;
|
||||
var maximum = Math.max(red, green, blue);
|
||||
var minimum = Math.min(red, green, blue);
|
||||
var delta = maximum - minimum;
|
||||
var hue = 0;
|
||||
if (delta !== 0) {
|
||||
if (maximum === red)
|
||||
hue = 60 * (((green - blue) / delta) % 6);
|
||||
else if (maximum === green)
|
||||
hue = 60 * ((blue - red) / delta + 2);
|
||||
else
|
||||
hue = 60 * ((red - green) / delta + 4);
|
||||
}
|
||||
if (hue < 0)
|
||||
hue += 360;
|
||||
return {
|
||||
h: Math.round(hue),
|
||||
s: Math.round((maximum === 0 ? 0 : delta / maximum) * 100),
|
||||
v: Math.round(maximum * 100)
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
MAX_NAME_LENGTH: MAX_NAME_LENGTH,
|
||||
shippedProfiles: shippedProfiles,
|
||||
curatedAccents: curatedAccents,
|
||||
validCustomProfiles: validCustomProfiles,
|
||||
profileCatalog: profileCatalog,
|
||||
createCustomProfile: createCustomProfile,
|
||||
findProfile: findProfile,
|
||||
editProfile: editProfile,
|
||||
deleteProfile: deleteProfile,
|
||||
curatedPair: curatedPair,
|
||||
matchingShippedProfile: matchingShippedProfile,
|
||||
curatedNameForProfile: curatedNameForProfile,
|
||||
hsvToHex: hsvToHex,
|
||||
hexToHsv: hexToHsv
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
pragma Singleton
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import "ThemeProfileModel.js" as ThemeProfileModel
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var customProfiles: ThemeProfileModel.validCustomProfiles(
|
||||
DesktopPreferences.get("themeProfiles"))
|
||||
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 activeProfile: ThemeProfileModel.findProfile(
|
||||
root.customProfiles, root.activeId) || ThemeProfileModel.shippedProfiles()[0]
|
||||
readonly property string activeAccentName: ThemeProfileModel.curatedNameForProfile(
|
||||
root.activeProfile)
|
||||
|
||||
property bool reconciling: false
|
||||
|
||||
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.
|
||||
const schemeAccepted = SystemSettings.commitPreference("colorScheme", profile.scheme);
|
||||
const profileAccepted = SystemSettings.commitPreference("themeProfileId", profile.id);
|
||||
return schemeAccepted && profileAccepted;
|
||||
}
|
||||
|
||||
function selectProfile(id: string): bool {
|
||||
return root.commitActive(ThemeProfileModel.findProfile(root.customProfiles, id));
|
||||
}
|
||||
|
||||
// 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
|
||||
});
|
||||
if (!result.profile)
|
||||
return false;
|
||||
if (!SystemSettings.commitPreference("themeProfiles", result.profiles))
|
||||
return false;
|
||||
return SystemSettings.commitPreference("themeProfileId", result.profile.id);
|
||||
}
|
||||
|
||||
function saveProfile(name: string): bool {
|
||||
const result = ThemeProfileModel.createCustomProfile(root.customProfiles, {
|
||||
name: name,
|
||||
scheme: root.activeProfile.scheme,
|
||||
accent: root.activeProfile.accent,
|
||||
secondary: root.activeProfile.secondary
|
||||
});
|
||||
if (!result.profile)
|
||||
return false;
|
||||
if (!SystemSettings.commitPreference("themeProfiles", result.profiles))
|
||||
return false;
|
||||
return SystemSettings.commitPreference("themeProfileId", result.profile.id);
|
||||
}
|
||||
|
||||
function deleteProfile(id: string): bool {
|
||||
const result = ThemeProfileModel.deleteProfile(root.customProfiles, id);
|
||||
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 true;
|
||||
}
|
||||
|
||||
function useCuratedAccent(name: string): bool {
|
||||
const scheme = DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark";
|
||||
const pair = ThemeProfileModel.curatedPair(name, scheme);
|
||||
const shipped = ThemeProfileModel.matchingShippedProfile(
|
||||
scheme, pair.accent, pair.secondary);
|
||||
const accepted = shipped
|
||||
? root.commitActive(shipped)
|
||||
: root.setAccentPair(pair.accent, pair.secondary);
|
||||
if (accepted)
|
||||
SystemSettings.commitPreference("accentName", name);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function reconcileScheme(): void {
|
||||
if (root.reconciling)
|
||||
return;
|
||||
const scheme = DesktopPreferences.get("colorScheme") === "light" ? "light" : "dark";
|
||||
if (root.activeProfile.scheme === scheme)
|
||||
return;
|
||||
root.reconciling = true;
|
||||
SystemSettings.commitPreference("themeProfileId", scheme === "light" ? "day" : "moon");
|
||||
root.reconciling = false;
|
||||
}
|
||||
|
||||
Component.onCompleted: root.reconcileScheme()
|
||||
|
||||
Connections {
|
||||
target: DesktopPreferences
|
||||
function onRevisionChanged(): void { root.reconcileScheme(); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user