Share the drag controller's piece building, settling, column flip and slot snap

This commit is contained in:
2026-10-16 14:07:39 +02:00
parent 11c1b71c85
commit 6e673a1cec
@@ -390,17 +390,20 @@ internal fun resizedSpan(
} }
if (wrongWay) return startMin to endMin if (wrongWay) return startMin to endMin
} }
val snapped = (edgeMin / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES val snapped = snapToSlot(edgeMin)
return when (edge) { return when (edge) {
ResizeEdge.Top -> minOf(snapped, endMin - DRAG_SNAP_MINUTES) to endMin ResizeEdge.Top -> minOf(snapped, endMin - DRAG_SNAP_MINUTES) to endMin
ResizeEdge.Bottom -> startMin to maxOf(snapped, startMin + DRAG_SNAP_MINUTES) ResizeEdge.Bottom -> startMin to maxOf(snapped, startMin + DRAG_SNAP_MINUTES)
} }
} }
/** [minutes] rounded to the nearest [DRAG_SNAP_MINUTES] slot. */
internal fun snapToSlot(minutes: Float): Int = (minutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
/** [minutes] snapped, kept at [held] until three quarters of a step away. */ /** [minutes] snapped, kept at [held] until three quarters of a step away. */
internal fun stickySnap(minutes: Float, held: Int?): Int { internal fun stickySnap(minutes: Float, held: Int?): Int {
if (held != null && kotlin.math.abs(minutes - held) < DRAG_SNAP_MINUTES * 0.75f) return held if (held != null && kotlin.math.abs(minutes - held) < DRAG_SNAP_MINUTES * 0.75f) return held
return (minutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES return snapToSlot(minutes)
} }
/** How far past its column's side the finger has to go before a resize changes day. */ /** How far past its column's side the finger has to go before a resize changes day. */
@@ -766,6 +769,12 @@ class TimelineDragController {
if (landed == null || landed.slot == origin) return null if (landed == null || landed.slot == origin) return null
// A draft dropped on a day off screen would vanish, so it stays where it was (#153). // A draft dropped on a day off screen would vanish, so it stays where it was (#153).
if (landed.edgeOnly && landed.event.isDraft) return null if (landed.edgeOnly && landed.event.isDraft) return null
settle(landed)
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
}
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */
private fun settle(landed: TimelineDrag, end: Instant? = null) {
settlingSelected = selection?.event?.instanceId == landed.event.instanceId settlingSelected = selection?.event?.instanceId == landed.event.instanceId
// An edge piece is a hint for the finger, not a preview of the landing // An edge piece is a hint for the finger, not a preview of the landing
// slot: the event belongs to a day this timeline has no column for, so // slot: the event belongs to a day this timeline has no column for, so
@@ -775,8 +784,8 @@ class TimelineDragController {
liftedInstanceId = landed.event.instanceId liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start settledOrigin = landed.event.eventId to landed.event.start
settledAbandonedMark = abandonedTicks?.value ?: 0 settledAbandonedMark = abandonedTicks?.value ?: 0
settledEnd = end
drops++ drops++
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
} }
/** /**
@@ -807,7 +816,7 @@ class TimelineDragController {
val edgeDay = if (edge == ResizeEdge.Top) 0 else (resizeEndMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0) val edgeDay = if (edge == ResizeEdge.Top) 0 else (resizeEndMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY)) val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
resizeColumn = edgeIndex.takeIf { it >= 0 } resizeColumn = edgeIndex.takeIf { it >= 0 }
?.let { if (geometry.isRtl) geometry.days.lastIndex - it else it } ?.let { column(it) }
resizeGrabMin = (pointerResizeMinutes() ?: 0f) - resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin
originSlot = null originSlot = null
@@ -845,13 +854,7 @@ class TimelineDragController {
cancel() cancel()
if (landed == null || landed.slot == origin) return null if (landed == null || landed.slot == origin) return null
val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone) val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
settlingSelected = selection?.event?.instanceId == landed.event.instanceId settle(landed, end = drop.end)
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start
settledAbandonedMark = abandonedTicks?.value ?: 0
settledEnd = drop.end
drops++
return drop return drop
} }
@@ -867,14 +870,13 @@ class TimelineDragController {
val days = geometry.days val days = geometry.days
if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return null if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return null
val origin = grid.positionInRoot() val origin = grid.positionInRoot()
val column = stickyColumn( val held = stickyColumn(
position = (pointer.x - origin.x) / columnPx, position = (pointer.x - origin.x) / columnPx,
held = resizeColumn, held = resizeColumn,
lastIndex = days.lastIndex, lastIndex = days.lastIndex,
) )
resizeColumn = column resizeColumn = held
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column val dayOffset = (days[column(held)].toEpochDays() - day.toEpochDays()).toInt()
val dayOffset = (days[dayIndex].toEpochDays() - day.toEpochDays()).toInt()
resizeEdgeDay = dayOffset resizeEdgeDay = dayOffset
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
} }
@@ -887,8 +889,6 @@ class TimelineDragController {
resizeSlot = slot resizeSlot = slot
val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat(), resizeEdgeDay) val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat(), resizeEdgeDay)
val days = geometry.days val days = geometry.days
val hourPx = geometry.hourPx
val columnPx = geometry.columnWidthPx
val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return
// Which column the start day sits in, possibly off either end of the view. // Which column the start day sits in, possibly off either end of the view.
val startIndex = (day.toEpochDays() - days.first().toEpochDays()).toInt() val startIndex = (day.toEpochDays() - days.first().toEpochDays()).toInt()
@@ -901,20 +901,7 @@ class TimelineDragController {
eventStartMin = start, eventStartMin = start,
eventSpanMin = end - start, eventSpanMin = end - start,
titleLines = titleLines, titleLines = titleLines,
pieces = slices.map { slice -> pieces = pieces(slices, startIndex, origin),
val dayIndex = startIndex + slice.dayOffset
val col = if (geometry.isRtl) days.lastIndex - dayIndex else dayIndex
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
TimelineDragPiece(
topLeftInRoot = Offset(x = origin.x + col * columnPx, y = origin.y + bounds.top),
sizePx = IntSize(
(columnPx - geometry.columnGapPx).roundToInt(),
bounds.height.roundToInt(),
),
continuesBefore = slice.continuesBefore,
continuesAfter = slice.continuesAfter,
)
},
edgeOnly = slices.isEmpty(), edgeOnly = slices.isEmpty(),
markerMin = if (edge == ResizeEdge.Top) { markerMin = if (edge == ResizeEdge.Top) {
start.mod(MINUTES_PER_DAY) start.mod(MINUTES_PER_DAY)
@@ -972,10 +959,9 @@ class TimelineDragController {
val origin = grid.positionInRoot() val origin = grid.positionInRoot()
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES val snapped = snapToSlot(rawMinutes)
val zone = dragZone ?: return val zone = dragZone ?: return
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex) val dayIndex = column(((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex))
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
val target = days[dayIndex] val target = days[dayIndex]
val keep = dragKept val keep = dragKept
// Measured where it would land: the drop keeps the real duration, which in // Measured where it would land: the drop keeps the real duration, which in
@@ -1003,14 +989,19 @@ class TimelineDragController {
titleLines = titleLines, 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 = pieces(slices, dayIndex, origin),
.map { slice -> edgeOnly = onGrid.isEmpty(),
val day = dayIndex + slice.dayOffset )
val col = if (geometry.isRtl) days.lastIndex - day else day }
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
/** [slices] as floating pieces, the first slice's day being [dayIndex] in the shown days. */
private fun pieces(slices: List<DragSlice>, dayIndex: Int, origin: Offset): List<TimelineDragPiece> {
val columnPx = geometry.columnWidthPx
return slices.map { slice ->
val bounds = dragPieceBounds(slice, geometry.hourPx, geometry.blockInsetPx)
TimelineDragPiece( TimelineDragPiece(
topLeftInRoot = Offset( topLeftInRoot = Offset(
x = origin.x + col * columnPx, x = origin.x + column(dayIndex + slice.dayOffset) * columnPx,
y = origin.y + bounds.top, y = origin.y + bounds.top,
), ),
sizePx = IntSize( sizePx = IntSize(
@@ -1020,10 +1011,11 @@ class TimelineDragController {
continuesBefore = slice.continuesBefore, continuesBefore = slice.continuesBefore,
continuesAfter = slice.continuesAfter, continuesAfter = slice.continuesAfter,
) )
},
edgeOnly = onGrid.isEmpty(),
)
} }
}
/** The on-screen column of a day index, and back: right-to-left lays the days out mirrored. */
private fun column(index: Int): Int = if (geometry.isRtl) geometry.days.lastIndex - index else index
/** /**
* Hold the scroll for the whole drag and nudge it once per frame while the * Hold the scroll for the whole drag and nudge it once per frame while the