Compare commits

..
Author SHA1 Message Date
makiolaj 63dbe46a88 Seat as many month chips as the row has height for 2026-09-23 17:06:47 +02:00
9 changed files with 280 additions and 151 deletions
@@ -1,57 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
/**
* The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in
* a 16sp line box — room a label wants when it stands on its own, and close to
* a fifth of an event row when it doesn't. The outer edges only, so a wrapped
* title keeps its interior line spacing (#190).
*
* Trimmed rather than set to a smaller line height: the font picker can load a
* serif, a monospace or a file of the user's own, and a line height under a
* face's own ascent and descent overlaps its lines. There is nothing to trim
* below that, so this is safe whatever font is chosen.
*/
private val TrimmedLines = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.Both,
)
/** Ascenders and descenders both, so a line is measured at its full extent. */
private const val LINE_SAMPLE = "Ag"
/** [this] with Material's outer leading trimmed — see [TrimmedLines]. */
fun TextStyle.trimmedLines(): TextStyle = copy(lineHeightStyle = TrimmedLines)
/**
* [style] with its wrapped lines packed onto the face's own extent instead of
* Material's line box.
*
* [trimmedLines] takes the leading off the outer edges of a run of text; this
* takes it from between the lines as well, which is the half a wrapped event
* title pays for twice over. The line height is *measured* from the font rather
* than picked, so it lands exactly on the face's ascent-plus-descent and can
* never be short enough to overlap — whatever the font picker has loaded (#190).
*/
@Composable
fun rememberPackedLines(style: TextStyle): TextStyle {
val line = rememberTrimmedLineHeight(style)
return with(LocalDensity.current) { style.trimmedLines().copy(lineHeight = line.toSp()) }
}
/** What one trimmed line of [style] actually draws in. */
@Composable
fun rememberTrimmedLineHeight(style: TextStyle): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
return remember(style, density, measurer) {
with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() }
}
}
@@ -115,7 +115,6 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
@@ -141,8 +140,7 @@ import kotlin.time.Clock
import java.util.Locale
import kotlin.math.roundToInt
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */
private val ALL_DAY_ROW_HEIGHT = 20.dp
private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp
/** Total all-day strip height for the day (0 when there are no all-day events). */
@@ -531,7 +529,7 @@ private fun AllDayBar(
val titleOverflow = eventTitleOverflow()
Text(
text = title,
style = MaterialTheme.typography.labelSmall.trimmedLines(),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
@@ -123,6 +123,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -153,7 +154,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
import de.jeanlucmakiola.calendula.ui.common.monthBarShape
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
@@ -775,14 +775,79 @@ private val CELL_SHAPE = RoundedCornerShape(CELL_CORNER)
/** Width of the split style's selected-day outline. */
private val SPLIT_SELECTION_STROKE = 1.5.dp
/** Lanes of bars/pills a day cell draws before the rest become overflow dots. */
internal const val MAX_EVENT_ROWS = 3
/**
* Dots a split-style cell draws. Fixed, unlike the paged grid's measured cap:
* the split rows are a fixed height whatever the screen is.
*
* Dot *i* is lane *i*, so a dot morphs into the bar the expanded grid draws
* there (#53) — [MonthWeek.laneEvents] seats the same events in the same order
* at any cap, so a larger one only appends. Where the expanded grid seats fewer
* lanes than this — a six-row month in a short landscape viewport — the dots
* past its cap have no bar to become and simply fade instead of travelling.
*/
internal const val SPLIT_DOT_LANES = 3
/** Diameter of a single overflow dot. */
private val OVERFLOW_DOT_SIZE = 6.dp
/**
* Height of the overflow row: the "+N" beside the dots, measured at the style it
* is drawn in.
*
* It was taking a whole event lane, which is more than a dot and a label line
* need and one lane fewer for the chips. Measured rather than picked, so it
* still holds the label at a large font scale — a fixed height clipped the "+N"
* at anything above the default.
*/
@Composable
private fun rememberOverflowRowHeight(): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val style = MaterialTheme.typography.labelSmall
return remember(style, density, measurer) {
with(density) { measurer.measure(OVERFLOW_SAMPLE, style).size.height.toDp() }
.coerceAtLeast(OVERFLOW_DOT_SIZE)
}
}
/** The tallest the counter gets; digits are tabular, so one is as wide as any. */
private const val OVERFLOW_SAMPLE = "+9"
/**
* Lanes of chips this week's cells draw in a row [rowHeight] tall, before the
* rest become dots.
*
* The cap used to be a flat three at every size, so a tall five-row month threw
* away lanes it had the room for while a cramped six-row one drew a third chip
* its band could not hold and put the dots below the clip, where nothing showed
* that the day held more at all.
*
* Measured instead, with no ceiling: a cell spends whatever height it was given.
* The overflow row is only charged for when some day in the week actually
* overflows — a week that fits gets that space as another lane rather than
* reserving room for a marker it will not draw.
*/
internal fun MonthWeek.laneCapFor(rowHeight: Dp, overflowRow: Dp): Int {
val band = monthBandHeight(rowHeight)
val full = (band / EVENT_ROW_HEIGHT).toInt().coerceAtLeast(1)
if (!overflowsAt(full)) return full
return ((band - overflowRow) / EVENT_ROW_HEIGHT).toInt().coerceAtLeast(1)
}
/** What a row [rowHeight] tall leaves for chips once the day number is drawn. */
internal fun monthBandHeight(rowHeight: Dp): Dp =
rowHeight - CELL_TOP_PADDING - DAY_NUMBER_HEIGHT - DAY_NUMBER_GAP
/** 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() }
/**
* Row height in the continuous grid. The paged grid divides the viewport between
* however many rows the month has; a scrolling stream has no such bound, so it
* fixes a height that seats the day number plus [MAX_EVENT_ROWS] event rows —
* close to what a five-row month gets on a typical phone.
* fixes one — close to what a five-row month gets on a typical phone. How many
* chips that seats is [MonthWeek.laneCapFor]'s answer like anywhere else, so a
* week that does not overflow fills the band rather than holding a lane back.
*/
private val CONTINUOUS_ROW_HEIGHT = 112.dp
@@ -794,6 +859,11 @@ private val CONTINUOUS_ROW_HEIGHT = 112.dp
*/
private val CONTINUOUS_MONTH_GAP = 20.dp
/** The paged grid's own vertical padding, and the gap between its week rows —
* named because [monthLaneCap] has to take them off the viewport first. */
private val GRID_VERTICAL_PADDING = 4.dp
private val GRID_ROW_GAP = 2.dp
/** Gap between the weekday header and the seamless stream's first week row. */
private val DENSE_HEADER_GAP = 4.dp
@@ -806,34 +876,43 @@ internal fun MonthGrid(
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
selected: LocalDate? = null,
) {
Column(
modifier = Modifier
.fillMaxSize()
// Match the weekday header's inset so day cells sit under their
// labels, and so the week-number gutter's centre lines up with the
// top bar's hamburger (4dp bar inset + 24dp half icon button).
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
val month = state.month
// Once per grid: the value depends on the typography, the density, the
// locale and the 24-hour setting, none of which vary by row (#219).
val timeChipWidth = rememberMonthTimeChipWidth()
state.weeks.forEach { week ->
MonthWeekRow(
week = week,
today = state.today,
zone = state.zone,
timeChipWidth = timeChipWidth,
inMonth = { it.month == month.month && it.year == month.year },
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
selected = selected,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
)
BoxWithConstraints(Modifier.fillMaxSize()) {
// The rows divide whatever the viewport leaves once the Column's own
// padding and the gaps between them are paid, so how many chips a cell
// can seat is only knowable here.
val rows = state.weeks.size.coerceAtLeast(1)
val rowHeight =
(maxHeight - GRID_VERTICAL_PADDING * 2 - GRID_ROW_GAP * (rows - 1)) / rows
Column(
modifier = Modifier
.fillMaxSize()
// Match the weekday header's inset so day cells sit under their
// labels, and so the week-number gutter's centre lines up with the
// top bar's hamburger (4dp bar inset + 24dp half icon button).
.padding(horizontal = AppBarSpacing.Inset, vertical = GRID_VERTICAL_PADDING),
verticalArrangement = Arrangement.spacedBy(GRID_ROW_GAP),
) {
val month = state.month
// Once per grid: the value depends on the typography, the density, the
// locale and the 24-hour setting, none of which vary by row (#219).
val timeChipWidth = rememberMonthTimeChipWidth()
state.weeks.forEach { week ->
MonthWeekRow(
week = week,
today = state.today,
zone = state.zone,
timeChipWidth = timeChipWidth,
rowHeight = rowHeight,
inMonth = { it.month == month.month && it.year == month.year },
showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
selected = selected,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
)
}
}
}
}
@@ -931,6 +1010,7 @@ private fun ContinuousMonthBlock(
today = today,
zone = zone,
timeChipWidth = timeChipWidth,
rowHeight = CONTINUOUS_ROW_HEIGHT,
inMonth = { it.month == month.month && it.year == month.year },
// The block owns its month alone: a day from either
// neighbour is left out entirely rather than dimmed.
@@ -1025,6 +1105,7 @@ internal fun DenseMonthGrid(
today = state.today,
zone = state.zone,
timeChipWidth = timeChipWidth,
rowHeight = CONTINUOUS_ROW_HEIGHT,
// Every day in the stream belongs to a month equally — there
// is no "other month" to recede here.
inMonth = { true },
@@ -1438,7 +1519,7 @@ private fun SplitExpandHandle(
*/
private val SPLIT_ROW_HEIGHT = 46.dp
private val SPLIT_DOT_SIZE = 5.dp
// Dots are capped by MAX_EVENT_ROWS, not a constant of their own: they stand for
// Dots are capped by SPLIT_DOT_LANES, not a constant of their own: they stand for
// the paged grid's lanes, so the two caps have to be the same number or a dot
// would have no bar to become (#53).
@@ -1495,11 +1576,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, MAX_EVENT_ROWS)
val seated = week.laneEvents(col, day, SPLIT_DOT_LANES)
SplitDayCell(
date = day,
events = seated,
hidden = week.overflowEvents(col, day, MAX_EVENT_ROWS),
hidden = week.overflowEvents(col, day, 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
@@ -1529,7 +1610,7 @@ internal fun SplitMonthGrid(
}
/**
* One compact day: its number over up to [MAX_EVENT_ROWS] lane-seated event dots.
* One compact day: its number over up to [SPLIT_DOT_LANES] lane-seated event dots.
*
* Selection and today are deliberately different signals — a tinted, outlined
* cell versus the filled circle the other views already use for today — so the
@@ -1903,7 +1984,7 @@ private fun rememberSkeletonPulse(): Float {
* One week of the grid. Bars (all-day / multi-day) are positioned absolutely so
* a multi-day event is one connected bar across the columns; single-day timed
* events sit beneath them as filled pills in their own cell. The cap is
* [MAX_EVENT_ROWS] rows of bars+pills, then a "+N" dot indicator per day.
* [SPLIT_DOT_LANES] rows of bars+pills, then a "+N" dot indicator per day.
* A transparent per-day layer on top turns a tap into "open that day".
*/
@Composable
@@ -1914,6 +1995,8 @@ private fun MonthWeekRow(
zone: TimeZone,
/** The narrowest chip that may carry a start time, measured once per grid (#219). */
timeChipWidth: Dp,
/** The height this row was given, which decides its lanes — see [laneCapFor]. */
rowHeight: Dp,
inMonth: (LocalDate) -> Boolean,
showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit,
@@ -1930,8 +2013,10 @@ private fun MonthWeekRow(
selected: LocalDate? = null,
) {
val dark = isSystemInDarkTheme()
val overflowRow = rememberOverflowRowHeight()
val laneCap = week.laneCapFor(rowHeight, overflowRow)
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
val shownLanes = laneCount.coerceAtMost(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
@@ -1984,9 +2069,9 @@ private fun MonthWeekRow(
band = bandCoordinates[0],
columnWidthPx = cell.size.width / 7f,
laneHeightPx = rowHeightPx,
laneCount = MAX_EVENT_ROWS,
laneCount = laneCap,
isRtl = isRtl,
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) },
chipAt = { col, lane -> week.chipAt(col, lane, laneCap) },
chipStart = { col, lane -> week.chipStartCol(col, lane) },
),
)
@@ -2027,6 +2112,7 @@ private fun MonthWeekRow(
controller = dragController,
band = bandCoordinates,
rowHeightPx = rowHeightPx,
laneCap = laneCap,
isRtl = isRtl,
chipTimes = chipTimes,
),
@@ -2035,6 +2121,11 @@ private fun MonthWeekRow(
// What a chip has to spend, against the [timeChipWidth] a start time
// costs it (#219).
val colW = maxWidth / 7
// Held here rather than read at the offset: the dots are placed
// inside a plain lambda, which is no longer in this scope. The box
// is the whole row, so the day number's share comes off it — the
// dots are positioned inside the band, not inside this.
val bandHeight = monthBandHeight(maxHeight)
// Per-day background pills — same surfaceContainer rounded surface the
// week/day views use, so the three views share one visual language.
@@ -2177,7 +2268,7 @@ private fun MonthWeekRow(
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
.map { it.lane }
.toSet()
val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied }
val freeSlots = (0 until laneCap).filter { it !in occupied }
val pillsShown = timed.take(freeSlots.size)
pillsShown.forEachIndexed { i, ev ->
MonthBar(
@@ -2211,8 +2302,20 @@ private fun MonthWeekRow(
events = hiddenEvents,
total = hidden,
dark = dark,
rowHeight = overflowRow,
modifier = Modifier
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
// After the chip lanes, but never past the
// band: on a row too short for the lanes it
// is holding, dots placed below it are
// clipped away entirely and the day looks
// like it has nothing more to show.
.offset(
x = colW * col,
y = minOf(
EVENT_ROW_HEIGHT * laneCap,
bandHeight - overflowRow,
),
)
.morphBounds(MonthMorphKey.Overflow(d))
.width(colW)
.padding(horizontal = 3.dp),
@@ -2287,6 +2390,7 @@ private fun MonthWeekRow(
bandCoordinates,
),
rowHeightPx = rowHeightPx,
laneCap = laneCap,
)
if (chip != null) onEventClick(chip) else onOpenDay(d)
},
@@ -2328,11 +2432,12 @@ internal fun MonthWeek.chipAtCellY(
cellY: Float,
bandTopInCell: Float?,
rowHeightPx: Float,
laneCap: Int,
): EventInstance? {
if (bandTopInCell == null || rowHeightPx <= 0f) return null
val bandY = cellY - bandTopInCell
if (bandY < 0f) return null
return chipAt(col, (bandY / rowHeightPx).toInt(), MAX_EVENT_ROWS)
return chipAt(col, (bandY / rowHeightPx).toInt(), laneCap)
}
/**
@@ -2347,6 +2452,7 @@ private fun monthChipDragModifier(
controller: MonthDragController?,
band: Array<LayoutCoordinates?>,
rowHeightPx: Float,
laneCap: Int,
isRtl: Boolean,
/** The row's formatted chip times by instance id, so the copy carries the
* one its source chip had rather than deriving another (#219). */
@@ -2365,7 +2471,7 @@ private fun monthChipDragModifier(
val event = if (bandY < 0f || columnPx <= 0f) {
null
} else {
week.chipAt(dayIndex, lane, MAX_EVENT_ROWS)
week.chipAt(dayIndex, lane, laneCap)
}
if (event == null || moveScope?.allows(event) != true) {
false
@@ -2545,7 +2651,7 @@ private fun MonthBar(
val titleOverflow = eventTitleOverflow()
Text(
text = label,
style = MaterialTheme.typography.labelSmall.trimmedLines(),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
@@ -2566,6 +2672,7 @@ private fun OverflowDots(
events: List<EventInstance>,
total: Int,
dark: Boolean,
rowHeight: Dp,
modifier: Modifier = Modifier,
) {
val soften = LocalSoftenColors.current
@@ -2573,14 +2680,14 @@ private fun OverflowDots(
val byColor = events.groupBy { it.color }
val dots = byColor.keys.take(3)
Row(
modifier = modifier.height(EVENT_ROW_HEIGHT),
modifier = modifier.height(rowHeight),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
dots.forEach { argb ->
Box(
modifier = Modifier
.size(6.dp)
.size(OVERFLOW_DOT_SIZE)
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
.background(eventAccent(argb, dark, soften), CircleShape),
)
@@ -1,24 +1,10 @@
package de.jeanlucmakiola.calendula.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.unit.sp
/**
* Tracking the two label roles the calendar grids are set in. Material gives
* both 0.5sp, tuned for isolated UI labels with room around them; a month chip
* is a text box some 37dp wide, where 0.5sp on an 11sp glyph spends most of a
* character on spacing alone. 0.1sp is what Material itself sets labelLarge to,
* so the label family stays coherent (#190).
* Default Material 3 Expressive typography. Custom font + tuned scale will
* land in a later UI-design iteration; the defaults are intentional for V1
* scaffolding to keep the foundation lean.
*/
private val LabelTracking = 0.1.sp
/**
* Material 3 Expressive typography with the label roles' tracking tightened.
* Everything else is the default scale.
*/
val CalendulaTypography: Typography = Typography().let { base ->
base.copy(
labelMedium = base.labelMedium.copy(letterSpacing = LabelTracking),
labelSmall = base.labelSmall.copy(letterSpacing = LabelTracking),
)
}
val CalendulaTypography = Typography()
@@ -118,7 +118,6 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
@@ -156,8 +155,7 @@ import kotlin.time.Clock
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */
private val ALL_DAY_ROW_HEIGHT = 20.dp
private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp
/** Gap between day columns; part of the column pitch a drag maps positions through. */
private val COLUMN_GAP = 2.dp
@@ -666,7 +664,7 @@ private fun AllDayBar(
val titleOverflow = eventTitleOverflow()
Text(
text = title,
style = MaterialTheme.typography.labelSmall.trimmedLines(),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
@@ -167,7 +167,7 @@ internal fun layoutAllDay(
// in non-decreasing start order, which the declined-last rule above breaks:
// a Monday bar seated after a Wednesday one would be refused a lane it is
// nowhere near, and each wasted lane costs the all-day strip a whole row and
// pushes a bar closer to the month grid's MAX_EVENT_ROWS cap. Seven columns
// pushes a bar closer to the month grid's lane cap. Seven columns
// and a handful of bars, so the scan is cheaper than the sort above it.
val laneCols = ArrayList<MutableList<IntRange>>()
return raw.map { r ->
@@ -56,7 +56,13 @@ class ChipAtCellYTest {
)
private fun MonthWeek.chipAt(col: Int, cellY: Float) =
chipAtCellY(col = col, cellY = cellY, bandTopInCell = bandTop, rowHeightPx = laneHeight)
chipAtCellY(
col = col,
cellY = cellY,
bandTopInCell = bandTop,
rowHeightPx = laneHeight,
laneCap = SPLIT_DOT_LANES,
)
@Test
fun `a tap on a lane resolves to the chip seated there`() {
@@ -98,13 +104,13 @@ class ChipAtCellYTest {
@Test
fun `a tap on the overflow row opens the day rather than a hidden event`() {
val events = (1..MAX_EVENT_ROWS + 2).map {
val events = (1..SPLIT_DOT_LANES + 2).map {
timed(LocalDate(2026, 7, 7), hour = it, id = it.toLong())
}
val week = rowOfJuly6(events)
// The dots sit one lane below the last one the row draws.
val overflowY = bandTop + laneHeight * MAX_EVENT_ROWS + 2f
val overflowY = bandTop + laneHeight * SPLIT_DOT_LANES + 2f
assertThat(week.chipAt(col = 1, cellY = overflowY)).isNull()
}
@@ -113,10 +119,22 @@ class ChipAtCellYTest {
val week = rowOfJuly6(listOf(timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)))
assertThat(
week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = null, rowHeightPx = laneHeight),
week.chipAtCellY(
col = 1,
cellY = 45f,
bandTopInCell = null,
rowHeightPx = laneHeight,
laneCap = SPLIT_DOT_LANES,
),
).isNull()
assertThat(
week.chipAtCellY(col = 1, cellY = 45f, bandTopInCell = bandTop, rowHeightPx = 0f),
week.chipAtCellY(
col = 1,
cellY = 45f,
bandTopInCell = bandTop,
rowHeightPx = 0f,
laneCap = SPLIT_DOT_LANES,
),
).isNull()
}
}
@@ -0,0 +1,93 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
* How many chip lanes a week row seats at a given height (#190) — the cap that
* used to be a flat three whatever the device had.
*/
class MonthLaneCapTest {
private val zone = TimeZone.UTC
private val jul26 = YearMonth(2026, Month.JULY)
private val monday = LocalDate(2026, 7, 6)
/** Cell chrome above the band: 6 + 22 + 4. */
private val header = 32.dp
private fun timed(day: LocalDate, hour: Int, id: Long) = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = "T$id",
start = day.atTime(hour, 0).toInstant(zone),
end = day.atTime(hour + 1, 0).toInstant(zone),
isAllDay = false,
color = 0xFFF44336.toInt(),
location = null,
)
/** Jul 6-12, wholly inside July 2026. */
private fun week(eventsOnMonday: Int) = layoutMonthWeeks(
jul26,
DayOfWeek.MONDAY,
(1..eventsOnMonday).map { timed(monday, hour = it, id = it.toLong()) },
zone,
)[1]
private fun rowFor(bandHeight: Int) = header + bandHeight.dp
/** A labelSmall line at font scale 1 — what the overflow row measures to. */
private val overflowRow = 16.dp
private fun MonthWeek.capAt(rowHeight: androidx.compose.ui.unit.Dp) =
laneCapFor(rowHeight, overflowRow)
@Test
fun `a week that fits spends the overflow row on another lane`() {
// 80dp of band is four 20dp lanes. Nothing overflows at four, so no
// room is set aside for a marker that would never be drawn.
assertThat(week(eventsOnMonday = 4).capAt(rowFor(80))).isEqualTo(4)
}
@Test
fun `a week that overflows pays for the dots out of its own lanes`() {
// The same 80dp, but a fifth event means the dots have to be drawn, and
// their 14dp comes off the band before it is divided.
assertThat(week(eventsOnMonday = 5).capAt(rowFor(80))).isEqualTo(3)
}
@Test
fun `a taller row seats more, with no ceiling`() {
assertThat(week(eventsOnMonday = 20).capAt(rowFor(120))).isEqualTo(5)
assertThat(week(eventsOnMonday = 20).capAt(rowFor(220))).isEqualTo(10)
}
@Test
fun `a cramped row seats fewer rather than drawing past its band`() {
// 58dp was three clipped lanes and dots nobody could see. It is two
// lanes and a visible marker.
assertThat(week(eventsOnMonday = 6).capAt(rowFor(58))).isEqualTo(2)
}
@Test
fun `a row with no height to give still seats one lane`() {
assertThat(week(eventsOnMonday = 6).capAt(rowFor(0))).isEqualTo(1)
assertThat(week(eventsOnMonday = 6).capAt(10.dp)).isEqualTo(1)
}
@Test
fun `an empty week never charges itself for dots`() {
assertThat(week(eventsOnMonday = 0).capAt(rowFor(80))).isEqualTo(4)
}
}
@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.theme
import androidx.compose.ui.unit.sp
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
@@ -49,19 +48,6 @@ class FontsTest {
assertThat(typography.labelSmall.fontFamily).isEqualTo(plain)
}
@Test
fun `picking a font keeps the tightened label tracking`() {
// The font picker only swaps the family; the tracking the grids are laid
// out against has to survive it (#190).
val typography = calendulaTypography(
brand = BundledFont.Lora.family,
plain = BundledFont.AtkinsonHyperlegible.family,
)
assertThat(typography.labelSmall.letterSpacing).isEqualTo(0.1.sp)
assertThat(typography.labelMedium.letterSpacing).isEqualTo(0.1.sp)
}
@Test
fun `a null role keeps that role's default family while the other is applied`() {
val plain = BundledFont.Lora.family