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 }; }