Resize a selected event from handles on its ends (#141)

This commit is contained in:
2026-10-09 11:13:44 +02:00
parent e7f3afe581
commit c8d3d21ba3
4 changed files with 421 additions and 35 deletions
@@ -0,0 +1,103 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
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
private val HANDLE_DOT = 12.dp
private val HANDLE_RING = 2.dp
/**
* A grip on one edge of a selected event (#141). It takes the gesture from the
* first movement, with no hold — the event is already selected — and hands the
* finger on in root coordinates. A tap is swallowed rather than falling through
* to the slot under it.
*
* No semantics of its own: TalkBack reaches the same change through the event's
* edit action.
*/
@Composable
fun ResizeHandle(
color: Color,
onStart: (pointerInRoot: Offset) -> Unit,
onMove: (pointerInRoot: Offset) -> Unit,
onEnd: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
val coordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
val currentStart by rememberUpdatedState(onStart)
val currentMove by rememberUpdatedState(onMove)
val currentEnd by rememberUpdatedState(onEnd)
val currentCancel by rememberUpdatedState(onCancel)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.size(ResizeHandleTarget)
.clearAndSetSemantics { }
.onGloballyPositioned { coordinates[0] = it }
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
down.consume()
var started = false
var ended = false
try {
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
// Claimed by an ancestor that outranks us — the pinch.
if (change.isConsumed) break
if (event.changes.count { it.pressed } > 1) break
change.consume()
if (!change.pressed) {
ended = started
break
}
val root = coordinates[0]?.takeIf { it.isAttached }
?.localToRoot(change.position) ?: break
if (started) {
currentMove(root)
} else {
started = true
currentStart(root)
}
}
} finally {
when {
ended -> currentEnd()
started -> currentCancel()
}
}
}
},
) {
Box(
Modifier
.size(HANDLE_DOT)
.background(color, CircleShape)
.border(HANDLE_RING, MaterialTheme.colorScheme.surface, CircleShape),
)
}
}
@@ -100,8 +100,11 @@ data class TimelineDrag(
*/
val edgeOnly: Boolean = false,
) {
/** What the snap haptics key off: one tick per changed slot, not per frame. */
val slot: Pair<LocalDate, Int> get() = date to startMin
/**
* What the snap haptics key off: one tick per changed slot, not per frame.
* Carries the length, which is all a bottom-edge resize changes.
*/
val slot: Triple<LocalDate, Int, Int> get() = Triple(date, startMin, eventSpanMin)
}
/**
@@ -256,6 +259,25 @@ data class TimelineDrop(
val clipOffsetMin: Int = 0,
)
/** Which end of an event a resize handle moves (#141). */
enum class ResizeEdge { Top, Bottom }
/** Where a finished resize asks its event to begin and end. */
data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant)
/**
* The span after dragging [edge] to [edgeMin], all in minutes from one midnight.
* The edge snaps to the grid, and never comes closer than one snap step to the
* other end — an event can't be resized away to nothing, or turned inside out.
*/
internal fun resizedSpan(edge: ResizeEdge, startMin: Int, endMin: Int, edgeMin: Float): Pair<Int, Int> {
val snapped = (edgeMin / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
return when (edge) {
ResizeEdge.Top -> minOf(snapped, endMin - DRAG_SNAP_MINUTES) to endMin
ResizeEdge.Bottom -> startMin to maxOf(snapped, startMin + DRAG_SNAP_MINUTES)
}
}
/**
* 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
@@ -328,6 +350,9 @@ class TimelineDragController {
*/
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
/** Where a finished resize is written — set by the screen that hosts this timeline. */
var onResize: (TimelineResizeDrop) -> Unit = {}
/** The selected event, if any — see [TimelineSelection]. */
var selection: TimelineSelection? by mutableStateOf(null)
private set
@@ -392,7 +417,18 @@ class TimelineDragController {
* 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).
*/
private var originSlot: Pair<LocalDate, Int>? = null
private var originSlot: Triple<LocalDate, Int, Int>? = null
/** The edge a resize is dragging, or null for a plain drag (#141). */
private var resizeEdge: ResizeEdge? = null
/** The day the resized event starts on; its edges are minutes from that midnight. */
private var resizeDay: LocalDate? = null
private var resizeStartMin = 0
private var resizeEndMin = 0
/** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */
private var resizeGrabMin = 0f
fun begin(
block: TimedBlock,
@@ -423,6 +459,8 @@ class TimelineDragController {
}
fun cancel() {
resizeEdge = null
resizeDay = null
source = null
clipOffsetMin = 0
titleLines = 0
@@ -465,6 +503,8 @@ class TimelineDragController {
.firstOrNull() ?: return
settledOnGrid = blocks.any { block ->
block.startMin == slice.startMin &&
// The end too: a bottom-edge resize keeps the start.
block.endMin == slice.startMin + slice.spanMin &&
(
block.event.eventId == landed.event.eventId ||
(
@@ -497,6 +537,114 @@ class TimelineDragController {
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
}
/**
* Grab one edge of [block] to resize it (#141). The edge follows the finger
* across days as a drag does; the other stays where it is.
*/
fun beginResize(
block: TimedBlock,
edge: ResizeEdge,
zone: TimeZone,
titleLines: Int,
pointerInRoot: Offset,
) {
val start = block.event.start.toLocalDateTime(zone)
source = block
resizeEdge = edge
resizeDay = start.date
resizeStartMin = start.time.toSecondOfDay() / 60
resizeEndMin = resizeStartMin +
(block.event.end - block.event.start).inWholeMinutes.toInt().coerceAtLeast(0)
clipOffsetMin = 0
this.titleLines = titleLines
settling = null
isDragging = true
liftedInstanceId = block.event.instanceId
pointer = pointerInRoot
resizeGrabMin = 0f
resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin
originSlot = null
recompute()
originSlot = drag?.slot
}
/**
* End a resize, handing back the event's new span — null when it never
* resolved or came back to the length it started at. Settles as [finish] does.
*/
fun finishResize(zone: TimeZone): TimelineResizeDrop? {
val landed = drag
val origin = originSlot
cancel()
if (landed == null || landed.slot == origin) return null
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
return TimelineResizeDrop(
event = landed.event,
start = wallClockInstant(landed.date, landed.eventStartMin, zone),
end = wallClockInstant(landed.date, landed.eventStartMin + landed.eventSpanMin, zone),
)
}
/**
* Where the finger is, in minutes from the resized event's start day —
* past 1440 in a later column, negative in an earlier one.
*/
private fun pointerResizeMinutes(): Float? {
val grid = geometry.grid?.takeIf { it.isAttached } ?: return null
val day = resizeDay ?: return null
val hourPx = geometry.hourPx
val columnPx = geometry.columnWidthPx
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 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
}
private fun recomputeResize(edge: ResizeEdge) {
val block = source ?: return
val day = resizeDay ?: return
val raw = pointerResizeMinutes() ?: return
val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, raw - resizeGrabMin)
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()
val visible = -startIndex..days.lastIndex - startIndex
val slices = dragSlices(start, end - start, within = visible)
drag = TimelineDrag(
event = block.event,
date = day,
startMin = start,
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,
)
},
edgeOnly = slices.isEmpty(),
)
}
/**
* Stop ghosting the source — the grid draws the drop itself by now — while
* the copy is still dissolving. Un-ghosting at [release] instead would leave
@@ -520,6 +668,10 @@ class TimelineDragController {
* no move events while auto-scroll walks the content underneath it.
*/
fun recompute() {
resizeEdge?.let {
recomputeResize(it)
return
}
val block = source ?: return
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
val hourPx = geometry.hourPx
@@ -647,14 +799,18 @@ fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
* of its own; `toInstant` resolves it forward by the missing hour, and the block
* visibly settles there.
*/
fun TimelineDrop.startInstant(zone: TimeZone): Instant {
// Wall clock throughout, and past this day at either end: a block's top edge
// can be dragged above its midnight and a tail's below the next one, so the
// start is resolved as a day plus a minute of it rather than as an offset
// from an instant — which would shift by an hour across a DST boundary.
val fromMidnight = startMin - clipOffsetMin
val day = date.plus(fromMidnight.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
val minute = fromMidnight.mod(MINUTES_PER_DAY)
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
wallClockInstant(date, startMin - clipOffsetMin, zone)
/**
* [minutes] past [date]'s midnight as an instant, read as wall clock in [zone].
* Resolved as a day plus a minute of it rather than as an offset from an
* instant, which would shift by an hour across a DST boundary; a block can be
* dragged above its midnight and past the next one, so either end may overflow.
*/
fun wallClockInstant(date: LocalDate, minutes: Int, zone: TimeZone): Instant {
val day = date.plus(minutes.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
val minute = minutes.mod(MINUTES_PER_DAY)
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
}
@@ -63,7 +63,14 @@ import androidx.activity.compose.BackHandler
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.zIndex
import androidx.compose.ui.geometry.Offset
import de.jeanlucmakiola.calendula.ui.common.PickUp
import de.jeanlucmakiola.calendula.ui.common.ResizeEdge
import de.jeanlucmakiola.calendula.ui.common.ResizeHandle
import de.jeanlucmakiola.calendula.ui.common.ResizeHandleTarget
import de.jeanlucmakiola.calendula.ui.common.TimelineResizeDrop
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.PublishSelection
import de.jeanlucmakiola.calendula.ui.common.eventSelectAllowed
import de.jeanlucmakiola.calendula.ui.common.selectionLift
@@ -537,6 +544,22 @@ private fun WeekContent(
)
}
// A resize keeps its event selected, at its new length (#141).
dragController.onResize = { drop: TimelineResizeDrop ->
dragController.select(
drop.event.copy(start = drop.start, end = drop.end),
drop.start.toLocalDateTime(zone).date,
)
move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
target = MoveTarget.Span(drop.start, drop.end),
),
)
}
// A long-pressed event stays selected (#141) until something else is touched:
// back, another page, a view switch, or any tap.
val selection = dragController.selection
@@ -1557,44 +1580,89 @@ private fun EventBlock(
val lifted = draggable && dragController.ghosts(block)
val ghost = ghostAlpha(lifted)
val raise = selectionLift(selected)
// Handles on the selected event's own ends — not on a cut at midnight — while
// nothing is in flight. A read-only event is selected but keeps its length.
val showHandles = selected && draggable && !lifted && !dragController.isDragging
Box(
modifier = (if (dimmed && !selected) modifier.alpha(EventDimAlpha) else modifier)
.zIndex(if (selected) 1f else 0f)
// The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.then(if (raise > 0f) Modifier.selectionLift(raise, shape) else Modifier)
.eventSurface(paint, shape, cuts)
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
},
.then(if (raise > 0f) Modifier.selectionLift(raise, shape) else Modifier),
) {
Column {
if (showTitle) {
BlockTitle(
title = title,
maxLines = titleMaxLines,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
Box(
modifier = Modifier
.fillMaxSize()
.eventSurface(paint, shape, cuts)
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
},
) {
Column {
if (showTitle) {
BlockTitle(
title = title,
maxLines = titleMaxLines,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
)
}
if (showTime) {
BlockTimeLabel(
label = shownTime,
color = paint.secondaryInk,
maxLines = timeMaxLines,
)
}
}
}
if (showHandles) {
val accent = eventAccent(block.event.color, dark, LocalSoftenColors.current)
val handleLines = if (showTitle) titleMaxLines else 0
val begin = { edge: ResizeEdge ->
{ pointer: Offset ->
dragController.beginResize(block, edge, zone, handleLines, pointer)
}
}
val end = { dragController.finishResize(zone)?.let(dragController.onResize) }
if (!cuts.top) {
ResizeHandle(
color = accent,
onStart = begin(ResizeEdge.Top),
onMove = dragController::move,
onEnd = { end() },
onCancel = dragController::cancel,
modifier = Modifier
.align(Alignment.TopStart)
.offset(x = HANDLE_INSET - ResizeHandleTarget / 2, y = -ResizeHandleTarget / 2),
)
}
if (showTime) {
BlockTimeLabel(
label = shownTime,
color = paint.secondaryInk,
maxLines = timeMaxLines,
if (!cuts.bottom) {
ResizeHandle(
color = accent,
onStart = begin(ResizeEdge.Bottom),
onMove = dragController::move,
onEnd = { end() },
onCancel = dragController::cancel,
modifier = Modifier
.align(Alignment.BottomEnd)
.offset(x = ResizeHandleTarget / 2 - HANDLE_INSET, y = ResizeHandleTarget / 2),
)
}
}
}
}
/** How far in from the block's side a resize handle's centre sits. */
private val HANDLE_INSET = 16.dp
private val SKELETON_HEADER_HEIGHT = 36.dp
private val SKELETON_HEADER_PADDING = 8.dp
@@ -0,0 +1,59 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/** Where a dragged resize handle leaves an event's ends (#141). */
class ResizedSpanTest {
// 09:00–10:00, in minutes from its day's midnight.
private val start = 9 * 60
private val end = 10 * 60
@Test
fun `the bottom edge snaps and leaves the start alone`() {
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 11 * 60 + 8f))
.isEqualTo(start to 11 * 60 + 15)
}
@Test
fun `the top edge snaps and leaves the end alone`() {
assertThat(resizedSpan(ResizeEdge.Top, start, end, 8 * 60 - 7f))
.isEqualTo(8 * 60 to end)
}
@Test
fun `an edge stops one snap step short of the other end`() {
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 7 * 60f))
.isEqualTo(start to start + DRAG_SNAP_MINUTES)
assertThat(resizedSpan(ResizeEdge.Top, start, end, 12 * 60f))
.isEqualTo(end - DRAG_SNAP_MINUTES to end)
}
@Test
fun `the bottom edge runs on past midnight`() {
assertThat(resizedSpan(ResizeEdge.Bottom, start, end, 24 * 60 + 90f))
.isEqualTo(start to 24 * 60 + 90)
}
@Test
fun `the top edge runs back into the day before`() {
assertThat(resizedSpan(ResizeEdge.Top, start, end, -60f))
.isEqualTo(-60 to end)
}
@Test
fun `wall clock minutes past either midnight land on the neighbouring day`() {
val zone = TimeZone.of("Europe/Berlin")
val day = LocalDate(2026, 10, 9)
assertThat(wallClockInstant(day, 24 * 60 + 30, zone))
.isEqualTo(LocalDateTime(LocalDate(2026, 10, 10), LocalTime(0, 30)).toInstant(zone))
assertThat(wallClockInstant(day, -30, zone))
.isEqualTo(LocalDateTime(LocalDate(2026, 10, 8), LocalTime(23, 30)).toInstant(zone))
}
}