diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt index d3b4566..923726a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/BlockPlacement.kt @@ -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. * diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index b3ff572..0f2fae4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -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?, ) { 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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index d717394..007fd06 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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, ) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/BlockTimeRangeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/BlockTimeRangeTest.kt new file mode 100644 index 0000000..883c467 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/BlockTimeRangeTest.kt @@ -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) + } +}