Compare commits

...
Author SHA1 Message Date
makiolaj 484d297d13 Show the title on short week and day blocks (#289) 2026-09-20 22:52:25 +02:00
5 changed files with 208 additions and 48 deletions
@@ -17,6 +17,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp 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. */ /** The same, above and below — what a block's height has to pay before any text. */
val BLOCK_TEXT_INSET = 2.dp 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. */ /** Most lines a time label may wrap over before it is worth more than a title line. */
const val MAX_TIME_LINES = 2 const val MAX_TIME_LINES = 2
@@ -70,9 +158,9 @@ fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int
*/ */
@Composable @Composable
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int { fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
val timeLineHeight = with(LocalDensity.current) { val timeLineHeight = rememberBlockLineHeight(
MaterialTheme.typography.labelSmall.lineHeight.toDp() MaterialTheme.typography.labelSmall.asEventTime(),
} )
return if (spare >= timeLineHeight) { return if (spare >= timeLineHeight) {
blockTextLines( blockTextLines(
text = label, text = label,
@@ -109,7 +197,8 @@ fun BlockTitle(
text = title, text = title,
modifier = modifier, modifier = modifier,
style = MaterialTheme.typography.labelMedium 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, maxLines = maxLines,
overflow = overflow.overflow, overflow = overflow.overflow,
softWrap = overflow.softWrap, softWrap = overflow.softWrap,
@@ -150,7 +239,7 @@ fun BlockTimeLabel(
Text( Text(
text = text, text = text,
// Regular weight against the title's medium above it (#219). // Regular weight against the title's medium above it (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(), style = MaterialTheme.typography.labelSmall.asEventTime().asBlockText(),
maxLines = maxLines, maxLines = maxLines,
overflow = overflow.overflow, overflow = overflow.overflow,
softWrap = overflow.softWrap, softWrap = overflow.softWrap,
@@ -790,20 +790,15 @@ private fun DragCopy(
val width = with(density) { sizePx.width.toDp() } val width = with(density) { sizePx.width.toDp() }
val height = with(density) { sizePx.height.toDp() } val height = with(density) { sizePx.height.toDp() }
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2 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 // 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 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 // the hour gutter down the side still says where the copy sits, so the
// range is the half that can afford to go. // range is the half that can afford to go.
val available = height - BLOCK_TEXT_INSET * 2 val metrics = rememberBlockTextMetrics(height)
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(0) val allowed = titleLines.coerceAtMost(metrics.titleBudget(metrics.available))
val allowed = titleLines.coerceAtMost(titleBudget)
// Re-measured at the copy's own width rather than spent on the source's // 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 // 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 // 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, max = allowed,
) )
} }
val left = available - titleLineHeight * lines val left = metrics.available - metrics.titleHeight(lines)
val showTime = label != null && left >= timeLineHeight val showTime = label != null && left >= metrics.timeLine
val timeMaxLines = if (showTime) { val timeMaxLines = if (showTime) {
blockTimeLines(label!!, textWidth, left - timeLineHeight) blockTimeLines(label!!, textWidth, left - metrics.timeLine)
} else { } else {
1 1
} }
@@ -857,7 +852,7 @@ private fun DragCopy(
clip = false clip = false
} }
.eventSurface(paint, shape, cuts) .eventSurface(paint, shape, cuts)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET), .padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset),
) { ) {
Column { Column {
if (lines > 0) { if (lines > 0) {
@@ -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.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET 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_PADDING
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel 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.rememberBlockTextMetrics
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
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
@@ -763,30 +763,24 @@ private fun EventBlock(
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" + val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, use24Hour, locale) minToHm(block.endMin, use24Hour, locale)
val density = LocalDensity.current val density = LocalDensity.current
val titleLineHeight = with(density) { val metrics = rememberBlockTextMetrics(height)
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// A block that cannot afford both lines spends its space on the title, and // 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. // 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 // 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. // half-hour block the user has pinched open to three times the room it needs.
val available = height - BLOCK_TEXT_INSET * 2 val showTime = metrics.available >= metrics.titleLine + metrics.timeLine
val showTime = available >= titleLineHeight + timeLineHeight val showTitle = metrics.fitsTitle
val showTitle = available >= titleLineHeight
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2 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 // 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 // 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 // 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). // 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) val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2)
// On a day column — wide enough for "09:3011:00" several times over — the // On a day column — wide enough for "09:3011:00" several times over — the
// range never needs the second line, until lanes cut the column down. // range never needs the second line, until lanes cut the column down.
val spare = available - titleLineHeight * titleMaxLines - val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
if (showTime) timeLineHeight 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(timeLabel, textWidth, spare) else 1
val paint = eventPaint(block.event, dark) val paint = eventPaint(block.event, dark)
val zone = remember { TimeZone.currentSystemDefault() } 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; // After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up. // the tap still works, since a drag consumes the up.
.then(dragModifier) .then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET) .padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
.semantics { .semantics {
contentDescription = "$title, $timeLabel" contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction) if (moveAction != null) customActions = listOf(moveAction)
@@ -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.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET 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_PADDING
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel 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.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
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
@@ -909,13 +909,7 @@ private fun EventBlock(
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" + val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}" +
minToHm(block.endMin, use24Hour, locale) minToHm(block.endMin, use24Hour, locale)
val density = LocalDensity.current val density = LocalDensity.current
val titleLineHeight = with(density) { val metrics = rememberBlockTextMetrics(height)
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
val available = height - BLOCK_TEXT_INSET * 2
// Only full-width (non-overlapping) blocks that are tall enough show the // 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 // 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 // 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 // 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. // block the user has pinched open to three times the room it needs.
val showTime = block.laneCount == 1 && val showTime = block.laneCount == 1 &&
available >= titleLineHeight + timeLineHeight 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
// 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
// the semantics description carries the full title either way. // 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 // 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, // 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 // 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. // syllable. Below that the extra lines just stack fragments of the word.
val contentHeight = available - if (showTime) timeLineHeight else 0.dp val contentHeight = metrics.available - if (showTime) metrics.timeLine else 0.dp
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1) val titleBudget = metrics.titleBudget(contentHeight).coerceAtLeast(1)
val paint = eventPaint(block.event, dark) val paint = eventPaint(block.event, dark)
// Every line the height affords, however narrow the lane: two events side by // 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 // side leave columns well under a word wide, and cutting the title to one
@@ -948,8 +942,8 @@ private fun EventBlock(
textWidth = textWidth, textWidth = textWidth,
max = titleBudget, max = titleBudget,
) )
val spare = available - titleLineHeight * titleMaxLines - val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
if (showTime) timeLineHeight 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(timeLabel, 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)
@@ -991,7 +985,7 @@ private fun EventBlock(
// After clickable, so it is the inner node and wins the main pass; // After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up. // the tap still works, since a drag consumes the up.
.then(dragModifier) .then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET) .padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
.semantics { .semantics {
contentDescription = "$title, $timeLabel" contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction) if (moveAction != null) customActions = listOf(moveAction)
@@ -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)
}
}
}