buildRetroCandidate function

ThemeSpec buildRetroCandidate(
  1. int seed,
  2. FamilyDefinition def,
  3. int engineVersion, {
  4. int attempt = 0,
  5. bool accessible = false,
})

Builds one unvalidated Retro candidate (the generator repairs and validates it, retries with palette/retry/n and finally falls back to a Retro hero).

Implementation

ThemeSpec buildRetroCandidate(
  int seed,
  FamilyDefinition def,
  int engineVersion, {
  int attempt = 0,
  bool accessible = false,
}) {
  final paletteTag = attempt == 0
      ? RngTag.palette
      : RngTag.sub(RngTag.palette, 'retry', attempt);
  final p = Rng.stream(seed, paletteTag);
  final pocket = p.pickWeighted(def.brightnessWeights) == 0;
  final brightness = pocket ? ThemeBrightness.dark : ThemeBrightness.light;
  final ranges = def.ranges(brightness);
  final panelKind = RetroPanel.values[p.pickWeighted(retroPanelWeights)];
  final lim = accessible
      ? GuardrailLimits.accessible
      : GuardrailLimits.standard;
  final policy = lim.labelPolicy;

  // Body.
  final bodyL = ranges.background.lMilli.sample(p);
  final int bodyHue;
  final int bodyC;
  if (pocket) {
    bodyHue = p.nextHueTenths();
    bodyC = p.nextIntIn(
      ranges.background.chroma.lo,
      ranges.background.chroma.hi,
    );
  } else {
    bodyHue = retroDeskHueTenths.sample(p);
    bodyC = retroDeskChromaMilli.sample(p);
  }
  final body = oklchToArgb(bodyL / 1000, bodyC / 1000, bodyHue / 10);

  // Accent.
  final accentHue = _wrap(
    pickWeightedValue(p, retroAccentHues) + p.nextJitterTenths(80),
  );
  final accentOps = p.nextChance(2, 3);

  // Key faces (lightness × 1000, chroma × 1000, hue tenths).
  final lightDigits = !pocket && p.nextChance(3, 5);
  final keyHue = bodyHue;
  var digitL = ranges.digit.lMilli.sample(p);
  var fnL = ranges.function.lMilli.sample(p);
  if (!pocket && !lightDigits) {
    // Dark-brown digits on beige: function keys a light warm grey.
    digitL = 300 + p.nextInt(81); // 0.30–0.38
    fnL = 680 + p.nextInt(61); // 0.68–0.74
  }
  if (pocket && digitL < bodyL + 80) digitL = bodyL + 80;
  final digitC = p.nextIntIn(ranges.digit.chroma.lo, ranges.digit.chroma.hi);
  final fnC = p.nextIntIn(ranges.function.chroma.lo, ranges.function.chroma.hi);

  var opL = ranges.operator.lMilli.sample(p);
  var eqL = ranges.equals.lMilli.sample(p);
  final opRel = p.nextIntIn(
    ranges.operator.chroma.lo,
    ranges.operator.chroma.hi,
  );
  final eqRel = p.nextIntIn(ranges.equals.chroma.lo, ranges.equals.chroma.hi);
  var neutralOpL = pocket
      ? 640 +
            p.nextInt(81) // light grey on graphite
      : (lightDigits ? 300 + p.nextInt(61) : 830 + p.nextInt(51));
  final neutralOpC = p.nextIntIn(0, 15);

  if (accessible) {
    // 7:1 labels exist only on faces with Y ≤ 0.1 (white) or ≥ 0.3
    // (black): keep every face out of the mid-tones.
    int out(int l) => l > 440 && l < 700 ? (l < 570 ? 440 : 700) : l;
    digitL = out(digitL);
    fnL = out(fnL);
    neutralOpL = out(neutralOpL);
    opL = pocket ? (opL < 720 ? 720 : opL) : (opL > 450 ? 450 : opL);
    eqL = pocket ? (eqL < 720 ? 720 : eqL) : (eqL > 440 ? 440 : eqL);
  }
  if (accentOps && (eqL - opL).abs() < 80) {
    final hi = accessible && !pocket ? 440 : ranges.equals.lMilli.hi;
    eqL = opL + 80 <= hi ? opL + 80 : opL - 80;
  }

  Lch accent(int lMilli, int rel, int cap) {
    final l = lMilli / 1000;
    final c = rel / 1000 * maxChroma(l, accentHue / 10);
    return (l: l, c: c < cap / 1000 ? c : cap / 1000, h: accentHue / 10);
  }

  final digitFace = (l: digitL / 1000, c: digitC / 1000, h: keyHue / 10);
  final fnFace = (l: fnL / 1000, c: fnC / 1000, h: keyHue / 10);
  final opFace = accentOps
      ? accent(opL, opRel, ranges.operator.chroma.capMilli)
      : (l: neutralOpL / 1000, c: neutralOpC / 1000, h: keyHue / 10);
  final eqFace = accent(eqL, eqRel, ranges.equals.chroma.capMilli);

  final labelChroma = p.nextIntIn(8, ranges.labelMaxChromaMilli) / 1000;
  KeyColors key(Lch face) {
    final start = face.l < 0.58 ? 0.97 : 0.17;
    final pair = repairPair(face, (
      l: start,
      c: labelChroma,
      h: face.h,
    ), policy: policy);
    final f = pair.faceLch;
    // The keycap skirt: a darker tone of the face (light faces) or a
    // slightly lighter one (near-black faces, so it still reads).
    final skirtL = f.l < 0.26 ? f.l + 0.06 : f.l - 0.11;
    return KeyColors(
      face: pair.face,
      label: pair.label,
      edge: oklchToArgb(clamp01(skirtL), f.c * 0.9, f.h),
    );
  }

  final digit = key(digitFace);
  final function = key(fnFace);
  final operator = key(opFace);
  var equals = key(eqFace);
  if (minCvdDeltaE(equals.face, operator.face) < 0.065) {
    // `=` must stand apart from the operators: move it 0.10 in lightness.
    final f = argbToOklch(equals.face);
    final o = argbToOklch(operator.face);
    final up = f.l >= o.l ? f.l + 0.10 <= 0.92 : f.l - 0.10 < 0.12;
    equals = key((l: clamp01(f.l + (up ? 0.10 : -0.10)), c: f.c, h: f.h));
  }

  // Panel and segments.
  final vfd = panelKind == RetroPanel.vfd;
  final int panel;
  final int segmentStart;
  final int previewStart;
  final int errorStart;
  final int caretStart;
  final int ghostRatio;
  if (vfd) {
    final hue = retroVfdHueTenths.sample(p) / 10;
    panel = oklchToArgb(
      retroVfdLMilli.sample(p) / 1000,
      retroVfdChromaMilli.sample(p) / 1000,
      hue,
    );
    final segHue =
        _wrap(
          pickWeightedValue(p, retroVfdSegmentHues) + p.nextJitterTenths(80),
        ) /
        10;
    final segL = p.nextIntIn(840, 900) / 1000;
    final segC = p.nextIntIn(600, 900) / 1000 * maxChroma(segL, segHue);
    segmentStart = oklchToArgb(segL, segC < 0.16 ? segC : 0.16, segHue);
    previewStart = oklchToArgb(0.70, 0.07, segHue);
    errorStart = oklchToArgb(0.74, 0.15, 40);
    caretStart = oklchToArgb(0.82, 0.12, segHue);
    ghostRatio = retroVfdGhostRatio.sample(p);
  } else {
    final hue = retroLcdHueTenths.sample(p);
    final panelL = retroLcdLMilli.sample(p) / 1000;
    panel = oklchToArgb(panelL, retroLcdChromaMilli.sample(p) / 1000, hue / 10);
    // Segments: warm black (the panel's hue) or the blue-black of
    // twisted-nematic cells.
    final segHue = p.nextChance(1, 2)
        ? _wrap(hue + p.nextJitterTenths(150))
        : p.nextIntIn(2400, 2700);
    final segL = p.nextIntIn(180, 260) / 1000;
    final segC = p.nextIntIn(10, 30) / 1000;
    segmentStart = oklchToArgb(segL, segC, segHue / 10);
    previewStart = oklchToArgb(0.40, segC, segHue / 10);
    errorStart = oklchToArgb(0.34, 0.12, 25);
    caretStart = oklchToArgb(0.30, 0.10, accentHue / 10);
    ghostRatio = retroLcdGhostRatio.sample(p);
  }
  final panels = [panel];
  int onPanel(int start, double target) =>
      solveOnAll(start, panels, target) ?? blackOrWhite(panel);
  final previewTarget = lim.previewRatio < ratioText
      ? ratioText
      : lim.previewRatio;
  final displayText = onPanel(segmentStart, lim.displayRatio);
  final previewText = onPanel(previewStart, previewTarget);
  final error = onPanel(errorStart, lim.displayRatio);
  final caret = onPanel(caretStart, lim.caretRatio);
  final ghost = lcdGhostColor(panel, displayText, ratio: ghostRatio / 100);

  // Body: solid, or a faint moulded sheen.
  final background = _background(
    Rng.stream(seed, RngTag.background),
    def,
    bodyL,
    bodyC,
    bodyHue,
    body,
  );
  final bgs = backgroundSamples(background);

  // Accent (chips, focus ring) on the body.
  final accentL = pocket ? 0.80 : 0.45;
  final accentC = 0.7 * maxChroma(accentL, accentHue / 10);
  final accentArgb =
      solveOnAll(
        oklchToArgb(accentL, accentC < 0.16 ? accentC : 0.16, accentHue / 10),
        bgs,
        lim.focusRingRatio,
      ) ??
      blackOrWhite(body);

  final palette = Palette(
    background: body,
    surface: panel,
    displayText: displayText,
    previewText: previewText,
    error: error,
    caret: caret,
    focusRing: accentArgb,
    accent: accentArgb,
    digit: digit,
    operator: operator,
    equals: equals,
    function: function,
    shadow: pocket ? 0x8C000000 : 0x47000000,
    highlight: pocket ? 0x2EFFFFFF : 0x8CFFFFFF,
    seedColor: equals.face,
    lcdGhost: ghost,
  );

  final motion = def.motionRanges.vary(
    def.motion,
    Rng.stream(seed, RngTag.motion),
  );
  final s = Rng.stream(seed, RngTag.shape);
  final keyShape = KeyShapeSpec(
    kind: pickWeightedValue(s, def.shapes),
    cornerPermille: def.cornerPermille.sampleStep(s, 10),
  );
  final st = Rng.stream(seed, RngTag.style);
  final keyStyle = def.keyStyle.copyWith(
    kind: pickWeightedValue(st, def.styles),
    extrudeDepthCdp: motion.depthCdp,
  );
  final t = Rng.stream(seed, RngTag.type);
  final keyWeight = def.keyWeight.sampleStep(t, 50);
  final opWeight = keyWeight + def.operatorWeightBonus.sampleStep(t, 50);
  final type = TypeSpec(
    displayFont: pickWeightedValue(t, def.displayFonts),
    keyFont: pickWeightedValue(t, def.keyFonts),
    displayWeight: def.displayWeight.sampleStep(t, 50),
    keyWeight: keyWeight,
    operatorWeight: opWeight > 900 ? 900 : opWeight,
    widthPermille: 750 + 50 * t.nextInt(3),
    lcd: true,
  );
  // Desk calculators click one time in three; pocket ones beep (motion
  // brief §2.3), and from engine v3 one pocket theme in three plays
  // 8-bit chiptune instead.
  final soundRng = Rng.stream(seed, RngTag.sound);
  final SoundPackId sound;
  if (!pocket) {
    sound = soundRng.nextChance(1, 3) ? SoundPackId.mechClicky : def.sound;
  } else if (engineVersion >= 3) {
    sound = soundRng.nextChance(1, 3) ? SoundPackId.chiptune : def.sound;
  } else {
    sound = def.sound;
  }
  final name = generateThemeName(
    rng: Rng.stream(seed, RngTag.name),
    accentArgb: equals.face,
    family: ThemeFamily.retro,
    dark: pocket,
  );

  return ThemeSpec(
    seed: seed,
    family: ThemeFamily.retro,
    engineVersion: engineVersion,
    brightness: brightness,
    accessible: accessible,
    palette: palette,
    keyShape: keyShape,
    keyStyle: keyStyle,
    type: type,
    background: background,
    motion: motion,
    sound: sound,
    name: name,
    nonColourOperatorCue: def.nonColourOperatorCue,
  );
}