Seat the month row from one MonthWeek.seating

This commit is contained in:
2026-10-16 14:37:44 +02:00
parent 8cfa361931
commit a5d20802ef
4 changed files with 79 additions and 87 deletions
@@ -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<EventInstance>,
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",
@@ -39,33 +39,55 @@ data class MonthWeek(
val countByDay: Map<LocalDate, Int>,
)
/** 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<Seat>, val hidden: List<EventInstance>)
/**
* 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<Seat>(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<EventInstance> {
val byLane = arrayOfNulls<EventInstance>(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<EventInstance> =
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<EventInst
*/
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
if (col !in days.indices || lane !in 0 until laneCap) return null
spanAt(col, lane)?.let { return it.event }
val occupied = spans
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
.map { it.lane }
.toSet()
val free = (0 until laneCap).filter { it !in occupied }
val index = free.indexOf(lane).takeIf { it >= 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<EventInstance> {
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<EventInstance> = seating(col, laneCap).hidden
/**
* State for the continuous style (#38): a vertical stream of *self-contained*
@@ -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 {
@@ -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)
}
}