Keep 12-hour times whole in timeline blocks and let the range use the spare lines
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user