Keep 12-hour times whole in timeline blocks and let the range use the spare lines

This commit is contained in:
2026-09-30 19:34:23 +02:00
parent a3ee15bde7
commit 9588698874
4 changed files with 110 additions and 22 deletions
@@ -99,9 +99,6 @@ fun rememberBlockTextMetrics(height: Dp): BlockTextMetrics {
)
}
/** Most lines a time label may wrap over before it is worth more than a title line. */
const val MAX_TIME_LINES = 2
/**
* Lines [text] needs to render whole at [textWidth], capped at [max].
*
@@ -131,29 +128,65 @@ fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int
* Lines the time label may take at [textWidth], out of the [spare] height left
* once the title and the label's own first line are paid for.
*
* A week column is narrower than a "09:30–11:00" range, so the label takes a
* second line rather than lose its end — but only out of a line the title
* measured itself as not needing, never one it would have filled.
* A week column is narrower than a "09:30–11:00" range — and a 12-hour
* "6:00 PM–7:00 PM" can want four lines — so the label wraps over as many as it
* needs rather than lose its end, but only out of lines the title measured
* itself as not needing, never one it would have filled.
*/
@Composable
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
val timeLineHeight = rememberTrimmedLineHeight(
MaterialTheme.typography.labelSmall.asEventTime(),
)
return if (spare >= timeLineHeight) {
blockTextLines(
text = label,
// The style it is drawn in, or the budget measures a line the label
// never uses (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
textWidth = textWidth,
max = MAX_TIME_LINES,
)
} else {
1
return blockTextLines(
text = label,
// The style it is drawn in, or the budget measures a line the label
// never uses (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
textWidth = textWidth,
max = timeLinesWithin(spare, timeLineHeight),
)
}
/** Lines a time label may take: its own first one, plus every whole [timeLine] in [spare]. */
internal fun timeLinesWithin(spare: Dp, timeLine: Dp): Int =
if (spare < timeLine || timeLine <= 0.dp) 1 else 1 + (spare / timeLine).toInt()
/**
* The time range as a timed block draws it.
*
* Each time stays whole where the column is wide enough for one, so a 12-hour
* label never parts "6:00" from its "PM": the range breaks at the dash, which
* rides on whichever side has room for it. Where not even one time fits, the
* text wraps wherever it can.
*/
@Composable
fun rememberBlockTimeRange(start: String, end: String, textWidth: Dp): String {
val measurer = rememberTextMeasurer()
val style = MaterialTheme.typography.labelSmall.asEventTime()
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
return remember(start, end, style, widthPx, measurer) {
blockTimeRange(start, end) {
measurer.measure(text = it, style = style, softWrap = false).size.width <= widthPx
}
}
}
internal fun blockTimeRange(start: String, end: String, fits: (String) -> Boolean): String {
val from = start.replace(' ', NO_BREAK_SPACE)
val to = end.replace(' ', NO_BREAK_SPACE)
return when {
fits("$from–$to") -> "$from–$to"
!fits(from) || !fits(to) -> "$start–$ZERO_WIDTH_SPACE$end"
fits("$from–") -> "$from–\n$to"
fits("–$to") -> "$from\n–$to"
else -> "$from\n$to"
}
}
private const val NO_BREAK_SPACE = ' '
private const val ZERO_WIDTH_SPACE = '​'
/**
* A timed block's title, over at most [maxLines], breaking at word boundaries.
*
@@ -710,7 +710,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
val startMin = drag.eventStartMin.mod(MINUTES_PER_DAY)
val rawEnd = startMin + drag.eventSpanMin
val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd
val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}–" +
val range = formatMinuteOfDay(startMin, use24Hour, locale) to
formatMinuteOfDay(endMin, use24Hour, locale)
// The timeline's own bounds. The copy is drawn over the whole calendar
// so no column clip or rounded corner cuts it, but it still belongs to
@@ -764,7 +764,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
title = title,
titleLines = drag.titleLines,
// Once for the whole event: repeated, it would name it per day.
label = label.takeIf { index == labelled },
range = range.takeIf { index == labelled },
)
}
}
@@ -784,7 +784,7 @@ private fun DragCopy(
alpha: Float,
title: String,
titleLines: Int,
label: String?,
range: Pair<String, String>?,
) {
val density = LocalDensity.current
val width = with(density) { sizePx.width.toDp() }
@@ -814,6 +814,7 @@ private fun DragCopy(
)
}
val left = metrics.available - metrics.titleHeight(lines)
val label = range?.let { rememberBlockTimeRange(it.first, it.second, textWidth) }
val showTime = label != null && left >= metrics.timeLine
val timeMaxLines = if (showTime) {
blockTimeLines(label!!, textWidth, left - metrics.timeLine)
@@ -112,6 +112,7 @@ import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
import de.jeanlucmakiola.calendula.ui.common.rememberBlockTimeRange
import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
@@ -1436,6 +1437,11 @@ private fun EventBlock(
val showTime = block.laneCount == 1 &&
metrics.available >= metrics.titleLine + metrics.timeLine
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
val shownTime = rememberBlockTimeRange(
minToHm(block.startMin, use24Hour, locale),
minToHm(block.endMin, use24Hour, locale),
textWidth,
)
// A short block drops the title rather than serving a horizontally sliced
// one: half a letter reads as a rendering fault, while a bare colour chip
// reads as what it is — an event too brief to label. Tap still opens it, and
@@ -1461,7 +1467,7 @@ private fun EventBlock(
)
val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
if (showTime) metrics.timeLine else 0.dp
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
val timeMaxLines = if (showTime) blockTimeLines(shownTime, textWidth, spare) else 1
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
val zone = remember { TimeZone.currentSystemDefault() }
@@ -1520,7 +1526,7 @@ private fun EventBlock(
}
if (showTime) {
BlockTimeLabel(
label = timeLabel,
label = shownTime,
color = paint.secondaryInk,
maxLines = timeMaxLines,
)
@@ -0,0 +1,48 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class BlockTimeRangeTest {
/** Fits whatever is at most [chars] characters wide. */
private fun upTo(chars: Int): (String) -> Boolean = { it.length <= chars }
@Test
fun `a range that fits stays on one line`() {
assertThat(blockTimeRange("6:00 PM", "7:00 PM", upTo(20)))
.isEqualTo("6:00\u00A0PM–7:00\u00A0PM")
}
@Test
fun `a narrow column hangs the dash off the start time`() {
assertThat(blockTimeRange("6:00 PM", "7:00 PM", upTo(8)))
.isEqualTo("6:00\u00A0PM–\n7:00\u00A0PM")
}
@Test
fun `the dash leads the end time when only that side has room`() {
assertThat(blockTimeRange("10:00 PM", "7:00 PM", upTo(8)))
.isEqualTo("10:00\u00A0PM\n–7:00\u00A0PM")
}
@Test
fun `with room for neither dash the times just stack`() {
assertThat(blockTimeRange("6:00 PM", "7:00 PM", upTo(7)))
.isEqualTo("6:00\u00A0PM\n7:00\u00A0PM")
}
@Test
fun `a time too wide for the column keeps its space to wrap at`() {
assertThat(blockTimeRange("6:00 PM", "7:00 PM", upTo(5)))
.isEqualTo("6:00 PM–\u200B7:00 PM")
}
@Test
fun `the label takes every whole line the spare height holds`() {
assertThat(timeLinesWithin(spare = 0.dp, timeLine = 13.dp)).isEqualTo(1)
assertThat(timeLinesWithin(spare = 13.dp, timeLine = 13.dp)).isEqualTo(2)
assertThat(timeLinesWithin(spare = 40.dp, timeLine = 13.dp)).isEqualTo(4)
}
}