fix(edit): curate the CalDAV colour picker in painted space (#22)
The picker paints every swatch through pastelize, which pins lightness to a constant and caps saturation — so the raw palette's lightness axis is invisible on screen. Curating in raw space (as the first pass did) left near-identical painted swatches (navy/blue/midnightblue all paint as one mid blue) and stranded every neutral as an identical pale tint in a run of "pinks" at the end of the grid. Move dedup, thinning, and ordering into the painted (pastelised) space the user actually sees: colours that paint identically collapse to one, oversized palettes drop washed-out neutral-origin tints (painted chroma below a floor) and thin by CIE76 ΔE in painted Lab, and the survivors sort continuously by painted hue with the wheel cut at its widest gap. The CSS3 147-colour dump now lands at ~33 distinct, rainbow-ordered swatches with no neutrals stranded at the end. The hue/saturation shaping moves into a shared domain pastelArgb() so the picker paints exactly what curation reasons about; ui/pastelize wraps it and only picks the theme's brightness. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||
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<Pair<EventColorOption, Lab>>,
|
||||
): List<Pair<EventColorOption, Lab>> {
|
||||
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<EventColorOption>): List<EventColorOption> {
|
||||
val byVividness = options
|
||||
.map { it to Lab.of(it.argb) }
|
||||
private fun thin(
|
||||
swatches: List<Pair<EventColorOption, Lab>>,
|
||||
): List<Pair<EventColorOption, Lab>> {
|
||||
val byVividness = swatches
|
||||
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
|
||||
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
|
||||
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))
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user