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