From eefd9430e067d01738f69ce19371879f6c2778ec Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 9 Oct 2026 11:39:49 +0200 Subject: [PATCH] Steady the resize edge and enlarge its handles (#141) --- .../calendula/ui/common/ResizeHandle.kt | 4 +- .../calendula/ui/common/TimelineDrag.kt | 45 ++++++++++++++++++- .../calendula/ui/common/ResizedSpanTest.kt | 26 +++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ResizeHandle.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ResizeHandle.kt index eeec55e8..5e5c6304 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ResizeHandle.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ResizeHandle.kt @@ -23,9 +23,9 @@ import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.dp /** The handle's touch target, centred on the edge it moves. */ -val ResizeHandleTarget = 40.dp +val ResizeHandleTarget = 48.dp -private val HANDLE_DOT = 12.dp +private val HANDLE_DOT = 16.dp private val HANDLE_RING = 2.dp /** 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 90616ad5..fc28f035 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 @@ -279,6 +279,32 @@ internal fun resizedSpan(edge: ResizeEdge, startMin: Int, endMin: Int, edgeMin: } } +/** + * [minutes] snapped to the grid, kept at [held] until the finger is three + * quarters of a step away from it: plain rounding flips between two slots + * whenever the finger rests near the line between them. + */ +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 +} + +/** How far past its column's side the finger has to go before a resize changes day. */ +private const val COLUMN_HYSTERESIS = 0.25f + +/** + * The column under a finger at [position] columns from the grid's start, kept at + * [held] until the finger is a [COLUMN_HYSTERESIS] into the next. A resize drags + * up and down: the finger drifts sideways as it goes, and in a narrow week column + * every drift over a border moved the edge by a whole day and back. + */ +internal fun stickyColumn(position: Float, held: Int?, lastIndex: Int): Int { + val under = position.toInt().coerceIn(0, lastIndex) + if (held == null) return under + val stays = position >= held - COLUMN_HYSTERESIS && position < held + 1 + COLUMN_HYSTERESIS + return if (stays) held else under +} + /** * The timeline's live geometry, republished on every layout. Plain fields rather * than snapshot state: it changes on every scroll frame, and the drag loop reads @@ -457,6 +483,12 @@ class TimelineDragController { /** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */ private var resizeGrabMin = 0f + /** The column the resized edge is in, held until the finger is well into another. */ + private var resizeColumn: Int? = null + + /** The slot the resized edge sits on, held against a finger trembling between two. */ + private var resizeSlot: Int? = null + fun begin( block: TimedBlock, clipOffsetMin: Int, @@ -488,6 +520,8 @@ class TimelineDragController { fun cancel() { resizeEdge = null resizeDay = null + resizeColumn = null + resizeSlot = null source = null clipOffsetMin = 0 titleLines = 0 @@ -628,7 +662,12 @@ class TimelineDragController { val days = geometry.days if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return null val origin = grid.positionInRoot() - val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex) + val column = 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() return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f @@ -638,7 +677,9 @@ class TimelineDragController { val block = source ?: return val day = resizeDay ?: return val raw = pointerResizeMinutes() ?: return - val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, raw - resizeGrabMin) + val slot = stickySnap(raw - resizeGrabMin, resizeSlot) + resizeSlot = slot + val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat()) val days = geometry.days val hourPx = geometry.hourPx val columnPx = geometry.columnWidthPx diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ResizedSpanTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ResizedSpanTest.kt index 2c30b1b1..63ee8254 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ResizedSpanTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ResizedSpanTest.kt @@ -56,4 +56,30 @@ class ResizedSpanTest { assertThat(wallClockInstant(day, -30, zone)) .isEqualTo(LocalDateTime(LocalDate(2026, 10, 8), LocalTime(23, 30)).toInstant(zone)) } + + @Test + fun `a finger drifting just over a column border keeps the edge on its day`() { + assertThat(stickyColumn(position = 3.1f, held = 2, lastIndex = 6)).isEqualTo(2) + assertThat(stickyColumn(position = 1.9f, held = 2, lastIndex = 6)).isEqualTo(2) + } + + @Test + fun `a finger well into the next column takes the edge there`() { + assertThat(stickyColumn(position = 3.4f, held = 2, lastIndex = 6)).isEqualTo(3) + assertThat(stickyColumn(position = 1.6f, held = 2, lastIndex = 6)).isEqualTo(1) + } + + @Test + fun `the first column is the one under the finger`() { + assertThat(stickyColumn(position = 4.9f, held = null, lastIndex = 6)).isEqualTo(4) + assertThat(stickyColumn(position = 9f, held = null, lastIndex = 6)).isEqualTo(6) + } + + @Test + fun `a finger resting between two slots keeps the one it is on`() { + // 09:08 rounds to 09:15, but from 09:00 it is not far enough to leave. + assertThat(stickySnap(9 * 60 + 8f, held = 9 * 60)).isEqualTo(9 * 60) + assertThat(stickySnap(9 * 60 + 12f, held = 9 * 60)).isEqualTo(9 * 60 + 15) + assertThat(stickySnap(9 * 60 + 8f, held = null)).isEqualTo(9 * 60 + 15) + } }