From 9287b7b0dafc722e42b6e8eb57d75afeab87d110 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 31 Jul 2026 19:39:11 +0200 Subject: [PATCH] Rebuild the event colour system on Oklch (#21, #36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../calendula/domain/EventColorPalette.kt | 155 +++++------------ .../calendula/domain/color/EventTone.kt | 124 ++++++++++++++ .../calendula/domain/color/Oklch.kt | 119 +++++++++++++ .../calendula/ui/agenda/AgendaRows.kt | 3 +- .../calendula/ui/calendars/CalendarsScreen.kt | 3 +- .../calendula/ui/common/CalendarColors.kt | 2 +- .../calendula/ui/common/EventColors.kt | 64 ++++--- .../calendula/ui/day/DayScreen.kt | 3 +- .../calendula/ui/detail/EventDetailScreen.kt | 3 +- .../calendula/ui/edit/EventEditScreen.kt | 3 +- .../calendula/ui/month/MonthScreen.kt | 5 +- .../calendula/ui/search/SearchScreen.kt | 3 +- .../calendula/ui/week/WeekScreen.kt | 3 +- .../calendula/widget/agenda/AgendaWidget.kt | 3 +- app/src/main/res/values/strings.xml | 4 +- .../calendula/domain/EventColorPaletteTest.kt | 13 +- .../calendula/domain/color/EventToneTest.kt | 159 ++++++++++++++++++ .../calendula/domain/color/OklchTest.kt | 64 +++++++ 18 files changed, 573 insertions(+), 160 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/color/EventTone.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/color/Oklch.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/color/OklchTest.kt 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 8130f57..9b44fde 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventColorPalette.kt @@ -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.curatedForPicker(): List { 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.curatedForPicker(): List { * instead of mid-family. Saturation breaks ties, vivid first. */ private fun orderAroundWheel( - swatches: List>, -): List> { + swatches: List>, +): List> { 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>, -): List> { + swatches: List>, +): List> { val byVividness = swatches - .sortedWith(compareByDescending> { it.second.chroma }.thenBy { it.first.key }) - val kept = mutableListOf>() + .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 + 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, 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)) - - 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/color/EventTone.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/color/EventTone.kt new file mode 100644 index 0000000..9ae51fe --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/color/EventTone.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/color/Oklch.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/color/Oklch.kt new file mode 100644 index 0000000..82bf791 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/color/Oklch.kt @@ -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 { + 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt index 9aedc1c..f2fdd95 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index c9190d0..8886ad2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -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 }, 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 b6b889f..b37b64a 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 @@ -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), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventColors.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventColors.kt index 938c337..e2ddafd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventColors.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventColors.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 6ed0599..0a51e2c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -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), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 2deeb4e..67709b8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index d495474..76b5cef 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index c6ca86c..d20eace 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 420be28..c424edd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 319a102..caa259a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index e092b9e..c40288a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2b34eda..a830a56 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -330,8 +330,8 @@ Default view Dynamic colour Requires Android 12 or newer - Soften calendar colours - Tone calendar and event colours down to fit the theme. Turn off to show the raw colours from the calendar source. + Harmonise calendar colours + 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. Headings font Body font System default 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 18d0aa7..57b9a1c 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventColorPaletteTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventColorPaletteTest.kt @@ -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 diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt new file mode 100644 index 0000000..caed3b2 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/EventToneTest.kt @@ -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()) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/OklchTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/OklchTest.kt new file mode 100644 index 0000000..c90a310 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/color/OklchTest.kt @@ -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) + } +}