Wrap the dragged copy's time as the block does (#267) (#269)

The floating drag copy hardcoded `maxLines = 1` on both its time label and its title, so picking up a block whose text wrapped clipped it to one line for as long as it was held, and it snapped back on drop.

- `DragCopy` now measures the range at its own width and height, exactly as the block does — same `MAX_TIME_LINES` cap, same spare-height rule.
- The block's measured title budget rides along on `TimelineDrag`, so the copy spends its height the same way the block did; the copy draws through `BlockTitle` now rather than a bare `Text`, so a wrapped title clips the way the block's does.
- That measurement moves into a shared `blockTimeLines`, which Week and Day each had their own copy of.

Deviation from the issue: it names only the time label, but the title had the identical hardcode and the two share one height budget — fixing the range alone would just have moved the mismatch to the title.

The copy still shows the range on a block too short to carry one; that is the feedback the drag exists for and is left as is.

Closes #267

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/269
This commit is contained in:
Jean-Luc Makiola
2026-09-07 16:47:40 +02:00
co-authored by makiolaj
parent 76603be0b3
commit 80bf96b7dc
7 changed files with 246 additions and 201 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
@@ -33,6 +29,9 @@ val BLOCK_OUTER_INSET = 1.dp
/** Padding between a timed block's edge and its text. */
val BLOCK_TEXT_PADDING = 4.dp
/** The same, above and below — what a block's height has to pay before any text. */
val BLOCK_TEXT_INSET = 2.dp
/** Most lines a time label may wrap over before it is worth more than a title line. */
const val MAX_TIME_LINES = 2
@@ -62,88 +61,61 @@ fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int
}
/**
* A timed block's title, over at most [maxLines].
* Lines the time label may take at [textWidth], out of the [spare] height left
* once the title and the label's own first line are paid for.
*
* 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…".
* A week column is narrower than a "09:3011:00" range, so the label takes a
* second line rather than lose its end — but only out of a line the title
* measured itself as not needing, never one it would have filled.
*/
@Composable
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
val timeLineHeight = with(LocalDensity.current) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
return if (spare >= timeLineHeight) {
blockTextLines(
text = label,
// The style it is drawn in, or the budget measures a line the label
// never uses (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
textWidth = textWidth,
max = MAX_TIME_LINES,
)
} else {
1
}
}
/**
* A timed block's title, over at most [maxLines], breaking at word boundaries.
*
* 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,
)
}
/**
@@ -11,23 +11,21 @@ data class EventTitleOverflow(val overflow: TextOverflow, val softWrap: Boolean)
/**
* Overflow for an event chip's title (#164). Chips are narrow enough that the
* "…" costs a couple of readable characters, so the title runs to the chip's
* edge and clips mid-glyph instead.
* edge and clips instead — mid-glyph on one line, at the last whole word it
* could fit on several.
*
* Two cases keep the ellipsis:
*
* - **[rtl].** With `softWrap` off Compose lays the line out at its full
* intrinsic width and clips to the node's left edge, which in RTL is the *end*
* of the string — an Arabic title would lose its beginning. The ellipsis
* truncates at the logical end in both directions.
* - **More than one line** ([singleLine] false). Wrapping needs `softWrap` on,
* and clipping with it on breaks the last line at the last whole word — less
* title than the ellipsis showed, not more.
* One case keeps the ellipsis: a single **[rtl]** line. With `softWrap` off
* Compose lays the line out at its full intrinsic width and clips to the node's
* left edge, which in RTL is the *end* of the string — an Arabic title would
* lose its beginning. The ellipsis truncates at the logical end in both
* directions. Wrapping needs `softWrap` on, which clips the right end either
* way, so more than one line needs no such exception.
*/
fun eventTitleOverflowFor(rtl: Boolean, singleLine: Boolean): EventTitleOverflow =
if (rtl || !singleLine) {
EventTitleOverflow(TextOverflow.Ellipsis, softWrap = true)
} else {
EventTitleOverflow(TextOverflow.Clip, softWrap = false)
when {
!singleLine -> EventTitleOverflow(TextOverflow.Clip, softWrap = true)
rtl -> EventTitleOverflow(TextOverflow.Ellipsis, softWrap = true)
else -> EventTitleOverflow(TextOverflow.Clip, softWrap = false)
}
/** [eventTitleOverflowFor] against the current layout direction. */
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
@@ -24,7 +25,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
@@ -81,6 +84,13 @@ data class TimelineDrag(
val eventSpanMin: Int,
/** The event as the grid would draw it, one piece per day column it covers. */
val pieces: List<TimelineDragPiece>,
/**
* Lines the block the finger picked up drew its title over, zero for one too
* short to have drawn it at all. The floating copy is that block's size, so
* it has to spend its height the same way or the text re-wraps under the
* finger and snaps back on drop (#267).
*/
val titleLines: Int,
/**
* True when [pieces] is only [edgeDragSlice]'s stand-in — the event has left
* every column this timeline shows. A hint under the finger, not where the
@@ -311,6 +321,9 @@ class TimelineDragController {
*/
private var clipOffsetMin = 0
/** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */
private var titleLines = 0
/**
* The slot the block already occupied when it was picked up — not the same
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
@@ -320,11 +333,13 @@ class TimelineDragController {
fun begin(
block: TimedBlock,
clipOffsetMin: Int,
titleLines: Int,
pointerInRoot: Offset,
blockInRoot: Offset,
) {
source = block
this.clipOffsetMin = clipOffsetMin
this.titleLines = titleLines
settling = null
isDragging = true
liftedInstanceId = block.event.instanceId
@@ -343,6 +358,7 @@ class TimelineDragController {
fun cancel() {
source = null
clipOffsetMin = 0
titleLines = 0
isDragging = false
liftedInstanceId = null
originSlot = null
@@ -465,6 +481,7 @@ class TimelineDragController {
startMin = startMin,
eventStartMin = eventStartMin,
eventSpanMin = eventSpan,
titleLines = titleLines,
// Bounded to the columns this timeline actually shows: a day it
// doesn't has no piece to draw. The write is unaffected.
pieces = slices
@@ -602,6 +619,7 @@ const val SETTLE_FADE_MILLIS: Int = 250
@Composable
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
var origin by remember { mutableStateOf(Offset.Zero) }
val density = LocalDensity.current
val dark = isSystemInDarkTheme()
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
@@ -663,23 +681,61 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd
val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}" +
formatMinuteOfDay(endMin, use24Hour, locale)
// The tallest piece carries the range: on the smallest it would be
// clipped away, which is exactly the case when a short tail is held.
val labelled = drag.pieces.indices.maxByOrNull { drag.pieces[it].sizePx.height }
drag.pieces.forEachIndexed { index, piece ->
DragCopy(
topLeftInRoot = piece.topLeftInRoot,
overlayOrigin = origin,
sizePx = piece.sizePx,
paint = paint,
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
cuts = timedBlockCuts(piece.continuesBefore, piece.continuesAfter),
lift = lift,
alpha = copyAlpha,
title = title,
// Once for the whole event: repeated, it would name it per day.
label = label.takeIf { index == labelled },
)
// The timeline's own bounds. The copy is drawn over the whole calendar
// so no column clip or rounded corner cuts it, but it still belongs to
// the timeline: a piece a whole day tall reaches far past both ends of
// the viewport, and unclipped it paints over the headers above (#267).
val port = controller.geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
// How much of each piece the viewport actually shows. A multi-day event
// has a piece per day, and the widest one is a full 24 hours — taller
// than the screen, its top at a midnight scrolled out of sight. Picking
// the range's piece by raw height put it there, off the top of the
// timeline; picking by *visible* height puts it where it can be read.
val shown = drag.pieces.map { piece ->
if (port == null) {
piece.sizePx.height.toFloat()
} else {
val top = maxOf(piece.topLeftInRoot.y, port.top)
val bottom = minOf(piece.topLeftInRoot.y + piece.sizePx.height, port.bottom)
bottom - top
}
}
val labelled = shown.indices.maxByOrNull { shown[it] }
Box(
modifier = if (port == null) {
Modifier
} else {
Modifier
.absoluteOffset {
IntOffset(
(port.left - origin.x).roundToInt(),
(port.top - origin.y).roundToInt(),
)
}
.size(
width = with(density) { port.width.toDp() },
height = with(density) { port.height.toDp() },
)
.clipToBounds()
},
) {
val pieceOrigin = port?.topLeft ?: origin
drag.pieces.forEachIndexed { index, piece ->
DragCopy(
topLeftInRoot = piece.topLeftInRoot,
overlayOrigin = pieceOrigin,
sizePx = piece.sizePx,
paint = paint,
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
cuts = timedBlockCuts(piece.continuesBefore, piece.continuesAfter),
lift = lift,
alpha = copyAlpha,
title = title,
titleLines = drag.titleLines,
// Once for the whole event: repeated, it would name it per day.
label = label.takeIf { index == labelled },
)
}
}
}
}
@@ -696,9 +752,48 @@ private fun DragCopy(
lift: Float,
alpha: Float,
title: String,
titleLines: Int,
label: String?,
) {
val density = LocalDensity.current
val width = with(density) { sizePx.width.toDp() }
val height = with(density) { sizePx.height.toDp() }
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
// on the block, so the copy hands back to the grid without shifting (#267).
// 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
// range is the half that can afford to go.
val available = height - BLOCK_TEXT_INSET * 2
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(0)
val allowed = titleLines.coerceAtMost(titleBudget)
// 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
// copy is a whole column, so the source's second line is one the copy never
// draws — and reserving it took the range away with it (#267).
val lines = if (allowed <= 0) {
0
} else {
blockTextLines(
text = title,
style = MaterialTheme.typography.labelMedium.withTitleWeight(paint),
textWidth = textWidth,
max = allowed,
)
}
val left = available - titleLineHeight * lines
val showTime = label != null && left >= timeLineHeight
val timeMaxLines = if (showTime) {
blockTimeLines(label!!, textWidth, left - timeLineHeight)
} else {
1
}
Box(
modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware
@@ -709,12 +804,20 @@ private fun DragCopy(
(topLeftInRoot.y - overlayOrigin.y).roundToInt(),
)
}
.size(
width = with(density) { sizePx.width.toDp() },
height = with(density) { sizePx.height.toDp() },
)
// Required: the parent is the viewport's size, and a plain `size`
// would let it cap a piece a whole day tall at one screen — drawn
// from a midnight scrolled off the top, that ended the column a
// scroll offset short of its own bottom (#267). The parent clips it.
.requiredSize(width = width, height = height)
.padding(horizontal = BLOCK_OUTER_INSET)
.graphicsLayer {
// Anchored at the top edge, not the middle: scaled about the
// centre, the 2% lift raises the top by 1% of the height — a few
// pixels on an hour, but a steady upward creep of the text as a
// dragged multi-day piece grows, and a shear against the
// timeline's clip. It snapped back when the lift animated out
// on drop (#267).
transformOrigin = TransformOrigin(0.5f, 0f)
scaleX = 1f + 0.02f * lift
scaleY = 1f + 0.02f * lift
shadowElevation = 8.dp.toPx() * lift
@@ -723,28 +826,27 @@ private fun DragCopy(
clip = false
}
.eventSurface(paint, shape, cuts)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp),
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET),
) {
Column {
val titleOverflow = eventTitleOverflow()
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = paint.titleInk,
fontWeight = paint.titleWeight,
textDecoration = paint.decoration,
)
if (label != null) {
if (lines > 0) {
BlockTitle(
title = title,
maxLines = lines,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
)
}
if (showTime) {
val overflow = eventTitleOverflow(singleLine = timeMaxLines == 1)
Text(
text = label,
// As the block it lifted off sets its own time (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
maxLines = 1,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
maxLines = timeMaxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
color = paint.secondaryInk,
)
}
@@ -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
@@ -83,10 +83,10 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
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_INSET
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.MAX_TIME_LINES
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
@@ -120,7 +120,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.asEventTime
import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
@@ -738,33 +737,25 @@ private fun EventBlock(
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// What's left for text once the 2.dp top/bottom padding is paid for. 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.
// 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.
// 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.
val available = height - 4.dp
val available = height - BLOCK_TEXT_INSET * 2
val showTime = available >= titleLineHeight + timeLineHeight
val showTitle = available >= titleLineHeight
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
val titleMaxLines = if (showTime) 1 else 2
// The range only wraps out of a line the title has not claimed, which on a
// day column — wide enough for "09:3011:00" several times over — means it
// never does, until lanes cut the column down.
// 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
// 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).
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(1)
val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2)
// 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.
val spare = available - titleLineHeight * titleMaxLines -
if (showTime) timeLineHeight else 0.dp
val timeMaxLines = if (showTime && spare >= timeLineHeight) {
blockTextLines(
text = timeLabel,
// The style it is drawn in, or the budget measures a line the label
// never uses (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
textWidth = textWidth,
max = MAX_TIME_LINES,
)
} else {
1
}
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
val paint = eventPaint(block.event, dark)
val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event)
@@ -780,7 +771,13 @@ private fun EventBlock(
enabled = draggable,
key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ ->
dragController.begin(block, clipOffset, pointer, blockRoot)
dragController.begin(
block = block,
clipOffsetMin = clipOffset,
titleLines = if (showTitle) titleMaxLines else 0,
pointerInRoot = pointer,
blockInRoot = blockRoot,
)
},
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
@@ -797,7 +794,7 @@ private fun EventBlock(
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
@@ -808,7 +805,6 @@ private fun EventBlock(
BlockTitle(
title = title,
maxLines = titleMaxLines,
textWidth = textWidth,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
@@ -91,10 +91,11 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
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_INSET
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.MAX_TIME_LINES
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
@@ -129,8 +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.asEventTime
import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
@@ -883,8 +882,7 @@ private fun EventBlock(
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// What's left for text once the 2.dp top/bottom padding is paid for.
val available = height - 4.dp
val available = height - BLOCK_TEXT_INSET * 2
// 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
// fill the whole block, mirroring Google Calendar — and a block that cannot
@@ -906,35 +904,20 @@ 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,
)
}
// A week column is narrower than a "09:3011:00" range, so the label takes a
// second line rather than lose its end — but only out of a line the title
// measured itself as not needing, never one it would have filled.
// 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 && spare >= timeLineHeight) {
blockTextLines(
text = timeLabel,
// The style it is drawn in, or the budget measures a line the label
// never uses (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
textWidth = textWidth,
max = MAX_TIME_LINES,
)
} else {
1
}
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
val zone = remember { TimeZone.currentSystemDefault() }
@@ -951,7 +934,13 @@ private fun EventBlock(
enabled = draggable,
key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ ->
dragController.begin(block, clipOffset, pointer, blockRoot)
dragController.begin(
block = block,
clipOffsetMin = clipOffset,
titleLines = if (showTitle) titleMaxLines else 0,
pointerInRoot = pointer,
blockInRoot = blockRoot,
)
},
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
@@ -968,7 +957,7 @@ private fun EventBlock(
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
@@ -979,7 +968,6 @@ private fun EventBlock(
BlockTitle(
title = title,
maxLines = titleMaxLines,
textWidth = textWidth,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
@@ -25,16 +25,16 @@ class EventTitleOverflowTest {
}
@Test
fun `a multi-line block keeps the ellipsis, since wrapping needs softWrap`() {
fun `a multi-line block wraps whole words and clips, spending none on a dot`() {
val result = eventTitleOverflowFor(rtl = false, singleLine = false)
assertThat(result.overflow).isEqualTo(TextOverflow.Ellipsis)
assertThat(result.overflow).isEqualTo(TextOverflow.Clip)
assertThat(result.softWrap).isTrue()
}
@Test
fun `multi-line in RTL keeps the ellipsis too`() {
fun `multi-line in RTL clips too, since softWrap cuts the logical end`() {
val result = eventTitleOverflowFor(rtl = true, singleLine = false)
assertThat(result.overflow).isEqualTo(TextOverflow.Ellipsis)
assertThat(result.overflow).isEqualTo(TextOverflow.Clip)
assertThat(result.softWrap).isTrue()
}
}