recolour method

ThemeSpec recolour(
  1. ThemeSpec spec,
  2. Rng r, {
  3. bool bodyAnchor = false,
  4. bool scaleChroma = true,
  5. bool accentsOnly = false,
  6. bool honoursHarmony = true,
  7. int pullPermille = 1000,
  8. double? keyChromaFloor,
  9. int minEqualsApartTenths = 0,
  10. bool keepLightness = false,
  11. bool neon = false,
})

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