state management

This commit is contained in:
Evgeny @ SimpleX Chat
2026-03-31 12:03:13 +00:00
parent 539f6db8e5
commit 7b78d68551
3 changed files with 106 additions and 43 deletions

View File

@@ -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 }
}
}

View File

@@ -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<SelectionCoords?>(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

View File

@@ -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`