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.animateDpAsState
import androidx.compose.animation.core.snap
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -15,14 +14,11 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
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.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
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 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
* ellipsis on multi-line chips: with `softWrap` on, the last visible line ends
* at a word boundary, so "Farmers Market" in a six-character column would clip
* to "Farmer" / "s" where the ellipsis at least reached "s Mar…".
*
* 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.
* #164 clipped the last line mid-glyph so none of a narrow chip's few
* characters went on an ellipsis. On a block that can wrap, whole words read
* better than full lines do: "Farmers Market" over two lines beats "Farmer" /
* "s Marke". A single line still clips, having nowhere to wrap to.
*/
@Composable
fun BlockTitle(
title: String,
maxLines: Int,
textWidth: Dp,
color: Color,
modifier: Modifier = Modifier,
textDecoration: TextDecoration? = null,
fontWeight: FontWeight? = null,
) {
// Folded into the style rather than passed to the Text, so the wrap measured
// below is the wrap that gets drawn.
val style = MaterialTheme.typography.labelMedium
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) }
val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val measurer = rememberTextMeasurer()
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
// Where the wrapped lines stop and the clipped tail starts — null when the
// title fits, and nothing needs splitting.
val headEnd = remember(title, style, widthPx, maxLines, rtl, measurer) {
if (rtl || maxLines < 2 || widthPx <= 0) {
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,
)
}
}
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
Text(
text = title,
modifier = modifier,
style = MaterialTheme.typography.labelMedium
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
maxLines = maxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
color = color,
textDecoration = textDecoration,
)
}
/**
@@ -814,7 +814,6 @@ private fun DragCopy(
BlockTitle(
title = title,
maxLines = lines,
textWidth = textWidth,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
@@ -96,17 +96,6 @@ fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
*/
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. */
val FIT_DAY_MIN = 24.dp
@@ -800,7 +800,6 @@ private fun EventBlock(
BlockTitle(
title = title,
maxLines = titleMaxLines,
textWidth = textWidth,
color = paint.titleInk,
textDecoration = paint.decoration,
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.LocalTimelineZoom
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.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
@@ -905,18 +904,17 @@ private fun EventBlock(
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
val paint = eventPaint(block.event, dark)
val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) {
1
} else {
blockTextLines(
text = title,
// At the weight BlockTitle will set it in, or an invited block —
// drawn a weight lighter — is budgeted a line it never fills (#230).
style = MaterialTheme.typography.labelMedium.withTitleWeight(paint),
textWidth = textWidth,
max = titleBudget,
)
}
// 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
// line there lost it outright where the block had the room to wrap it.
val titleMaxLines = blockTextLines(
text = title,
// At the weight BlockTitle will set it in, or an invited block — drawn a
// weight lighter — is budgeted a line it never fills (#230).
style = MaterialTheme.typography.labelMedium.withTitleWeight(paint),
textWidth = textWidth,
max = titleBudget,
)
val spare = available - titleLineHeight * titleMaxLines -
if (showTime) timeLineHeight else 0.dp
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
@@ -970,7 +968,6 @@ private fun EventBlock(
BlockTitle(
title = title,
maxLines = titleMaxLines,
textWidth = textWidth,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,