aboutsummaryrefslogtreecommitdiff
path: root/dynamiccolor/DynamicColor.java
blob: 412e33bedf12be936352f4c9fecb6f645e9386cc (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
/*
 * Copyright 2022 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.google.ux.material.libmonet.dynamiccolor;

import static java.lang.Math.max;
import static java.lang.Math.min;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.errorprone.annotations.Var;
import com.google.ux.material.libmonet.contrast.Contrast;
import com.google.ux.material.libmonet.hct.Hct;
import com.google.ux.material.libmonet.palettes.TonalPalette;
import com.google.ux.material.libmonet.scheme.DynamicScheme;
import com.google.ux.material.libmonet.utils.MathUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.function.Function;

/**
 * A color that adjusts itself based on UI state, represented by DynamicScheme.
 *
 * <p>This color automatically adjusts to accommodate a desired contrast level, or other adjustments
 * such as differing in light mode versus dark mode, or what the theme is, or what the color that
 * produced the theme is, etc.
 *
 * <p>Colors without backgrounds do not change tone when contrast changes. Colors with backgrounds
 * become closer to their background as contrast lowers, and further when contrast increases.
 *
 * <p>Prefer the static constructors. They provide a much more simple interface, such as requiring
 * just a hexcode, or just a hexcode and a background.
 *
 * <p>Ultimately, each component necessary for calculating a color, adjusting it for a desired
 * contrast level, and ensuring it has a certain lightness/tone difference from another color, is
 * provided by a function that takes a DynamicScheme and returns a value. This ensures ultimate
 * flexibility, any desired behavior of a color for any design system, but it usually unnecessary.
 * See the default constructor for more information.
 */
// Prevent lint for Function.apply not being available on Android before API level 14 (4.0.1).
// "AndroidJdkLibsChecker" for Function, "NewApi" for Function.apply().
// A java_library Bazel rule with an Android constraint cannot skip these warnings without this
// annotation; another solution would be to create an android_library rule and supply
// AndroidManifest with an SDK set higher than 14.
@SuppressWarnings({"AndroidJdkLibsChecker", "NewApi"})
public final class DynamicColor {
  public final String name;
  public final Function<DynamicScheme, TonalPalette> palette;
  public final Function<DynamicScheme, Double> tone;
  public final boolean isBackground;
  public final Function<DynamicScheme, DynamicColor> background;
  public final Function<DynamicScheme, DynamicColor> secondBackground;
  public final ContrastCurve contrastCurve;
  public final Function<DynamicScheme, ToneDeltaPair> toneDeltaPair;

  public final Function<DynamicScheme, Double> opacity;

  private final HashMap<DynamicScheme, Hct> hctCache = new HashMap<>();

  /**
   * A constructor for DynamicColor.
   *
   * <p>_Strongly_ prefer using one of the convenience constructors. This class is arguably too
   * flexible to ensure it can support any scenario. Functional arguments allow overriding without
   * risks that come with subclasses.
   *
   * <p>For example, the default behavior of adjust tone at max contrast to be at a 7.0 ratio with
   * its background is principled and matches accessibility guidance. That does not mean it's the
   * desired approach for _every_ design system, and every color pairing, always, in every case.
   *
   * <p>For opaque colors (colors with alpha = 100%).
   *
   * @param name The name of the dynamic color.
   * @param palette Function that provides a TonalPalette given DynamicScheme. A TonalPalette is
   *     defined by a hue and chroma, so this replaces the need to specify hue/chroma. By providing
   *     a tonal palette, when contrast adjustments are made, intended chroma can be preserved.
   * @param tone Function that provides a tone, given a DynamicScheme.
   * @param isBackground Whether this dynamic color is a background, with some other color as the
   *     foreground.
   * @param background The background of the dynamic color (as a function of a `DynamicScheme`), if
   *     it exists.
   * @param secondBackground A second background of the dynamic color (as a function of a
   *     `DynamicScheme`), if it exists.
   * @param contrastCurve A `ContrastCurve` object specifying how its contrast against its
   *     background should behave in various contrast levels options.
   * @param toneDeltaPair A `ToneDeltaPair` object specifying a tone delta constraint between two
   *     colors. One of them must be the color being constructed.
   */
  public DynamicColor(
      @NonNull String name,
      @NonNull Function<DynamicScheme, TonalPalette> palette,
      @NonNull Function<DynamicScheme, Double> tone,
      boolean isBackground,
      @Nullable Function<DynamicScheme, DynamicColor> background,
      @Nullable Function<DynamicScheme, DynamicColor> secondBackground,
      @Nullable ContrastCurve contrastCurve,
      @Nullable Function<DynamicScheme, ToneDeltaPair> toneDeltaPair) {

    this.name = name;
    this.palette = palette;
    this.tone = tone;
    this.isBackground = isBackground;
    this.background = background;
    this.secondBackground = secondBackground;
    this.contrastCurve = contrastCurve;
    this.toneDeltaPair = toneDeltaPair;
    this.opacity = null;
  }

  /**
   * A constructor for DynamicColor.
   *
   * <p>_Strongly_ prefer using one of the convenience constructors. This class is arguably too
   * flexible to ensure it can support any scenario. Functional arguments allow overriding without
   * risks that come with subclasses.
   *
   * <p>For example, the default behavior of adjust tone at max contrast to be at a 7.0 ratio with
   * its background is principled and matches accessibility guidance. That does not mean it's the
   * desired approach for _every_ design system, and every color pairing, always, in every case.
   *
   * <p>For opaque colors (colors with alpha = 100%).
   *
   * @param name The name of the dynamic color.
   * @param palette Function that provides a TonalPalette given DynamicScheme. A TonalPalette is
   *     defined by a hue and chroma, so this replaces the need to specify hue/chroma. By providing
   *     a tonal palette, when contrast adjustments are made, intended chroma can be preserved.
   * @param tone Function that provides a tone, given a DynamicScheme.
   * @param isBackground Whether this dynamic color is a background, with some other color as the
   *     foreground.
   * @param background The background of the dynamic color (as a function of a `DynamicScheme`), if
   *     it exists.
   * @param secondBackground A second background of the dynamic color (as a function of a
   *     `DynamicScheme`), if it exists.
   * @param contrastCurve A `ContrastCurve` object specifying how its contrast against its
   *     background should behave in various contrast levels options.
   * @param toneDeltaPair A `ToneDeltaPair` object specifying a tone delta constraint between two
   *     colors. One of them must be the color being constructed.
   * @param opacity A function returning the opacity of a color, as a number between 0 and 1.
   */
  public DynamicColor(
      @NonNull String name,
      @NonNull Function<DynamicScheme, TonalPalette> palette,
      @NonNull Function<DynamicScheme, Double> tone,
      boolean isBackground,
      @Nullable Function<DynamicScheme, DynamicColor> background,
      @Nullable Function<DynamicScheme, DynamicColor> secondBackground,
      @Nullable ContrastCurve contrastCurve,
      @Nullable Function<DynamicScheme, ToneDeltaPair> toneDeltaPair,
      @Nullable Function<DynamicScheme, Double> opacity) {
    this.name = name;
    this.palette = palette;
    this.tone = tone;
    this.isBackground = isBackground;
    this.background = background;
    this.secondBackground = secondBackground;
    this.contrastCurve = contrastCurve;
    this.toneDeltaPair = toneDeltaPair;
    this.opacity = opacity;
  }

  /**
   * A convenience constructor for DynamicColor.
   *
   * <p>_Strongly_ prefer using one of the convenience constructors. This class is arguably too
   * flexible to ensure it can support any scenario. Functional arguments allow overriding without
   * risks that come with subclasses.
   *
   * <p>For example, the default behavior of adjust tone at max contrast to be at a 7.0 ratio with
   * its background is principled and matches accessibility guidance. That does not mean it's the
   * desired approach for _every_ design system, and every color pairing, always, in every case.
   *
   * <p>For opaque colors (colors with alpha = 100%).
   *
   * <p>For colors that are not backgrounds, and do not have backgrounds.
   *
   * @param name The name of the dynamic color.
   * @param palette Function that provides a TonalPalette given DynamicScheme. A TonalPalette is
   *     defined by a hue and chroma, so this replaces the need to specify hue/chroma. By providing
   *     a tonal palette, when contrast adjustments are made, intended chroma can be preserved.
   * @param tone Function that provides a tone, given a DynamicScheme.
   */
  @NonNull
  public static DynamicColor fromPalette(
      @NonNull String name,
      @NonNull Function<DynamicScheme, TonalPalette> palette,
      @NonNull Function<DynamicScheme, Double> tone) {
    return new DynamicColor(
        name,
        palette,
        tone,
        /* isBackground= */ false,
        /* background= */ null,
        /* secondBackground= */ null,
        /* contrastCurve= */ null,
        /* toneDeltaPair= */ null);
  }

  /**
   * A convenience constructor for DynamicColor.
   *
   * <p>_Strongly_ prefer using one of the convenience constructors. This class is arguably too
   * flexible to ensure it can support any scenario. Functional arguments allow overriding without
   * risks that come with subclasses.
   *
   * <p>For example, the default behavior of adjust tone at max contrast to be at a 7.0 ratio with
   * its background is principled and matches accessibility guidance. That does not mean it's the
   * desired approach for _every_ design system, and every color pairing, always, in every case.
   *
   * <p>For opaque colors (colors with alpha = 100%).
   *
   * <p>For colors that do not have backgrounds.
   *
   * @param name The name of the dynamic color.
   * @param palette Function that provides a TonalPalette given DynamicScheme. A TonalPalette is
   *     defined by a hue and chroma, so this replaces the need to specify hue/chroma. By providing
   *     a tonal palette, when contrast adjustments are made, intended chroma can be preserved.
   * @param tone Function that provides a tone, given a DynamicScheme.
   * @param isBackground Whether this dynamic color is a background, with some other color as the
   *     foreground.
   */
  @NonNull
  public static DynamicColor fromPalette(
      @NonNull String name,
      @NonNull Function<DynamicScheme, TonalPalette> palette,
      @NonNull Function<DynamicScheme, Double> tone,
      boolean isBackground) {
    return new DynamicColor(
        name,
        palette,
        tone,
        isBackground,
        /* background= */ null,
        /* secondBackground= */ null,
        /* contrastCurve= */ null,
        /* toneDeltaPair= */ null);
  }

  /**
   * Create a DynamicColor from a hex code.
   *
   * <p>Result has no background; thus no support for increasing/decreasing contrast for a11y.
   *
   * @param name The name of the dynamic color.
   * @param argb The source color from which to extract the hue and chroma.
   */
  @NonNull
  public static DynamicColor fromArgb(@NonNull String name, int argb) {
    Hct hct = Hct.fromInt(argb);
    TonalPalette palette = TonalPalette.fromInt(argb);
    return DynamicColor.fromPalette(name, (s) -> palette, (s) -> hct.getTone());
  }

  /**
   * Returns an ARGB integer (i.e. a hex code).
   *
   * @param scheme Defines the conditions of the user interface, for example, whether or not it is
   *     dark mode or light mode, and what the desired contrast level is.
   */
  public int getArgb(@NonNull DynamicScheme scheme) {
    int argb = getHct(scheme).toInt();
    if (opacity == null) {
      return argb;
    }
    double percentage = opacity.apply(scheme);
    int alpha = MathUtils.clampInt(0, 255, (int) Math.round(percentage * 255));
    return (argb & 0x00ffffff) | (alpha << 24);
  }

  /**
   * Returns an HCT object.
   *
   * @param scheme Defines the conditions of the user interface, for example, whether or not it is
   *     dark mode or light mode, and what the desired contrast level is.
   */
  @NonNull
  public Hct getHct(@NonNull DynamicScheme scheme) {
    Hct cachedAnswer = hctCache.get(scheme);
    if (cachedAnswer != null) {
      return cachedAnswer;
    }
    // This is crucial for aesthetics: we aren't simply the taking the standard color
    // and changing its tone for contrast. Rather, we find the tone for contrast, then
    // use the specified chroma from the palette to construct a new color.
    //
    // For example, this enables colors with standard tone of T90, which has limited chroma, to
    // "recover" intended chroma as contrast increases.
    double tone = getTone(scheme);
    Hct answer = palette.apply(scheme).getHct(tone);
    // NOMUTANTS--trivial test with onerous dependency injection requirement.
    if (hctCache.size() > 4) {
      hctCache.clear();
    }
    // NOMUTANTS--trivial test with onerous dependency injection requirement.
    hctCache.put(scheme, answer);
    return answer;
  }

  /** Returns the tone in HCT, ranging from 0 to 100, of the resolved color given scheme. */
  public double getTone(@NonNull DynamicScheme scheme) {
    boolean decreasingContrast = scheme.contrastLevel < 0;

    // Case 1: dual foreground, pair of colors with delta constraint.
    if (toneDeltaPair != null) {
      ToneDeltaPair toneDeltaPair = this.toneDeltaPair.apply(scheme);
      DynamicColor roleA = toneDeltaPair.getRoleA();
      DynamicColor roleB = toneDeltaPair.getRoleB();
      double delta = toneDeltaPair.getDelta();
      TonePolarity polarity = toneDeltaPair.getPolarity();
      boolean stayTogether = toneDeltaPair.getStayTogether();

      DynamicColor bg = background.apply(scheme);
      double bgTone = bg.getTone(scheme);

      boolean aIsNearer =
          (polarity == TonePolarity.NEARER
              || (polarity == TonePolarity.LIGHTER && !scheme.isDark)
              || (polarity == TonePolarity.DARKER && scheme.isDark));
      DynamicColor nearer = aIsNearer ? roleA : roleB;
      DynamicColor farther = aIsNearer ? roleB : roleA;
      boolean amNearer = name.equals(nearer.name);
      double expansionDir = scheme.isDark ? 1 : -1;

      // 1st round: solve to min, each
      double nContrast = nearer.contrastCurve.getContrast(scheme.contrastLevel);
      double fContrast = farther.contrastCurve.getContrast(scheme.contrastLevel);

      // If a color is good enough, it is not adjusted.
      // Initial and adjusted tones for `nearer`
      double nInitialTone = nearer.tone.apply(scheme);
      @Var
      double nTone =
          Contrast.ratioOfTones(bgTone, nInitialTone) >= nContrast
              ? nInitialTone
              : DynamicColor.foregroundTone(bgTone, nContrast);
      // Initial and adjusted tones for `farther`
      double fInitialTone = farther.tone.apply(scheme);
      @Var
      double fTone =
          Contrast.ratioOfTones(bgTone, fInitialTone) >= fContrast
              ? fInitialTone
              : DynamicColor.foregroundTone(bgTone, fContrast);

      if (decreasingContrast) {
        // If decreasing contrast, adjust color to the "bare minimum"
        // that satisfies contrast.
        nTone = DynamicColor.foregroundTone(bgTone, nContrast);
        fTone = DynamicColor.foregroundTone(bgTone, fContrast);
      }

      // If constraint is not satisfied, try another round.
      if ((fTone - nTone) * expansionDir < delta) {
        // 2nd round: expand farther to match delta.
        fTone = MathUtils.clampDouble(0, 100, nTone + delta * expansionDir);
        // If constraint is not satisfied, try another round.
        if ((fTone - nTone) * expansionDir < delta) {
          // 3rd round: contract nearer to match delta.
          nTone = MathUtils.clampDouble(0, 100, fTone - delta * expansionDir);
        }
      }

      // Avoids the 50-59 awkward zone.
      if (50 <= nTone && nTone < 60) {
        // If `nearer` is in the awkward zone, move it away, together with
        // `farther`.
        if (expansionDir > 0) {
          nTone = 60;
          fTone = max(fTone, nTone + delta * expansionDir);
        } else {
          nTone = 49;
          fTone = min(fTone, nTone + delta * expansionDir);
        }
      } else if (50 <= fTone && fTone < 60) {
        if (stayTogether) {
          // Fixes both, to avoid two colors on opposite sides of the "awkward
          // zone".
          if (expansionDir > 0) {
            nTone = 60;
            fTone = max(fTone, nTone + delta * expansionDir);
          } else {
            nTone = 49;
            fTone = min(fTone, nTone + delta * expansionDir);
          }
        } else {
          // Not required to stay together; fixes just one.
          if (expansionDir > 0) {
            fTone = 60;
          } else {
            fTone = 49;
          }
        }
      }

      // Returns `nTone` if this color is `nearer`, otherwise `fTone`.
      return amNearer ? nTone : fTone;
    } else {
      // Case 2: No contrast pair; just solve for itself.
      @Var double answer = tone.apply(scheme);

      if (background == null) {
        return answer; // No adjustment for colors with no background.
      }

      double bgTone = background.apply(scheme).getTone(scheme);

      double desiredRatio = contrastCurve.getContrast(scheme.contrastLevel);

      if (Contrast.ratioOfTones(bgTone, answer) >= desiredRatio) {
        // Don't "improve" what's good enough.
      } else {
        // Rough improvement.
        answer = DynamicColor.foregroundTone(bgTone, desiredRatio);
      }

      if (decreasingContrast) {
        answer = DynamicColor.foregroundTone(bgTone, desiredRatio);
      }

      if (isBackground && 50 <= answer && answer < 60) {
        // Must adjust
        if (Contrast.ratioOfTones(49, bgTone) >= desiredRatio) {
          answer = 49;
        } else {
          answer = 60;
        }
      }

      if (secondBackground != null) {
        // Case 3: Adjust for dual backgrounds.

        double bgTone1 = background.apply(scheme).getTone(scheme);
        double bgTone2 = secondBackground.apply(scheme).getTone(scheme);

        double upper = max(bgTone1, bgTone2);
        double lower = min(bgTone1, bgTone2);

        if (Contrast.ratioOfTones(upper, answer) >= desiredRatio
            && Contrast.ratioOfTones(lower, answer) >= desiredRatio) {
          return answer;
        }

        // The darkest light tone that satisfies the desired ratio,
        // or -1 if such ratio cannot be reached.
        double lightOption = Contrast.lighter(upper, desiredRatio);

        // The lightest dark tone that satisfies the desired ratio,
        // or -1 if such ratio cannot be reached.
        double darkOption = Contrast.darker(lower, desiredRatio);

        // Tones suitable for the foreground.
        ArrayList<Double> availables = new ArrayList<>();
        if (lightOption != -1) {
          availables.add(lightOption);
        }
        if (darkOption != -1) {
          availables.add(darkOption);
        }

        boolean prefersLight =
            DynamicColor.tonePrefersLightForeground(bgTone1)
                || DynamicColor.tonePrefersLightForeground(bgTone2);
        if (prefersLight) {
          return (lightOption == -1) ? 100 : lightOption;
        }
        if (availables.size() == 1) {
          return availables.get(0);
        }
        return (darkOption == -1) ? 0 : darkOption;
      }

      return answer;
    }
  }

  /**
   * Given a background tone, find a foreground tone, while ensuring they reach a contrast ratio
   * that is as close to ratio as possible.
   */
  public static double foregroundTone(double bgTone, double ratio) {
    double lighterTone = Contrast.lighterUnsafe(bgTone, ratio);
    double darkerTone = Contrast.darkerUnsafe(bgTone, ratio);
    double lighterRatio = Contrast.ratioOfTones(lighterTone, bgTone);
    double darkerRatio = Contrast.ratioOfTones(darkerTone, bgTone);
    boolean preferLighter = tonePrefersLightForeground(bgTone);

    if (preferLighter) {
      // "Neglible difference" handles an edge case where the initial contrast ratio is high
      // (ex. 13.0), and the ratio passed to the function is that high ratio, and both the lighter
      // and darker ratio fails to pass that ratio.
      //
      // This was observed with Tonal Spot's On Primary Container turning black momentarily between
      // high and max contrast in light mode. PC's standard tone was T90, OPC's was T10, it was
      // light mode, and the contrast level was 0.6568521221032331.
      boolean negligibleDifference =
          Math.abs(lighterRatio - darkerRatio) < 0.1 && lighterRatio < ratio && darkerRatio < ratio;
      if (lighterRatio >= ratio || lighterRatio >= darkerRatio || negligibleDifference) {
        return lighterTone;
      } else {
        return darkerTone;
      }
    } else {
      return darkerRatio >= ratio || darkerRatio >= lighterRatio ? darkerTone : lighterTone;
    }
  }

  /**
   * Adjust a tone down such that white has 4.5 contrast, if the tone is reasonably close to
   * supporting it.
   */
  public static double enableLightForeground(double tone) {
    if (tonePrefersLightForeground(tone) && !toneAllowsLightForeground(tone)) {
      return 49.0;
    }
    return tone;
  }

  /**
   * People prefer white foregrounds on ~T60-70. Observed over time, and also by Andrew Somers
   * during research for APCA.
   *
   * <p>T60 used as to create the smallest discontinuity possible when skipping down to T49 in order
   * to ensure light foregrounds.
   *
   * <p>Since `tertiaryContainer` in dark monochrome scheme requires a tone of 60, it should not be
   * adjusted. Therefore, 60 is excluded here.
   */
  public static boolean tonePrefersLightForeground(double tone) {
    return Math.round(tone) < 60;
  }

  /** Tones less than ~T50 always permit white at 4.5 contrast. */
  public static boolean toneAllowsLightForeground(double tone) {
    return Math.round(tone) <= 49;
  }
}