recolour method
spec turned to the intent (engine v5): every colour's hue turns by
the angle that puts the anchor (the most colourful of the operator,
= and accent colours; the background when bodyAnchor) on the
intent's base hue; a second colour turns the = key on its own and a
third the function keys; a
neutral first colour quietens everything; with scaleChroma, chroma
scales to the intent's level (capped to the gamut). Lightness and
alpha are kept. keyChromaFloor keeps colourful key colours at
least that colourful when chroma scales down. A second colour within
minEqualsApartTenths of the first leaves = its own hue.
keepLightness stops key colours moving in lightness; neon moves
colourful key colours towards their hue's most saturated lightness.
With
pullPermille
below 1000 every hue also moves
towards its colour, keeping that share of its distance. A harmony
the family honoursHarmony places the
second and third colours' keys instead. With accentsOnly (the generator's fallback before
a hero), only the operator, = and accent colours turn. Draws three
times from r.
Implementation
ThemeSpec recolour(
ThemeSpec spec,
Rng r, {
bool bodyAnchor = false,
bool scaleChroma = true,
bool accentsOnly = false,
bool honoursHarmony = true,
int pullPermille = 1000,
double? keyChromaFloor,
int minEqualsApartTenths = 0,
bool keepLightness = false,
bool neon = false,
}) {
final byColour = intent.harmony == null || !honoursHarmony;
final baseDraw = r.nextU32();
final equalsDraw = r.nextU32();
final functionDraw = r.nextU32();
final p = spec.palette;
final range = _baseRange();
var turn = 0;
int? target;
if (range != null) {
// The operators (four keys) carry the colour when they have one.
final anchor = bodyAnchor && !accentsOnly
? p.background
: (argbToOklch(p.operator.face | 0xFF000000).c >= 0.05
? p.operator.face
: _mostColourful([p.operator.face, p.equals.face, p.accent]));
target = _pick(range, baseDraw);
turn = (target - _hueTenths(anchor)) % 3600;
}
var equalsTurn = turn;
var equalsTarget = target;
final colors = intent.colors;
if (byColour && colors.length >= 2 && !colors[1].neutral) {
equalsTarget = _pick(
_HueRange(colors[1].loTenths, colors[1].hiTenths),
equalsDraw,
);
equalsTurn = (equalsTarget - _hueTenths(p.equals.face)) % 3600;
if (target != null &&
_apart(target, equalsTarget) < minEqualsApartTenths) {
// Too close to the first colour for this family: `=` keeps its own
// hue, turned with the palette.
equalsTurn = turn;
equalsTarget = target;
}
}
var functionTurn = turn;
var functionTarget = target;
if (byColour && colors.length >= 3 && !colors[2].neutral) {
functionTarget = _pick(
_HueRange(colors[2].loTenths, colors[2].hiTenths),
functionDraw,
);
functionTurn = (functionTarget - _hueTenths(p.function.face)) % 3600;
}
var scale = 1000;
if (scaleChroma) {
scale = switch (chroma) {
null => 1000,
IntentChroma.muted => 500,
IntentChroma.balanced => 850,
IntentChroma.vivid => 1300,
};
}
if (colors.isNotEmpty && colors.first.neutral && intent.hueRange == null) {
scale = scale * 350 ~/ 1000;
}
if (turn == 0 && equalsTurn == 0 && functionTurn == 0 && scale == 1000) {
return spec;
}
// Each key role's turn and the hue it pulls towards.
(int, int?) of(String role) => switch (role) {
'equals' => (equalsTurn, equalsTarget),
'function' => (functionTurn, functionTarget),
_ => (turn, target),
};
// Roles a colour of the intent is meant for: their faces take it even
// when the family left them neutral.
final named = <String>{
if (target != null) 'operator',
if (equalsTarget != target) 'equals',
if (functionTarget != target) 'function',
};
final ownColour = <String>{
for (final role in named)
if (_neutralKey(p, role)) role,
};
int compute(
int argb,
String role, {
required bool key,
bool lift = false,
bool face = false,
}) {
final alpha = argb & 0xFF000000;
final lch = argbToOklch(argb | 0xFF000000);
final (t, pullTo) = of(role);
if (lch.c < 0.004 && !lift) return argb;
var tenths = lift && pullTo != null
? pullTo
: ((lch.h * 10).round() + t) % 3600;
if (pullTo != null && pullPermille < 1000 && !lift) {
// Draw the palette's other hues towards the colour, so a one-colour
// intent reads as that colour in families with several accents.
// Digit faces move less, keeping them apart from the operators
// (their labels and outlines are accents, and pull fully).
final keep = role == 'digit' && face
? pullPermille + (1000 - pullPermille) ~/ 2
: pullPermille;
var off = (tenths - pullTo) % 3600;
if (off > 1800) off -= 3600;
tenths = (pullTo + off * keep ~/ 1000) % 3600;
}
if (face &&
key &&
tenths > _goldTenths &&
tenths <= _yellowHiTenths &&
!_wantsGreenYellow) {
// Yellow faces lean gold: a contrast repair that darkens them then
// lands on gold, not olive.
tenths = _goldTenths;
}
final h = tenths / 10;
// A neutral key face the intent names a colour for takes real colour.
final source = lift ? _liftChroma : lch.c;
var c = source * scale / 1000;
if (key) {
// Colourful key colours stay visible when chroma scales down.
final floor = keyChromaFloor ?? _keyChromaFloor;
final keep = source < floor ? source : floor;
if (c < keep) c = keep;
}
if (face && named.contains(role)) {
// A key face a colour is named for shows it clearly (softly under
// a muted intent).
final least = chroma == IntentChroma.muted
? _keyChromaFloor
: _namedFaceChroma;
if (c < least) c = least;
}
var l = lch.l;
if (neon && key && source >= _shiftMinChroma) {
// Neon keys deepen towards where their hue is most saturated (its
// cusp), never below 0.68 (slabs stay brighter than the plates and
// visible on the dark scene).
final cusp = _cuspL(h);
final floor = cusp > _neonFloorL ? cusp : _neonFloorL;
if (floor < l) l = floor;
} else if (lift && !keepLightness) {
// A neutral face given a colour sits where that hue is most
// saturated (a black key turned orange would read brown), up to
// 0.3 from where the family put it.
final d = _cuspL(h) - l;
l += d > 0.3 ? 0.3 : (d < -0.3 ? -0.3 : d);
} else if (key && !keepLightness && source >= _shiftMinChroma) {
// A key colour whose new hue can't hold its colour at this
// lightness (a light neon turned red comes out pink) moves up to
// 0.24 towards where it can; the repair re-solves its label.
final want = c * 0.75;
if (maxChroma(l, h) < want) {
var best = l;
var bestC = maxChroma(l, h);
for (var step = 1; step <= 8 && bestC < want; step++) {
for (final cand in [lch.l - step * 0.03, lch.l + step * 0.03]) {
if (cand <= 0.05 || cand >= 0.97) continue;
final m = maxChroma(cand, h);
if (m > bestC) {
best = cand;
bestC = m;
}
}
}
l = best;
}
// Yellows go olive in the dark: keep them light.
if (tenths >= _yellowLoTenths &&
tenths <= _yellowHiTenths &&
l < _yellowMinL) {
l = l + 0.32 < _yellowMinL ? l + 0.32 : _yellowMinL;
}
}
if (!key) {
// Scene and backdrop colours keep their luminance, so every
// contrast against them (and each family's scene bands) holds.
l = _sameLuminance(argb | 0xFF000000, l, c, h);
}
// oklchToArgb maps out-of-gamut chroma to the gamut edge.
return alpha | (oklchToArgb(l, c, h) & 0x00FFFFFF);
}
// Themes repeat colours (faces, edges, scene stops): convert each once.
// The ink (the palette's shadow, which several families draw outlines
// with) always turns as the ink, so outlines stay that ink.
final ink = p.shadow;
final memo = <(int, String, bool, bool, bool), int>{};
int turned(
int argb,
String role, {
required bool key,
bool lift = false,
bool face = false,
}) {
final isInk = argb == ink && !lift;
final r = isInk ? '' : role;
final k = !isInk && key;
final f = !isInk && face;
return memo[(argb, r, k, lift, f)] ??= compute(
argb,
r,
key: k,
lift: lift,
face: f,
);
}
const accentKeys = {'operator', 'equals', 'accent', 'caret', 'focusRing'};
// [role]: the palette key role a colour belongs to; `inPalette`: a key
// or text colour (not the background or scene).
Object? walk(
Object? v,
String key,
String role, {
bool inPalette = false,
}) {
if (v is Map) {
return <String, Object?>{
for (final e in v.entries)
e.key as String: walk(
e.value,
e.key as String,
key == 'palette' ? e.key as String : role,
inPalette:
inPalette ||
(key == 'palette' && !_sceneKeys.contains(e.key)),
),
};
}
if (v is List) {
return [for (final x in v) walk(x, key, role, inPalette: inPalette)];
}
if (v is String && key != 'name' && _hex.hasMatch(v)) {
return argbToHex(
turned(
argbFromHex(v),
role,
key: inPalette,
lift: key == 'face' && ownColour.contains(role),
face: key == 'face',
),
);
}
return v;
}
final json = jsonDecode(spec.toJsonString()) as Map<String, Object?>;
if (accentsOnly) {
final palette = json['palette']! as Map<String, Object?>;
json['palette'] = {
for (final e in palette.entries)
e.key: accentKeys.contains(e.key)
? walk(e.value, e.key, e.key, inPalette: true)
: e.value,
};
return ThemeSpec.fromJson(json);
}
final out = walk(json, '', '')! as Map<String, Object?>;
final palette = out['palette']! as Map<String, Object?>;
if (named.contains('equals') && !keepLightness) {
_separateEquals(palette, minL: neon ? _neonFloorL : 0.1);
}
_darkenLightLabels(palette);
return ThemeSpec.fromJson(out);
}