All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.androidx.compose.foundation.text.selection.SelectionLayout.kt Maven / Gradle / Ivy

/*
 * Copyright 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.compose.foundation.text.selection

import androidx.collection.LongIntMap
import androidx.collection.LongObjectMap
import androidx.collection.MutableLongIntMap
import androidx.collection.MutableLongObjectMap
import androidx.collection.longObjectMapOf
import androidx.collection.mutableLongIntMapOf
import androidx.collection.mutableLongObjectMapOf
import androidx.compose.foundation.internal.checkPrecondition
import androidx.compose.foundation.text.selection.Direction.AFTER
import androidx.compose.foundation.text.selection.Direction.BEFORE
import androidx.compose.foundation.text.selection.Direction.ON
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.style.ResolvedTextDirection
import androidx.compose.ui.util.fastForEachIndexed

/**
 * Selection data around how the pointer relates to the actual positions of the Text components.
 *
 * # Explanation of Slots
 *
 * The Slot value is meant to sit either *on* an index or *between* indices. The former means the
 * pointer is on a `Text` (like slot value `1` and index `0` below). The latter means the pointer is
 * not on a `Text`, but between `Text`s (like slot value `0` or `2` below). So a slot value of `2`
 * means that the pointer is between the `Text`s at index `0` and `1`, perhaps in padding or a
 * non-selectable `Text`.
 *
 * ```
 * slot value  0  1  2  3  4  5  6  7  8  9  10
 * info index     0     1     2     3     4
 * ```
 *
 * ## Mappings:
 * The `X` represents an impossible slot assignment The `|`, `/`, and `\` represent a slot mapping.
 *
 * ### Mapping minimum slot:
 * ```
 * slot value  0  1  2  3  4  5  6  7  8  9  10
 *              \ |   \ |   \ |   \ |   \ | X
 * info index     0     1     2     3     4
 *
 * min-slot index = slot / 2
 * ```
 *
 * Minimum slot cannot be after the final `Text` (index `4`).
 *
 * ### Mapping maximum slot:
 * ```
 * slot value  0  1  2  3  4  5  6  7  8  9  10
 *              X | /   | /   | /   | /   | /
 * info index     0     1     2     3     4
 * max-slot index = (slot - 1) / 2
 * ```
 *
 * Maximum slot cannot be before the first `Text` (index `0`).
 *
 * ## Assertions
 * * The non-dragging slot should always be directly on a text (odd) because the non-dragging handle
 *   must be anchored somewhere.
 *     * Because of this, we can determine that if `startSlot == endSlot` then it also follows that
 *       `startSlot` and `endSlot` are even.
 */
internal interface SelectionLayout {
    /** The number of [SelectableInfo]s in this [SelectionLayout]. */
    val size: Int

    /** The slot of the start anchor. */
    val startSlot: Int

    /** The slot of the end anchor. */
    val endSlot: Int

    /** The [CrossStatus] of this layout as determined by the slot/offsets. */
    val crossStatus: CrossStatus

    /** The [SelectableInfo] that the start is on. */
    val startInfo: SelectableInfo

    /** The [SelectableInfo] that the end is on. */
    val endInfo: SelectableInfo

    /** The [SelectableInfo] that the start/end is on as determined by [isStartHandle]. */
    val currentInfo: SelectableInfo

    /** The [SelectableInfo] for the first selectable on the screen. */
    val firstInfo: SelectableInfo

    /** The [SelectableInfo] for the last selectable on the screen. */
    val lastInfo: SelectableInfo

    /**
     * Run a function on every [SelectableInfo] between [firstInfo] and [lastInfo] (not including
     * [firstInfo]/[lastInfo]).
     */
    fun forEachMiddleInfo(block: (SelectableInfo) -> Unit)

    /** Whether the start or end anchor is currently being moved. */
    val isStartHandle: Boolean

    /** The previous [Selection] that we are modifying. */
    val previousSelection: Selection?

    /**
     * Whether this layout, compared to another layout, has any relevant changes that would require
     * recomputing selection.
     *
     * @param other the selection layout to check for changes compared to this one
     */
    fun shouldRecomputeSelection(other: SelectionLayout?): Boolean

    /**
     * Splits a selection into a Map of selectable ID to Selections limited to that selectable ID.
     *
     * @param selection The selection to turn into subSelections
     */
    fun createSubSelections(selection: Selection): LongObjectMap
}

private class MultiSelectionLayout(
    val selectableIdToInfoListIndex: LongIntMap,
    val infoList: List,
    override val startSlot: Int,
    override val endSlot: Int,
    override val isStartHandle: Boolean,
    override val previousSelection: Selection?,
) : SelectionLayout {
    init {
        checkPrecondition(infoList.size > 1) {
            "MultiSelectionLayout requires an infoList size greater than 1, was ${infoList.size}."
        }
    }

    // Most of these properties are unused unless shouldRecomputeSelection returns true,
    // hence why getters are used everywhere.

    override val size
        get() = infoList.size

    override val crossStatus: CrossStatus
        get() =
            when {
                startSlot < endSlot -> CrossStatus.NOT_CROSSED
                startSlot > endSlot -> CrossStatus.CROSSED
                // because one of the slots is not-dragging, it must be on a text directly
                // because one of the slots is on a text directly and the start/end slots are equal,
                // they both must be odd. Given this, dividing the slot by 2 should give us the
                // correct
                // info index.
                else -> infoList[startSlot / 2].rawCrossStatus
            }

    override val startInfo: SelectableInfo
        get() = infoList[startOrEndSlotToIndex(startSlot, isStartSlot = true)]

    override val endInfo: SelectableInfo
        get() = infoList[startOrEndSlotToIndex(endSlot, isStartSlot = false)]

    override val currentInfo: SelectableInfo
        get() = if (isStartHandle) startInfo else endInfo

    override val firstInfo: SelectableInfo
        get() = if (crossStatus == CrossStatus.CROSSED) endInfo else startInfo

    override val lastInfo: SelectableInfo
        get() = if (crossStatus == CrossStatus.CROSSED) startInfo else endInfo

    override fun forEachMiddleInfo(block: (SelectableInfo) -> Unit) {
        val minIndex = getInfoListIndexBySelectableId(firstInfo.selectableId)
        val maxIndex = getInfoListIndexBySelectableId(lastInfo.selectableId)
        if (minIndex + 1 >= maxIndex) {
            return
        }

        for (i in minIndex + 1 until maxIndex) {
            block(infoList[i])
        }
    }

    override fun shouldRecomputeSelection(other: SelectionLayout?): Boolean =
        previousSelection == null ||
            other == null ||
            other !is MultiSelectionLayout ||
            isStartHandle != other.isStartHandle ||
            startSlot != other.startSlot ||
            endSlot != other.endSlot ||
            shouldAnyInfoRecomputeSelection(other)

    private fun shouldAnyInfoRecomputeSelection(other: MultiSelectionLayout): Boolean {
        if (size != other.size) return true
        for (i in infoList.indices) {
            val thisInfo = infoList[i]
            val otherInfo = other.infoList[i]
            if (thisInfo.shouldRecomputeSelection(otherInfo)) {
                return true
            }
        }
        return false
    }

    override fun createSubSelections(selection: Selection): LongObjectMap =
        // Selection is within one selectable, we can return a singleton map of this selection.
        if (selection.start.selectableId == selection.end.selectableId) {
            // this check, if not passed, leads to exceptions when selection
            // highlighting is rendered, so check here instead.
            checkPrecondition(
                (selection.handlesCrossed && selection.start.offset >= selection.end.offset) ||
                    (!selection.handlesCrossed && selection.start.offset <= selection.end.offset)
            ) {
                "unexpectedly miss-crossed selection: $selection"
            }
            longObjectMapOf(selection.start.selectableId, selection)
        } else
            mutableLongObjectMapOf().apply {
                val minAnchor = with(selection) { if (handlesCrossed) end else start }
                createAndPutSubSelection(
                    selection,
                    firstInfo,
                    minAnchor.offset,
                    firstInfo.textLength
                )

                forEachMiddleInfo { info ->
                    createAndPutSubSelection(selection, info, minOffset = 0, info.textLength)
                }

                val maxAnchor = with(selection) { if (handlesCrossed) start else end }
                createAndPutSubSelection(selection, lastInfo, minOffset = 0, maxAnchor.offset)
            }

    private fun MutableLongObjectMap.createAndPutSubSelection(
        selection: Selection,
        info: SelectableInfo,
        minOffset: Int,
        maxOffset: Int
    ) {
        val subSelection =
            if (selection.handlesCrossed) {
                info.makeSingleLayoutSelection(start = maxOffset, end = minOffset)
            } else {
                info.makeSingleLayoutSelection(start = minOffset, end = maxOffset)
            }

        // this check, if not passed, leads to exceptions when selection
        // highlighting is rendered, so check here instead.
        checkPrecondition(minOffset <= maxOffset) {
            "minOffset should be less than or equal to maxOffset: $subSelection"
        }

        put(info.selectableId, subSelection)
    }

    override fun toString(): String =
        "MultiSelectionLayout(isStartHandle=$isStartHandle, " +
            "startPosition=${(startSlot + 1).toFloat() / 2}, " +
            "endPosition=${(endSlot + 1).toFloat() / 2}, " +
            "crossed=$crossStatus, " +
            "infos=${
            buildString {
                append("[\n\t")
                var first = true
                infoList
                    .fastForEachIndexed { index, info ->
                        if (first) {
                            first = false
                        } else {
                            append(",\n\t")
                        }
                        append("${(index + 1)} -> $info")
                    }
                append("\n]")
            }
        })"

    private fun startOrEndSlotToIndex(slot: Int, isStartSlot: Boolean): Int =
        slotToIndex(
            slot = slot,
            isMinimumSlot =
                when (crossStatus) {
                    // collapsed: doesn't matter whether true or false, it will result in the same
                    // index
                    CrossStatus.COLLAPSED -> true
                    CrossStatus.NOT_CROSSED -> isStartSlot
                    CrossStatus.CROSSED -> !isStartSlot
                }
        )

    private fun slotToIndex(slot: Int, isMinimumSlot: Boolean): Int {
        val slotAdjustment = if (isMinimumSlot) 0 else 1
        return (slot - slotAdjustment) / 2
    }

    private fun getInfoListIndexBySelectableId(id: Long): Int =
        try {
            selectableIdToInfoListIndex[id]
        } catch (e: NoSuchElementException) {
            throw IllegalStateException("Invalid selectableId: $id", e)
        }
}

/**
 * Create a selection layout that has only one slot.
 *
 * @param isStartHandle whether this is the start or end anchor
 * @param previousSelection the previous selection
 * @param info the single [SelectableInfo]
 */
private class SingleSelectionLayout(
    override val isStartHandle: Boolean,
    override val startSlot: Int,
    override val endSlot: Int,
    override val previousSelection: Selection?,
    private val info: SelectableInfo,
) : SelectionLayout {
    companion object {
        const val DEFAULT_SLOT = 1
        const val DEFAULT_SELECTABLE_ID = 1L
    }

    override val size
        get() = 1

    override val crossStatus: CrossStatus
        get() =
            when {
                startSlot < endSlot -> CrossStatus.NOT_CROSSED
                startSlot > endSlot -> CrossStatus.CROSSED
                else -> info.rawCrossStatus
            }

    override val startInfo: SelectableInfo
        get() = info

    override val endInfo: SelectableInfo
        get() = info

    override val currentInfo: SelectableInfo
        get() = info

    override val firstInfo: SelectableInfo
        get() = info

    override val lastInfo: SelectableInfo
        get() = info

    override fun forEachMiddleInfo(block: (SelectableInfo) -> Unit) {
        // there are no middle infos, so do nothing
    }

    override fun shouldRecomputeSelection(other: SelectionLayout?): Boolean =
        previousSelection == null ||
            other == null ||
            other !is SingleSelectionLayout ||
            startSlot != other.startSlot ||
            endSlot != other.endSlot ||
            isStartHandle != other.isStartHandle ||
            info.shouldRecomputeSelection(other.info)

    override fun createSubSelections(selection: Selection): LongObjectMap {
        val finalSelection =
            selection.run {
                // uncross handles if necessary
                if (
                    (!handlesCrossed && start.offset > end.offset) ||
                        (handlesCrossed && start.offset <= end.offset)
                ) {
                    copy(handlesCrossed = !handlesCrossed)
                } else {
                    this
                }
            }
        return longObjectMapOf(info.selectableId, finalSelection)
    }

    override fun toString(): String =
        "SingleSelectionLayout(isStartHandle=$isStartHandle, crossed=$crossStatus, info=\n\t$info)"
}

/**
 * Create a selection layout that has only one slot.
 *
 * This is intended for TextField, where multiple selectables is of no concern.
 *
 * @param layoutResult the [TextLayoutResult] for the text field
 * @param rawStartHandleOffset the index of the start handle
 * @param rawEndHandleOffset the index of the end handle
 * @param rawPreviousHandleOffset the previous handle offset based on [isStartHandle], or
 *   [UNASSIGNED_SLOT] if none
 * @param previousSelectionRange the previous selection
 * @param isStartOfSelection whether this is the start of a selection gesture (no previous context)
 * @param isStartHandle whether this is the start or end anchor
 */
internal fun getTextFieldSelectionLayout(
    layoutResult: TextLayoutResult,
    rawStartHandleOffset: Int,
    rawEndHandleOffset: Int,
    rawPreviousHandleOffset: Int,
    previousSelectionRange: TextRange,
    isStartOfSelection: Boolean,
    isStartHandle: Boolean,
): SelectionLayout =
    SingleSelectionLayout(
        isStartHandle = isStartHandle,
        startSlot = SingleSelectionLayout.DEFAULT_SLOT,
        endSlot = SingleSelectionLayout.DEFAULT_SLOT,
        previousSelection =
            if (isStartOfSelection) null
            else
                Selection(
                    start =
                        Selection.AnchorInfo(
                            layoutResult.getTextDirectionForOffset(previousSelectionRange.start),
                            previousSelectionRange.start,
                            SingleSelectionLayout.DEFAULT_SELECTABLE_ID
                        ),
                    end =
                        Selection.AnchorInfo(
                            layoutResult.getTextDirectionForOffset(previousSelectionRange.end),
                            previousSelectionRange.end,
                            SingleSelectionLayout.DEFAULT_SELECTABLE_ID
                        ),
                    handlesCrossed = previousSelectionRange.reversed
                ),
        info =
            SelectableInfo(
                selectableId = SingleSelectionLayout.DEFAULT_SELECTABLE_ID,
                slot = SingleSelectionLayout.DEFAULT_SLOT,
                rawStartHandleOffset = rawStartHandleOffset,
                rawEndHandleOffset = rawEndHandleOffset,
                textLayoutResult = layoutResult,
                rawPreviousHandleOffset = rawPreviousHandleOffset
            ),
    )

/** Whether something is crossed as determined by the position of the start/end. */
internal enum class CrossStatus {
    /** The start comes after the end. */
    CROSSED,

    /** The start comes before the end. */
    NOT_CROSSED,

    /** The start is the same as the end. */
    COLLAPSED
}

/** Slot has not been assigned yet */
internal const val UNASSIGNED_SLOT = -1

/**
 * A builder for [SelectionLayout] that ensures the data structures and slots are properly
 * constructed.
 *
 * @param previousHandlePosition the previous handle position matching the handle directed to by
 *   [isStartHandle]
 * @param containerCoordinates the coordinates of the [SelectionContainer] for converting
 *   [SelectionContainer] coordinates to their respective [Selectable] coordinates
 * @param isStartHandle whether the currently pressed/clicked handle is the start
 * @param selectableIdOrderingComparator determines the ordering of selectables by their IDs
 */
internal class SelectionLayoutBuilder(
    val currentPosition: Offset,
    val previousHandlePosition: Offset,
    val containerCoordinates: LayoutCoordinates,
    val isStartHandle: Boolean,
    val previousSelection: Selection?,
    val selectableIdOrderingComparator: Comparator
) {
    private val selectableIdToInfoListIndex: MutableLongIntMap = mutableLongIntMapOf()
    private val infoList: MutableList = mutableListOf()
    private var startSlot: Int = UNASSIGNED_SLOT
    private var endSlot: Int = UNASSIGNED_SLOT
    private var currentSlot: Int = UNASSIGNED_SLOT

    /**
     * Finishes building the [SelectionLayout] and returns it.
     *
     * @return the [SelectionLayout] or null if no [SelectableInfo]s were added.
     */
    fun build(): SelectionLayout? {
        val lastSlot = currentSlot + 1
        return when (infoList.size) {
            0 -> {
                return null
            }
            1 -> {
                SingleSelectionLayout(
                    info = infoList.single(),
                    startSlot = if (startSlot == UNASSIGNED_SLOT) lastSlot else startSlot,
                    endSlot = if (endSlot == UNASSIGNED_SLOT) lastSlot else endSlot,
                    previousSelection = previousSelection,
                    isStartHandle = isStartHandle,
                )
            }
            else -> {
                MultiSelectionLayout(
                    selectableIdToInfoListIndex = selectableIdToInfoListIndex,
                    infoList = infoList,
                    startSlot = if (startSlot == UNASSIGNED_SLOT) lastSlot else startSlot,
                    endSlot = if (endSlot == UNASSIGNED_SLOT) lastSlot else endSlot,
                    isStartHandle = isStartHandle,
                    previousSelection = previousSelection,
                )
            }
        }
    }

    /** Appends a selection info to this builder. */
    fun appendInfo(
        selectableId: Long,
        rawStartHandleOffset: Int,
        startXHandleDirection: Direction,
        startYHandleDirection: Direction,
        rawEndHandleOffset: Int,
        endXHandleDirection: Direction,
        endYHandleDirection: Direction,
        rawPreviousHandleOffset: Int,
        textLayoutResult: TextLayoutResult,
    ): SelectableInfo {
        // We need currentSlot to equal the slot of the "last" info when getLayout is called,
        // so increment this before adding the info and leave the correct slot in place at the end.
        currentSlot += 2

        val selectableInfo =
            SelectableInfo(
                selectableId = selectableId,
                slot = currentSlot,
                rawStartHandleOffset = rawStartHandleOffset,
                rawEndHandleOffset = rawEndHandleOffset,
                rawPreviousHandleOffset = rawPreviousHandleOffset,
                textLayoutResult = textLayoutResult,
            )

        startSlot = updateSlot(startSlot, startXHandleDirection, startYHandleDirection)
        endSlot = updateSlot(endSlot, endXHandleDirection, endYHandleDirection)
        selectableIdToInfoListIndex[selectableId] = infoList.size
        infoList += selectableInfo
        return selectableInfo
    }

    /**
     * Find the slot for a selectable given the current position's directions from the selectable.
     *
     * The selectables must be ordered in the order in which they would be selected, and then this
     * function should be called for each of those selectables.
     *
     * It is expected that the input [slot] is also assigned the result of this function.
     *
     * This function is stateful.
     *
     * @param slot the current value of this slot.
     * @param xPositionDirection Where the x-position is relative to the selectable
     * @param yPositionDirection Where the y-position is relative to the selectable
     */
    private fun updateSlot(
        slot: Int,
        xPositionDirection: Direction,
        yPositionDirection: Direction,
    ): Int {
        if (slot != UNASSIGNED_SLOT) {
            // don't overwrite if the slot has already been determined
            return slot
        }

        // slot has not been determined yet,
        // see if we are on or past the selectable we are looking for
        return when (resolve2dDirection(xPositionDirection, yPositionDirection)) {
            // If we get here, that means we never found a selectable that contains our gesture
            // position. This is the first selectable that is after the position,
            // so our slot must be between the previous and current selectables.
            BEFORE -> currentSlot - 1

            // The gesture position is directly on this selectable, so use this one.
            ON -> currentSlot

            // keep looking
            AFTER -> slot
        }
    }
}

/** Where the position of a cursor/press is compared to a selectable. */
internal enum class Direction {
    /** The cursor/press is before the selectable */
    BEFORE,

    /** The cursor/press is on the selectable */
    ON,

    /** The cursor/press is after the selectable */
    AFTER
}

/**
 * Determine direction based on an x/y direction.
 *
 * This will use the [y] direction unless it is [ON], in which case it will use the [x] direction.
 */
internal fun resolve2dDirection(x: Direction, y: Direction): Direction =
    when (y) {
        BEFORE -> BEFORE
        ON ->
            when (x) {
                BEFORE -> BEFORE
                ON -> ON
                AFTER -> AFTER
            }
        AFTER -> AFTER
    }

/** Data about a specific selectable within a [SelectionLayout]. */
internal class SelectableInfo(
    val selectableId: Long,
    val slot: Int,
    val rawStartHandleOffset: Int,
    val rawEndHandleOffset: Int,
    val rawPreviousHandleOffset: Int,
    val textLayoutResult: TextLayoutResult,
) {

    /** The [String] in the selectable. */
    val inputText: String
        get() = textLayoutResult.layoutInput.text.text

    /** The length of the [String] in the selectable. */
    val textLength: Int
        get() = inputText.length

    /** Whether the raw offsets of this info are crossed. */
    val rawCrossStatus: CrossStatus
        get() =
            when {
                rawStartHandleOffset < rawEndHandleOffset -> CrossStatus.NOT_CROSSED
                rawStartHandleOffset > rawEndHandleOffset -> CrossStatus.CROSSED
                else -> CrossStatus.COLLAPSED
            }

    private val startRunDirection
        get() = textLayoutResult.getTextDirectionForOffset(rawStartHandleOffset)

    private val endRunDirection
        get() = textLayoutResult.getTextDirectionForOffset(rawEndHandleOffset)

    /**
     * Whether this info, compared to another info, has any relevant changes that would require
     * recomputing selection.
     *
     * @param other the selectable info to check for changes compared to this one
     */
    fun shouldRecomputeSelection(other: SelectableInfo): Boolean =
        selectableId != other.selectableId ||
            rawStartHandleOffset != other.rawStartHandleOffset ||
            rawEndHandleOffset != other.rawEndHandleOffset

    /** Get a [Selection.AnchorInfo] for this [SelectableInfo] at the given [offset]. */
    fun anchorForOffset(offset: Int): Selection.AnchorInfo =
        Selection.AnchorInfo(
            direction = textLayoutResult.getTextDirectionForOffset(offset),
            offset = offset,
            selectableId = selectableId
        )

    /**
     * Get a [Selection] within the selectable represented by this [SelectableInfo] for the given
     * [start] and [end] offsets.
     */
    fun makeSingleLayoutSelection(start: Int, end: Int): Selection =
        Selection(
            start = anchorForOffset(start),
            end = anchorForOffset(end),
            handlesCrossed = start > end
        )

    override fun toString(): String =
        "SelectionInfo(id=$selectableId, " +
            "range=($rawStartHandleOffset-$startRunDirection,$rawEndHandleOffset-$endRunDirection), " +
            "prevOffset=$rawPreviousHandleOffset)"
}

/**
 * Get the text direction for a given offset.
 *
 * This simply calls [TextLayoutResult.getBidiRunDirection] with one exception, if the offset is an
 * empty line, then we defer to [TextLayoutResult.multiParagraph] and
 * [androidx.compose.ui.text.MultiParagraph.getParagraphDirection]. This is because an empty line
 * always resolves to LTR, even if the paragraph is RTL.
 */
// TODO(b/295197585)
//   Can this logic be moved to a new method in `androidx.compose.ui.text.Paragraph`?
private fun TextLayoutResult.getTextDirectionForOffset(offset: Int): ResolvedTextDirection =
    if (isOffsetAnEmptyLine(offset)) getParagraphDirection(offset) else getBidiRunDirection(offset)

private fun TextLayoutResult.isOffsetAnEmptyLine(offset: Int): Boolean =
    layoutInput.text.isEmpty() ||
        getLineForOffset(offset).let { currentLine ->
            // verify the previous and next offsets either don't exist because they're at a boundary
            // or that they are different lines than the current line.
            (offset == 0 || currentLine != getLineForOffset(offset - 1)) &&
                (offset == layoutInput.text.length || currentLine != getLineForOffset(offset + 1))
        }

/**
 * Verify that the selection is truly collapsed.
 *
 * If the selection is contained within one selectable, this simply checks if the offsets are equal.
 *
 * If the Selection spans multiple selectables, then this will verify that every selected selectable
 * contains a zero-width selection.
 */
internal fun Selection?.isCollapsed(layout: SelectionLayout?): Boolean {
    this ?: return true
    layout ?: return true

    // Selection is within one selectable, simply check if the offsets are the same.
    if (start.selectableId == end.selectableId) {
        return start.offset == end.offset
    }

    // check that maxAnchor offset is 0, else the selection cannot be collapsed.
    val maxAnchor = if (handlesCrossed) start else end
    if (maxAnchor.offset != 0) {
        return false
    }

    // check that the minAnchor offset is equal to the length of the text,
    // else the selection is not collapsed
    val minAnchor = if (handlesCrossed) end else start
    if (layout.firstInfo.textLength != minAnchor.offset) {
        return false
    }

    // Every selectable between the min and max must have empty text,
    // else there is some text selected.
    var allTextsEmpty = true
    layout.forEachMiddleInfo {
        if (it.inputText.isNotEmpty()) {
            allTextsEmpty = false
        }
    }

    return allTextsEmpty
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy