From 7b78d6855152f7662f8ed6a667cf0905dd28156c Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:03:13 +0000 Subject: [PATCH] state management --- .../simplex/common/views/chat/ChatView.kt | 2 +- .../common/views/chat/TextSelection.kt | 51 +++++----- plans/2026-03-29-desktop-text-selection.md | 96 ++++++++++++++++--- 3 files changed, 106 insertions(+), 43 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index dfebe93907..7da0a857ec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -966,7 +966,7 @@ fun ChatLayout( val selectionManager = if (appPlatform.isDesktop) remember { SelectionManager() } else null if (selectionManager != null) { LaunchedEffect(selectionManager) { - snapshotFlow { selectionManager.selectionActive } + snapshotFlow { selectionManager.selectionState != SelectionState.Idle } .collect { chatsCtx.chatState.selectionActive = it } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt index 2c0ae3e336..0103ebc70f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt @@ -66,15 +66,15 @@ interface SelectionParticipant { fun calculateHighlightRange(coords: SelectionCoords): IntRange? } +enum class SelectionState { Idle, Selecting, Selected } + class SelectionManager { + var selectionState by mutableStateOf(SelectionState.Idle) + private set + var coords by mutableStateOf(null) private set - var isSelecting by mutableStateOf(false) - private set - - val selectionActive: Boolean get() = coords != null - var lastPointerWindowY: Float = 0f private set var lastPointerWindowX: Float = 0f @@ -94,7 +94,7 @@ class SelectionManager { fun startSelection(startY: Float, startX: Float) { coords = SelectionCoords(startY, startX, startY, startX) - isSelecting = true + selectionState = SelectionState.Selecting lastPointerWindowY = startY lastPointerWindowX = startX captured.clear() @@ -109,12 +109,12 @@ class SelectionManager { } fun endSelection() { - isSelecting = false + selectionState = SelectionState.Selected } fun clearSelection() { coords = null - isSelecting = false + selectionState = SelectionState.Idle captured.clear() } @@ -236,7 +236,7 @@ fun BoxScope.SelectionHandler( LaunchedEffect(manager) { snapshotFlow { listState.value.firstVisibleItemScrollOffset } .collect { - if (manager.isSelecting) { + if (manager.selectionState == SelectionState.Selecting) { manager.updateSelection( manager.lastPointerWindowY, manager.lastPointerWindowX @@ -246,7 +246,7 @@ fun BoxScope.SelectionHandler( } // Copy button - if (manager.captured.isNotEmpty() && !manager.isSelecting) { + if (manager.selectionState == SelectionState.Selected) { SelectionCopyButton( onCopy = { clipboard.setText(AnnotatedString(manager.getSelectedText())) @@ -258,7 +258,7 @@ fun BoxScope.SelectionHandler( .focusRequester(focusRequester) .focusable() .onKeyEvent { event -> - if (manager.captured.isNotEmpty() + if (manager.selectionState == SelectionState.Selected && (event.isCtrlPressed || event.isMetaPressed) && event.key == Key.C && event.type == KeyEventType.KeyDown @@ -277,6 +277,11 @@ fun BoxScope.SelectionHandler( awaitEachGesture { val down = awaitPointerEvent(PointerEventPass.Initial) val firstChange = down.changes.first() + if (!firstChange.pressed) return@awaitEachGesture // skip hover, scroll + + val wasSelected = manager.selectionState == SelectionState.Selected + if (wasSelected) firstChange.consume() // prevent link/menu activation on click-to-clear + val localStart = firstChange.position val windowStart = localStart + positionInWindow var totalDrag = Offset.Zero @@ -290,11 +295,11 @@ fun BoxScope.SelectionHandler( autoScrollJob?.cancel() autoScrollJob = null if (isDragging) { - manager.endSelection() - } else if (manager.captured.isNotEmpty()) { - // Click without drag clears selection - manager.clearSelection() + manager.endSelection() // Selecting → Selected + } else if (wasSelected) { + manager.clearSelection() // Selected → Idle } + // Idle + click: do nothing, event passed through to children break } @@ -302,7 +307,7 @@ fun BoxScope.SelectionHandler( if (!isDragging && totalDrag.getDistance() > touchSlop) { isDragging = true - manager.startSelection(windowStart.y, windowStart.x) + manager.startSelection(windowStart.y, windowStart.x) // → Selecting try { focusRequester.requestFocus() } catch (_: Exception) {} change.consume() } @@ -323,7 +328,7 @@ fun BoxScope.SelectionHandler( if (shouldAutoScroll && autoScrollJob?.isActive != true) { autoScrollJob = scope.launch { - while (isActive && manager.isSelecting) { + while (isActive && manager.selectionState == SelectionState.Selecting) { val curEdge = if (draggingDown) { viewportBottom - manager.lastPointerWindowY } else { @@ -331,13 +336,8 @@ fun BoxScope.SelectionHandler( } if (curEdge >= AUTO_SCROLL_ZONE_PX) break - val speed = lerp( - MIN_SCROLL_SPEED, MAX_SCROLL_SPEED, - 1f - (curEdge / AUTO_SCROLL_ZONE_PX).coerceIn(0f, 1f) - ) - // reverseLayout = true: - // drag down (toward newer) = scrollBy(-speed) - // drag up (toward older) = scrollBy(speed) + val fraction = 1f - (curEdge / AUTO_SCROLL_ZONE_PX).coerceIn(0f, 1f) + val speed = MIN_SCROLL_SPEED + (MAX_SCROLL_SPEED - MIN_SCROLL_SPEED) * fraction listState.value.scrollBy(if (draggingDown) -speed else speed) delay(16) } @@ -369,6 +369,3 @@ private fun BoxScope.SelectionCopyButton(onCopy: () -> Unit) { Text(generalGetString(MR.strings.copy_verb), color = MaterialTheme.colors.primary) } } - -private fun lerp(start: Float, stop: Float, fraction: Float): Float = - start + (stop - start) * fraction diff --git a/plans/2026-03-29-desktop-text-selection.md b/plans/2026-03-29-desktop-text-selection.md index b94f45fa7e..fdbb58ab74 100644 --- a/plans/2026-03-29-desktop-text-selection.md +++ b/plans/2026-03-29-desktop-text-selection.md @@ -83,30 +83,96 @@ annotatedTextState?.value = annotatedText.text // .text is documented Annotated The participant reads `annotatedTextState.value.substring(clampedStart, clampedEnd)` for copy. Fallback: `ci.text` if state is empty. -### Overlay +### Pointer Handling -Overlay sits on top of LazyColumnWithScrollBar. Uses `PointerEventPass.Initial` -to observe pointer events without consuming. Only consumes after drag threshold -(differentiates click from drag). +Selection pointer handler is a `pointerInput` modifier on the LazyColumnWithScrollBar +modifier chain (parent of content, NOT a sibling overlay). Uses `PointerEventPass.Initial` +to observe before children. All handler logic is in `SelectionHandler` composable +(TextSelection.kt) which returns the Modifier and emits the copy button. -Scroll wheel events are NEVER consumed — pass through to LazyColumn. +Scroll wheel and hover events are never processed — skipped immediately. -### Selection Lifecycle +### Selection State Machine -- **Click without drag**: Does nothing. Selection is NOT cleared. Links work via pass-through. -- **New drag**: Clears any existing selection, starts new one (`startSelection` calls `captured.clear()`). -- **Right-click**: Does nothing to selection. `contextMenuOpenDetector` handles it independently. - No need to detect mouse button — we simply never clear on non-drag pointer-up. -- **Ctrl+C**: Copies selected text to clipboard. -- **Copy button**: Appears after drag ends when `captured.isNotEmpty()`. Has explicit dismiss. -- **Dismiss**: Copy button dismiss clears selection. Or starting a new drag clears it. +Three explicit states: -This avoids needing `PointerEvent.button` API (not used in codebase, availability uncertain). +``` + drag threshold + Idle ─────────────────→ Selecting + ↑ │ + │ click │ pointer up + │ ▼ + ←──────────────────── Selected +``` + +```kotlin +enum class SelectionState { Idle, Selecting, Selected } + +// Derived from existing fields: +val state: SelectionState get() = when { + isSelecting -> Selecting + captured.isNotEmpty() -> Selected + else -> Idle +} +``` + +### Pointer Handler Behavior Per State + +The handler starts each gesture by waiting for a pointer event. Non-press events +(hover, scroll) are skipped immediately (`return@awaitEachGesture`). + +For press events, the current state is captured at gesture start (`wasSelected`) +and not re-read mid-gesture. Behavior depends on this captured state: + +**Idle state** (no selection exists): +- Down event: NOT consumed → passes through to children (links, context menus) +- Drag threshold exceeded: consume, → Selecting +- Pointer up without drag: do nothing → children handle the click + +**Selecting state** (drag in progress): +- Pointer move: update selection coords, consume +- Pointer up: → Selected + +**Selected state** (selection exists, drag finished): +- Down event: CONSUMED → prevents children from firing links/menus +- Drag threshold exceeded: clear old selection, start new → Selecting +- Pointer up without drag: clear selection → Idle +- Hover/scroll: skipped, selection persists + +Key insight: consuming the down event in Selected state prevents link activation +on click-to-clear. In Idle state, not consuming allows links/menus to work normally. + +### Ctrl+C / Cmd+C + +Handled via `onKeyEvent` on the LazyColumn modifier (inside `SelectionHandler`). +Checks both `isCtrlPressed` (Windows/Linux) and `isMetaPressed` (Mac). +Focus is requested to the LazyColumn area when selection starts (`focusRequester.requestFocus()`). +When user taps compose box, focus moves there — Ctrl+C goes to compose box's handler. + +### Copy Button + +Emitted by `SelectionHandler` in BoxScope. Visible when `state == Selected`. +Copies text to clipboard without clearing selection (user may want to copy again). +Selection clears on click in chat area or on starting a new drag. + +### Selection Lifecycle Summary + +- **Hover/scroll in any state**: Ignored by handler, passes through to LazyColumn. +- **Click in Idle**: Passes through — links, context menus, long-click all work. +- **Drag from Idle**: New selection → Selecting → pointer up → Selected. +- **Click in Selected**: Consumes click, clears selection → Idle. +- **Drag from Selected**: Consumes, clears old, starts new → Selecting. +- **Right-click**: May arrive as a press event. In Idle state: not consumed, + `contextMenuOpenDetector` on the bubble handles it. In Selected state: consumed, + clears selection (same as left click). Needs empirical verification — if right-click + should preserve selection, filter by button in the handler. +- **Ctrl+C / Cmd+C**: Copies selected text when LazyColumn has focus. +- **Copy button click**: Copies selected text (works regardless of focus). ### Coordinate System All coordinates in window space during drag computation: -- Overlay: `positionInWindow()` + local pointer → window coords +- Handler: `positionInWindow()` + local pointer → window coords - Items: `boundsInWindow()` → window coords - `calculateRangeForElement` adjusts X by `bounds.left` for `getOffsetForPosition`