Compare commits

..

3 Commits

Author SHA1 Message Date
c2fbfb0bef Trim the drag copy's layout note (#267) 2026-09-04 17:27:09 +02:00
8db99868af 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.
2026-09-04 17:26:32 +02:00
3a29e6f676 Wrap the dragged copy's time as the block does (#267)
The floating copy hardcoded maxLines = 1 on its time label and its
title, so lifting a block whose text wrapped clipped it for as long as
it was held and snapped back on drop. The copy now measures the range
the same way the block does, and carries the block's title budget
through the drag.

Extracts that measurement into blockTimeLines, which Week and Day had
a copy of each.
2026-09-04 16:55:21 +02:00
4 changed files with 108 additions and 64 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
@@ -61,6 +64,33 @@ fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int
} }
} }
/**
* 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.
*
* 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]. * A timed block's title, over at most [maxLines].
* *

View File

@@ -81,6 +81,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 +318,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 +330,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 +355,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 +478,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
@@ -677,6 +691,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
lift = lift, lift = lift,
alpha = copyAlpha, alpha = copyAlpha,
title = title, title = title,
titleLines = drag.titleLines,
// Once for the whole event: repeated, it would name it per day. // Once for the whole event: repeated, it would name it per day.
label = label.takeIf { index == labelled }, label = label.takeIf { index == labelled },
) )
@@ -696,9 +711,28 @@ 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, 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.
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)
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,10 +743,7 @@ private fun DragCopy(
(topLeftInRoot.y - overlayOrigin.y).roundToInt(), (topLeftInRoot.y - overlayOrigin.y).roundToInt(),
) )
} }
.size( .size(width = width, height = height)
width = with(density) { sizePx.width.toDp() },
height = with(density) { sizePx.height.toDp() },
)
.padding(horizontal = BLOCK_OUTER_INSET) .padding(horizontal = BLOCK_OUTER_INSET)
.graphicsLayer { .graphicsLayer {
scaleX = 1f + 0.02f * lift scaleX = 1f + 0.02f * lift
@@ -723,28 +754,28 @@ 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, textWidth = textWidth,
overflow = titleOverflow.overflow, color = paint.titleInk,
softWrap = titleOverflow.softWrap, textDecoration = paint.decoration,
color = paint.titleInk, fontWeight = paint.titleWeight,
fontWeight = paint.titleWeight, )
textDecoration = paint.decoration, }
)
if (label != null) { if (label != null) {
val overflow = eventTitleOverflow(singleLine = timeMaxLines == 1)
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,
) )
} }

View File

@@ -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,20 @@ 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 val titleMaxLines = if (showTime) 1 else 2
// The range only wraps out of a line the title has not claimed, which on a // On a day column — wide enough for "09:3011:00" several times over — the
// day column — wide enough for "09:3011:00" several times over — means it // range never needs the second line, until lanes cut the column down.
// never does, 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 +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, 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 +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,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
@@ -130,7 +131,6 @@ 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.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 +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
@@ -918,23 +917,9 @@ private fun EventBlock(
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 +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, 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 +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)