From 2dc22dc4807f607f35e5b27786780701ac8f51ee Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 23 Sep 2026 17:14:29 +0200 Subject: [PATCH] Show the title on short week and day blocks (#317) --- .../calendula/ui/common/BlockPlacement.kt | 79 +++++++++++++++-- .../calendula/ui/common/TimelineDrag.kt | 23 ++--- .../calendula/ui/day/DayScreen.kt | 22 ++--- .../calendula/ui/week/WeekScreen.kt | 24 ++--- .../ui/common/BlockTextMetricsTest.kt | 88 +++++++++++++++++++ 5 files changed, 186 insertions(+), 50 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/BlockTextMetricsTest.kt 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 5605e13..4b1fcf7 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 @@ -32,6 +32,68 @@ val BLOCK_TEXT_PADDING = 4.dp /** The same, above and below — what a block's height has to pay before any text. */ val BLOCK_TEXT_INSET = 2.dp +/** + * What a timed block of a given height has to spend on text, and what each line + * of it costs. + */ +@Immutable +data class BlockTextMetrics( + /** Padding above and below the text — see [rememberBlockTextMetrics]. */ + val inset: Dp, + /** Height left for text once [inset] is paid at both edges. */ + val available: Dp, + /** What the first line of a title draws in. */ + val titleLine: Dp, + /** What every title line after the first adds. */ + val titleLeading: Dp, + /** What the time label's one line draws in. */ + val timeLine: Dp, +) { + /** Whether the block can draw a title at all. */ + val fitsTitle: Boolean get() = available >= titleLine + + /** Height a title of [lines] lines occupies. */ + fun titleHeight(lines: Int): Dp = + if (lines <= 0) 0.dp else titleLine + titleLeading * (lines - 1) + + /** Title lines that fit [within], which may be none. */ + fun titleBudget(within: Dp): Int = + if (within < titleLine) 0 else 1 + ((within - titleLine) / titleLeading).toInt() +} + +/** + * The vertical padding a block [height] tall can afford around a title line of + * [titleLine]. + * + * The inset is what the block gives up first: breathing room is worth having + * where there is room to breathe, but on a block down to its last few pixels a + * bare colour chip where a label would have fit reads as a rendering fault. It + * tapers rather than snapping, so a pinch closes the gap gradually instead of + * dropping it in one frame (#289). + */ +internal fun blockTextInset(height: Dp, titleLine: Dp): Dp = + minOf(BLOCK_TEXT_INSET, (height - titleLine) / 2).coerceAtLeast(0.dp) + +/** Text metrics for a timed block [height] tall. */ +@Composable +fun rememberBlockTextMetrics(height: Dp): BlockTextMetrics { + val titleStyle = MaterialTheme.typography.labelMedium + val titleLine = rememberTrimmedLineHeight(titleStyle) + // Packed, so a second line costs what the first did rather than a whole + // Material line box — the gap between two lines of a wrapped title is the + // one place a block pays that leading twice (#190). + val titleLeading = titleLine + val timeLine = rememberTrimmedLineHeight(MaterialTheme.typography.labelSmall.asEventTime()) + val inset = blockTextInset(height, titleLine) + return BlockTextMetrics( + inset = inset, + available = height - inset * 2, + titleLine = titleLine, + titleLeading = titleLeading, + timeLine = timeLine, + ) +} + /** Most lines a time label may wrap over before it is worth more than a title line. */ const val MAX_TIME_LINES = 2 @@ -70,9 +132,9 @@ fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int */ @Composable fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int { - val timeLineHeight = with(LocalDensity.current) { - MaterialTheme.typography.labelSmall.lineHeight.toDp() - } + val timeLineHeight = rememberTrimmedLineHeight( + MaterialTheme.typography.labelSmall.asEventTime(), + ) return if (spare >= timeLineHeight) { blockTextLines( text = label, @@ -108,8 +170,10 @@ fun BlockTitle( Text( text = title, modifier = modifier, - style = MaterialTheme.typography.labelMedium - .let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) }, + style = rememberPackedLines( + MaterialTheme.typography.labelMedium + .let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) }, + ), maxLines = maxLines, overflow = overflow.overflow, softWrap = overflow.softWrap, @@ -141,6 +205,8 @@ fun BlockTimeLabel( MaterialTheme.motionScheme.fastEffectsSpec() } val overflow = eventTitleOverflow(singleLine = maxLines == 1) + // Regular weight against the title's medium above it (#219). + val style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime()) Crossfade( targetState = label, animationSpec = spec, @@ -149,8 +215,7 @@ fun BlockTimeLabel( ) { text -> Text( text = text, - // Regular weight against the title's medium above it (#219). - style = MaterialTheme.typography.labelSmall.asEventTime(), + style = style, maxLines = maxLines, overflow = overflow.overflow, softWrap = overflow.softWrap, 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 e5ac2f2..5ee2863 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 @@ -790,20 +790,15 @@ private fun DragCopy( val width = with(density) { sizePx.width.toDp() } val height = with(density) { sizePx.height.toDp() } val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2 - val titleLineHeight = with(density) { - MaterialTheme.typography.labelMedium.lineHeight.toDp() - } - val timeLineHeight = with(density) { - MaterialTheme.typography.labelSmall.lineHeight.toDp() - } // The block's size, spent the block's way — text sits at the top as it does - // on the block, so the copy hands back to the grid without shifting (#267). + // on the block, so the copy hands back to the grid without shifting (#267), + // and it squeezes its inset on the same terms so a short block's title does + // not vanish the moment it is lifted (#289). // The title is served in full first and the range lives off what is left: // the hour gutter down the side still says where the copy sits, so the // range is the half that can afford to go. - val available = height - BLOCK_TEXT_INSET * 2 - val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(0) - val allowed = titleLines.coerceAtMost(titleBudget) + val metrics = rememberBlockTextMetrics(height) + val allowed = titleLines.coerceAtMost(metrics.titleBudget(metrics.available)) // Re-measured at the copy's own width rather than spent on the source's // count: a block sharing its column with another is a lane wide where the // copy is a whole column, so the source's second line is one the copy never @@ -818,10 +813,10 @@ private fun DragCopy( max = allowed, ) } - val left = available - titleLineHeight * lines - val showTime = label != null && left >= timeLineHeight + val left = metrics.available - metrics.titleHeight(lines) + val showTime = label != null && left >= metrics.timeLine val timeMaxLines = if (showTime) { - blockTimeLines(label!!, textWidth, left - timeLineHeight) + blockTimeLines(label!!, textWidth, left - metrics.timeLine) } else { 1 } @@ -857,7 +852,7 @@ private fun DragCopy( clip = false } .eventSurface(paint, shape, cuts) - .padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET), + .padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset), ) { Column { if (lines > 0) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 1639b0d..49d6678 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -83,9 +83,9 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING -import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel import de.jeanlucmakiola.calendula.ui.common.BlockTitle +import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics import de.jeanlucmakiola.calendula.ui.common.blockTimeLines import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement import de.jeanlucmakiola.calendula.ui.common.ghostAlpha @@ -765,30 +765,24 @@ private fun EventBlock( val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" + minToHm(block.endMin, use24Hour, locale) val density = LocalDensity.current - val titleLineHeight = with(density) { - MaterialTheme.typography.labelMedium.lineHeight.toDp() - } - val timeLineHeight = with(density) { - MaterialTheme.typography.labelSmall.lineHeight.toDp() - } + val metrics = rememberBlockTextMetrics(height) // A block that cannot afford both lines spends its space on the title, and // one too short even for that drops the title rather than serving a sliced one. // Height alone decides: a duration threshold would keep hiding the time on a // half-hour block the user has pinched open to three times the room it needs. - val available = height - BLOCK_TEXT_INSET * 2 - val showTime = available >= titleLineHeight + timeLineHeight - val showTitle = available >= titleLineHeight + val showTime = metrics.available >= metrics.titleLine + metrics.timeLine + val showTitle = metrics.fitsTitle val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2 // Only lines the block can actually draw: a block too short for the time is // too short for a second title line too, and asking for one served a sliced // one — as well as handing the drag copy a count it couldn't honour, so the // title re-wrapped the moment the block was lifted (#267). - val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(1) + val titleBudget = metrics.titleBudget(metrics.available).coerceAtLeast(1) val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2) // On a day column — wide enough for "09:30–11:00" several times over — the // range never needs the second line, until lanes cut the column down. - val spare = available - titleLineHeight * titleMaxLines - - if (showTime) timeLineHeight else 0.dp + 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 paint = eventPaint(block.event, dark) val zone = remember { TimeZone.currentSystemDefault() } @@ -829,7 +823,7 @@ private fun EventBlock( // After clickable, so it is the inner node and wins the main pass; // the tap still works, since a drag consumes the up. .then(dragModifier) - .padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET) + .padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset) .semantics { contentDescription = "$title, $timeLabel" if (moveAction != null) customActions = listOf(moveAction) 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 7380709..17f1ed6 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 @@ -91,11 +91,11 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING -import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET 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.rememberBlockTextMetrics import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.LocalEventMove @@ -911,13 +911,7 @@ private fun EventBlock( val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" + minToHm(block.endMin, use24Hour, locale) val density = LocalDensity.current - val titleLineHeight = with(density) { - MaterialTheme.typography.labelMedium.lineHeight.toDp() - } - val timeLineHeight = with(density) { - MaterialTheme.typography.labelSmall.lineHeight.toDp() - } - val available = height - BLOCK_TEXT_INSET * 2 + val metrics = rememberBlockTextMetrics(height) // Only full-width (non-overlapping) blocks that are tall enough show the // time. On narrow overlapping columns we drop it so the title can wrap to // fill the whole block, mirroring Google Calendar — and a block that cannot @@ -925,19 +919,19 @@ private fun EventBlock( // its own: a duration threshold would keep hiding the time on a half-hour // block the user has pinched open to three times the room it needs. val showTime = block.laneCount == 1 && - available >= titleLineHeight + timeLineHeight + metrics.available >= metrics.titleLine + metrics.timeLine val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2 // 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 // the semantics description carries the full title either way. - val showTitle = available >= titleLineHeight + val showTitle = metrics.fitsTitle // The title is served first, out of everything the block has left once the // time is down to one line — but only takes the lines it will actually use, // and only wraps at all once a line is wide enough to hold more than a // syllable. Below that the extra lines just stack fragments of the word. - val contentHeight = available - if (showTime) timeLineHeight else 0.dp - val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1) + val contentHeight = metrics.available - if (showTime) metrics.timeLine else 0.dp + val titleBudget = metrics.titleBudget(contentHeight).coerceAtLeast(1) val paint = eventPaint(block.event, dark) // Every line the height affords, however narrow the lane: two events side by // side leave columns well under a word wide, and cutting the title to one @@ -950,8 +944,8 @@ private fun EventBlock( textWidth = textWidth, max = titleBudget, ) - val spare = available - titleLineHeight * titleMaxLines - - if (showTime) timeLineHeight else 0.dp + 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 dimCutoff = LocalDimCutoff.current val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff) @@ -993,7 +987,7 @@ private fun EventBlock( // After clickable, so it is the inner node and wins the main pass; // the tap still works, since a drag consumes the up. .then(dragModifier) - .padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET) + .padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset) .semantics { contentDescription = "$title, $timeLabel" if (moveAction != null) customActions = listOf(moveAction) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/BlockTextMetricsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/BlockTextMetricsTest.kt new file mode 100644 index 0000000..8516b28 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/BlockTextMetricsTest.kt @@ -0,0 +1,88 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class BlockTextMetricsTest { + + /** One trimmed labelMedium line at font scale 1: a 12sp glyph, leading off. */ + private val titleLine = 14.dp + + /** A block with room to spare, for the line arithmetic. */ + private fun metrics(height: Dp = 100.dp) = BlockTextMetrics( + inset = blockTextInset(height, titleLine), + available = height - blockTextInset(height, titleLine) * 2, + titleLine = titleLine, + titleLeading = 16.dp, + timeLine = 13.dp, + ) + + @Test + fun `a block with room keeps the full inset`() { + assertThat(blockTextInset(height = 60.dp, titleLine = titleLine)) + .isEqualTo(BLOCK_TEXT_INSET) + } + + @Test + fun `the inset tapers instead of snapping as the block shrinks`() { + // Half the inset left over is half the inset kept, so a pinch closes the + // gap frame by frame rather than dropping it in one. + assertThat(blockTextInset(height = titleLine + 2.dp, titleLine = titleLine)) + .isEqualTo(1.dp) + } + + @Test + fun `a block exactly one title tall spends nothing on padding`() { + assertThat(blockTextInset(height = titleLine, titleLine = titleLine)).isEqualTo(0.dp) + } + + @Test + fun `a block shorter than a line never insets negatively`() { + assertThat(blockTextInset(height = 4.dp, titleLine = titleLine)).isEqualTo(0.dp) + } + + @Test + fun `the title survives a block that used to be too short for it`() { + // 18dp is under the old floor — a title line plus 2dp of inset at each + // edge — and over the new one, which is the line on its own (#289). + val m = metrics(height = 18.dp) + assertThat(m.fitsTitle).isTrue() + } + + @Test + fun `a block under one line still drops the title`() { + assertThat(metrics(height = 10.dp).fitsTitle).isFalse() + } + + @Test + fun `every title line after the first costs a whole line box`() { + // The trim reaches the outer edges only, so the leading between two + // lines is still there to pay for. + val m = metrics() + assertThat(m.titleHeight(1)).isEqualTo(14.dp) + assertThat(m.titleHeight(2)).isEqualTo(30.dp) + assertThat(m.titleHeight(3)).isEqualTo(46.dp) + assertThat(m.titleHeight(0)).isEqualTo(0.dp) + } + + @Test + fun `the budget is what the block can actually draw, not what divides into it`() { + val m = metrics() + // Two lines cost 30dp: 29 buys one, 30 buys the second. + assertThat(m.titleBudget(29.dp)).isEqualTo(1) + assertThat(m.titleBudget(30.dp)).isEqualTo(2) + assertThat(m.titleBudget(13.dp)).isEqualTo(0) + } + + @Test + fun `a budget line is always one the block can pay for`() { + val m = metrics() + (0..80).forEach { dp -> + val within = dp.dp + val budget = m.titleBudget(within) + assertThat(m.titleHeight(budget)).isAtMost(within) + } + } +}