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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user