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 8e02288..47f9771 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 @@ -20,6 +20,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 @@ -57,7 +58,10 @@ interface CalendarDataSource { /** * The event-colour palette the calendar's account publishes - * (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the + * (`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. @@ -420,7 +424,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..1f7ab70 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt @@ -0,0 +1,98 @@ +package de.jeanlucmakiola.calendula.domain + +import kotlin.math.atan2 +import kotlin.math.cbrt +import kotlin.math.hypot +import kotlin.math.pow +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). Three steps tame that without breaking sync round-tripping (every + * surviving option keeps its provider [EventColorOption.key]): + * + * 1. Exact duplicates collapse to one swatch (alphabetically-first key wins, + * deterministically). + * 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) are thinned to visually + * distinct colours: most vivid first, a colour is kept only when at least + * [MIN_DELTA_E] (CIE76, Lab) from every colour already kept. Small + * palettes are already curated by their adapter and pass through whole. + * 3. The result is ordered like a rainbow — hue buckets, light-to-dark + * within each, achromatics last — instead of the provider's key order, + * which for CSS3 names scatters hues alphabetically. + */ +fun List.curatedForPicker(): List { + val distinct = sortedBy { it.key }.distinctBy { it.argb } + val kept = if (distinct.size <= CURATION_TRIGGER_SIZE) distinct else thin(distinct) + return kept + .map { it to Lab.of(it.argb) } + .sortedWith( + compareBy( + { (_, lab) -> if (lab.chroma < ACHROMATIC_MAX_CHROMA) 1 else 0 }, + { (_, lab) -> if (lab.chroma < ACHROMATIC_MAX_CHROMA) 0 else lab.hueBucket }, + { (_, lab) -> lab.l }, + ), + ) + .map { (option, _) -> option } +} + +/** Greedy max-distance filter: vivid colours stake out clusters first. */ +private fun thin(options: List): List { + val byVividness = options + .map { it to Lab.of(it.argb) } + .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.map { it.first } +} + +/** Palettes at most this big skip the distinctness thinning (Google's ~26 pass through). */ +private const val CURATION_TRIGGER_SIZE = 36 + +/** Minimum CIE76 ΔE between surviving swatches; 25 keeps ~42 of CSS3's 147. */ +private const val MIN_DELTA_E = 25.0 + +/** Below this Lab chroma a colour reads as gray and sorts into the trailing bucket. */ +private const val ACHROMATIC_MAX_CHROMA = 12.0 + +/** Sorting granularity around the hue wheel. */ +private const val HUE_BUCKETS = 12 + +/** 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) + + val hueBucket: Int + get() { + val degrees = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0 + return (degrees * HUE_BUCKETS / 360.0).toInt().coerceAtMost(HUE_BUCKETS - 1) + } + + 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 166c0fd..434733e 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 @@ -1712,12 +1712,20 @@ private fun ColorPickerDialog( text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { if (palette.isNotEmpty()) { + // The event's current colour may not be in the curated + // palette (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 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..12b1baf --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventColorPaletteTest.kt @@ -0,0 +1,181 @@ +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 `chromatic colours sort as a rainbow with achromatics last`() { + val curated = listOf( + EventColorOption("gray", 0xFF808080.toInt()), + EventColorOption("blue", 0xFF0000FF.toInt()), + EventColorOption("black", 0xFF000000.toInt()), + EventColorOption("red", 0xFFFF0000.toInt()), + ).curatedForPicker() + + // Red's Lab hue angle precedes blue's; grays trail, darkest first. + assertThat(curated.map { it.key }) + .containsExactly("red", "blue", "black", "gray") + .inOrder() + } + + @Test + fun `CSS3 survivors cover the full hue range`() { + val curated = css3Palette().curatedForPicker().map { it.key }.toSet() + + // One representative from every major hue family must survive thinning. + assertThat(curated).contains("red") + assertThat(curated).contains("yellow") + assertThat(curated).contains("lime") + assertThat(curated).contains("blue") + assertThat(curated).contains("black") + assertThat(curated.any { it.contains("orange") }).isTrue() + assertThat(curated.any { it.contains("pink") }).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, + ) +}