Skip to content

Instantly share code, notes, and snippets.

@dzfranklin
Last active March 1, 2021 20:17
Show Gist options
  • Save dzfranklin/db21db2598a93294533764bcc4e6c5e8 to your computer and use it in GitHub Desktop.
Save dzfranklin/db21db2598a93294533764bcc4e6c5e8 to your computer and use it in GitHub Desktop.
// NOTE: PageRenderer translates coordinates and offsets relative to the start of the page
// to coordinates relative to the backing MultiParagraph, calls functions on it, and
// translates the return values
package org.danielzfranklin.librereader.ui.screen.reader.paginatedText
import android.os.Parcelable
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.text.selection.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.layout.*
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.TextToolbar
import androidx.compose.ui.platform.TextToolbarStatus
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.style.ResolvedTextDirection
import androidx.compose.ui.unit.*
import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastFirstOrNull
import kotlinx.coroutines.*
import kotlinx.parcelize.Parcelize
import org.danielzfranklin.librereader.ui.screen.reader.PageRenderer
import org.danielzfranklin.librereader.util.contains
import timber.log.Timber
import kotlin.coroutines.CoroutineContext
import kotlin.math.max
internal fun Modifier.selectablePageText(
page: PageRenderer,
enabled: State<Boolean>,
manager: PageTextSelectionManager,
) = if (!enabled.value) this else graphicsLayer().composed {
// Based on Compose MultiWidgetSelectionDelegate, with many features we don't need stripped out
val colors = LocalTextSelectionColors.current
pointerInput(page) {
while (true) {
val down = awaitPointerEventScope { awaitFirstDown(requireUnconsumed = false) }
val position = down.position.round()
val inSelection = manager.selection.value != null
when {
inSelection && manager.insideHandle(position, true) -> {
awaitHandleDrag(manager, down, true)
}
inSelection && manager.insideHandle(position, false) -> {
awaitHandleDrag(manager, down, false)
}
!inSelection -> {
awaitWordBasedDrag(manager, down)
}
manager.insideSelection(position) -> {
down.consumeAllChanges()
awaitChangedToUp(down.id)
}
else -> {
manager.deselect()
}
}
}
} then graphicsLayer().drawBehind {
val selection = manager.selection.value ?: return@drawBehind
val highlight = manager.computeHighlightPath(selection)
drawPath(highlight, colors.backgroundColor)
val startHandle = manager.computeHandlePath(selection, true)
drawPath(startHandle, colors.handleColor)
val endHandle = manager.computeHandlePath(selection, false)
drawPath(endHandle, colors.handleColor)
}
}
private suspend fun PointerInputScope.awaitHandleDrag(
manager: PageTextSelectionManager,
down: PointerInputChange,
draggingStartHandle: Boolean
) {
down.consumeAllChanges()
manager.hideSelectionToolbar()
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if (event.isPointerUp(down.id)) break
val drag = event.changes.fastFirstOrNull { it.id == down.id } ?: break
manager.selection.value ?: break
if (draggingStartHandle) {
manager.update(
startPosition = drag.position,
isStartHandleDragged = true
)
} else {
manager.update(
endPosition = drag.position,
)
}
drag.consumeAllChanges()
}
}
manager.showSelectionToolbar()
}
private suspend fun PointerInputScope.awaitWordBasedDrag(
manager: PageTextSelectionManager,
down: PointerInputChange
) {
down.consumeAllChanges()
manager.hideSelectionToolbar()
manager.update(
startPosition = down.position,
endPosition = down.position,
wordBased = true,
)
if (awaitLongPressOrCancellation(down) == null) {
manager.showSelectionToolbar()
return
}
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if (event.isPointerUp(down.id)) break
val drag = event.changes.fastFirstOrNull { it.id == down.id } ?: break
manager.update(
endPosition = drag.position,
wordBased = true,
)
drag.consumeAllChanges()
}
}
manager.showSelectionToolbar()
}
private suspend fun PointerInputScope.awaitChangedToUp(id: PointerId) {
awaitPointerEventScope {
do {
val event = awaitPointerEvent().changes.fastFirstOrNull { it.id == id }
event?.consumeAllChanges()
} while (event != null && !event.changedToUpIgnoreConsumed())
}
}
internal class PageTextSelectionManager(
override val coroutineContext: CoroutineContext,
private val page: PageRenderer,
private val textToolbar: TextToolbar,
private val clipboardManager: ClipboardManager,
private val density: Density,
initialSelection: Selection? = null
) : CoroutineScope {
// Loosely based off of SelectionManager (See usage in SelectionContainer)
private val _selection = mutableStateOf(initialSelection)
val selection: State<Selection?> = _selection
internal fun insideSelection(offset: IntOffset): Boolean {
val selection = _selection.value ?: return false
return computeHighlightPath(selection).contains(offset)
}
internal fun insideHandle(offset: IntOffset, isStartHandle: Boolean): Boolean {
val selection = _selection.value ?: return false
return computeHandlePath(selection, isStartHandle).contains(offset)
}
internal fun handlePosition(selection: Selection, isStartHandle: Boolean): Offset {
val absolute = if (!selection.handlesCrossed) isStartHandle else !isStartHandle
return if (absolute) {
page.getBoundingBox(selection.start.offset).bottomLeft
} else {
page.getBoundingBox(selection.end.offset).bottomLeft
}
}
// TODO cache result
internal fun computeHighlightPath(selection: Selection): Path {
val range = selection.toTextRange()
if (range.min == range.max) return Path()
return page.getPathForRange(range.min, range.max)
}
// TODO: Cache results
internal fun computeHandlePath(selection: Selection, isStartHandle: Boolean): Path {
val directions = selection.start.direction to selection.end.direction
val isLeft = isLeft(isStartHandle, directions, selection.handlesCrossed)
val position = handlePosition(selection, isStartHandle)
// Path dimensions copied from Compose
return Path().apply {
with(density) {
addRect(
Rect(
top = 0f,
bottom = 0.5f * HANDLE_HEIGHT.toPx(),
left = if (isLeft) 0.5f * HANDLE_WIDTH.toPx() else 0f,
right = if (isLeft) HANDLE_WIDTH.toPx() else 0.5f * HANDLE_WIDTH.toPx()
)
)
addOval(
Rect(
top = 0f,
bottom = HANDLE_HEIGHT.toPx(),
left = 0f,
right = HANDLE_WIDTH.toPx()
)
)
translate(position)
if (isLeft) {
translate(Offset(-HANDLE_WIDTH.toPx(), 0f))
}
}
}
}
private fun getSelectionInfo(
startPosition: Offset,
endPosition: Offset,
previous: Selection?,
wordBased: Boolean = false,
isStartHandleDragged: Boolean = false,
): Selection? {
// Based on Compose MultiWidgetSelectionDelegate getSelectionInfo, processAsSingleComposable,
// and getRefinedSelectionInfo, with many features we don't need stripped out
var startOffset = page.getOffsetForPosition(startPosition)
var endOffset = page.getOffsetForPosition(endPosition)
if (startOffset == endOffset) {
// If the start and end offset are at the same character, and it's not the initial
// selection, then bound to at least one character.
val textRange = ensureAtLeastOneChar(
offset = startOffset,
lastOffset = page.lastOffset,
previousSelection = previous?.toTextRange(),
isStartHandle = isStartHandleDragged,
handlesCrossed = previous?.handlesCrossed ?: false
)
startOffset = textRange.start
endOffset = textRange.end
}
// nothing is selected
if (startOffset == -1 && endOffset == -1) return null
// Check if the start and end handles are crossed each other.
val handlesCrossed = startOffset > endOffset
// If under long press, update the selection to word-based.
if (wordBased) {
val startWordBoundary = page.getWordBoundary(startOffset.coerceIn(0, page.lastOffset))
val endWordBoundary = page.getWordBoundary(endOffset.coerceIn(0, page.lastOffset))
// If handles are not crossed, start should be snapped to the start of the word containing the
// start offset, and end should be snapped to the end of the word containing the end offset.
// If handles are crossed, start should be snapped to the end of the word containing the start
// offset, and end should be snapped to the start of the word containing the end offset.
startOffset = if (handlesCrossed) startWordBoundary.end else startWordBoundary.start
endOffset = if (handlesCrossed) endWordBoundary.start else endWordBoundary.end
}
return Selection(
start = Selection.AnchorInfo(
direction = page.getBidiRunDirection(startOffset),
offset = startOffset,
),
end = Selection.AnchorInfo(
direction = page.getBidiRunDirection(max(endOffset - 1, 0)),
offset = endOffset,
),
handlesCrossed = handlesCrossed
)
}
fun deselect() {
_selection.value = null
hideSelectionToolbar()
}
var lastStartPosition: Offset? = null
var lastEndPosition: Offset? = null
internal fun update(
startPosition: Offset? = null,
endPosition: Offset? = null,
wordBased: Boolean = false,
isStartHandleDragged: Boolean = false
) {
val actualStart = startPosition ?: lastStartPosition!!
val actualEnd = endPosition ?: lastEndPosition!!
_selection.value = getSelectionInfo(
startPosition = actualStart,
endPosition = actualEnd,
previous = _selection.value,
wordBased = wordBased,
isStartHandleDragged = isStartHandleDragged
)
lastStartPosition = actualStart
lastEndPosition = actualEnd
}
internal fun showSelectionToolbar() {
val selection = _selection.value ?: return
textToolbar.showMenu(
selectionRect(selection),
onCopyRequested = {
copySelectionToClipboard()
hideSelectionToolbar()
}
)
}
internal fun hideSelectionToolbar() {
if (textToolbar.status == TextToolbarStatus.Shown) {
textToolbar.hide()
}
}
private fun copySelectionToClipboard() {
val selection = _selection.value ?: return
val first = if (!selection.handlesCrossed) selection.start else selection.end
val end = if (!selection.handlesCrossed) selection.end else selection.start
val text = page.getText(first.offset, end.offset + 1)
clipboardManager.setText(text)
deselect()
}
/**
* Copied from compose
*
* Calculate selected region as Rect. The top is the top of the first selected line, and the
* bottom is the bottom of the last selected line. The left is the leftmost handle's horizontal
* coordinates, and the right is the rightmost handle's coordinates. */
private fun selectionRect(selection: Selection): Rect {
val start = page.getBoundingBox(selection.start.offset)
val end = page.getBoundingBox(selection.end.offset)
val leftmost = if (start.left < end.left) start else end
val rightmost = if (start.right > end.right) start else end
val top = if (!selection.handlesCrossed) start.top else end.top
val bottom = if (!selection.handlesCrossed) end.bottom else start.bottom
return Rect(
top = top,
bottom = bottom,
left = leftmost.left,
right = rightmost.right
)
}
/**
* Copied from Compose
*
* Computes whether the handle's appearance should be left-pointing or right-pointing.
*/
private fun isLeft(
isStartHandle: Boolean,
directions: Pair<ResolvedTextDirection, ResolvedTextDirection>,
handlesCrossed: Boolean
): Boolean {
return if (isStartHandle) {
isHandleLtrDirection(directions.first, handlesCrossed)
} else {
!isHandleLtrDirection(directions.second, handlesCrossed)
}
}
/**
* Copied from compose
*
* This method is to check if the selection handles should use the natural Ltr pointing
* direction.
* If the context is Ltr and the handles are not crossed, or if the context is Rtl and the handles
* are crossed, return true.
*
* In Ltr context, the start handle should point to the left, and the end handle should point to
* the right. However, in Rtl context or when handles are crossed, the start handle should point to
* the right, and the end handle should point to left.
*/
private fun isHandleLtrDirection(
direction: ResolvedTextDirection,
areHandlesCrossed: Boolean
): Boolean {
return direction == ResolvedTextDirection.Ltr && !areHandlesCrossed ||
direction == ResolvedTextDirection.Rtl && areHandlesCrossed
}
@Parcelize
private data class SaverData(val selection: Selection?) : Parcelable
companion object {
fun saver(
coroutineContext: CoroutineContext,
page: PageRenderer,
textToolbar: TextToolbar,
clipboardManager: ClipboardManager,
density: Density,
): Saver<PageTextSelectionManager, *> = Saver(
save = { SaverData(it.selection.value) },
restore = {
PageTextSelectionManager(
coroutineContext,
page,
textToolbar,
clipboardManager,
density,
it.selection
)
}
)
private val HANDLE_WIDTH = 25.dp
private val HANDLE_HEIGHT = 25.dp
}
}
/**
* Copied from Compose
*
* This method adjusts the raw start and end offset and bounds the selection to one character. The
* logic of bounding evaluates the last selection result, which handle is being dragged, and if
* selection reaches the boundary.
*
* @param offset unprocessed start and end offset calculated directly from input position, in
* this case start and offset equals to each other.
* @param lastOffset last offset of the text. It's actually the length of the text.
* @param previousSelection previous selected text range.
* @param isStartHandle true if the start handle is being dragged
* @param handlesCrossed true if the selection handles are crossed
*
* @return the adjusted [TextRange].
*/
private fun ensureAtLeastOneChar(
offset: Int,
lastOffset: Int,
previousSelection: TextRange?,
isStartHandle: Boolean,
handlesCrossed: Boolean
): TextRange {
// When lastOffset is 0, it can only return an empty TextRange.
// When previousSelection is null, it won't start a selection and return an empty TextRange.
if (lastOffset == 0 || previousSelection == null) return TextRange(offset, offset)
// When offset is at the boundary, the handle that is not dragged should be at [offset]. Here
// the other handle's position is computed accordingly.
if (offset == 0) {
return if (isStartHandle) {
TextRange(1, 0)
} else {
TextRange(0, 1)
}
}
if (offset == lastOffset) {
return if (isStartHandle) {
TextRange(lastOffset - 1, lastOffset)
} else {
TextRange(lastOffset, lastOffset - 1)
}
}
// In other cases, this function will try to maintain the current cross handle states.
// Only in this way the selection can be stable.
return if (isStartHandle) {
if (!handlesCrossed) {
// Handle is NOT crossed, and the start handle is dragged.
TextRange(offset - 1, offset)
} else {
// Handle is crossed, and the start handle is dragged.
TextRange(offset + 1, offset)
}
} else {
if (!handlesCrossed) {
// Handle is NOT crossed, and the end handle is dragged.
TextRange(offset, offset + 1)
} else {
// Handle is crossed, and the end handle is dragged.
TextRange(offset, offset - 1)
}
}
}
/**
* Copied from Compose
* Information about the current Selection.
*/
@Immutable
@Parcelize
internal data class Selection(
/**
* Information about the start of the selection.
*/
val start: AnchorInfo,
/**
* Information about the end of the selection.
*/
val end: AnchorInfo,
/**
* The flag to show that the selection handles are dragged across each other. After selection
* is initialized, if user drags one handle to cross the other handle, this is true, otherwise
* it's false.
*/
// If selection happens in single widget, checking [TextRange.start] > [TextRange.end] is
// enough.
// But when selection happens across multiple widgets, this value needs more complicated
// calculation. To avoid repeated calculation, making it as a flag is cheaper.
val handlesCrossed: Boolean = false
) : Parcelable {
/**
* Contains information about an anchor (start/end) of selection.
*/
@Immutable
@Parcelize
internal data class AnchorInfo(
/**
* Text direction of the character in selection edge.
*/
val direction: ResolvedTextDirection,
/**
* Character offset for the selection edge. This offset is within the page
*/
val offset: Int,
) : Parcelable
/**
* Returns the selection offset information as a [TextRange]
*/
fun toTextRange(): TextRange {
return TextRange(start.offset, end.offset)
}
}
private suspend fun PointerInputScope.awaitLongPressOrCancellation(
initialDown: PointerInputChange
): PointerInputChange? {
var longPress: PointerInputChange? = null
var currentDown = initialDown
val longPressTimeout = viewConfiguration.longPressTimeoutMillis
return try {
// wait for first tap up or long press
withTimeout(longPressTimeout) {
awaitPointerEventScope {
var finished = false
while (!finished) {
val event = awaitPointerEvent(PointerEventPass.Main)
if (event.changes.fastAll { it.changedToUpIgnoreConsumed() }) {
// All pointers are up
finished = true
}
if (
event.changes.fastAny { it.consumed.downChange || it.isOutOfBounds(size) }
) {
finished = true // Canceled
}
// Check for cancel by position consumption. We can look on the Final pass of
// the existing pointer event because it comes after the Main pass we checked
// above.
val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
if (consumeCheck.changes.fastAny { it.positionChangeConsumed() }) {
finished = true
}
if (!event.isPointerUp(currentDown.id)) {
longPress = event.changes.firstOrNull { it.id == currentDown.id }
} else {
val newPressed = event.changes.fastFirstOrNull { it.pressed }
if (newPressed != null) {
currentDown = newPressed
longPress = currentDown
} else {
// should technically never happen as we checked it above
finished = true
}
}
}
}
}
null
} catch (_: TimeoutCancellationException) {
longPress ?: initialDown
}
}
private fun PointerEvent.isPointerUp(pointerId: PointerId): Boolean =
changes.firstOrNull { it.id == pointerId }?.pressed != true
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment