Files
Panama/config/dot/quickshell/services/ThemeProfileModel.js
T

680 lines
23 KiB
JavaScript

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",
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 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) {
var result = {
id: profile.id,
name: profile.name,
scheme: profile.scheme,
accent: profile.accent,
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 shippedList(shipped) {
return Array.isArray(shipped) && shipped.length > 0 ? shipped : SHIPPED;
}
function shippedProfiles(shipped) {
return shippedList(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;
var result = {
id: id,
name: name,
scheme: value.scheme,
accent: accent,
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;
}
// The id is the identity here, and every custom id is custom-* namespaced, so
// it can never collide with a shipped one by accident; a repeat inside the
// stored list is a genuine duplicate and is dropped.
//
// A NAME is only a label, and a colliding one is renamed rather than dropped.
// Dropping was data loss on a delay: this list is also what gets written back
// to the preference, so a custom theme whose name a later Panama release
// happened to ship was silently deleted at the user's next theme edit.
function validCustomProfiles(values, shipped) {
if (!Array.isArray(values))
return [];
var catalog = shippedList(shipped);
var ids = {};
catalog.forEach(function(profile) { ids[profile.id] = true; });
var result = [];
values.forEach(function(value) {
var profile = normalizeStoredProfile(value);
if (!profile || ids[profile.id])
return;
ids[profile.id] = true;
profile.name = uniqueName(profile.name, catalog.concat(result));
result.push(profile);
});
return result;
}
function profileCatalog(values, shipped) {
return shippedProfiles(shipped).concat(validCustomProfiles(values, shipped));
}
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, 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);
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
};
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, shipped) {
var catalog = profileCatalog(values, shipped);
for (var index = 0; index < catalog.length; index++) {
if (catalog[index].id === id)
return catalog[index];
}
return null;
}
function editProfile(values, selected, changes, shipped) {
var customs = validCustomProfiles(values, shipped);
if (!selected || typeof selected !== "object")
return { profiles: customs, profile: null };
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,
palette: pick("palette", selected.palette),
ansi: pick("ansi", selected.ansi),
effects: pick("effects", selected.effects)
}, shipped);
}
var stored = normalizeStoredProfile(selected);
if (!stored)
return { profiles: customs, profile: null };
var updated = Object.assign({}, stored, {
scheme: scheme,
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)
return profile;
found = true;
return updated;
});
if (!found)
next.push(updated);
return { profiles: next, profile: updated };
}
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; });
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, shipped) {
var first = normalizedColor(accent);
var second = normalizedColor(secondary);
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);
});
// Validated on the way out like every other palette this module builds: a
// factor that is not a number survives clamp() as NaN and turns every token
// into a string no palette reader can use. Returning null makes that a
// rejection the caller can see instead of a corrupt theme it stores.
return normalizePalette(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 "";
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,
PALETTE_KEYS: PALETTE_KEYS,
ANSI_KEYS: ANSI_KEYS,
EFFECT_SPEC: EFFECT_SPEC,
shippedProfiles: shippedProfiles,
curatedAccents: curatedAccents,
validCustomProfiles: validCustomProfiles,
profileCatalog: profileCatalog,
createCustomProfile: createCustomProfile,
findProfile: findProfile,
editProfile: editProfile,
deleteProfile: deleteProfile,
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
};
}