680 lines
23 KiB
JavaScript
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
|
|
};
|
|
}
|