Keep the dragged copy inside the block it lifted off (#267)

The copy always draws the range but was budgeting height for the title
first, so a block too short to have shown either — Day hands its whole
height to the title there, and passed that as the lines it drew — spilled
text past a chip nothing clips.

The copy now pays for the range first and gives the title what of its
lines still fit, and the blocks report the lines they actually drew.
The 2.dp text inset the three of them each spelled out is now a constant.
This commit is contained in:
2026-09-04 17:26:32 +02:00
parent 3a29e6f676
commit 8db99868af
4 changed files with 54 additions and 31 deletions

View File

@@ -33,6 +33,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

View File

@@ -82,9 +82,10 @@ data class TimelineDrag(
/** 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 gave its title. The floating copy is * Lines the block the finger picked up drew its title over, zero for one too
* that block's size, so it has to spend its height the same way or the text * short to have drawn it at all. The floating copy is that block's size, so
* re-wraps under the finger and snaps back on drop (#267). * 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, val titleLines: Int,
/** /**
@@ -317,8 +318,8 @@ class TimelineDragController {
*/ */
private var clipOffsetMin = 0 private var clipOffsetMin = 0
/** What the picked-up block measured its title over — see [TimelineDrag.titleLines]. */ /** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */
private var titleLines = 1 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
@@ -354,7 +355,7 @@ class TimelineDragController {
fun cancel() { fun cancel() {
source = null source = null
clipOffsetMin = 0 clipOffsetMin = 0
titleLines = 1 titleLines = 0
isDragging = false isDragging = false
liftedInstanceId = null liftedInstanceId = null
originSlot = null originSlot = null
@@ -717,18 +718,23 @@ private fun DragCopy(
val width = with(density) { sizePx.width.toDp() } val width = with(density) { sizePx.width.toDp() }
val height = with(density) { sizePx.height.toDp() } val height = with(density) { sizePx.height.toDp() }
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2 val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
// The copy is the block's size and spends its height the same way, so text
// wraps here exactly where it wrapped there. Held to one line the range
// clipped for the drag's duration and snapped back on drop (#267). The one
// thing the copy does that a short block won't is always show the range —
// that is the feedback the drag is for.
val titleLineHeight = with(density) { val titleLineHeight = with(density) {
MaterialTheme.typography.labelMedium.lineHeight.toDp() MaterialTheme.typography.labelMedium.lineHeight.toDp()
} }
val timeLineHeight = with(density) { val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp() MaterialTheme.typography.labelSmall.lineHeight.toDp()
} }
val spare = height - 4.dp - titleLineHeight * titleLines - timeLineHeight // The copy is the block's size and spends its height the same way, so text
// wraps here exactly where it wrapped there rather than reflowing under the
// finger and snapping back on drop (#267). The range is paid for first —
// where the drop lands is what the drag is for, and the block may have been
// too short to show it — and the title takes what of its lines still fit,
// since nothing clips a copy that outgrows its block.
val available = height - BLOCK_TEXT_INSET * 2
val reserved = if (label == null) 0.dp else timeLineHeight
val titleBudget = ((available - reserved) / 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 timeMaxLines = if (label == null) 1 else blockTimeLines(label, textWidth, spare)
Box( Box(
modifier = Modifier modifier = Modifier
@@ -751,17 +757,19 @@ 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 {
if (lines > 0) {
BlockTitle( BlockTitle(
title = title, title = title,
maxLines = titleLines, maxLines = lines,
textWidth = textWidth, textWidth = textWidth,
color = paint.titleInk, color = paint.titleInk,
textDecoration = paint.decoration, textDecoration = paint.decoration,
fontWeight = paint.titleWeight, fontWeight = paint.titleWeight,
) )
}
if (label != null) { if (label != null) {
val overflow = eventTitleOverflow(singleLine = timeMaxLines == 1) val overflow = eventTitleOverflow(singleLine = timeMaxLines == 1)
Text( Text(

View File

@@ -83,6 +83,7 @@ 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.blockTimeLines import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
@@ -736,12 +737,11 @@ 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
@@ -766,7 +766,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, titleMaxLines, 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) },
@@ -783,7 +789,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)

View File

@@ -91,6 +91,7 @@ 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.blockTextLines import de.jeanlucmakiola.calendula.ui.common.blockTextLines
@@ -882,8 +883,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
@@ -936,7 +936,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, titleMaxLines, 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) },
@@ -953,7 +959,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)