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..4f47957 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 @@ -17,6 +17,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp @@ -32,6 +33,93 @@ 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 +/** + * Block text with Material's outer leading trimmed off. The label roles wrap a + * 12sp glyph in a 16sp line box, and on a block short enough to be at risk that + * leading is the difference between a title and a bare colour chip (#289). + * Outer edges only, so a wrapped title keeps its interior line spacing. + */ +private val BlockLineHeight = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both, +) + +/** Ascenders and descenders both, so a line is measured at its full extent. */ +private const val LINE_SAMPLE = "Ag" + +/** [this] as a timed block draws it — see [BlockLineHeight]. */ +fun TextStyle.asBlockText(): TextStyle = copy(lineHeightStyle = BlockLineHeight) + +/** What one trimmed line of [style] actually draws in. */ +@Composable +fun rememberBlockLineHeight(style: TextStyle): Dp { + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + return remember(style, density, measurer) { + with(density) { measurer.measure(LINE_SAMPLE, style.asBlockText()).size.height.toDp() } + } +} + +/** + * 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: a whole line box, the trim + * reaching only the outer edges. */ + 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 = rememberBlockLineHeight(titleStyle) + val titleLeading = with(LocalDensity.current) { titleStyle.lineHeight.toDp() } + val timeLine = rememberBlockLineHeight(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 +158,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 = rememberBlockLineHeight( + MaterialTheme.typography.labelSmall.asEventTime(), + ) return if (spare >= timeLineHeight) { blockTextLines( text = label, @@ -109,7 +197,8 @@ fun BlockTitle( text = title, modifier = modifier, style = MaterialTheme.typography.labelMedium - .let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) }, + .let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) } + .asBlockText(), maxLines = maxLines, overflow = overflow.overflow, softWrap = overflow.softWrap, @@ -150,7 +239,7 @@ fun BlockTimeLabel( Text( text = text, // Regular weight against the title's medium above it (#219). - style = MaterialTheme.typography.labelSmall.asEventTime(), + style = MaterialTheme.typography.labelSmall.asEventTime().asBlockText(), 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 ba70d12..f2e25a4 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 @@ -763,30 +763,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() } @@ -827,7 +821,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 7c55ce1..3bc6791 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 @@ -909,13 +909,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 @@ -923,19 +917,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 @@ -948,8 +942,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) @@ -991,7 +985,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) + } + } +}