Compare commits
2 Commits
release/v2
...
1fe887fbc4
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fe887fbc4 | |||
| bf4f0a208d |
@@ -24,6 +24,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
suggestions ([#18], [#20]).
|
||||
|
||||
### Fixed
|
||||
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
|
||||
app (such as DAVx5), the event colour picker showed every colour the account
|
||||
publishes — nearly 150 swatches in alphabetical order, many of them
|
||||
duplicates or near-identical shades. The picker now shows only visually
|
||||
distinct colours, arranged as a rainbow with the grays at the end. Picked
|
||||
colours still sync exactly as before, and calendars with hand-picked
|
||||
palettes (like Google's) are unaffected. Thanks to @ptab for the report
|
||||
([#22]).
|
||||
- Month widget arrows and "today" button work again. On release builds the
|
||||
prev/next-month arrows and the jump-to-today control on the month widget did
|
||||
nothing when tapped — code shrinking had stripped the tap handlers behind
|
||||
@@ -768,3 +776,4 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
|
||||
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
||||
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
||||
[#22]: https://codeberg.org/jlmakiola/calendula/issues/22
|
||||
|
||||
@@ -20,6 +20,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
@@ -57,7 +58,10 @@ interface CalendarDataSource {
|
||||
|
||||
/**
|
||||
* The event-colour palette the calendar's account publishes
|
||||
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
|
||||
* (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped,
|
||||
* thinned to visually distinct swatches when oversized (CalDAV adapters
|
||||
* publish all ~147 CSS3 names, #22) and hue-sorted; see
|
||||
* [curatedForPicker]. Empty when the
|
||||
* account exposes no palette (most local calendars, some CalDAV) — the
|
||||
* signal that a custom colour can only be written as a raw `EVENT_COLOR`,
|
||||
* which a synced calendar may drop on its next sync.
|
||||
@@ -420,7 +424,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
||||
}
|
||||
?.filter { it.key.isNotEmpty() }
|
||||
?.sortedBy { it.key }
|
||||
?.curatedForPicker()
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cbrt
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Curates an account's published event palette for the colour picker.
|
||||
*
|
||||
* Sync adapters differ wildly in what they publish: Google exposes a
|
||||
* hand-picked two-dozen set, while CalDAV adapters (DAVx5) dump all ~147 CSS3
|
||||
* named colours — including exact-value aliases (aqua/cyan, the gray/grey
|
||||
* spelling pairs) and dozens of visually indistinguishable whites and grays
|
||||
* (#22). Three steps tame that without breaking sync round-tripping (every
|
||||
* surviving option keeps its provider [EventColorOption.key]):
|
||||
*
|
||||
* 1. Exact duplicates collapse to one swatch (alphabetically-first key wins,
|
||||
* deterministically).
|
||||
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) are thinned to visually
|
||||
* distinct colours: most vivid first, a colour is kept only when at least
|
||||
* [MIN_DELTA_E] (CIE76, Lab) from every colour already kept. Small
|
||||
* palettes are already curated by their adapter and pass through whole.
|
||||
* 3. The result is ordered like a rainbow — hue buckets, light-to-dark
|
||||
* within each, achromatics last — instead of the provider's key order,
|
||||
* which for CSS3 names scatters hues alphabetically.
|
||||
*/
|
||||
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||
val distinct = sortedBy { it.key }.distinctBy { it.argb }
|
||||
val kept = if (distinct.size <= CURATION_TRIGGER_SIZE) distinct else thin(distinct)
|
||||
return kept
|
||||
.map { it to Lab.of(it.argb) }
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ (_, lab) -> if (lab.chroma < ACHROMATIC_MAX_CHROMA) 1 else 0 },
|
||||
{ (_, lab) -> if (lab.chroma < ACHROMATIC_MAX_CHROMA) 0 else lab.hueBucket },
|
||||
{ (_, lab) -> lab.l },
|
||||
),
|
||||
)
|
||||
.map { (option, _) -> option }
|
||||
}
|
||||
|
||||
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
||||
private fun thin(options: List<EventColorOption>): List<EventColorOption> {
|
||||
val byVividness = options
|
||||
.map { it to Lab.of(it.argb) }
|
||||
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
|
||||
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
|
||||
for (candidate in byVividness) {
|
||||
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
|
||||
}
|
||||
return kept.map { it.first }
|
||||
}
|
||||
|
||||
/** Palettes at most this big skip the distinctness thinning (Google's ~26 pass through). */
|
||||
private const val CURATION_TRIGGER_SIZE = 36
|
||||
|
||||
/** Minimum CIE76 ΔE between surviving swatches; 25 keeps ~42 of CSS3's 147. */
|
||||
private const val MIN_DELTA_E = 25.0
|
||||
|
||||
/** Below this Lab chroma a colour reads as gray and sorts into the trailing bucket. */
|
||||
private const val ACHROMATIC_MAX_CHROMA = 12.0
|
||||
|
||||
/** Sorting granularity around the hue wheel. */
|
||||
private const val HUE_BUCKETS = 12
|
||||
|
||||
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
|
||||
private class Lab(val l: Double, val a: Double, val b: Double) {
|
||||
val chroma: Double get() = hypot(a, b)
|
||||
|
||||
val hueBucket: Int
|
||||
get() {
|
||||
val degrees = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
|
||||
return (degrees * HUE_BUCKETS / 360.0).toInt().coerceAtMost(HUE_BUCKETS - 1)
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1712,12 +1712,20 @@ private fun ColorPickerDialog(
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (palette.isNotEmpty()) {
|
||||
// The event's current colour may not be in the curated
|
||||
// palette (thinned near-duplicate, or a raw colour set
|
||||
// elsewhere) — append it so the selection ring has a home.
|
||||
val swatches = palette.map { it.argb }.let {
|
||||
if (selected != null && selected !in it) it + selected else it
|
||||
}
|
||||
ColorSwatchRow(
|
||||
colors = palette.map { it.argb },
|
||||
colors = swatches,
|
||||
selected = selected,
|
||||
onSelect = { argb ->
|
||||
palette.firstOrNull { it.argb == argb }
|
||||
?.let { onPickKey(it.key, it.argb) }
|
||||
val option = palette.firstOrNull { it.argb == argb }
|
||||
// The appended current colour has no key to write —
|
||||
// it is already the event's colour, so just close.
|
||||
if (option != null) onPickKey(option.key, option.argb) else onDismiss()
|
||||
},
|
||||
dark = dark,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class EventColorPaletteTest {
|
||||
|
||||
@Test
|
||||
fun `empty palette stays empty`() {
|
||||
assertThat(emptyList<EventColorOption>().curatedForPicker()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exact duplicate values collapse to the alphabetically first key`() {
|
||||
val curated = listOf(
|
||||
EventColorOption("cyan", 0xFF00FFFF.toInt()),
|
||||
EventColorOption("aqua", 0xFF00FFFF.toInt()),
|
||||
EventColorOption("red", 0xFFFF0000.toInt()),
|
||||
).curatedForPicker()
|
||||
|
||||
assertThat(curated.map { it.key }).containsExactly("aqua", "red")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `small palettes pass through whole, so Google's curated set is untouched`() {
|
||||
// A Google-like palette: two dozen distinct hand-picked colours.
|
||||
val palette = (0 until 24).map {
|
||||
val hue = it * 15
|
||||
EventColorOption("$it", hsvArgb(hue.toFloat()))
|
||||
}
|
||||
|
||||
val curated = palette.curatedForPicker()
|
||||
|
||||
assertThat(curated).containsExactlyElementsIn(palette)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oversized CSS3 palette thins to a pickable number of distinct swatches`() {
|
||||
val curated = css3Palette().curatedForPicker()
|
||||
|
||||
// The whole point of #22: ~147 published colours become a single
|
||||
// manageable grid instead of a full screen.
|
||||
assertThat(curated.size).isAtLeast(30)
|
||||
assertThat(curated.size).isAtMost(60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `curation never invents colours or drops keys`() {
|
||||
val source = css3Palette()
|
||||
val curated = source.curatedForPicker()
|
||||
|
||||
assertThat(source).containsAtLeastElementsIn(curated)
|
||||
assertThat(curated.map { it.argb }).containsNoDuplicates()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spelling-alias pairs never both survive`() {
|
||||
val keys = css3Palette().curatedForPicker().map { it.key }.toSet()
|
||||
|
||||
val aliasPairs = listOf(
|
||||
"aqua" to "cyan",
|
||||
"fuchsia" to "magenta",
|
||||
"gray" to "grey",
|
||||
"darkgray" to "darkgrey",
|
||||
"dimgray" to "dimgrey",
|
||||
"lightgray" to "lightgrey",
|
||||
"slategray" to "slategrey",
|
||||
"lightslategray" to "lightslategrey",
|
||||
"darkslategray" to "darkslategrey",
|
||||
)
|
||||
aliasPairs.forEach { (a, b) ->
|
||||
assertThat(keys.contains(a) && keys.contains(b)).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chromatic colours sort as a rainbow with achromatics last`() {
|
||||
val curated = listOf(
|
||||
EventColorOption("gray", 0xFF808080.toInt()),
|
||||
EventColorOption("blue", 0xFF0000FF.toInt()),
|
||||
EventColorOption("black", 0xFF000000.toInt()),
|
||||
EventColorOption("red", 0xFFFF0000.toInt()),
|
||||
).curatedForPicker()
|
||||
|
||||
// Red's Lab hue angle precedes blue's; grays trail, darkest first.
|
||||
assertThat(curated.map { it.key })
|
||||
.containsExactly("red", "blue", "black", "gray")
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CSS3 survivors cover the full hue range`() {
|
||||
val curated = css3Palette().curatedForPicker().map { it.key }.toSet()
|
||||
|
||||
// One representative from every major hue family must survive thinning.
|
||||
assertThat(curated).contains("red")
|
||||
assertThat(curated).contains("yellow")
|
||||
assertThat(curated).contains("lime")
|
||||
assertThat(curated).contains("blue")
|
||||
assertThat(curated).contains("black")
|
||||
assertThat(curated.any { it.contains("orange") }).isTrue()
|
||||
assertThat(curated.any { it.contains("pink") }).isTrue()
|
||||
}
|
||||
|
||||
private fun hsvArgb(hue: Float): Int {
|
||||
val h = hue / 60f
|
||||
val sector = h.toInt() % 6
|
||||
val f = h - h.toInt()
|
||||
val q = ((1 - f) * 255).toInt()
|
||||
val t = (f * 255).toInt()
|
||||
return when (sector) {
|
||||
0 -> argb(255, t, 0)
|
||||
1 -> argb(q, 255, 0)
|
||||
2 -> argb(0, 255, t)
|
||||
3 -> argb(0, q, 255)
|
||||
4 -> argb(t, 0, 255)
|
||||
else -> argb(255, 0, q)
|
||||
}
|
||||
}
|
||||
|
||||
private fun argb(r: Int, g: Int, b: Int): Int =
|
||||
(0xFF shl 24) or (r shl 16) or (g shl 8) or b
|
||||
|
||||
/** The exact set ical4android/DAVx5 publishes: CSS3's 147 named colours. */
|
||||
private fun css3Palette(): List<EventColorOption> = CSS3.map { (name, rgb) ->
|
||||
EventColorOption(name, 0xFF000000.toInt() or rgb)
|
||||
}
|
||||
|
||||
private val CSS3 = mapOf(
|
||||
"aliceblue" to 0xF0F8FF, "antiquewhite" to 0xFAEBD7, "aqua" to 0x00FFFF,
|
||||
"aquamarine" to 0x7FFFD4, "azure" to 0xF0FFFF, "beige" to 0xF5F5DC,
|
||||
"bisque" to 0xFFE4C4, "black" to 0x000000, "blanchedalmond" to 0xFFEBCD,
|
||||
"blue" to 0x0000FF, "blueviolet" to 0x8A2BE2, "brown" to 0xA52A2A,
|
||||
"burlywood" to 0xDEB887, "cadetblue" to 0x5F9EA0, "chartreuse" to 0x7FFF00,
|
||||
"chocolate" to 0xD2691E, "coral" to 0xFF7F50, "cornflowerblue" to 0x6495ED,
|
||||
"cornsilk" to 0xFFF8DC, "crimson" to 0xDC143C, "cyan" to 0x00FFFF,
|
||||
"darkblue" to 0x00008B, "darkcyan" to 0x008B8B, "darkgoldenrod" to 0xB8860B,
|
||||
"darkgray" to 0xA9A9A9, "darkgreen" to 0x006400, "darkgrey" to 0xA9A9A9,
|
||||
"darkkhaki" to 0xBDB76B, "darkmagenta" to 0x8B008B, "darkolivegreen" to 0x556B2F,
|
||||
"darkorange" to 0xFF8C00, "darkorchid" to 0x9932CC, "darkred" to 0x8B0000,
|
||||
"darksalmon" to 0xE9967A, "darkseagreen" to 0x8FBC8F, "darkslateblue" to 0x483D8B,
|
||||
"darkslategray" to 0x2F4F4F, "darkslategrey" to 0x2F4F4F, "darkturquoise" to 0x00CED1,
|
||||
"darkviolet" to 0x9400D3, "deeppink" to 0xFF1493, "deepskyblue" to 0x00BFFF,
|
||||
"dimgray" to 0x696969, "dimgrey" to 0x696969, "dodgerblue" to 0x1E90FF,
|
||||
"firebrick" to 0xB22222, "floralwhite" to 0xFFFAF0, "forestgreen" to 0x228B22,
|
||||
"fuchsia" to 0xFF00FF, "gainsboro" to 0xDCDCDC, "ghostwhite" to 0xF8F8FF,
|
||||
"gold" to 0xFFD700, "goldenrod" to 0xDAA520, "gray" to 0x808080,
|
||||
"green" to 0x008000, "greenyellow" to 0xADFF2F, "grey" to 0x808080,
|
||||
"honeydew" to 0xF0FFF0, "hotpink" to 0xFF69B4, "indianred" to 0xCD5C5C,
|
||||
"indigo" to 0x4B0082, "ivory" to 0xFFFFF0, "khaki" to 0xF0E68C,
|
||||
"lavender" to 0xE6E6FA, "lavenderblush" to 0xFFF0F5, "lawngreen" to 0x7CFC00,
|
||||
"lemonchiffon" to 0xFFFACD, "lightblue" to 0xADD8E6, "lightcoral" to 0xF08080,
|
||||
"lightcyan" to 0xE0FFFF, "lightgoldenrodyellow" to 0xFAFAD2, "lightgray" to 0xD3D3D3,
|
||||
"lightgreen" to 0x90EE90, "lightgrey" to 0xD3D3D3, "lightpink" to 0xFFB6C1,
|
||||
"lightsalmon" to 0xFFA07A, "lightseagreen" to 0x20B2AA, "lightskyblue" to 0x87CEFA,
|
||||
"lightslategray" to 0x778899, "lightslategrey" to 0x778899, "lightsteelblue" to 0xB0C4DE,
|
||||
"lightyellow" to 0xFFFFE0, "lime" to 0x00FF00, "limegreen" to 0x32CD32,
|
||||
"linen" to 0xFAF0E6, "magenta" to 0xFF00FF, "maroon" to 0x800000,
|
||||
"mediumaquamarine" to 0x66CDAA, "mediumblue" to 0x0000CD, "mediumorchid" to 0xBA55D3,
|
||||
"mediumpurple" to 0x9370DB, "mediumseagreen" to 0x3CB371, "mediumslateblue" to 0x7B68EE,
|
||||
"mediumspringgreen" to 0x00FA9A, "mediumturquoise" to 0x48D1CC,
|
||||
"mediumvioletred" to 0xC71585, "midnightblue" to 0x191970, "mintcream" to 0xF5FFFA,
|
||||
"mistyrose" to 0xFFE4E1, "moccasin" to 0xFFE4B5, "navajowhite" to 0xFFDEAD,
|
||||
"navy" to 0x000080, "oldlace" to 0xFDF5E6, "olive" to 0x808000,
|
||||
"olivedrab" to 0x6B8E23, "orange" to 0xFFA500, "orangered" to 0xFF4500,
|
||||
"orchid" to 0xDA70D6, "palegoldenrod" to 0xEEE8AA, "palegreen" to 0x98FB98,
|
||||
"paleturquoise" to 0xAFEEEE, "palevioletred" to 0xDB7093, "papayawhip" to 0xFFEFD5,
|
||||
"peachpuff" to 0xFFDAB9, "peru" to 0xCD853F, "pink" to 0xFFC0CB,
|
||||
"plum" to 0xDDA0DD, "powderblue" to 0xB0E0E6, "purple" to 0x800080,
|
||||
"red" to 0xFF0000, "rosybrown" to 0xBC8F8F, "royalblue" to 0x4169E1,
|
||||
"saddlebrown" to 0x8B4513, "salmon" to 0xFA8072, "sandybrown" to 0xF4A460,
|
||||
"seagreen" to 0x2E8B57, "seashell" to 0xFFF5EE, "sienna" to 0xA0522D,
|
||||
"silver" to 0xC0C0C0, "skyblue" to 0x87CEEB, "slateblue" to 0x6A5ACD,
|
||||
"slategray" to 0x708090, "slategrey" to 0x708090, "snow" to 0xFFFAFA,
|
||||
"springgreen" to 0x00FF7F, "steelblue" to 0x4682B4, "tan" to 0xD2B48C,
|
||||
"teal" to 0x008080, "thistle" to 0xD8BFD8, "tomato" to 0xFF6347,
|
||||
"turquoise" to 0x40E0D0, "violet" to 0xEE82EE, "wheat" to 0xF5DEB3,
|
||||
"white" to 0xFFFFFF, "whitesmoke" to 0xF5F5F5, "yellow" to 0xFFFF00,
|
||||
"yellowgreen" to 0x9ACD32,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user