From f81f5930179186b6d09aef7730997e9f071b6ef4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 12 Jul 2026 11:20:07 +0200 Subject: [PATCH] fix(edit): curate the CalDAV colour picker (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CalDAV sync adapters (DAVx5) publish all ~147 CSS3 named colours into CalendarContract.Colors, so the event-colour picker showed a full screen of alphabetically-scrambled, partly duplicated swatches. Curation now runs in the space the picker actually paints — every swatch is softened through pastelize, which pins lightness and caps saturation, so the raw palette's lightness axis is invisible on screen. Judging distinctness there: colours that paint identically collapse to one (folding aliases, dark/light shades of a hue, and the neutrals together), oversized palettes drop washed-out neutral-origin tints and thin by CIE76 ΔE in painted Lab, and survivors sort continuously by painted hue with the wheel cut at its single widest gap. The CSS3 dump lands at ~33 distinct, rainbow-ordered swatches; small hand-picked palettes (Google's) pass through untouched. Every surviving swatch keeps its provider colour key so picks still round-trip through sync. This revives work stranded on fix/caldav-color-picker (never merged) and adapts it to the floret-kit extraction of pastelize: the curation's painted-space transform now lives self-contained in domain/pastelArgb as a mirror of floret's pastelize shaping, rather than the two sharing one function. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 ++ .../data/calendar/CalendarDataSource.kt | 13 +- .../calendula/domain/EventColorPalette.kt | 185 +++++++++++++++ .../calendula/ui/edit/EventEditScreen.kt | 15 +- .../calendula/domain/EventColorPaletteTest.kt | 214 ++++++++++++++++++ 5 files changed, 433 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/EventColorPaletteTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d8da9d1..748ecc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV + app (such as DAVx5), the event colour picker showed every colour the account + publishes — nearly 150 swatches in alphabetical order, many of them + duplicates or near-identical shades. The picker now shows only visually + distinct colours, arranged as a rainbow; near-duplicate shades and the + washed-out neutrals are folded away so no two swatches look alike. Picked + colours still sync exactly as before, and calendars with hand-picked + palettes (like Google's) are unaffected. Thanks to @ptab for the report + ([#22]). + ## [2.14.0] — 2026-07-06 ### Added @@ -865,6 +878,7 @@ automatically, with zero telemetry and no internet permission. [#18]: https://codeberg.org/jlmakiola/calendula/issues/18 [#19]: https://codeberg.org/jlmakiola/calendula/issues/19 [#20]: https://codeberg.org/jlmakiola/calendula/issues/20 +[#22]: https://codeberg.org/jlmakiola/calendula/issues/22 [#24]: https://codeberg.org/jlmakiola/calendula/issues/24 [#25]: https://codeberg.org/jlmakiola/calendula/issues/25 [#27]: https://codeberg.org/jlmakiola/calendula/issues/27 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index eeb7821..2a3b3ac 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventDetail +import de.jeanlucmakiola.calendula.domain.curatedForPicker import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventStatus @@ -63,10 +64,12 @@ interface CalendarDataSource { /** * The event-colour palette the calendar's account publishes - * (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the - * account exposes no palette (most local calendars, some CalDAV) — the - * signal that a custom colour can only be written as a raw `EVENT_COLOR`, - * which a synced calendar may drop on its next sync. + * (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped, + * thinned to visually distinct swatches when oversized (CalDAV adapters + * publish all ~147 CSS3 names, #22) and hue-sorted; see [curatedForPicker]. + * Empty when the account exposes no palette (most local calendars, some + * CalDAV) — the signal that a custom colour can only be written as a raw + * `EVENT_COLOR`, which a synced calendar may drop on its next sync. */ fun eventColorPalette(calendarId: Long): List @@ -611,7 +614,7 @@ class AndroidCalendarDataSource @Inject constructor( c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) } } ?.filter { it.key.isNotEmpty() } - ?.sortedBy { it.key } + ?.curatedForPicker() ?: emptyList() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt new file mode 100644 index 0000000..8130f57 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt @@ -0,0 +1,185 @@ +package de.jeanlucmakiola.calendula.domain + +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cbrt +import kotlin.math.hypot +import kotlin.math.pow +import kotlin.math.roundToInt +import kotlin.math.sqrt + +/** + * Curates an account's published event palette for the colour picker. + * + * Sync adapters differ wildly in what they publish: Google exposes a + * hand-picked two-dozen set, while CalDAV adapters (DAVx5) dump all ~147 CSS3 + * named colours — including exact-value aliases (aqua/cyan, the gray/grey + * spelling pairs) and dozens of visually indistinguishable whites and grays + * (#22). + * + * Crucially, curation runs against the colour the picker actually *paints*, not + * the raw provider value. The picker softens every swatch through [pastelArgb]: + * it pins lightness to a constant and caps saturation, so the raw palette's + * lightness axis is invisible on screen. Two raw colours that look different — + * a navy and a mid blue — paint as one swatch, and every neutral (black, the + * grays, white) paints as the same pale tint. Judging distinctness in raw + * space, as before, left near-identical painted swatches and stranded the + * neutrals as a run of look-alike "pinks" at the end of the grid. + * + * Three steps, all in painted space: + * 1. Collapse swatches that paint identically to one (alphabetically-first key + * wins, deterministically) — this folds aliases, dark/light shades of a + * hue, and all the neutrals together. + * 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out + * neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are + * then thinned to visually distinct colours: most vivid first, a colour is + * kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every + * colour already kept. Small palettes are already curated by their adapter + * and pass through whole. + * 3. The survivors are ordered like a rainbow — continuously by painted hue — + * with the wheel cut at its single widest empty gap so the one unavoidable + * seam lands in dead space and no hue family is torn across both ends. + * + * Every surviving option keeps its provider [EventColorOption.key], so a pick + * still round-trips through sync. + */ +fun List.curatedForPicker(): List { + val painted = sortedBy { it.key } + .distinctBy { pastelArgb(it.argb) } + .map { it to Lab.of(pastelArgb(it.argb)) } + val kept = if (painted.size <= CURATION_TRIGGER_SIZE) { + painted + } else { + thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR }) + } + return orderAroundWheel(kept).map { (option, _) -> option } +} + +/** + * Orders swatches continuously around the (painted) hue wheel, then cuts the + * circle at its widest angular gap so the single seam lands in empty space + * instead of mid-family. Saturation breaks ties, vivid first. + */ +private fun orderAroundWheel( + swatches: List>, +): List> { + if (swatches.size < 2) return swatches + val byHue = swatches.sortedWith( + compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }), + ) + // Split the wheel after the largest empty arc between neighbouring hues; + // the default is the wrap gap (last hue back round to the first), i.e. the + // familiar 0→360 order, and we only rotate away from it for a wider void. + var cutAfter = byHue.lastIndex + var widestGap = 360.0 - byHue.last().second.hue + byHue.first().second.hue + for (i in 0 until byHue.lastIndex) { + val gap = byHue[i + 1].second.hue - byHue[i].second.hue + if (gap > widestGap) { + widestGap = gap + cutAfter = i + } + } + return byHue.subList(cutAfter + 1, byHue.size) + byHue.subList(0, cutAfter + 1) +} + +/** Greedy max-distance filter: vivid colours stake out clusters first. */ +private fun thin( + swatches: List>, +): List> { + val byVividness = swatches + .sortedWith(compareByDescending> { it.second.chroma }.thenBy { it.first.key }) + val kept = mutableListOf>() + for (candidate in byVividness) { + if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate + } + return kept +} + +/** + * The softening the colour picker paints over every swatch: keep the hue, scale + * and clamp saturation into a gentle band, and pin value to a constant so + * nothing screams and everything reads on the surface. Value is fixed here so + * curation is theme-independent — only hue and saturation distinguish painted + * swatches. + * + * This is a self-contained mirror of floret-kit's `pastelize` hue/saturation + * shaping (`de.jeanlucmakiola.floret.components.pastelize`), with value pinned + * rather than theme-picked. Curation must reason about the colour the picker + * paints, so the two shapings have to agree: if floret's saturation band or + * curve changes, update this in step. + */ +fun pastelArgb(rawArgb: Int): Int { + val r = ((rawArgb shr 16) and 0xFF) / 255f + val g = ((rawArgb shr 8) and 0xFF) / 255f + val b = (rawArgb and 0xFF) / 255f + val max = maxOf(r, g, b) + val min = minOf(r, g, b) + val delta = max - min + val hue = when { + delta == 0f -> 0f + max == r -> 60f * (((g - b) / delta) % 6f) + max == g -> 60f * (((b - r) / delta) + 2f) + else -> 60f * (((r - g) / delta) + 4f) + }.let { if (it < 0f) it + 360f else it } + val sat = (if (max == 0f) 0f else delta / max) * 0.6f + val s = sat.coerceIn(0.25f, 0.65f) + val v = PASTEL_VALUE + val c = v * s + val x = c * (1f - abs((hue / 60f) % 2f - 1f)) + val m = v - c + val (rr, gg, bb) = when { + hue < 60f -> Triple(c, x, 0f) + hue < 120f -> Triple(x, c, 0f) + hue < 180f -> Triple(0f, c, x) + hue < 240f -> Triple(0f, x, c) + hue < 300f -> Triple(x, 0f, c) + else -> Triple(c, 0f, x) + } + fun channel(value: Float) = ((value + m) * 255f).roundToInt().coerceIn(0, 255) + return (0xFF shl 24) or (channel(rr) shl 16) or (channel(gg) shl 8) or channel(bb) +} + +/** Reference lightness for curation; the picker paints at this on dark surfaces. */ +private const val PASTEL_VALUE = 0.82f + +/** Palettes at most this big skip the thinning (Google's ~26 pass through). */ +private const val CURATION_TRIGGER_SIZE = 36 + +/** Minimum CIE76 ΔE between surviving painted swatches. */ +private const val MIN_DELTA_E = 13.0 + +/** + * Painted-chroma floor for oversized palettes: below this a swatch is a washed- + * out tint — the neutrals and near-whites the saturation clamp muddies — so it + * is dropped rather than shown as pale filler. + */ +private const val PASTEL_CHROMA_FLOOR = 22.0 + +/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */ +private class Lab(val l: Double, val a: Double, val b: Double) { + val chroma: Double get() = hypot(a, b) + + /** Hue angle in degrees, 0–360, around the Lab a-b plane. */ + val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0 + + fun deltaE(other: Lab): Double = + sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2)) + + companion object { + fun of(argb: Int): Lab { + fun linear(shift: Int): Double { + val c = ((argb shr shift) and 0xFF) / 255.0 + return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4) + } + val r = linear(16) + val g = linear(8) + val b = linear(0) + val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047 + val y = 0.2126 * r + 0.7152 * g + 0.0722 * b + val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883 + fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0 + val fy = f(y) + return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z))) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 66944f1..1e13366 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -1747,12 +1747,21 @@ private fun ColorPickerDialog( modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), ) { if (palette.isNotEmpty()) { + // The event's current colour may not be in the curated palette + // (a thinned near-duplicate, or a raw colour set elsewhere) — + // append it so the selection ring has a home. + val swatches = palette.map { it.argb }.let { + if (selected != null && selected !in it) it + selected else it + } ColorSwatchRow( - colors = palette.map { it.argb }, + colors = swatches, selected = selected, onSelect = { argb -> - palette.firstOrNull { it.argb == argb } - ?.let { onPickKey(it.key, it.argb) } + val option = palette.firstOrNull { it.argb == argb } + // The appended current colour has no provider key to + // write — it is already the event's colour, so just + // close. + if (option != null) onPickKey(option.key, option.argb) else onDismiss() }, dark = dark, ) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventColorPaletteTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventColorPaletteTest.kt new file mode 100644 index 0000000..18d0aa7 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventColorPaletteTest.kt @@ -0,0 +1,214 @@ +package de.jeanlucmakiola.calendula.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class EventColorPaletteTest { + + @Test + fun `empty palette stays empty`() { + assertThat(emptyList().curatedForPicker()).isEmpty() + } + + @Test + fun `exact duplicate values collapse to the alphabetically first key`() { + val curated = listOf( + EventColorOption("cyan", 0xFF00FFFF.toInt()), + EventColorOption("aqua", 0xFF00FFFF.toInt()), + EventColorOption("red", 0xFFFF0000.toInt()), + ).curatedForPicker() + + assertThat(curated.map { it.key }).containsExactly("aqua", "red") + } + + @Test + fun `small palettes pass through whole, so Google's curated set is untouched`() { + // A Google-like palette: two dozen distinct hand-picked colours. + val palette = (0 until 24).map { + val hue = it * 15 + EventColorOption("$it", hsvArgb(hue.toFloat())) + } + + val curated = palette.curatedForPicker() + + assertThat(curated).containsExactlyElementsIn(palette) + } + + @Test + fun `oversized CSS3 palette thins to a pickable number of distinct swatches`() { + val curated = css3Palette().curatedForPicker() + + // The whole point of #22: ~147 published colours become a single + // manageable grid instead of a full screen. + assertThat(curated.size).isAtLeast(30) + assertThat(curated.size).isAtMost(60) + } + + @Test + fun `curation never invents colours or drops keys`() { + val source = css3Palette() + val curated = source.curatedForPicker() + + assertThat(source).containsAtLeastElementsIn(curated) + assertThat(curated.map { it.argb }).containsNoDuplicates() + } + + @Test + fun `spelling-alias pairs never both survive`() { + val keys = css3Palette().curatedForPicker().map { it.key }.toSet() + + val aliasPairs = listOf( + "aqua" to "cyan", + "fuchsia" to "magenta", + "gray" to "grey", + "darkgray" to "darkgrey", + "dimgray" to "dimgrey", + "lightgray" to "lightgrey", + "slategray" to "slategrey", + "lightslategray" to "lightslategrey", + "darkslategray" to "darkslategrey", + ) + aliasPairs.forEach { (a, b) -> + assertThat(keys.contains(a) && keys.contains(b)).isFalse() + } + } + + @Test + fun `neutrals collapse to one painted tint instead of a run of look-alikes`() { + // Black and every gray paint as the same pale swatch (the picker pins + // lightness and floors saturation), so only one survives — no stranded + // run of look-alike "pinks" at the end of the grid (#22). + val curated = listOf( + EventColorOption("black", 0xFF000000.toInt()), + EventColorOption("gray", 0xFF808080.toInt()), + EventColorOption("darkgray", 0xFFA9A9A9.toInt()), + EventColorOption("blue", 0xFF0000FF.toInt()), + EventColorOption("red", 0xFFFF0000.toInt()), + ).curatedForPicker().map { it.key } + + assertThat(curated).containsNoneOf("gray", "darkgray") // folded into black + assertThat(curated).containsAtLeast("black", "red", "blue") + } + + @Test + fun `a dark and a light shade of one hue collapse to a single swatch`() { + // The picker paints every swatch at one fixed lightness, so navy and a + // mid blue are indistinguishable once painted — keep just one. + val curated = listOf( + EventColorOption("navy", 0xFF000080.toInt()), + EventColorOption("blue", 0xFF0000FF.toInt()), + ).curatedForPicker() + + assertThat(curated).hasSize(1) + } + + @Test + fun `the wheel is cut once, keeping each hue family contiguous`() { + // Twelve pure hues, deliberately shuffled; a small palette passes the + // thinning stage untouched so only the ordering is under test. + val shuffledHues = listOf(0, 300, 60, 180, 120, 240, 30, 330, 90, 210, 150, 270) + val curated = shuffledHues + .map { EventColorOption("$it", hsvArgb(it.toFloat())) } + .curatedForPicker() + .map { it.key.toInt() } + + // A proper single-seam sweep around the wheel descends exactly once + // (at the seam). The old bucketed sort could scatter a family across + // both ends, producing extra descents. + val descents = curated.indices.count { i -> + curated[(i + 1) % curated.size] < curated[i] + } + assertThat(descents).isEqualTo(1) + } + + @Test + fun `CSS3 survivors span the whole rainbow`() { + val keys = css3Palette().curatedForPicker().map { it.key } + fun has(vararg families: String) = keys.any { k -> families.any { k.contains(it) } } + + // Which exact name represents a hue family depends on the vivid-first + // thinning, so assert each family survives, not a specific key. + assertThat(has("red", "crimson", "firebrick", "tomato", "maroon", "brown")).isTrue() + assertThat(has("orange", "gold", "goldenrod", "peru", "sienna", "salmon")).isTrue() + assertThat(has("green", "olive", "lime", "chartreuse", "forest", "sea")).isTrue() + assertThat(has("blue", "navy", "dodger", "steel", "royal", "sky", "aqua")).isTrue() + assertThat(has("violet", "purple", "magenta", "orchid", "fuchsia", "indigo", "plum")).isTrue() + } + + private fun hsvArgb(hue: Float): Int { + val h = hue / 60f + val sector = h.toInt() % 6 + val f = h - h.toInt() + val q = ((1 - f) * 255).toInt() + val t = (f * 255).toInt() + return when (sector) { + 0 -> argb(255, t, 0) + 1 -> argb(q, 255, 0) + 2 -> argb(0, 255, t) + 3 -> argb(0, q, 255) + 4 -> argb(t, 0, 255) + else -> argb(255, 0, q) + } + } + + private fun argb(r: Int, g: Int, b: Int): Int = + (0xFF shl 24) or (r shl 16) or (g shl 8) or b + + /** The exact set ical4android/DAVx5 publishes: CSS3's 147 named colours. */ + private fun css3Palette(): List = CSS3.map { (name, rgb) -> + EventColorOption(name, 0xFF000000.toInt() or rgb) + } + + private val CSS3 = mapOf( + "aliceblue" to 0xF0F8FF, "antiquewhite" to 0xFAEBD7, "aqua" to 0x00FFFF, + "aquamarine" to 0x7FFFD4, "azure" to 0xF0FFFF, "beige" to 0xF5F5DC, + "bisque" to 0xFFE4C4, "black" to 0x000000, "blanchedalmond" to 0xFFEBCD, + "blue" to 0x0000FF, "blueviolet" to 0x8A2BE2, "brown" to 0xA52A2A, + "burlywood" to 0xDEB887, "cadetblue" to 0x5F9EA0, "chartreuse" to 0x7FFF00, + "chocolate" to 0xD2691E, "coral" to 0xFF7F50, "cornflowerblue" to 0x6495ED, + "cornsilk" to 0xFFF8DC, "crimson" to 0xDC143C, "cyan" to 0x00FFFF, + "darkblue" to 0x00008B, "darkcyan" to 0x008B8B, "darkgoldenrod" to 0xB8860B, + "darkgray" to 0xA9A9A9, "darkgreen" to 0x006400, "darkgrey" to 0xA9A9A9, + "darkkhaki" to 0xBDB76B, "darkmagenta" to 0x8B008B, "darkolivegreen" to 0x556B2F, + "darkorange" to 0xFF8C00, "darkorchid" to 0x9932CC, "darkred" to 0x8B0000, + "darksalmon" to 0xE9967A, "darkseagreen" to 0x8FBC8F, "darkslateblue" to 0x483D8B, + "darkslategray" to 0x2F4F4F, "darkslategrey" to 0x2F4F4F, "darkturquoise" to 0x00CED1, + "darkviolet" to 0x9400D3, "deeppink" to 0xFF1493, "deepskyblue" to 0x00BFFF, + "dimgray" to 0x696969, "dimgrey" to 0x696969, "dodgerblue" to 0x1E90FF, + "firebrick" to 0xB22222, "floralwhite" to 0xFFFAF0, "forestgreen" to 0x228B22, + "fuchsia" to 0xFF00FF, "gainsboro" to 0xDCDCDC, "ghostwhite" to 0xF8F8FF, + "gold" to 0xFFD700, "goldenrod" to 0xDAA520, "gray" to 0x808080, + "green" to 0x008000, "greenyellow" to 0xADFF2F, "grey" to 0x808080, + "honeydew" to 0xF0FFF0, "hotpink" to 0xFF69B4, "indianred" to 0xCD5C5C, + "indigo" to 0x4B0082, "ivory" to 0xFFFFF0, "khaki" to 0xF0E68C, + "lavender" to 0xE6E6FA, "lavenderblush" to 0xFFF0F5, "lawngreen" to 0x7CFC00, + "lemonchiffon" to 0xFFFACD, "lightblue" to 0xADD8E6, "lightcoral" to 0xF08080, + "lightcyan" to 0xE0FFFF, "lightgoldenrodyellow" to 0xFAFAD2, "lightgray" to 0xD3D3D3, + "lightgreen" to 0x90EE90, "lightgrey" to 0xD3D3D3, "lightpink" to 0xFFB6C1, + "lightsalmon" to 0xFFA07A, "lightseagreen" to 0x20B2AA, "lightskyblue" to 0x87CEFA, + "lightslategray" to 0x778899, "lightslategrey" to 0x778899, "lightsteelblue" to 0xB0C4DE, + "lightyellow" to 0xFFFFE0, "lime" to 0x00FF00, "limegreen" to 0x32CD32, + "linen" to 0xFAF0E6, "magenta" to 0xFF00FF, "maroon" to 0x800000, + "mediumaquamarine" to 0x66CDAA, "mediumblue" to 0x0000CD, "mediumorchid" to 0xBA55D3, + "mediumpurple" to 0x9370DB, "mediumseagreen" to 0x3CB371, "mediumslateblue" to 0x7B68EE, + "mediumspringgreen" to 0x00FA9A, "mediumturquoise" to 0x48D1CC, + "mediumvioletred" to 0xC71585, "midnightblue" to 0x191970, "mintcream" to 0xF5FFFA, + "mistyrose" to 0xFFE4E1, "moccasin" to 0xFFE4B5, "navajowhite" to 0xFFDEAD, + "navy" to 0x000080, "oldlace" to 0xFDF5E6, "olive" to 0x808000, + "olivedrab" to 0x6B8E23, "orange" to 0xFFA500, "orangered" to 0xFF4500, + "orchid" to 0xDA70D6, "palegoldenrod" to 0xEEE8AA, "palegreen" to 0x98FB98, + "paleturquoise" to 0xAFEEEE, "palevioletred" to 0xDB7093, "papayawhip" to 0xFFEFD5, + "peachpuff" to 0xFFDAB9, "peru" to 0xCD853F, "pink" to 0xFFC0CB, + "plum" to 0xDDA0DD, "powderblue" to 0xB0E0E6, "purple" to 0x800080, + "red" to 0xFF0000, "rosybrown" to 0xBC8F8F, "royalblue" to 0x4169E1, + "saddlebrown" to 0x8B4513, "salmon" to 0xFA8072, "sandybrown" to 0xF4A460, + "seagreen" to 0x2E8B57, "seashell" to 0xFFF5EE, "sienna" to 0xA0522D, + "silver" to 0xC0C0C0, "skyblue" to 0x87CEEB, "slateblue" to 0x6A5ACD, + "slategray" to 0x708090, "slategrey" to 0x708090, "snow" to 0xFFFAFA, + "springgreen" to 0x00FF7F, "steelblue" to 0x4682B4, "tan" to 0xD2B48C, + "teal" to 0x008080, "thistle" to 0xD8BFD8, "tomato" to 0xFF6347, + "turquoise" to 0x40E0D0, "violet" to 0xEE82EE, "wheat" to 0xF5DEB3, + "white" to 0xFFFFFF, "whitesmoke" to 0xF5F5F5, "yellow" to 0xFFFF00, + "yellowgreen" to 0x9ACD32, + ) +}