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, + ) +}