mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-03-31 20:36:19 +00:00
state management
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user