diff --git a/CHANGELOG.md b/CHANGELOG.md index abc8da3..dfe967c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 with the grays at the end. Picked + 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]). diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt index 1f7ab70..e4ff30a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt @@ -1,9 +1,11 @@ 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 /** @@ -13,67 +15,146 @@ import kotlin.math.sqrt * 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]): + * (#22). * - * 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. + * 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 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 } + 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(options: List): List { - val byVividness = options - .map { it to Lab.of(it.argb) } +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.map { it.first } + return kept } -/** Palettes at most this big skip the distinctness thinning (Google's ~26 pass through). */ +/** + * The softening the colour picker paints over every swatch (mirrored by + * ui/pastelize, which wraps this): 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. + */ +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 swatches; 25 keeps ~42 of CSS3's 147. */ -private const val MIN_DELTA_E = 25.0 +/** Minimum CIE76 ΔE between surviving painted swatches. */ +private const val MIN_DELTA_E = 13.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 +/** + * 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) - 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) - } + /** 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)) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarColors.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarColors.kt index 5360124..9bbbf33 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarColors.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarColors.kt @@ -15,17 +15,21 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.domain.pastelArgb /** * Soften a raw calendar color toward a pastel that fits the active theme. * - Keeps the hue (so users still recognise their calendars) * - Caps saturation so harsh provider colors stop screaming * - Pins value/brightness to a band that reads on both light and dark surfaces + * + * The hue/saturation shaping lives in [pastelArgb] so the event-colour picker's + * curation reasons about the exact colour painted here (#22); this only picks + * the theme's brightness. */ fun pastelize(rawArgb: Int, dark: Boolean): Color { val hsv = FloatArray(3) - android.graphics.Color.colorToHSV(rawArgb, hsv) - hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f) + android.graphics.Color.colorToHSV(pastelArgb(rawArgb), hsv) hsv[2] = if (dark) 0.82f else 0.72f return Color(android.graphics.Color.HSVToColor(hsv)) } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventColorPaletteTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventColorPaletteTest.kt index 12b1baf..18d0aa7 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventColorPaletteTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventColorPaletteTest.kt @@ -74,32 +74,65 @@ class EventColorPaletteTest { } @Test - fun `chromatic colours sort as a rainbow with achromatics last`() { + 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("gray", 0xFF808080.toInt()), - EventColorOption("blue", 0xFF0000FF.toInt()), EventColorOption("black", 0xFF000000.toInt()), + EventColorOption("gray", 0xFF808080.toInt()), + EventColorOption("darkgray", 0xFFA9A9A9.toInt()), + EventColorOption("blue", 0xFF0000FF.toInt()), EventColorOption("red", 0xFFFF0000.toInt()), - ).curatedForPicker() + ).curatedForPicker().map { it.key } - // Red's Lab hue angle precedes blue's; grays trail, darkest first. - assertThat(curated.map { it.key }) - .containsExactly("red", "blue", "black", "gray") - .inOrder() + assertThat(curated).containsNoneOf("gray", "darkgray") // folded into black + assertThat(curated).containsAtLeast("black", "red", "blue") } @Test - fun `CSS3 survivors cover the full hue range`() { - val curated = css3Palette().curatedForPicker().map { it.key }.toSet() + 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() - // 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() + 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 {