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].
|
* 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
|
* 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.
|
* 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
|
* A week column is narrower than a "09:30–11:00" range — and a 12-hour
|
||||||
* second line rather than lose its end — but only out of a line the title
|
* "6:00 PM–7:00 PM" can want four lines — so the label wraps over as many as it
|
||||||
* measured itself as not needing, never one it would have filled.
|
* 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
|
@Composable
|
||||||
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
|
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
|
||||||
val timeLineHeight = rememberTrimmedLineHeight(
|
val timeLineHeight = rememberTrimmedLineHeight(
|
||||||
MaterialTheme.typography.labelSmall.asEventTime(),
|
MaterialTheme.typography.labelSmall.asEventTime(),
|
||||||
)
|
)
|
||||||
return if (spare >= timeLineHeight) {
|
return blockTextLines(
|
||||||
blockTextLines(
|
text = label,
|
||||||
text = label,
|
// The style it is drawn in, or the budget measures a line the label
|
||||||
// The style it is drawn in, or the budget measures a line the label
|
// never uses (#219).
|
||||||
// never uses (#219).
|
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||||
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
textWidth = textWidth,
|
||||||
textWidth = textWidth,
|
max = timeLinesWithin(spare, timeLineHeight),
|
||||||
max = MAX_TIME_LINES,
|
)
|
||||||
)
|
}
|
||||||
} else {
|
|
||||||
1
|
/** 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.
|
* 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 startMin = drag.eventStartMin.mod(MINUTES_PER_DAY)
|
||||||
val rawEnd = startMin + drag.eventSpanMin
|
val rawEnd = startMin + drag.eventSpanMin
|
||||||
val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd
|
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)
|
formatMinuteOfDay(endMin, use24Hour, locale)
|
||||||
// The timeline's own bounds. The copy is drawn over the whole calendar
|
// 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
|
// 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,
|
title = title,
|
||||||
titleLines = drag.titleLines,
|
titleLines = drag.titleLines,
|
||||||
// Once for the whole event: repeated, it would name it per day.
|
// 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,
|
alpha: Float,
|
||||||
title: String,
|
title: String,
|
||||||
titleLines: Int,
|
titleLines: Int,
|
||||||
label: String?,
|
range: Pair<String, String>?,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val width = with(density) { sizePx.width.toDp() }
|
val width = with(density) { sizePx.width.toDp() }
|
||||||
@@ -814,6 +814,7 @@ private fun DragCopy(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
val left = metrics.available - metrics.titleHeight(lines)
|
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 showTime = label != null && left >= metrics.timeLine
|
||||||
val timeMaxLines = if (showTime) {
|
val timeMaxLines = if (showTime) {
|
||||||
blockTimeLines(label!!, textWidth, left - metrics.timeLine)
|
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.BlockTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
|
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
|
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.rememberBlockTextMetrics
|
||||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||||
@@ -1436,6 +1437,11 @@ private fun EventBlock(
|
|||||||
val showTime = block.laneCount == 1 &&
|
val showTime = block.laneCount == 1 &&
|
||||||
metrics.available >= metrics.titleLine + metrics.timeLine
|
metrics.available >= metrics.titleLine + metrics.timeLine
|
||||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
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
|
// 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
|
// 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
|
// 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) -
|
val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
|
||||||
if (showTime) metrics.timeLine else 0.dp
|
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 dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
@@ -1520,7 +1526,7 @@ private fun EventBlock(
|
|||||||
}
|
}
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
BlockTimeLabel(
|
BlockTimeLabel(
|
||||||
label = timeLabel,
|
label = shownTime,
|
||||||
color = paint.secondaryInk,
|
color = paint.secondaryInk,
|
||||||
maxLines = timeMaxLines,
|
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