Steady the resize edge and enlarge its handles (#141)

This commit is contained in:
2026-10-09 11:39:49 +02:00
parent 38a860aafd
commit eefd9430e0
3 changed files with 71 additions and 4 deletions
@@ -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
/**
@@ -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
@@ -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)
}
}