Wrap block titles at word boundaries (#267)

Two events side by side leave a column well under a word wide, and the
36dp floor forced any block below it onto one clipped line however much
height it had — so the narrower the lane, the more of the title was lost.
The floor is gone; a title now takes every line its block affords.

Titles also wrap whole words again. #164 clipped the last line mid-glyph
so a narrow chip spent none of its few characters on an ellipsis, which
on a block with room to wrap reads worse than the ellipsis does:
"Farmers Market" over two lines beats "Farmer" / "s Marke".
This commit is contained in:
2026-09-07 14:50:40 +02:00
parent 16120a7a70
commit 775ba400a9
5 changed files with 28 additions and 102 deletions
@@ -4,7 +4,6 @@ import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.snap import androidx.compose.animation.core.snap
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -15,14 +14,11 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
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.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
@@ -92,88 +88,34 @@ fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
} }
/** /**
* A timed block's title, over at most [maxLines]. * A timed block's title, over at most [maxLines], breaking at word boundaries.
* *
* Wrapping and clipping pull against each other, which is why #164 left the * #164 clipped the last line mid-glyph so none of a narrow chip's few
* ellipsis on multi-line chips: with `softWrap` on, the last visible line ends * characters went on an ellipsis. On a block that can wrap, whole words read
* at a word boundary, so "Farmers Market" in a six-character column would clip * better than full lines do: "Farmers Market" over two lines beats "Farmer" /
* to "Farmer" / "s" where the ellipsis at least reached "s Mar…". * "s Marke". A single line still clips, having nowhere to wrap to.
*
* So the block wraps every line but the last through one `Text` and hands the
* remainder to a second that clips mid-glyph the way a single-line chip does.
* Every line is then full and none of them spends two of its few characters on
* a "…". RTL keeps the ellipsis for the reason [eventTitleOverflowFor] gives.
*/ */
@Composable @Composable
fun BlockTitle( fun BlockTitle(
title: String, title: String,
maxLines: Int, maxLines: Int,
textWidth: Dp,
color: Color, color: Color,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
textDecoration: TextDecoration? = null, textDecoration: TextDecoration? = null,
fontWeight: FontWeight? = null, fontWeight: FontWeight? = null,
) { ) {
// Folded into the style rather than passed to the Text, so the wrap measured val overflow = eventTitleOverflow(singleLine = maxLines == 1)
// below is the wrap that gets drawn. Text(
val style = MaterialTheme.typography.labelMedium text = title,
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) } modifier = modifier,
val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl style = MaterialTheme.typography.labelMedium
val measurer = rememberTextMeasurer() .let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() } maxLines = maxLines,
// Where the wrapped lines stop and the clipped tail starts — null when the overflow = overflow.overflow,
// title fits, and nothing needs splitting. softWrap = overflow.softWrap,
val headEnd = remember(title, style, widthPx, maxLines, rtl, measurer) { color = color,
if (rtl || maxLines < 2 || widthPx <= 0) { textDecoration = textDecoration,
null )
} else {
val layout = measurer.measure(
text = title,
style = style,
constraints = Constraints(maxWidth = widthPx),
)
if (layout.lineCount <= maxLines) {
null
} else {
layout.getLineEnd(maxLines - 2, visibleEnd = true)
}
}
}
if (headEnd == null) {
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
Text(
text = title,
modifier = modifier,
style = style,
maxLines = maxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
color = color,
textDecoration = textDecoration,
)
} else {
val tail = eventTitleOverflow(singleLine = true)
Column(modifier = modifier) {
Text(
text = title.substring(0, headEnd),
style = style,
maxLines = maxLines - 1,
overflow = TextOverflow.Clip,
softWrap = true,
color = color,
textDecoration = textDecoration,
)
Text(
text = title.substring(headEnd).trimStart(),
style = style,
maxLines = 1,
overflow = tail.overflow,
softWrap = tail.softWrap,
color = color,
textDecoration = textDecoration,
)
}
}
} }
/** /**
@@ -814,7 +814,6 @@ private fun DragCopy(
BlockTitle( BlockTitle(
title = title, title = title,
maxLines = lines, maxLines = lines,
textWidth = textWidth,
color = paint.titleInk, color = paint.titleInk,
textDecoration = paint.decoration, textDecoration = paint.decoration,
fontWeight = paint.titleWeight, fontWeight = paint.titleWeight,
@@ -96,17 +96,6 @@ fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
*/ */
const val MIN_EVENT_FRACTION = 26f / 60f const val MIN_EVENT_FRACTION = 26f / 60f
/**
* Narrowest an event block may be and still wrap its title over several lines.
*
* Wrapping is driven by the block's height, so a tall block on a lane-split
* column would otherwise stack two or three characters per line — "Da/ily",
* "Fa/rmer/s…" — which reads worse than one ellipsised line. A full week column
* clears this on any phone; a split one never does, while the day view's much
* wider columns keep wrapping even several lanes deep.
*/
val MIN_TITLE_WRAP_WIDTH = 36.dp
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */ /** Smallest hour height [TimelineScale.FitDay] will resolve to. */
val FIT_DAY_MIN = 24.dp val FIT_DAY_MIN = 24.dp
@@ -800,7 +800,6 @@ private fun EventBlock(
BlockTitle( BlockTitle(
title = title, title = title,
maxLines = titleMaxLines, maxLines = titleMaxLines,
textWidth = textWidth,
color = paint.titleInk, color = paint.titleInk,
textDecoration = paint.decoration, textDecoration = paint.decoration,
fontWeight = paint.titleWeight, fontWeight = paint.titleWeight,
@@ -130,7 +130,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH
import de.jeanlucmakiola.calendula.ui.common.hourHeight import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
@@ -905,18 +904,17 @@ private fun EventBlock(
val contentHeight = available - if (showTime) timeLineHeight else 0.dp val contentHeight = available - if (showTime) timeLineHeight else 0.dp
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1) val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
val paint = eventPaint(block.event, dark) val paint = eventPaint(block.event, dark)
val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) { // Every line the height affords, however narrow the lane: two events side by
1 // side leave columns well under a word wide, and cutting the title to one
} else { // line there lost it outright where the block had the room to wrap it.
blockTextLines( val titleMaxLines = blockTextLines(
text = title, text = title,
// At the weight BlockTitle will set it in, or an invited block — // At the weight BlockTitle will set it in, or an invited block — drawn a
// drawn a weight lighter — is budgeted a line it never fills (#230). // weight lighter — is budgeted a line it never fills (#230).
style = MaterialTheme.typography.labelMedium.withTitleWeight(paint), style = MaterialTheme.typography.labelMedium.withTitleWeight(paint),
textWidth = textWidth, textWidth = textWidth,
max = titleBudget, max = titleBudget,
) )
}
val spare = available - titleLineHeight * titleMaxLines - val spare = available - titleLineHeight * titleMaxLines -
if (showTime) timeLineHeight else 0.dp if (showTime) timeLineHeight else 0.dp
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1 val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
@@ -970,7 +968,6 @@ private fun EventBlock(
BlockTitle( BlockTitle(
title = title, title = title,
maxLines = titleMaxLines, maxLines = titleMaxLines,
textWidth = textWidth,
color = paint.titleInk, color = paint.titleInk,
textDecoration = paint.decoration, textDecoration = paint.decoration,
fontWeight = paint.titleWeight, fontWeight = paint.titleWeight,