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.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
@@ -33,6 +29,9 @@ val BLOCK_OUTER_INSET = 1.dp
/** Padding between a timed block's edge and its text. */ /** Padding between a timed block's edge and its text. */
val BLOCK_TEXT_PADDING = 4.dp 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. */ /** Most lines a time label may wrap over before it is worth more than a title line. */
const val MAX_TIME_LINES = 2 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 * A week column is narrower than a "09:3011:00" range, so the label takes a
* ellipsis on multi-line chips: with `softWrap` on, the last visible line ends * second line rather than lose its end — but only out of a line the title
* at a word boundary, so "Farmers Market" in a six-character column would clip * measured itself as not needing, never one it would have filled.
* to "Farmer" / "s" where the ellipsis at least reached "s Mar…". */
@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 * #164 clipped the last line mid-glyph so none of a narrow chip's few
* remainder to a second that clips mid-glyph the way a single-line chip does. * characters went on an ellipsis. On a block that can wrap, whole words read
* Every line is then full and none of them spends two of its few characters on * better than full lines do: "Farmers Market" over two lines beats "Farmer" /
* a "…". RTL keeps the ellipsis for the reason [eventTitleOverflowFor] gives. * "s Marke". A single line still clips, having nowhere to wrap to.
*/ */
@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,
)
}
}
} }
/** /**
@@ -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 * 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 * "…" 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: * 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
* - **[rtl].** With `softWrap` off Compose lays the line out at its full * left edge, which in RTL is the *end* of the string — an Arabic title would
* intrinsic width and clips to the node's left edge, which in RTL is the *end* * lose its beginning. The ellipsis truncates at the logical end in both
* of the string — an Arabic title would lose its beginning. The ellipsis * directions. Wrapping needs `softWrap` on, which clips the right end either
* truncates at the logical end in both directions. * way, so more than one line needs no such exception.
* - **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.
*/ */
fun eventTitleOverflowFor(rtl: Boolean, singleLine: Boolean): EventTitleOverflow = fun eventTitleOverflowFor(rtl: Boolean, singleLine: Boolean): EventTitleOverflow =
if (rtl || !singleLine) { when {
EventTitleOverflow(TextOverflow.Ellipsis, softWrap = true) !singleLine -> EventTitleOverflow(TextOverflow.Clip, softWrap = true)
} else { rtl -> EventTitleOverflow(TextOverflow.Ellipsis, softWrap = true)
EventTitleOverflow(TextOverflow.Clip, softWrap = false) else -> EventTitleOverflow(TextOverflow.Clip, softWrap = false)
} }
/** [eventTitleOverflowFor] against the current layout direction. */ /** [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.fillMaxSize
import androidx.compose.foundation.layout.absoluteOffset import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -24,7 +25,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.boundsInRoot
@@ -81,6 +84,13 @@ data class TimelineDrag(
val eventSpanMin: Int, val eventSpanMin: Int,
/** The event as the grid would draw it, one piece per day column it covers. */ /** The event as the grid would draw it, one piece per day column it covers. */
val pieces: List<TimelineDragPiece>, 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 * 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 * every column this timeline shows. A hint under the finger, not where the
@@ -311,6 +321,9 @@ class TimelineDragController {
*/ */
private var clipOffsetMin = 0 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 * 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). * as its start, since the target snaps to the grid (09:07 lifts to 09:00).
@@ -320,11 +333,13 @@ class TimelineDragController {
fun begin( fun begin(
block: TimedBlock, block: TimedBlock,
clipOffsetMin: Int, clipOffsetMin: Int,
titleLines: Int,
pointerInRoot: Offset, pointerInRoot: Offset,
blockInRoot: Offset, blockInRoot: Offset,
) { ) {
source = block source = block
this.clipOffsetMin = clipOffsetMin this.clipOffsetMin = clipOffsetMin
this.titleLines = titleLines
settling = null settling = null
isDragging = true isDragging = true
liftedInstanceId = block.event.instanceId liftedInstanceId = block.event.instanceId
@@ -343,6 +358,7 @@ class TimelineDragController {
fun cancel() { fun cancel() {
source = null source = null
clipOffsetMin = 0 clipOffsetMin = 0
titleLines = 0
isDragging = false isDragging = false
liftedInstanceId = null liftedInstanceId = null
originSlot = null originSlot = null
@@ -465,6 +481,7 @@ class TimelineDragController {
startMin = startMin, startMin = startMin,
eventStartMin = eventStartMin, eventStartMin = eventStartMin,
eventSpanMin = eventSpan, eventSpanMin = eventSpan,
titleLines = titleLines,
// Bounded to the columns this timeline actually shows: a day it // Bounded to the columns this timeline actually shows: a day it
// doesn't has no piece to draw. The write is unaffected. // doesn't has no piece to draw. The write is unaffected.
pieces = slices pieces = slices
@@ -602,6 +619,7 @@ const val SETTLE_FADE_MILLIS: Int = 250
@Composable @Composable
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) { fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
var origin by remember { mutableStateOf(Offset.Zero) } var origin by remember { mutableStateOf(Offset.Zero) }
val density = LocalDensity.current
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val use24Hour = LocalUse24HourFormat.current val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale() 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 endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd
val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}" + val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}" +
formatMinuteOfDay(endMin, use24Hour, locale) formatMinuteOfDay(endMin, use24Hour, locale)
// The tallest piece carries the range: on the smallest it would be // The timeline's own bounds. The copy is drawn over the whole calendar
// clipped away, which is exactly the case when a short tail is held. // so no column clip or rounded corner cuts it, but it still belongs to
val labelled = drag.pieces.indices.maxByOrNull { drag.pieces[it].sizePx.height } // the timeline: a piece a whole day tall reaches far past both ends of
drag.pieces.forEachIndexed { index, piece -> // the viewport, and unclipped it paints over the headers above (#267).
DragCopy( val port = controller.geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
topLeftInRoot = piece.topLeftInRoot, // How much of each piece the viewport actually shows. A multi-day event
overlayOrigin = origin, // has a piece per day, and the widest one is a full 24 hours — taller
sizePx = piece.sizePx, // than the screen, its top at a midnight scrolled out of sight. Picking
paint = paint, // the range's piece by raw height put it there, off the top of the
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter), // timeline; picking by *visible* height puts it where it can be read.
cuts = timedBlockCuts(piece.continuesBefore, piece.continuesAfter), val shown = drag.pieces.map { piece ->
lift = lift, if (port == null) {
alpha = copyAlpha, piece.sizePx.height.toFloat()
title = title, } else {
// Once for the whole event: repeated, it would name it per day. val top = maxOf(piece.topLeftInRoot.y, port.top)
label = label.takeIf { index == labelled }, 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, lift: Float,
alpha: Float, alpha: Float,
title: String, title: String,
titleLines: Int,
label: String?, label: String?,
) { ) {
val density = LocalDensity.current 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( Box(
modifier = Modifier modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware // Absolute: these are root coordinates, and the direction-aware
@@ -709,12 +804,20 @@ private fun DragCopy(
(topLeftInRoot.y - overlayOrigin.y).roundToInt(), (topLeftInRoot.y - overlayOrigin.y).roundToInt(),
) )
} }
.size( // Required: the parent is the viewport's size, and a plain `size`
width = with(density) { sizePx.width.toDp() }, // would let it cap a piece a whole day tall at one screen — drawn
height = with(density) { sizePx.height.toDp() }, // 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) .padding(horizontal = BLOCK_OUTER_INSET)
.graphicsLayer { .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 scaleX = 1f + 0.02f * lift
scaleY = 1f + 0.02f * lift scaleY = 1f + 0.02f * lift
shadowElevation = 8.dp.toPx() * lift shadowElevation = 8.dp.toPx() * lift
@@ -723,28 +826,27 @@ private fun DragCopy(
clip = false clip = false
} }
.eventSurface(paint, shape, cuts) .eventSurface(paint, shape, cuts)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp), .padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET),
) { ) {
Column { Column {
val titleOverflow = eventTitleOverflow() if (lines > 0) {
Text( BlockTitle(
text = title, title = title,
style = MaterialTheme.typography.labelMedium, maxLines = lines,
maxLines = 1, color = paint.titleInk,
overflow = titleOverflow.overflow, textDecoration = paint.decoration,
softWrap = titleOverflow.softWrap, fontWeight = paint.titleWeight,
color = paint.titleInk, )
fontWeight = paint.titleWeight, }
textDecoration = paint.decoration, if (showTime) {
) val overflow = eventTitleOverflow(singleLine = timeMaxLines == 1)
if (label != null) {
Text( Text(
text = label, text = label,
// As the block it lifted off sets its own time (#219). // As the block it lifted off sets its own time (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(), style = MaterialTheme.typography.labelSmall.asEventTime(),
maxLines = 1, maxLines = timeMaxLines,
overflow = titleOverflow.overflow, overflow = overflow.overflow,
softWrap = titleOverflow.softWrap, softWrap = overflow.softWrap,
color = paint.secondaryInk, color = paint.secondaryInk,
) )
} }
@@ -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
@@ -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.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET 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_PADDING
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.BlockTitle import de.jeanlucmakiola.calendula.ui.common.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.MAX_TIME_LINES import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove 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.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.asEventTime
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
@@ -738,33 +737,25 @@ private fun EventBlock(
val timeLineHeight = with(density) { val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp() MaterialTheme.typography.labelSmall.lineHeight.toDp()
} }
// What's left for text once the 2.dp top/bottom padding is paid for. A block // A block that cannot afford both lines spends its space on the title, and
// that cannot afford both lines spends its space on the title, and one too // one too short even for that drops the title rather than serving a sliced one.
// 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 // 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. // 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 showTime = available >= titleLineHeight + timeLineHeight
val showTitle = available >= titleLineHeight val showTitle = available >= titleLineHeight
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2 val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
val titleMaxLines = if (showTime) 1 else 2 // Only lines the block can actually draw: a block too short for the time is
// The range only wraps out of a line the title has not claimed, which on a // too short for a second title line too, and asking for one served a sliced
// day column — wide enough for "09:3011:00" several times over — means it // one — as well as handing the drag copy a count it couldn't honour, so the
// never does, until lanes cut the column down. // 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 - val spare = available - titleLineHeight * titleMaxLines -
if (showTime) timeLineHeight else 0.dp if (showTime) timeLineHeight else 0.dp
val timeMaxLines = if (showTime && spare >= timeLineHeight) { val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
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 paint = eventPaint(block.event, dark) val paint = eventPaint(block.event, dark)
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event) val moveAction = eventMoveAction(block.event)
@@ -780,7 +771,13 @@ private fun EventBlock(
enabled = draggable, enabled = draggable,
key = block.event.instanceId, key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ -> 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, onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) }, 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; // After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up. // the tap still works, since a drag consumes the up.
.then(dragModifier) .then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp) .padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
.semantics { .semantics {
contentDescription = "$title, $timeLabel" contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction) if (moveAction != null) customActions = listOf(moveAction)
@@ -808,7 +805,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,
@@ -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.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET 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_PADDING
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.BlockTitle 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.blockTextLines
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove 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.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.asEventTime
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
@@ -883,8 +882,7 @@ private fun EventBlock(
val timeLineHeight = with(density) { val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp() MaterialTheme.typography.labelSmall.lineHeight.toDp()
} }
// What's left for text once the 2.dp top/bottom padding is paid for. val available = height - BLOCK_TEXT_INSET * 2
val available = height - 4.dp
// Only full-width (non-overlapping) blocks that are tall enough show the // 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 // 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 // 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 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,
) )
}
// 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.
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 && spare >= timeLineHeight) { val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
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 dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff) val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
@@ -951,7 +934,13 @@ private fun EventBlock(
enabled = draggable, enabled = draggable,
key = block.event.instanceId, key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ -> 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, onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) }, 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; // After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up. // the tap still works, since a drag consumes the up.
.then(dragModifier) .then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp) .padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
.semantics { .semantics {
contentDescription = "$title, $timeLabel" contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction) if (moveAction != null) customActions = listOf(moveAction)
@@ -979,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,
@@ -25,16 +25,16 @@ class EventTitleOverflowTest {
} }
@Test @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) val result = eventTitleOverflowFor(rtl = false, singleLine = false)
assertThat(result.overflow).isEqualTo(TextOverflow.Ellipsis) assertThat(result.overflow).isEqualTo(TextOverflow.Clip)
assertThat(result.softWrap).isTrue() assertThat(result.softWrap).isTrue()
} }
@Test @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) val result = eventTitleOverflowFor(rtl = true, singleLine = false)
assertThat(result.overflow).isEqualTo(TextOverflow.Ellipsis) assertThat(result.overflow).isEqualTo(TextOverflow.Clip)
assertThat(result.softWrap).isTrue() assertThat(result.softWrap).isTrue()
} }
} }