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 9a605929..5d31e297 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 @@ -390,17 +390,20 @@ internal fun resizedSpan( } if (wrongWay) return startMin to endMin } - val snapped = (edgeMin / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES + val snapped = snapToSlot(edgeMin) return when (edge) { ResizeEdge.Top -> minOf(snapped, endMin - DRAG_SNAP_MINUTES) to endMin 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. */ internal fun stickySnap(minutes: Float, held: Int?): Int { 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. */ @@ -766,6 +769,12 @@ class TimelineDragController { 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). 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 // 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 @@ -775,8 +784,8 @@ class TimelineDragController { liftedInstanceId = landed.event.instanceId settledOrigin = landed.event.eventId to landed.event.start settledAbandonedMark = abandonedTicks?.value ?: 0 + settledEnd = end 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 edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY)) resizeColumn = edgeIndex.takeIf { it >= 0 } - ?.let { if (geometry.isRtl) geometry.days.lastIndex - it else it } + ?.let { column(it) } resizeGrabMin = (pointerResizeMinutes() ?: 0f) - if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin originSlot = null @@ -845,13 +854,7 @@ class TimelineDragController { cancel() if (landed == null || landed.slot == origin) return null val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone) - settlingSelected = selection?.event?.instanceId == landed.event.instanceId - 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++ + settle(landed, end = drop.end) return drop } @@ -867,14 +870,13 @@ class TimelineDragController { val days = geometry.days if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return null val origin = grid.positionInRoot() - val column = stickyColumn( + val held = stickyColumn( position = (pointer.x - origin.x) / columnPx, held = resizeColumn, lastIndex = days.lastIndex, ) - resizeColumn = column - val dayIndex = if (geometry.isRtl) days.lastIndex - column else column - val dayOffset = (days[dayIndex].toEpochDays() - day.toEpochDays()).toInt() + resizeColumn = held + val dayOffset = (days[column(held)].toEpochDays() - day.toEpochDays()).toInt() resizeEdgeDay = dayOffset return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f } @@ -887,8 +889,6 @@ class TimelineDragController { resizeSlot = slot val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat(), resizeEdgeDay) val days = geometry.days - val hourPx = geometry.hourPx - val columnPx = geometry.columnWidthPx val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return // Which column the start day sits in, possibly off either end of the view. val startIndex = (day.toEpochDays() - days.first().toEpochDays()).toInt() @@ -901,20 +901,7 @@ class TimelineDragController { eventStartMin = start, eventSpanMin = end - start, titleLines = titleLines, - pieces = slices.map { slice -> - 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, - ) - }, + pieces = pieces(slices, startIndex, origin), edgeOnly = slices.isEmpty(), markerMin = if (edge == ResizeEdge.Top) { start.mod(MINUTES_PER_DAY) @@ -972,10 +959,9 @@ class TimelineDragController { val origin = grid.positionInRoot() 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 column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex) - val dayIndex = if (geometry.isRtl) days.lastIndex - column else column + val dayIndex = column(((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)) val target = days[dayIndex] val keep = dragKept // Measured where it would land: the drop keeps the real duration, which in @@ -1003,28 +989,34 @@ class TimelineDragController { titleLines = titleLines, // Bounded to the columns this timeline actually shows: a day it // doesn't has no piece to draw. The write is unaffected. - pieces = slices - .map { slice -> - val day = dayIndex + slice.dayOffset - val col = if (geometry.isRtl) days.lastIndex - day else day - 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, - ) - }, + pieces = pieces(slices, dayIndex, origin), edgeOnly = onGrid.isEmpty(), ) } + /** [slices] as floating pieces, the first slice's day being [dayIndex] in the shown days. */ + private fun pieces(slices: List, dayIndex: Int, origin: Offset): List { + val columnPx = geometry.columnWidthPx + return slices.map { slice -> + val bounds = dragPieceBounds(slice, geometry.hourPx, geometry.blockInsetPx) + TimelineDragPiece( + topLeftInRoot = Offset( + x = origin.x + column(dayIndex + slice.dayOffset) * columnPx, + y = origin.y + bounds.top, + ), + sizePx = IntSize( + (columnPx - geometry.columnGapPx).roundToInt(), + bounds.height.roundToInt(), + ), + continuesBefore = slice.continuesBefore, + continuesAfter = slice.continuesAfter, + ) + } + } + + /** 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 * finger sits near an edge. One `scroll` call, since a loop of