Compare commits

..
Author SHA1 Message Date
makiolaj c82d6edcf0 Size the week-number gutter to the number it seats (#213, #189) 2026-09-20 22:56:03 +02:00
Jean-Luc Makiolaandmakiolaj bdcf2b3823 Stop a fully zoomed-out timeline from scrolling (#315)
The pinch clamped its lower bound to `ceil(fillPx)`. Rounding a fill height up
by the fraction of a pixel that 24 hours don't divide the viewport into makes
the timeline up to one pixel per hour taller than the viewport those hours are
supposed to fill — roughly 24px of leftover scroll, enough to bounce off
Android's overscroll stretch. `FitDay` resolves to the unrounded height and sits
still, so the two ways of reaching "the whole day on one screen" disagreed.
That inconsistency is what #290 is about.

Clamps to `fillPx` itself. The whole-pixel rounding exists so the hour gutter's
24 stacked boxes share a grid with the lines and blocks drawn at the fractional
height, and it still applies everywhere the pinch is free to move. The floor is
the one height where matching `FitDay` matters more — and because it is the
clamp *result* rather than a bound the gesture is merely held against, the pinch
lands on it exactly, so the next frame reads it back unchanged and the focal
anchor gets no correction to apply.

Week and Day both measure their viewport inside a `BoxWithConstraints` below the
all-day strip, so the strip appearing only changes the height both paths agree
on — that half of the report needed no change.

Tests: the two cases that encoded the old rounding are updated (the dead-space
invariant still holds, now exactly rather than by a pixel), plus one for the
reported symptom and one asserting the pinch floor equals what `FitDay`
resolves to at the same viewport.

Closes #290

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/315
2026-09-20 22:28:35 +02:00
5 changed files with 85 additions and 24 deletions
@@ -19,7 +19,6 @@ import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor import kotlin.math.floor
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -197,18 +196,26 @@ fun rememberTimelinePinchZoom(
* names, jumping the whole column as a pinch drifts across each half pixel. * names, jumping the whole column as a pinch drifts across each half pixel.
* Pinning the hour to whole pixels keeps every part of the timeline on one grid. * Pinning the hour to whole pixels keeps every part of the timeline on one grid.
* *
* The bounds themselves are pulled onto that grid too, each in the direction * The ceiling is pulled onto that grid too, downwards, so it stays a height the
* that keeps its own promise — up for the fill floor, so no dead space opens * pinch can actually land on.
* under midnight, down for the ceiling. A fractional bound would be a height the *
* pinch can be held against but never actually land on, and the difference feeds * [fillPx] is deliberately *not* rounded (#290). It is the one height the whole
* the focal anchor a scroll correction on every frame the fingers sit still. * day exactly fills the viewport at, and it is the same value
* [TimelineScale.FitDay] resolves to — rounding it up by the fraction of a pixel
* that 24 hours don't divide the viewport into leaves the timeline a pixel per
* hour taller than its own viewport, so a pinched-all-the-way-out day still
* scrolls a hair and bounces off Android's overscroll stretch, while the
* identical FitDay preset sits still. Being the clamp result rather than a bound
* the gesture is merely held against, it is a height the pinch does land on: the
* next frame reads it back unchanged and the focal anchor is handed nothing to
* correct.
*/ */
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float = internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
// Filling the viewport wins over the ceiling: on a screen tall enough for // Filling the viewport wins over the ceiling: on a screen tall enough for
// the two to disagree, dead space is the worse of the two failures. // the two to disagree, dead space is the worse of the two failures.
target.roundToInt().toFloat() target.roundToInt().toFloat()
.coerceAtMost(floor(maxPx)) .coerceAtMost(floor(maxPx))
.coerceAtLeast(ceil(fillPx)) .coerceAtLeast(fillPx)
/** /**
* The scroll offset that keeps the moment under [centroidY] under it after the * The scroll offset that keeps the moment under [centroidY] under it after the
@@ -123,6 +123,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -744,7 +745,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp), .padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
) { ) {
// Reserve the gutter so the weekday labels stay over their day columns. // Reserve the gutter so the weekday labels stay over their day columns.
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER)) if (showWeekNumbers) Spacer(Modifier.width(rememberWeekNumberGutter()))
days.forEach { dow -> days.forEach { dow ->
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1) val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
@@ -762,9 +763,34 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
private val EVENT_ROW_HEIGHT = 20.dp private val EVENT_ROW_HEIGHT = 20.dp
private val DAY_NUMBER_HEIGHT = 22.dp private val DAY_NUMBER_HEIGHT = 22.dp
/** Width of the optional left calendar-week gutter (#25); narrow, since it only /** Padding between the week-number pill's edge and the number inside it. */
* seats a one- or two-digit week number in a full-height tonal pill. */ private val WEEK_NUMBER_PADDING = 6.dp
private val WEEK_NUMBER_GUTTER = 40.dp
/** The widest week number an ISO year reaches; digits are tabular, so one
* measurement of it prices every week in the grid. */
private const val WEEK_NUMBER_SAMPLE = "53"
/**
* Width of the optional left calendar-week gutter (#25), measured rather than
* fixed: it is sized to the number it seats at the style the pill draws it in,
* so it follows the font scale instead of reserving slack for it, and spends
* nothing more on a column the grid would rather hand to the seven days (#213).
*/
@Composable
private fun rememberWeekNumberGutter(): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val style = weekNumberStyle()
return remember(style, density, measurer) {
val text = with(density) { measurer.measure(WEEK_NUMBER_SAMPLE, style).size.width.toDp() }
text + (WEEK_NUMBER_PADDING + CELL_GAP) * 2
}
}
/** The week number's own style — a step down from the day numbers beside it. */
@Composable
private fun weekNumberStyle() =
MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold)
private val DAY_NUMBER_GAP = 4.dp private val DAY_NUMBER_GAP = 4.dp
private val CELL_TOP_PADDING = 6.dp private val CELL_TOP_PADDING = 6.dp
/** Named separately because the split style's selection outline draws its own /** Named separately because the split style's selection outline draws its own
@@ -1486,7 +1512,7 @@ internal fun SplitMonthGrid(
WeekNumberGutter( WeekNumberGutter(
weekStart = week.days.first(), weekStart = week.days.first(),
modifier = Modifier modifier = Modifier
.width(WEEK_NUMBER_GUTTER) .width(rememberWeekNumberGutter())
.fillMaxHeight(), .fillMaxHeight(),
) )
} }
@@ -2007,7 +2033,7 @@ private fun MonthWeekRow(
WeekNumberGutter( WeekNumberGutter(
weekStart = week.days.first(), weekStart = week.days.first(),
modifier = Modifier modifier = Modifier
.width(WEEK_NUMBER_GUTTER) .width(rememberWeekNumberGutter())
.fillMaxHeight(), .fillMaxHeight(),
) )
} }
@@ -2422,8 +2448,7 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier
) { ) {
Text( Text(
text = weekNumber.toString(), text = weekNumber.toString(),
style = MaterialTheme.typography.titleSmall, style = weekNumberStyle(),
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer, color = MaterialTheme.colorScheme.onSecondaryContainer,
) )
} }
@@ -571,7 +571,7 @@ private fun WeekDayHeader(
} }
/** Calendar-week badge shown in the header gutter, deliberately set apart with a /** Calendar-week badge shown in the header gutter, deliberately set apart with a
* filled box and bold number. */ * filled box and bold number — at the month grid's size, so the two agree (#213). */
@Composable @Composable
private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) { private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
val label = stringResource(R.string.week_number_label) val label = stringResource(R.string.week_number_label)
@@ -583,9 +583,9 @@ private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
) { ) {
Text( Text(
text = weekNumber.toString(), text = weekNumber.toString(),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp),
) )
} }
} }
@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -75,17 +76,16 @@ class TimelineZoomTest {
} }
@Test @Test
fun `a pinch held against a fractional bound stays put`() { fun `a pinch held against either bound stays put`() {
// A bound that is not a whole pixel is a height the pinch can be pushed
// against but never land on, so every frame of a held gesture would look
// like a scale change and hand the focal anchor a scroll correction.
val fillPx = 62.083f val fillPx = 62.083f
val maxPx = 616.5f val maxPx = 616.5f
val floor = pinchedHourHeightPx(target = 1f, fillPx, maxPx) val floor = pinchedHourHeightPx(target = 1f, fillPx, maxPx)
val ceiling = pinchedHourHeightPx(target = 9_000f, fillPx, maxPx) val ceiling = pinchedHourHeightPx(target = 9_000f, fillPx, maxPx)
assertThat(floor).isEqualTo(63f) // The ceiling is pulled onto the pixel grid so it stays landable; the
// fill floor is landable as it is, being the clamp result itself.
assertThat(floor).isEqualTo(fillPx)
assertThat(ceiling).isEqualTo(616f) assertThat(ceiling).isEqualTo(616f)
// Landing there and being pushed further must not move them again. // Landing there and being pushed further must not move them again.
assertThat(pinchedHourHeightPx(floor * 0.9f, fillPx, maxPx)).isEqualTo(floor) assertThat(pinchedHourHeightPx(floor * 0.9f, fillPx, maxPx)).isEqualTo(floor)
@@ -100,6 +100,35 @@ class TimelineZoomTest {
assertThat(floor * 24).isAtLeast(viewport) assertThat(floor * 24).isAtLeast(viewport)
} }
@Test
fun `pinching all the way out leaves nothing to scroll`() {
// #290: rounding the fill floor up to a whole pixel made the day one
// pixel per hour taller than the viewport it was supposed to fill, so a
// fully zoomed-out timeline still scrolled a hair and bounced off
// Android's overscroll stretch -- while FitDay, at the same zoom, sat
// still. 1490 is deliberately not divisible by 24.
val viewport = 1490f
val floor = pinchedHourHeightPx(target = 1f, fillPx = viewport / 24f, maxPx = 616f)
assertThat(floor * 24).isWithin(0.01f).of(viewport)
}
@Test
fun `the pinch floor is the height FitDay resolves to`() {
// The inconsistency the issue is about: the two ways to reach "the whole
// day on one screen" have to arrive at the same height.
val density = Density(2.5f)
val viewport = 596.dp
with(density) {
val fitDay = TimelineScale.FitDay.hourHeight(viewport).toPx()
val pinched = pinchedHourHeightPx(
target = 1f,
fillPx = fillHourHeight(viewport).toPx(),
maxPx = MAX_PINCH_HOUR_HEIGHT.toPx(),
)
assertThat(pinched).isWithin(0.01f).of(fitDay)
}
}
@Test @Test
fun `a settled pinch is what gets persisted`() { fun `a settled pinch is what gets persisted`() {
var persisted: TimelineScale? = null var persisted: TimelineScale? = null
+1 -1
View File
@@ -22,7 +22,7 @@ kotlinxDatetime = "0.7.0"
kotlinxCoroutines = "1.11.0" kotlinxCoroutines = "1.11.0"
turbine = "1.2.1" turbine = "1.2.1"
hiltNavigationCompose = "1.4.0" hiltNavigationCompose = "1.4.0"
lifecycleCompose = "2.11.0" lifecycleCompose = "2.10.0"
androidxTestRules = "1.7.0" androidxTestRules = "1.7.0"
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha). # Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
glance = "1.1.1" glance = "1.1.1"