From c8d3d21ba399b4aa90203895ced646276ec9b8e9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 9 Oct 2026 11:13:44 +0200 Subject: [PATCH] Resize a selected event from handles on its ends (#141) --- .../calendula/ui/common/ResizeHandle.kt | 103 ++++++++++ .../calendula/ui/common/TimelineDrag.kt | 178 ++++++++++++++++-- .../calendula/ui/week/WeekScreen.kt | 116 +++++++++--- .../calendula/ui/common/ResizedSpanTest.kt | 59 ++++++ 4 files changed, 421 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ResizeHandle.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ResizedSpanTest.kt 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 new file mode 100644 index 00000000..eeec55e8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ResizeHandle.kt @@ -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(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), + ) + } +} 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 d4fde98b..18449cd8 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 @@ -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 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 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 { + 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? 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? = null + private var originSlot: Triple? = 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) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 0f0e511a..b8d6f574 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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 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 new file mode 100644 index 00000000..2c30b1b1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ResizedSpanTest.kt @@ -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)) + } +}