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:
@@ -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
|
app (such as DAVx5), the event colour picker showed every colour the account
|
||||||
publishes — nearly 150 swatches in alphabetical order, many of them
|
publishes — nearly 150 swatches in alphabetical order, many of them
|
||||||
duplicates or near-identical shades. The picker now shows only visually
|
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
|
colours still sync exactly as before, and calendars with hand-picked
|
||||||
palettes (like Google's) are unaffected. Thanks to @ptab for the report
|
palettes (like Google's) are unaffected. Thanks to @ptab for the report
|
||||||
([#22]).
|
([#22]).
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
import kotlin.math.atan2
|
import kotlin.math.atan2
|
||||||
import kotlin.math.cbrt
|
import kotlin.math.cbrt
|
||||||
import kotlin.math.hypot
|
import kotlin.math.hypot
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import kotlin.math.sqrt
|
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
|
* hand-picked two-dozen set, while CalDAV adapters (DAVx5) dump all ~147 CSS3
|
||||||
* named colours — including exact-value aliases (aqua/cyan, the gray/grey
|
* named colours — including exact-value aliases (aqua/cyan, the gray/grey
|
||||||
* spelling pairs) and dozens of visually indistinguishable whites and grays
|
* spelling pairs) and dozens of visually indistinguishable whites and grays
|
||||||
* (#22). Three steps tame that without breaking sync round-tripping (every
|
* (#22).
|
||||||
* surviving option keeps its provider [EventColorOption.key]):
|
|
||||||
*
|
*
|
||||||
* 1. Exact duplicates collapse to one swatch (alphabetically-first key wins,
|
* Crucially, curation runs against the colour the picker actually *paints*, not
|
||||||
* deterministically).
|
* the raw provider value. The picker softens every swatch through [pastelArgb]:
|
||||||
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) are thinned to visually
|
* it pins lightness to a constant and caps saturation, so the raw palette's
|
||||||
* distinct colours: most vivid first, a colour is kept only when at least
|
* lightness axis is invisible on screen. Two raw colours that look different —
|
||||||
* [MIN_DELTA_E] (CIE76, Lab) from every colour already kept. Small
|
* a navy and a mid blue — paint as one swatch, and every neutral (black, the
|
||||||
* palettes are already curated by their adapter and pass through whole.
|
* grays, white) paints as the same pale tint. Judging distinctness in raw
|
||||||
* 3. The result is ordered like a rainbow — hue buckets, light-to-dark
|
* space, as before, left near-identical painted swatches and stranded the
|
||||||
* within each, achromatics last — instead of the provider's key order,
|
* neutrals as a run of look-alike "pinks" at the end of the grid.
|
||||||
* which for CSS3 names scatters hues alphabetically.
|
*
|
||||||
|
* 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> {
|
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||||
val distinct = sortedBy { it.key }.distinctBy { it.argb }
|
val painted = sortedBy { it.key }
|
||||||
val kept = if (distinct.size <= CURATION_TRIGGER_SIZE) distinct else thin(distinct)
|
.distinctBy { pastelArgb(it.argb) }
|
||||||
return kept
|
.map { it to Lab.of(pastelArgb(it.argb)) }
|
||||||
.map { it to Lab.of(it.argb) }
|
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
|
||||||
.sortedWith(
|
painted
|
||||||
compareBy(
|
} else {
|
||||||
{ (_, lab) -> if (lab.chroma < ACHROMATIC_MAX_CHROMA) 1 else 0 },
|
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
|
||||||
{ (_, lab) -> if (lab.chroma < ACHROMATIC_MAX_CHROMA) 0 else lab.hueBucket },
|
}
|
||||||
{ (_, lab) -> lab.l },
|
return orderAroundWheel(kept).map { (option, _) -> option }
|
||||||
),
|
}
|
||||||
)
|
|
||||||
.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. */
|
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
||||||
private fun thin(options: List<EventColorOption>): List<EventColorOption> {
|
private fun thin(
|
||||||
val byVividness = options
|
swatches: List<Pair<EventColorOption, Lab>>,
|
||||||
.map { it to Lab.of(it.argb) }
|
): List<Pair<EventColorOption, Lab>> {
|
||||||
|
val byVividness = swatches
|
||||||
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
|
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
|
||||||
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
|
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
|
||||||
for (candidate in byVividness) {
|
for (candidate in byVividness) {
|
||||||
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
|
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
|
private const val CURATION_TRIGGER_SIZE = 36
|
||||||
|
|
||||||
/** Minimum CIE76 ΔE between surviving swatches; 25 keeps ~42 of CSS3's 147. */
|
/** Minimum CIE76 ΔE between surviving painted swatches. */
|
||||||
private const val MIN_DELTA_E = 25.0
|
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
|
* 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
|
||||||
/** Sorting granularity around the hue wheel. */
|
* is dropped rather than shown as pale filler.
|
||||||
private const val HUE_BUCKETS = 12
|
*/
|
||||||
|
private const val PASTEL_CHROMA_FLOOR = 22.0
|
||||||
|
|
||||||
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
|
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
|
||||||
private class Lab(val l: Double, val a: Double, val b: Double) {
|
private class Lab(val l: Double, val a: Double, val b: Double) {
|
||||||
val chroma: Double get() = hypot(a, b)
|
val chroma: Double get() = hypot(a, b)
|
||||||
|
|
||||||
val hueBucket: Int
|
/** Hue angle in degrees, 0–360, around the Lab a-b plane. */
|
||||||
get() {
|
val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
|
||||||
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 =
|
fun deltaE(other: Lab): Double =
|
||||||
sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
|
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.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
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.
|
* Soften a raw calendar color toward a pastel that fits the active theme.
|
||||||
* - Keeps the hue (so users still recognise their calendars)
|
* - Keeps the hue (so users still recognise their calendars)
|
||||||
* - Caps saturation so harsh provider colors stop screaming
|
* - Caps saturation so harsh provider colors stop screaming
|
||||||
* - Pins value/brightness to a band that reads on both light and dark surfaces
|
* - 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 {
|
fun pastelize(rawArgb: Int, dark: Boolean): Color {
|
||||||
val hsv = FloatArray(3)
|
val hsv = FloatArray(3)
|
||||||
android.graphics.Color.colorToHSV(rawArgb, hsv)
|
android.graphics.Color.colorToHSV(pastelArgb(rawArgb), hsv)
|
||||||
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
|
|
||||||
hsv[2] = if (dark) 0.82f else 0.72f
|
hsv[2] = if (dark) 0.82f else 0.72f
|
||||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,32 +74,65 @@ class EventColorPaletteTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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(
|
val curated = listOf(
|
||||||
EventColorOption("gray", 0xFF808080.toInt()),
|
|
||||||
EventColorOption("blue", 0xFF0000FF.toInt()),
|
|
||||||
EventColorOption("black", 0xFF000000.toInt()),
|
EventColorOption("black", 0xFF000000.toInt()),
|
||||||
|
EventColorOption("gray", 0xFF808080.toInt()),
|
||||||
|
EventColorOption("darkgray", 0xFFA9A9A9.toInt()),
|
||||||
|
EventColorOption("blue", 0xFF0000FF.toInt()),
|
||||||
EventColorOption("red", 0xFFFF0000.toInt()),
|
EventColorOption("red", 0xFFFF0000.toInt()),
|
||||||
).curatedForPicker()
|
).curatedForPicker().map { it.key }
|
||||||
|
|
||||||
// Red's Lab hue angle precedes blue's; grays trail, darkest first.
|
assertThat(curated).containsNoneOf("gray", "darkgray") // folded into black
|
||||||
assertThat(curated.map { it.key })
|
assertThat(curated).containsAtLeast("black", "red", "blue")
|
||||||
.containsExactly("red", "blue", "black", "gray")
|
|
||||||
.inOrder()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `CSS3 survivors cover the full hue range`() {
|
fun `a dark and a light shade of one hue collapse to a single swatch`() {
|
||||||
val curated = css3Palette().curatedForPicker().map { it.key }.toSet()
|
// 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).hasSize(1)
|
||||||
assertThat(curated).contains("red")
|
}
|
||||||
assertThat(curated).contains("yellow")
|
|
||||||
assertThat(curated).contains("lime")
|
@Test
|
||||||
assertThat(curated).contains("blue")
|
fun `the wheel is cut once, keeping each hue family contiguous`() {
|
||||||
assertThat(curated).contains("black")
|
// Twelve pure hues, deliberately shuffled; a small palette passes the
|
||||||
assertThat(curated.any { it.contains("orange") }).isTrue()
|
// thinning stage untouched so only the ordering is under test.
|
||||||
assertThat(curated.any { it.contains("pink") }).isTrue()
|
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 {
|
private fun hsvArgb(hue: Float): Int {
|
||||||
|
|||||||
Reference in New Issue
Block a user