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 ca58c5f9..099d57f1 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 @@ -841,7 +841,7 @@ internal fun monthBandHeight(rowHeight: Dp): Dp = /** Whether any day in this week holds more than [lanes] lanes can seat. */ private fun MonthWeek.overflowsAt(lanes: Int): Boolean = - days.withIndex().any { (col, day) -> overflowEvents(col, day, lanes).isNotEmpty() } + days.indices.any { col -> overflowEvents(col, lanes).isNotEmpty() } /** * Row height in the continuous grid. The paged grid divides the viewport between @@ -1627,11 +1627,11 @@ internal fun SplitMonthGrid( val inMonth = day.month == month.month && day.year == month.year // Seated by lane rather than gathered by colour, so each dot // is the event the expanded grid draws in that same lane. - val seated = week.laneEvents(col, day, SPLIT_DOT_LANES) + val seated = week.laneEvents(col, SPLIT_DOT_LANES) SplitDayCell( date = day, events = seated, - hidden = week.overflowEvents(col, day, SPLIT_DOT_LANES), + hidden = week.overflowEvents(col, SPLIT_DOT_LANES), isToday = day == state.today, // A page marks only the days its own month owns. Paging // moves the selection before this month's replacement @@ -2098,8 +2098,8 @@ private fun MonthWeekRow( val ripple = rememberTodayRipple(active = todayCol != null) val overflowRow = rememberOverflowRowHeight() val laneCap = week.laneCapFor(rowHeight, overflowRow) - val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 - val shownLanes = laneCount.coerceAtMost(laneCap) + // Once per row rather than per frame: a drag recomposes every frame. + val seatings = remember(week, laneCap) { week.days.indices.map { week.seating(it, laneCap) } } val morphing = morphInFlight() // Every chip's start time for this row at once, and only when the row's own // inputs change: formatting is a parsed pattern per call, and the dim cutoff @@ -2283,7 +2283,7 @@ private fun MonthWeekRow( .then(if (morphing || dragging) Modifier else Modifier.clipToBounds()), ) { // Spanning bars on their shared lanes. - week.spans.filter { it.lane < shownLanes }.forEach { span -> + week.spans.filter { it.lane < laneCap }.forEach { span -> val cols = span.endCol - span.startCol + 1 MonthBar( event = span.event, @@ -2355,19 +2355,13 @@ private fun MonthWeekRow( ) } } - // Single-day timed pills + overflow, per column. Pills fill the - // lane slots no bar occupies on THIS day (top-most first), so a - // bar-free day isn't pushed down by a multi-day event that only - // sits on other days of the week. + // Single-day timed pills + overflow, per column, in the lanes + // [seating] gave them. week.days.forEachIndexed { col, d -> - val timed = week.timedByDay[d].orEmpty() - val occupied = week.spans - .filter { it.lane < shownLanes && col in it.startCol..it.endCol } - .map { it.lane } - .toSet() - val freeSlots = (0 until laneCap).filter { it !in occupied } - val pillsShown = timed.take(freeSlots.size) - pillsShown.forEachIndexed { i, ev -> + val seating = seatings[col] + seating.seated.forEach { seat -> + if (seat.bar) return@forEach + val ev = seat.event MonthBar( event = ev, dark = dark, @@ -2379,7 +2373,7 @@ private fun MonthWeekRow( modifier = Modifier .offset( x = colW * col, - y = EVENT_ROW_HEIGHT * freeSlots[i], + y = EVENT_ROW_HEIGHT * seat.lane, ) .morphBounds(MonthMorphKey.Event(d, ev.instanceId)) .then( @@ -2395,18 +2389,10 @@ private fun MonthWeekRow( .padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp), ) } - val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size - if (hidden > 0) { - val hiddenEvents = buildList { - week.spans - .filter { it.lane >= shownLanes && col in it.startCol..it.endCol } - .forEach { add(it.event) } - addAll(timed.drop(pillsShown.size)) - } + if (seating.hidden.isNotEmpty()) { OverflowDots( - events = hiddenEvents, + events = seating.hidden, day = d, - total = hidden, dark = dark, rowHeight = overflowRow, modifier = Modifier @@ -2786,7 +2772,6 @@ private fun MonthBar( private fun OverflowDots( events: List, day: LocalDate, - total: Int, dark: Boolean, rowHeight: Dp, modifier: Modifier = Modifier, @@ -2808,7 +2793,7 @@ private fun OverflowDots( .background(eventAccent(argb, dark, soften), CircleShape), ) } - val extra = total - dots.size + val extra = events.size - dots.size if (extra > 0) { Text( text = "+$extra", diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt index cfb3c833..a6cdfcc0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthUiState.kt @@ -39,33 +39,55 @@ data class MonthWeek( val countByDay: Map, ) +/** One event seated in [lane] of a day; [bar] is a spanning bar rather than that day's own pill. */ +data class Seat(val lane: Int, val event: EventInstance, val bar: Boolean) + /** - * The events occupying each lane of [day] — column [col] of this week — in lane - * order, capped at [laneCap] lanes. + * How one day of a week row is seated with [laneCap] lanes: what sits in each + * lane, top-most first with empty lanes left out, and what had no lane left. + */ +data class DaySeating(val seated: List, val hidden: List) + +/** + * The seating of column [col] with [laneCap] lanes — the one every reader of a + * row builds on: the bars and pills the paged grid draws, the split style's dots + * ([laneEvents]), the hit test ([chipAt]) and the overflow ([overflowEvents]). * - * This is the same seating [spans]/[timedByDay] get when the paged grid draws a - * week: bars keep the lane the row layout gave them, and the day's timed events - * fill whatever slots are left, top-most first. Reading it here means the split - * style's dots and the paged style's bars describe a day in the *same order*, so - * dot _i_ and lane _i_ are the same event and one can morph into the other (#53). + * Bars keep the lane the row layout gave them, and the day's timed events fill + * whatever slots are left, top-most first, so a bar-free day isn't pushed down by + * a multi-day event that only sits on other days of the week. Overflow is the + * exact complement, bars before pills. + */ +fun MonthWeek.seating(col: Int, laneCap: Int): DaySeating { + val covering = spans.filter { col in it.startCol..it.endCol } + val byLane = arrayOfNulls(laneCap.coerceAtLeast(0)) + covering.forEach { span -> + if (span.lane < laneCap) byLane[span.lane] = Seat(span.lane, span.event, bar = true) + } + val timed = timedByDay[days[col]].orEmpty() + var pills = 0 + for (lane in byLane.indices) { + if (byLane[lane] == null && pills < timed.size) byLane[lane] = Seat(lane, timed[pills++], bar = false) + } + return DaySeating( + seated = byLane.filterNotNull(), + hidden = covering.filter { it.lane >= laneCap }.map { it.event } + timed.drop(pills), + ) +} + +/** + * The events occupying each lane of column [col], in lane order, capped at + * [laneCap] lanes — the same [seating] the paged grid draws a week with. Reading + * it here means the split style's dots and the paged style's bars describe a day + * in the *same order*, so dot _i_ and lane _i_ are the same event and one can + * morph into the other (#53). * * Deriving the dots independently — by distinct colour, as they first were — * left dot _i_ standing for no particular event, and quietly merged two events * that shared a calendar into a single dot. */ -fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List { - val byLane = arrayOfNulls(laneCap) - spans.forEach { span -> - if (span.lane < laneCap && col in span.startCol..span.endCol) { - byLane[span.lane] = span.event - } - } - val free = (0 until laneCap).filter { byLane[it] == null } - timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event -> - byLane[free[i]] = event - } - return byLane.filterNotNull() -} +fun MonthWeek.laneEvents(col: Int, laneCap: Int): List = + seating(col, laneCap).seated.map { it.event } /** * The event drawn in lane [lane] of column [col], or null for an empty slot. @@ -74,14 +96,7 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List= 0 } ?: return null - return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index) + return seating(col, laneCap).seated.firstOrNull { it.lane == lane }?.event } /** @@ -98,19 +113,11 @@ fun MonthWeek.spanAt(col: Int, lane: Int): MonthSpan? = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol } /** - * The events on [day] that [laneEvents] had no lane left for — its exact + * The events in column [col] that [laneEvents] had no lane left for — its exact * complement, in the same bars-then-pills order. Returned as events rather than * a count because dimming is a per-event question (#79). */ -fun MonthWeek.overflowEvents(col: Int, day: LocalDate, laneCap: Int): List { - val seatedLanes = spans.count { it.lane < laneCap && col in it.startCol..it.endCol } - return buildList { - spans.forEach { span -> - if (span.lane >= laneCap && col in span.startCol..span.endCol) add(span.event) - } - addAll(timedByDay[day].orEmpty().drop(laneCap - seatedLanes)) - } -} +fun MonthWeek.overflowEvents(col: Int, laneCap: Int): List = seating(col, laneCap).hidden /** * State for the continuous style (#38): a vertical stream of *self-contained* diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt index 63d37e44..876f52a6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt @@ -37,7 +37,7 @@ class LaneEventsTest { val week = rowOfJuly6(listOf(bar, meeting)) // Jul 7 is column 1 of a Monday-anchored row starting Jul 6. - assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3)) + assertThat(week.laneEvents(col = 1, laneCap = 3)) .containsExactly(bar, meeting) .inOrder() } @@ -48,7 +48,7 @@ class LaneEventsTest { val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L) val week = rowOfJuly6(listOf(bar, monday)) - assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)) + assertThat(week.laneEvents(col = 0, laneCap = 3)) .containsExactly(monday) } @@ -58,10 +58,9 @@ class LaneEventsTest { val week = rowOfJuly6(listOf(bar)) (1..3).forEach { col -> - val day = LocalDate(2026, 7, 6 + col) - assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar) + assertThat(week.laneEvents(col, laneCap = 3)).containsExactly(bar) } - assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty() + assertThat(week.laneEvents(col = 4, laneCap = 3)).isEmpty() } /** The bug the colour-gathered dots had: one dot for two events on one calendar. */ @@ -71,7 +70,7 @@ class LaneEventsTest { val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED) val week = rowOfJuly6(listOf(first, second)) - assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)) + assertThat(week.laneEvents(col = 0, laneCap = 3)) .containsExactly(first, second) .inOrder() } @@ -81,7 +80,7 @@ class LaneEventsTest { val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) } val week = rowOfJuly6(events) - val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3) + val seated = week.laneEvents(col = 0, laneCap = 3) assertThat(seated).hasSize(3) assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder() assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2) @@ -95,7 +94,7 @@ class LaneEventsTest { } val week = rowOfJuly6(bars) - val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3) + val seated = week.laneEvents(col = 0, laneCap = 3) assertThat(seated).hasSize(3) assertThat(week.spans.filter { it.lane >= 3 }.map { it.event }) .containsNoneIn(seated) @@ -105,14 +104,13 @@ class LaneEventsTest { fun `overflow is exactly what seating left behind`() { val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) } val week = rowOfJuly6(events) - val day = LocalDate(2026, 7, 6) - val seated = week.laneEvents(col = 0, day = day, laneCap = 3) - val overflow = week.overflowEvents(col = 0, day = day, laneCap = 3) + val seated = week.laneEvents(col = 0, laneCap = 3) + val overflow = week.overflowEvents(col = 0, laneCap = 3) assertThat(overflow).containsExactlyElementsIn(events.drop(3)).inOrder() assertThat(seated + overflow).containsExactlyElementsIn(events) - assertThat(seated.size + overflow.size).isEqualTo(week.countByDay[day]) + assertThat(seated.size + overflow.size).isEqualTo(week.countByDay[LocalDate(2026, 7, 6)]) } @Test @@ -124,8 +122,7 @@ class LaneEventsTest { val parked = week.spans.filter { it.lane >= 3 }.map { it.event } (0..2).forEach { col -> - val day = LocalDate(2026, 7, 6 + col) - assertThat(week.overflowEvents(col, day, laneCap = 3)) + assertThat(week.overflowEvents(col, laneCap = 3)) .containsExactlyElementsIn(parked) } } @@ -134,7 +131,7 @@ class LaneEventsTest { fun `a day that fits has no overflow`() { val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 6), hour = 9, id = 1L))) - assertThat(week.overflowEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)).isEmpty() + assertThat(week.overflowEvents(col = 0, laneCap = 3)).isEmpty() } private companion object { diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthSeatingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthSeatingTest.kt index 926bc9ff..1aa3dc9d 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthSeatingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthSeatingTest.kt @@ -66,14 +66,17 @@ class MonthSeatingTest { assertWithMessage("$where: dot count vs hidden list") .that(row.hiddenCount.coerceAtLeast(0)).isEqualTo(row.hiddenEvents.size) - assertWithMessage("$where: laneEvents").that(week.laneEvents(col, day, laneCap)).isEqualTo(dots) - assertWithMessage("$where: overflowEvents").that(week.overflowEvents(col, day, laneCap)).isEqualTo(overflow) + assertWithMessage("$where: laneEvents").that(week.laneEvents(col, laneCap)).isEqualTo(dots) + assertWithMessage("$where: overflowEvents").that(week.overflowEvents(col, laneCap)).isEqualTo(overflow) (-1..laneCap).forEach { lane -> assertWithMessage("$where: chipAt lane $lane") .that(week.chipAt(col, lane, laneCap)) .isEqualTo(refChipAt(week, col, lane, laneCap)) } - //SEATING + val seating = week.seating(col, laneCap) + assertWithMessage("$where: seating lanes") + .that(seating.seated.map { it.lane to it.event }).isEqualTo(row.seated) + assertWithMessage("$where: seating hidden").that(seating.hidden).isEqualTo(row.hiddenEvents) } }