Rebuild the event colour system on Oklch (#21, #36)

Colours were shaped in HSV, whose "value" is not perceived brightness:
at one pinned value the hues spread across relative luminance 0.10 to
0.45, straddling the black/white ink crossover at 0.179. So the ink was
decided by hue — red and purple took white, orange and blue took black —
and the fills nearest the crossover were the mid colours that contrast
badly with either ink.

Shape them in Oklch instead, where lightness matches what the eye calls
brightness, and split the two jobs one colour was doing: a container
sits behind text and must contrast with its ink, an accent is a mark on
a surface and must contrast with the surface. Nine call sites that were
painting marks now ask for the accent.

Colours go to whichever pole they already sit nearer rather than all
being forced deep — a dark orange is brown and a dark yellow is olive,
so the warm, naturally light hues keep their character and take dark ink
while the rest go deep and take white. Either way they land clear of the
middle, which was the fault. White ink is now 5.6-6.3:1 on every deep
hue and dark ink 11:1 on the light ones, against 2.7-8.2:1 before.

Picker curation now calls the same eventTone the picker paints with,
instead of a copy of the shaping kept in step by hand, and drops its CIE
Lab helper for Oklab distance. Renames the setting to "harmonise", since
it no longer softens anything.
This commit is contained in:
2026-07-31 19:39:11 +02:00
parent 37995d1fb3
commit 9287b7b0da
18 changed files with 573 additions and 160 deletions

View File

@@ -1,12 +1,8 @@
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
import de.jeanlucmakiola.calendula.domain.color.Oklch
import de.jeanlucmakiola.calendula.domain.color.eventTone
import de.jeanlucmakiola.calendula.domain.color.oklchOf
/**
* Curates an account's published event palette for the colour picker.
@@ -18,24 +14,25 @@ import kotlin.math.sqrt
* (#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.
* the raw provider value — and it gets that colour from the same [eventTone]
* the picker calls, rather than from a copy of its shaping kept in step by hand.
* Because a harmonised container pins lightness, 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 grey. Judging distinctness in raw space, as before, left
* near-identical painted swatches and stranded the neutrals as a run of
* look-alike tints 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.
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the neutral-origin
* swatches — with lightness pinned, a grey source paints as plain grey, so
* "has no hue left" is simply zero chroma — and are then thinned to visually
* distinct colours: most vivid first, a colour is kept only when at least
* [MIN_DISTANCE] away in Oklab 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.
@@ -45,12 +42,12 @@ import kotlin.math.sqrt
*/
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
val painted = sortedBy { it.key }
.distinctBy { pastelArgb(it.argb) }
.map { it to Lab.of(pastelArgb(it.argb)) }
.distinctBy { paintedArgb(it.argb) }
.map { it to oklchOf(paintedArgb(it.argb)) }
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
painted
} else {
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
thin(painted.filter { (_, painted) -> painted.chroma > 0f })
}
return orderAroundWheel(kept).map { (option, _) -> option }
}
@@ -61,17 +58,17 @@ fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
* instead of mid-family. Saturation breaks ties, vivid first.
*/
private fun orderAroundWheel(
swatches: List<Pair<EventColorOption, Lab>>,
): List<Pair<EventColorOption, Lab>> {
swatches: List<Pair<EventColorOption, Oklch>>,
): List<Pair<EventColorOption, Oklch>> {
if (swatches.size < 2) return swatches
val byHue = swatches.sortedWith(
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }),
compareBy({ (_, painted) -> painted.hue }, { (_, painted) -> -painted.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
var widestGap = 360f - 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) {
@@ -84,102 +81,32 @@ private fun orderAroundWheel(
/** Greedy max-distance filter: vivid colours stake out clusters first. */
private fun thin(
swatches: List<Pair<EventColorOption, Lab>>,
): List<Pair<EventColorOption, Lab>> {
swatches: List<Pair<EventColorOption, Oklch>>,
): List<Pair<EventColorOption, Oklch>> {
val byVividness = swatches
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
.sortedWith(
compareByDescending<Pair<EventColorOption, Oklch>> { it.second.chroma }
.thenBy { it.first.key },
)
val kept = mutableListOf<Pair<EventColorOption, Oklch>>()
for (candidate in byVividness) {
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
if (kept.none { it.second.distanceTo(candidate.second) < MIN_DISTANCE }) 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
/** The colour the picker paints for [argb]; the light theme stands in as the
* reference, since a harmonised container differs only in lightness by theme
* and curation compares hue and chroma. */
private fun paintedArgb(argb: Int): Int =
eventTone(argb, dark = false, harmonise = true).container
/** 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.
* Minimum Oklab distance between surviving painted swatches. Painted colours all
* share one lightness, so this is really a hue/chroma separation — far enough
* apart that two swatches never read as the same colour in the grid.
*/
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, 0360, 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)))
}
}
}
private const val MIN_DISTANCE = 0.025f

View File

@@ -0,0 +1,124 @@
package de.jeanlucmakiola.calendula.domain.color
/**
* The colours one calendar's identity resolves to in one theme.
*
* The app paints a calendar's colour in two structurally different places, and
* they want opposite things:
*
* - a **[container]** sits behind text (week and day blocks, month bars, widget
* rows, picker swatches), so it has to contrast with its own ink;
* - an **[accent]** is a mark on an ordinary app surface (day dots, agenda and
* search stripes, calendar icon tints, the detail header), so it has to
* contrast with the *surface* instead.
*
* Painting both from one colour is what made this hard to get right: deep enough
* to carry white text is too dark to see as a dot on a dark surface, and light
* enough to show up there is too pale to carry white text. Splitting them lets
* each be pinned to the lightness its job needs, while hue and chroma — the parts
* that actually say *which calendar this is* — stay shared, so the two roles
* still read as the same colour.
*/
data class EventTone(
val container: Int,
val onContainer: Int,
val accent: Int,
)
/**
* Resolve [rawArgb] for the current theme.
*
* With [harmonise] on, hue and chroma are kept and lightness is re-pinned per
* role, which is what makes the ink predictable: every container lands at the
* same perceptual lightness, so one ink colour serves every hue at ≥ 6:1. With
* it off the provider's colour is painted verbatim — the sync source's own look,
* as DAVx5/CalDAV users expect — and the ink is then chosen per colour, since
* nothing constrains what the provider sends.
*/
fun eventTone(rawArgb: Int, dark: Boolean, harmonise: Boolean): EventTone {
val opaque = rawArgb or 0xFF000000.toInt()
val raw = oklchOf(opaque)
if (!harmonise) {
return EventTone(
container = opaque,
onContainer = inkFor(raw.lightness),
accent = opaque,
)
}
// A near-grey source has no hue worth keeping, so it stays grey rather than
// being pushed to an arbitrary one; everything else is held inside a band
// that keeps calendars apart without going neon.
val chroma = if (raw.chroma < GREY_CHROMA) 0f else raw.chroma.coerceIn(MIN_CHROMA, MAX_CHROMA)
// Two poles rather than one. Forcing every hue deep is what turned the warm,
// naturally light ones muddy — a dark orange is brown and a dark yellow is
// olive, which is a fact about those hues, not a tuning miss. So a colour is
// sent to whichever pole it already sits nearer: most land deep and carry
// white ink, while genuinely light sources (yellows, creams, pale tints)
// stay light and carry dark ink, keeping the character that made them
// recognisable. Either way the colour is pulled clear of the middle, where
// neither ink reads well — which was the original fault.
val staysLight = raw.lightness >= LIGHT_POLE_THRESHOLD
val containerLightness = when {
staysLight && dark -> LIGHT_CONTAINER_LIGHTNESS_DARK
staysLight -> LIGHT_CONTAINER_LIGHTNESS_LIGHT
dark -> CONTAINER_LIGHTNESS_DARK
else -> CONTAINER_LIGHTNESS_LIGHT
}
// The accent takes no pole: it is a mark on the app's surface, so it has to
// contrast with that surface whichever way its container went.
val accentLightness = if (dark) ACCENT_LIGHTNESS_DARK else ACCENT_LIGHTNESS_LIGHT
return EventTone(
container = Oklch(containerLightness, chroma, raw.hue).toArgb(),
onContainer = inkFor(containerLightness),
accent = Oklch(accentLightness, chroma, raw.hue).toArgb(),
)
}
/**
* White or black ink for a background of the given perceptual [lightness].
*
* Derived rather than hardcoded so it stays right for raw provider colours,
* where lightness is whatever the sync source sent. For harmonised containers it
* is constant by construction — that is the point of pinning the lightness.
*/
fun inkFor(lightness: Float): Int =
if (lightness < INK_FLIP_LIGHTNESS) 0xFFFFFFFF.toInt() else 0xFF000000.toInt()
/**
* Lightness at which white ink overtakes black. Sits above the midpoint because
* lightness is perceptual: a colour has to be distinctly light before black wins.
*/
const val INK_FLIP_LIGHTNESS = 0.62f
/**
* Raw lightness at or above which a colour keeps its light character instead of
* being pushed deep. Set high enough that oranges and warm reds still go deep —
* a burnt orange reads as orange, where a dark yellow does not read as yellow —
* so only the genuinely pale sources take the light pole.
*/
const val LIGHT_POLE_THRESHOLD = 0.72f
/** Container lightness: deep enough that white ink clears 5.5:1 on every hue. */
const val CONTAINER_LIGHTNESS_LIGHT = 0.45f
/** Same in dark mode, a shade lighter so a block separates from the surface. */
const val CONTAINER_LIGHTNESS_DARK = 0.48f
/** Light-pole container: pale enough that dark ink clears 11:1 on every hue. */
const val LIGHT_CONTAINER_LIGHTNESS_LIGHT = 0.88f
/** Same in dark mode, held down a little so a pale block doesn't glare. */
const val LIGHT_CONTAINER_LIGHTNESS_DARK = 0.84f
/** Accent lightness: dark enough to read as a mark on a pale surface. */
const val ACCENT_LIGHTNESS_LIGHT = 0.55f
/** Same against a dark surface, where the mark has to be the light one. */
const val ACCENT_LIGHTNESS_DARK = 0.78f
/** Below this chroma a colour counts as grey and keeps no hue. */
const val GREY_CHROMA = 0.02f
/** Chroma band for harmonised colours: distinct, but never electric. */
const val MIN_CHROMA = 0.07f
const val MAX_CHROMA = 0.16f

View File

@@ -0,0 +1,119 @@
package de.jeanlucmakiola.calendula.domain.color
import kotlin.math.atan2
import kotlin.math.cbrt
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
/**
* A colour in Oklch — Oklab's cylindrical form: perceptual [lightness], [chroma]
* (colourfulness) and [hue] in degrees.
*
* The point of using it over HSV is that its lightness axis matches what the eye
* calls brightness. HSV's "value" does not: at a single pinned value the hues
* spread across relative luminance 0.10 (indigo) to 0.45 (yellow), which is why
* pinning value produced fills that needed different ink per hue. Pin Oklch
* lightness instead and every hue lands at the same apparent brightness, so one
* ink serves all of them.
*
* [lightness] runs 0 (black) to 1 (white); [chroma] is 0 (grey) to about 0.32 at
* the sRGB limit.
*/
data class Oklch(val lightness: Float, val chroma: Float, val hue: Float) {
/**
* Perceptual distance to [other] — plain Euclidean in Oklab, which is what
* Oklab is built for (unlike CIE Lab, where CIE76 is known to misjudge
* saturated blues).
*/
fun distanceTo(other: Oklch): Float {
val (a1, b1) = chroma * cosDeg(hue) to chroma * sinDeg(hue)
val (a2, b2) = other.chroma * cosDeg(other.hue) to other.chroma * sinDeg(other.hue)
val dl = (lightness - other.lightness).toDouble()
return sqrt(dl * dl + (a1 - a2).pow(2) + (b1 - b2).pow(2)).toFloat()
}
}
/** Read [argb]'s opaque colour as Oklch. */
fun oklchOf(argb: Int): Oklch {
val r = toLinear(((argb shr 16) and 0xFF) / 255.0)
val g = toLinear(((argb shr 8) and 0xFF) / 255.0)
val b = toLinear((argb and 0xFF) / 255.0)
val l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
val m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
val s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
val lightness = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s
val aAxis = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s
val bAxis = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
val hue = (Math.toDegrees(atan2(bAxis, aAxis)) + 360.0) % 360.0
return Oklch(lightness.toFloat(), hypot(aAxis, bAxis).toFloat(), hue.toFloat())
}
/**
* The opaque sRGB colour for this Oklch, gamut-mapped: most of the Oklch cylinder
* falls outside sRGB, so a colour that does not fit keeps its lightness and hue
* and gives up chroma until it does. Holding lightness is what matters here —
* it's the axis the contrast guarantees rest on.
*/
fun Oklch.toArgb(): Int {
val fitted = if (inSrgb(lightness, chroma, hue)) {
chroma
} else {
var low = 0f
var high = chroma
repeat(GAMUT_STEPS) {
val mid = (low + high) / 2f
if (inSrgb(lightness, mid, hue)) low = mid else high = mid
}
low
}
val (r, g, b) = linearSrgbOf(lightness, fitted, hue)
return 0xFF shl 24 or
(channel(r) shl 16) or
(channel(g) shl 8) or
channel(b)
}
private fun linearSrgbOf(lightness: Float, chroma: Float, hue: Float): Triple<Double, Double, Double> {
val a = chroma * cosDeg(hue)
val b = chroma * sinDeg(hue)
val l = (lightness + 0.3963377774 * a + 0.2158037573 * b).pow(3)
val m = (lightness - 0.1055613458 * a - 0.0638541728 * b).pow(3)
val s = (lightness - 0.0894841775 * a - 1.2914855480 * b).pow(3)
return Triple(
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
)
}
private fun inSrgb(lightness: Float, chroma: Float, hue: Float): Boolean {
val (r, g, b) = linearSrgbOf(lightness, chroma, hue)
return r in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON) &&
g in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON) &&
b in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON)
}
private fun channel(linear: Double): Int =
(toSrgb(linear.coerceIn(0.0, 1.0)) * 255.0).toInt().coerceIn(0, 255)
private fun toLinear(c: Double): Double =
if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
private fun toSrgb(c: Double): Double =
if (c <= 0.0031308) 12.92 * c else 1.055 * c.pow(1.0 / 2.4) - 0.055
private fun cosDeg(deg: Float) = cos(Math.toRadians(deg.toDouble()))
private fun sinDeg(deg: Float) = sin(Math.toRadians(deg.toDouble()))
/** Bisection steps when pulling an out-of-gamut colour back into sRGB. */
private const val GAMUT_STEPS = 24
/** Slack for the gamut test, so rounding at the boundary doesn't reject a fit. */
private const val GAMUT_EPSILON = 1e-4

View File

@@ -31,6 +31,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.components.GroupedRow
@@ -137,7 +138,7 @@ internal fun AgendaEventRow(
modifier = Modifier
.size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp))
.background(eventFill(event.color, dark, soften)),
.background(eventAccent(event.color, dark, soften)),
)
},
onClick = onClick,

View File

@@ -88,6 +88,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
@@ -442,7 +443,7 @@ private fun CalendarEditor(
)
}
}
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
InlineTextField(
value = name,
onValueChange = { name = it },

View File

@@ -35,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
Icon(
Icons.Filled.CalendarMonth,
contentDescription = null,
tint = eventFill(color, dark, soften),
tint = eventAccent(color, dark, soften),
modifier = Modifier.size(22.dp),
)
}

View File

@@ -2,45 +2,53 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.calendula.domain.color.eventTone
import de.jeanlucmakiola.calendula.domain.color.inkFor
import de.jeanlucmakiola.calendula.domain.color.oklchOf
/**
* Whether calendar/event colours are softened toward theme-fitting pastels
* before display (issue #36). Provided app-wide from the "soften colours"
* setting; the default `true` keeps the historical look. When off, the raw
* provider colour is painted verbatim matching the sync source (DAVx5/CalDAV)
* and other calendar apps. Widgets live outside this composition and read the
* preference directly, then pass the flag to [eventFill] / [eventInk].
* Whether calendar/event colours are harmonised — hue kept, lightness re-pinned
* per role — before display (issue #36). Provided app-wide from the setting; the
* default `true` is the app's own look. When off, the raw provider colour is
* painted verbatim, matching the sync source (DAVx5/CalDAV) and other calendar
* apps. Widgets live outside this composition and read the preference directly,
* then pass the flag through.
*/
val LocalSoftenColors = staticCompositionLocalOf { true }
/**
* Display fill for an event chip/bar or a calendar tint: the [pastelize]d colour
* when [soften] is on, else the raw provider ARGB verbatim (forced opaque, since
* pastelize also returns an opaque colour).
* Fill for a surface that carries text on it — a week/day block, a month bar, a
* widget row, a picker swatch. Pair it with [eventInk].
*/
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
if (soften) pastelize(rawArgb, dark) else Color(rawArgb or 0xFF000000.toInt())
Color(eventTone(rawArgb, dark, soften).container)
/**
* Contrast ink (title text / glyph) for a filled chip painted with [eventFill]:
* white on a dark fill, near-black on a light one (issue #21). Applies to both
* softened and raw fills — a saturated hue (deep blue, purple, red) is
* perceptually dark even after softening pins its HSV value, so black text on it
* reads poorly. The choice is objective, not a tuned threshold: white wins only
* when it out-contrasts black against the fill, which (by the WCAG contrast
* ratio) is at relative luminance ≈ 0.18 — so mid and light colours keep
* near-black text. [alpha] carries the caller's soft emphasis.
* Colour for a mark on an ordinary app surface — a day dot, an agenda or search
* stripe, a calendar icon tint, the detail header. Same identity as [eventFill],
* pinned to contrast with the surface instead of with ink.
*/
fun eventInk(fill: Color, alpha: Float = 0.8f): Color {
val useWhite = fill.luminance() < INK_LUMINANCE_CROSSOVER
return (if (useWhite) Color.White else Color.Black).copy(alpha = alpha)
}
fun eventAccent(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
Color(eventTone(rawArgb, dark, soften).accent)
/**
* Relative-luminance crossover where white text starts to out-contrast black.
* Solving `contrast(white, L) = contrast(black, L)` on the WCAG ratio gives
* `L = sqrt(1.05 * 0.05) - 0.05 ≈ 0.179`.
* Ink for text drawn on an [eventFill]. [alpha] carries the caller's emphasis.
*
* Takes the fill rather than the raw colour so it stays correct for the raw
* (un-harmonised) path too, where the provider decides the lightness.
*/
private const val INK_LUMINANCE_CROSSOVER = 0.179f
fun eventInk(fill: Color, alpha: Float = 0.8f): Color =
Color(inkFor(oklchOf(fill.toArgbInt()).lightness)).copy(alpha = alpha)
/**
* Ink alpha for a block's secondary line (the time under the title). Held above
* a fainter value because harmonised containers are deep: the small time text
* needs to stay clear of the 4.5:1 WCAG asks of body text.
*/
const val SECONDARY_INK_ALPHA = 0.8f
private fun Color.toArgbInt(): Int =
(0xFF shl 24) or
((red * 255f).toInt().coerceIn(0, 255) shl 16) or
((green * 255f).toInt().coerceIn(0, 255) shl 8) or
(blue * 255f).toInt().coerceIn(0, 255)

View File

@@ -89,6 +89,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
@@ -670,7 +671,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.6f),
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
)
}
}

View File

@@ -100,6 +100,7 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.floret.locale.currentLocale
@@ -386,7 +387,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
val instance = detail.instance
val dark = isSystemInDarkTheme()
val locale = currentDetailLocale()
val accent = eventFill(instance.color, dark, LocalSoftenColors.current)
val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
Column(
modifier = modifier

View File

@@ -133,6 +133,7 @@ import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
@@ -563,7 +564,7 @@ private fun EventEditContent(
// The accent ties the form to the detail screen's design language: the
// bar under the title takes the target calendar's colour.
val soften = LocalSoftenColors.current
val accent = selectedCalendar?.let { eventFill(it.color, dark, soften) }
val accent = selectedCalendar?.let { eventAccent(it.color, dark, soften) }
?: MaterialTheme.colorScheme.primary
val gap = 12.dp

View File

@@ -110,6 +110,7 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
@@ -1473,7 +1474,7 @@ private fun SplitDots(
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
.size(SPLIT_DOT_SIZE)
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
.background(eventFill(event.color, dark, soften), CircleShape),
.background(eventAccent(event.color, dark, soften), CircleShape),
)
}
if (hidden.isNotEmpty()) {
@@ -2098,7 +2099,7 @@ private fun OverflowDots(
modifier = Modifier
.size(6.dp)
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
.background(eventFill(argb, dark, soften), CircleShape),
.background(eventAccent(argb, dark, soften), CircleShape),
)
}
val extra = total - dots.size

View File

@@ -56,6 +56,7 @@ import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
@@ -208,7 +209,7 @@ private fun SearchResultRow(
modifier = Modifier
.size(width = 6.dp, height = 36.dp)
.clip(RoundedCornerShape(3.dp))
.background(eventFill(event.color, dark, soften)),
.background(eventAccent(event.color, dark, soften)),
)
},
onClick = onClick,

View File

@@ -100,6 +100,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScale
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
@@ -835,7 +836,7 @@ private fun EventBlock(
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.6f),
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
)
}
}

View File

@@ -58,6 +58,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
@@ -382,7 +383,7 @@ private fun EventRow(
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
// dropping both text lines to the lower-emphasis on-surface-variant tone.
val stripeColor = eventFill(event.color, dark, soften).let {
val stripeColor = eventAccent(event.color, dark, soften).let {
if (dimmed) it.copy(alpha = EventDimAlpha) else it
}
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface

View File

@@ -330,8 +330,8 @@
<string name="settings_default_view">Default view</string>
<string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
<string name="settings_soften_colors">Soften calendar colours</string>
<string name="settings_soften_colors_summary">Tone calendar and event colours down to fit the theme. Turn off to show the raw colours from the calendar source.</string>
<string name="settings_soften_colors">Harmonise calendar colours</string>
<string name="settings_soften_colors_summary">Keep each calendar\'s hue but even out its brightness, so every event stays legible and the colours sit together. Turn off to show the raw colours from the calendar source.</string>
<string name="settings_font_headings">Headings font</string>
<string name="settings_font_body">Body font</string>
<string name="settings_font_system">System default</string>

View File

@@ -74,10 +74,10 @@ class EventColorPaletteTest {
}
@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).
fun `neutrals collapse instead of forming a run of look-alikes`() {
// Neutrals lose their hue entirely when painted, so they collapse onto
// the one or two greys the poles offer — a dark and a pale — instead of
// the old stranded run of look-alike "pinks" at the end of the grid (#22).
val curated = listOf(
EventColorOption("black", 0xFF000000.toInt()),
EventColorOption("gray", 0xFF808080.toInt()),
@@ -86,8 +86,11 @@ class EventColorPaletteTest {
EventColorOption("red", 0xFFFF0000.toInt()),
).curatedForPicker().map { it.key }
assertThat(curated).containsNoneOf("gray", "darkgray") // folded into black
assertThat(curated).doesNotContain("gray") // paints as black's grey
assertThat(curated).containsAtLeast("black", "red", "blue")
// darkgray is light enough to keep a pale character, so the three
// neutrals still fold down to at most two swatches, never a run.
assertThat(curated.count { it in listOf("black", "gray", "darkgray") }).isAtMost(2)
}
@Test

View File

@@ -0,0 +1,159 @@
package de.jeanlucmakiola.calendula.domain.color
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class EventToneTest {
/** Hues at full chroma, standing in for the provider colours in the wild. */
private val hues = (0 until 360 step 15).map { Oklch(0.65f, 0.2f, it.toFloat()).toArgb() }
private fun relativeLuminance(argb: Int): Double {
fun channel(shift: Int): Double {
val c = ((argb shr shift) and 0xFF) / 255.0
return if (c <= 0.04045) c / 12.92 else Math.pow((c + 0.055) / 1.055, 2.4)
}
return 0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0)
}
private fun contrast(a: Int, b: Int): Double {
val hi = maxOf(relativeLuminance(a), relativeLuminance(b))
val lo = minOf(relativeLuminance(a), relativeLuminance(b))
return (hi + 0.05) / (lo + 0.05)
}
/** The other ink, for asserting the chosen one is the better of the two. */
private fun flip(ink: Int) =
if (ink == 0xFFFFFFFF.toInt()) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
/** [ink] over [fill] at [alpha], the way a title actually renders. */
private fun composite(ink: Int, fill: Int, alpha: Double): Int {
fun mix(shift: Int): Int {
val i = (ink shr shift) and 0xFF
val f = (fill shr shift) and 0xFF
return (alpha * i + (1 - alpha) * f).toInt().coerceIn(0, 255)
}
return (0xFF shl 24) or (mix(16) shl 16) or (mix(8) shl 8) or mix(0)
}
@Test
fun `hues of one lightness all get the same ink`() {
// The bug this replaced: at a pinned HSV value the hues straddled the ink
// crossover, so red took white while orange beside it took black. Ink may
// still differ by *pole*, but never by hue within a pole.
for (dark in listOf(false, true)) {
val inks = hues.map { eventTone(it, dark, harmonise = true).onContainer }.toSet()
assertThat(inks).containsExactly(0xFFFFFFFF.toInt())
}
}
@Test
fun `naturally light colours keep their character instead of turning muddy`() {
// Forcing every hue deep made a yellow into olive and a cream into brown.
// Pale sources stay pale — and then take dark ink.
for (pale in listOf(0xFFF6BF26, 0xFFFFF8DC, 0xFFF0E68C, 0xFFFFFFFF)) {
val tone = eventTone(pale.toInt(), dark = false, harmonise = true)
assertThat(oklchOf(tone.container).lightness).isGreaterThan(0.8f)
assertThat(tone.onContainer).isEqualTo(0xFF000000.toInt())
}
}
@Test
fun `warm mid colours still go deep, so orange reads as orange and not as cream`() {
// The threshold has to sit above orange: a burnt orange still says
// "orange", which is why it belongs on the deep pole with white ink.
// Note the ceiling this implies: a *bright* orange (#FF8C00, #FFA500) is
// genuinely a light colour and takes the light pole instead — forcing it
// deep is exactly what produced brown.
for (warm in listOf(0xFFF4511E, 0xFFE67C73, 0xFFD50000, 0xFFE65100)) {
val tone = eventTone(warm.toInt(), dark = false, harmonise = true)
assertThat(oklchOf(tone.container).lightness).isLessThan(0.6f)
assertThat(tone.onContainer).isEqualTo(0xFFFFFFFF.toInt())
}
}
@Test
fun `title ink clears WCAG AA on every hue`() {
for (dark in listOf(false, true)) {
for (hue in hues) {
val tone = eventTone(hue, dark, harmonise = true)
val ink = composite(tone.onContainer, tone.container, 0.85)
assertThat(contrast(tone.container, ink)).isGreaterThan(4.5)
assertThat(contrast(tone.container, ink))
.isGreaterThan(contrast(tone.container, composite(flip(tone.onContainer), tone.container, 0.85)))
}
}
}
@Test
fun `the secondary line also clears WCAG AA on every hue`() {
for (dark in listOf(false, true)) {
for (hue in hues) {
val tone = eventTone(hue, dark, harmonise = true)
val ink = composite(tone.onContainer, tone.container, 0.8)
assertThat(contrast(tone.container, ink)).isGreaterThan(4.5)
}
}
}
@Test
fun `an accent stays visible against its own theme's surface`() {
// Accents carry no text, so the bar is WCAG's 3:1 for non-text contrast.
val lightSurface = 0xFFFEF7FF.toInt()
val darkSurface = 0xFF141218.toInt()
for ((dark, surface) in listOf(false to lightSurface, true to darkSurface)) {
for (hue in hues) {
val accent = eventTone(hue, dark, harmonise = true).accent
assertThat(contrast(accent, surface)).isGreaterThan(3.0)
}
}
}
@Test
fun `container and accent keep the same hue, so one calendar reads as one colour`() {
for (hue in hues) {
val tone = eventTone(hue, dark = false, harmonise = true)
assertThat(oklchOf(tone.accent).hue).isWithin(2f).of(oklchOf(tone.container).hue)
}
}
@Test
fun `orange no longer gets dark ink`() {
// The reported case: a plain orange calendar used to fall just above the
// crossover and take black text while its neighbours took white.
val tone = eventTone(0xFFF4511E.toInt(), dark = false, harmonise = true)
assertThat(tone.onContainer).isEqualTo(0xFFFFFFFF.toInt())
}
@Test
fun `different hues stay distinguishable`() {
val containers = hues.map { eventTone(it, dark = false, harmonise = true).container }
assertThat(containers.toSet()).hasSize(hues.size)
}
@Test
fun `a grey source stays grey rather than gaining an invented hue`() {
for (grey in listOf(0xFF000000, 0xFF808080, 0xFFFFFFFF, 0xFF9E9E9E)) {
val tone = eventTone(grey.toInt(), dark = false, harmonise = true)
assertThat(oklchOf(tone.container).chroma).isLessThan(0.01f)
}
}
@Test
fun `raw colours are painted verbatim when harmonising is off`() {
val raw = 0xFFF4511E.toInt()
val tone = eventTone(raw, dark = false, harmonise = false)
assertThat(tone.container).isEqualTo(raw)
assertThat(tone.accent).isEqualTo(raw)
}
@Test
fun `raw mode still picks a readable ink per colour`() {
// Nothing constrains what a provider sends, so the ink cannot be constant
// on this path the way it is for harmonised containers.
assertThat(eventTone(0xFF101010.toInt(), dark = false, harmonise = false).onContainer)
.isEqualTo(0xFFFFFFFF.toInt())
assertThat(eventTone(0xFFFFF6C0.toInt(), dark = false, harmonise = false).onContainer)
.isEqualTo(0xFF000000.toInt())
}
}

View File

@@ -0,0 +1,64 @@
package de.jeanlucmakiola.calendula.domain.color
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class OklchTest {
@Test
fun `matches Oklab's published anchors`() {
// Guards the conversion matrices against a transcription slip: these are
// Ottosson's own reference values for the space.
assertThat(oklchOf(0xFFFFFFFF.toInt()).lightness).isWithin(0.001f).of(1f)
assertThat(oklchOf(0xFF000000.toInt()).lightness).isWithin(0.001f).of(0f)
assertThat(oklchOf(0xFFFF0000.toInt()).lightness).isWithin(0.001f).of(0.6280f)
}
@Test
fun `white and black have no chroma`() {
assertThat(oklchOf(0xFFFFFFFF.toInt()).chroma).isWithin(0.001f).of(0f)
assertThat(oklchOf(0xFF000000.toInt()).chroma).isWithin(0.001f).of(0f)
assertThat(oklchOf(0xFF808080.toInt()).chroma).isWithin(0.001f).of(0f)
}
@Test
fun `converting to sRGB and back round-trips`() {
for (argb in listOf(0xFFF4511E, 0xFF039BE5, 0xFF0B8043, 0xFF8E24AA, 0xFFFBD75B)) {
val original = oklchOf(argb.toInt())
val roundTripped = oklchOf(original.toArgb())
assertThat(roundTripped.lightness).isWithin(0.01f).of(original.lightness)
assertThat(roundTripped.chroma).isWithin(0.01f).of(original.chroma)
assertThat(roundTripped.hue).isWithin(1f).of(original.hue)
}
}
@Test
fun `an out-of-gamut request keeps its lightness and gives up chroma`() {
// Most of the Oklch cylinder is outside sRGB. Lightness is the axis the
// contrast guarantees rest on, so it is the one that must survive.
val requested = Oklch(0.45f, 0.35f, 150f)
val actual = oklchOf(requested.toArgb())
assertThat(actual.lightness).isWithin(0.02f).of(requested.lightness)
assertThat(actual.chroma).isLessThan(requested.chroma)
}
@Test
fun `pinning lightness holds luminance far tighter than pinning HSV value did`() {
// The whole reason for the space swap. At a pinned HSV value the hues
// spread across relative luminance 0.10..0.45; pinned Oklch lightness
// must keep them in a narrow band, or one ink cannot serve them all.
val luminances = (0 until 360 step 15).map { hue ->
val argb = Oklch(0.45f, 0.16f, hue.toFloat()).toArgb()
relativeLuminance(argb)
}
assertThat(luminances.max() / luminances.min()).isLessThan(2.5)
}
private fun relativeLuminance(argb: Int): Double {
fun channel(shift: Int): Double {
val c = ((argb shr shift) and 0xFF) / 255.0
return if (c <= 0.04045) c / 12.92 else Math.pow((c + 0.055) / 1.055, 2.4)
}
return 0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0)
}
}