From 038250a062974d7e122e3f9f70b54a109b7f1237 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 7 Sep 2026 14:21:30 +0200 Subject: [PATCH] Keep the dragged copy's text inside the timeline (#267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-day event drags one piece per day, and a middle day's piece is a full 24 hours — taller than the viewport, its top at a midnight scrolled out of sight. The range went to the tallest piece by raw height, so it was drawn at that top, off the timeline entirely, and the overlay draws unclipped so the piece painted over the headers as well. The copy is now clipped to the timeline, the range goes to the piece with the most of itself in view, and a piece whose top has scrolled off brings its text down to the visible edge and budgets from there. The title is also served in full before the range: the hour gutter still says where the copy sits, so the range is the half that can afford to go. --- .../calendula/ui/common/TimelineDrag.kt | 111 ++++++++++++++---- 1 file changed, 85 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index f6a4193..3851364 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -24,6 +24,7 @@ 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.graphicsLayer import androidx.compose.ui.layout.LayoutCoordinates @@ -616,6 +617,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() @@ -677,24 +679,69 @@ 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, - titleLines = drag.titleLines, - // 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, + // How far this piece's top has scrolled out of the timeline, + // so its text can come down to meet the edge instead of + // being clipped away with it. + hiddenTopPx = if (port == null) { + 0f + } else { + (port.top - piece.topLeftInRoot.y).coerceAtLeast(0f) + }, + // Once for the whole event: repeated, it would name it per day. + label = label.takeIf { index == labelled }, + ) + } } } } @@ -712,6 +759,7 @@ private fun DragCopy( alpha: Float, title: String, titleLines: Int, + hiddenTopPx: Float, label: String?, ) { val density = LocalDensity.current @@ -725,14 +773,25 @@ private fun DragCopy( MaterialTheme.typography.labelSmall.lineHeight.toDp() } // The block's size, spent the block's way, so nothing reflows under the - // finger (#267) — but the range is paid for first, since it may be showing - // where a block too short for it will land, and nothing clips the overrun. + // finger (#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 reserved = if (label == null) 0.dp else timeLineHeight - val titleBudget = ((available - reserved) / titleLineHeight).toInt().coerceAtLeast(0) + // A piece whose top has scrolled off brings its text down to the edge, and + // budgets against what is left below it rather than against a height most + // of which is above the timeline. + val textTop = with(density) { hiddenTopPx.toDp() } + .coerceIn(0.dp, (available - titleLineHeight).coerceAtLeast(0.dp)) + val room = available - textTop + val titleBudget = (room / titleLineHeight).toInt().coerceAtLeast(0) val lines = titleLines.coerceAtMost(titleBudget) - val spare = available - titleLineHeight * lines - reserved - val timeMaxLines = if (label == null) 1 else blockTimeLines(label, textWidth, spare) + val left = room - 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 @@ -756,7 +815,7 @@ private fun DragCopy( .eventSurface(paint, shape, cuts) .padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET), ) { - Column { + Column(modifier = Modifier.padding(top = textTop)) { if (lines > 0) { BlockTitle( title = title, @@ -767,7 +826,7 @@ private fun DragCopy( fontWeight = paint.titleWeight, ) } - if (label != null) { + if (showTime) { val overflow = eventTitleOverflow(singleLine = timeMaxLines == 1) Text( text = label,