SemanticsProperties.kt

/*
 * Copyright 2019 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.ui.semantics

import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.util.annotation.IntRange
import kotlin.reflect.KProperty

/**
 * General semantics properties, mainly used for accessibility and testing.
 */
object SemanticsProperties {
    /**
     * Developer-set content description of the semantics node. If this is not set, accessibility
     * services will present the [Text] of this node as content part.
     *
     * @see SemanticsPropertyReceiver.contentDescription
     */
    val ContentDescription = SemanticsPropertyKey<String>(
        name = "ContentDescription",
        mergePolicy = { parentValue, childValue ->
            if (parentValue == null) {
                childValue
            } else {
                "$parentValue, $childValue"
            }
        }
    )

    /**
     * Developer-set state description of the semantics node. For example: on/off. If this not
     * set, accessibility services will derive the state from other semantics properties, like
     * [AccessibilityRangeInfo], but it is not guaranteed and the format will be decided by
     * accessibility services.
     *
     * @see SemanticsPropertyReceiver.stateDescription
     */
    val StateDescription = SemanticsPropertyKey<String>("StateDescription")

    /**
     * The node is a range with current value.
     *
     * @see SemanticsPropertyReceiver.stateDescriptionRange
     */
    val AccessibilityRangeInfo =
        SemanticsPropertyKey<AccessibilityRangeInfo>("AccessibilityRangeInfo")

    /**
     * Whether this semantics node is disabled.
     *
     * @see SemanticsPropertyReceiver.disabled
     */
    val Disabled = SemanticsPropertyKey<Unit>("Disabled")

    /**
     * Whether this semantics node is input focused.
     *
     * @see SemanticsPropertyReceiver.focused
     */
    val Focused = SemanticsPropertyKey<Boolean>("Focused")

    /**
     * Whether this semantics node is hidden. A hidden node is a node that is not visible for
     * accessibility. It will still be shown, but it will be skipped by accessibility services.
     *
     * @see SemanticsPropertyReceiver.hidden
     */
    val Hidden = SemanticsPropertyKey<Unit>(
        name = "Hidden",
        mergePolicy = { parentValue, _ ->
            parentValue
        }
    )

    /**
     * The horizontal scroll state of this node if this node is scrollable.
     *
     * @see SemanticsPropertyReceiver.horizontalAccessibilityScrollState
     */
    val HorizontalAccessibilityScrollState =
        SemanticsPropertyKey<AccessibilityScrollState>("HorizontalAccessibilityScrollState")

    /**
     * Whether this semantics node represents a Popup. Not to be confused with if this node is
     * _part of_ a Popup.
     *
     * @see SemanticsPropertyReceiver.popup
     */
    val IsPopup = SemanticsPropertyKey<Unit>(
        name = "IsPopup",
        mergePolicy = { _, _ ->
            throw IllegalStateException(
                "merge function called on unmergeable property IsPopup. " +
                    "A popup should not be a child of a clickable/focusable node."
            )
        }
    )

    /**
     * Whether this element is a Dialog. Not to be confused with if this element is _part of_ a
     * Dialog.
     */
    val IsDialog = SemanticsPropertyKey<Unit>(
        name = "IsDialog",
        mergePolicy = { _, _ ->
            throw IllegalStateException(
                "merge function called on unmergeable property IsDialog. " +
                    "A dialog should not be a child of a clickable/focusable node."
            )
        }
    )

    // TODO(b/138172781): Move to FoundationSemanticsProperties
    /**
     * Test tag attached to this semantics node.
     *
     * @see SemanticsPropertyReceiver.testTag
     */
    val TestTag = SemanticsPropertyKey<String>(
        name = "TestTag",
        mergePolicy = { parentValue, _ ->
            // Never merge TestTags, to avoid leaking internal test tags to parents.
            parentValue
        }
    )

    /**
     * Text of the semantics node. It must be the actual text displayed by this component instead
     * of developer-set content description.
     *
     * @see SemanticsPropertyReceiver.text
     */
    val Text = SemanticsPropertyKey<AnnotatedString>(
        name = "Text",
        mergePolicy = { parentValue, childValue ->
            if (parentValue == null) {
                childValue
            } else {
                buildAnnotatedString {
                    append(parentValue)
                    append(", ")
                    append(childValue)
                }
            }
        }
    )

    /**
     * Text selection range for edit text.
     *
     * @see TextRange
     * @see SemanticsPropertyReceiver.textSelectionRange
     */
    val TextSelectionRange = SemanticsPropertyKey<TextRange>("TextSelectionRange")

    /**
     * Contains the IME action provided by the node.
     *
     *  @see SemanticsPropertyReceiver.imeAction
     */
    val ImeAction = SemanticsPropertyKey<ImeAction>("ImeAction")

    /**
     * The vertical scroll state of this node if this node is scrollable.
     *
     * @see SemanticsPropertyReceiver.verticalAccessibilityScrollState
     */
    val VerticalAccessibilityScrollState =
        SemanticsPropertyKey<AccessibilityScrollState>("VerticalAccessibilityScrollState")

    /**
     * Whether this element is selected (out of a list of possible selections).
     * The presence of this property indicates that the element is selectable.
     *
     * @see SemanticsPropertyReceiver.selected
     */
    val Selected = SemanticsPropertyKey<Boolean>("Selected")

    /**
     * The state of a toggleable component.
     * The presence of this property indicates that the element is toggleable.
     *
     * @see SemanticsPropertyReceiver.toggleableState
     */
    val ToggleableState = SemanticsPropertyKey<ToggleableState>("ToggleableState")
}

/**
 * Ths object defines keys of the actions which can be set in semantics and performed on the
 * semantics node.
 */
object SemanticsActions {
    /**
     * Action to get a Text/TextField node's [TextLayoutResult]. The result is the first element
     * of layout(the argument of the AccessibilityAction).
     *
     * @see SemanticsPropertyReceiver.getTextLayoutResult
     */
    val GetTextLayoutResult = SemanticsPropertyKey<AccessibilityAction<
            (MutableList<TextLayoutResult>) -> Boolean>>("GetTextLayoutResult")

    /**
     * Action to be performed when the node is clicked.
     *
     * @see SemanticsPropertyReceiver.onClick
     */
    val OnClick = SemanticsPropertyKey<AccessibilityAction<() -> Boolean>>("OnClick")

    /**
     * Action to be performed when the node is long clicked.
     *
     * @see SemanticsPropertyReceiver.onLongClick
     */
    val OnLongClick = SemanticsPropertyKey<AccessibilityAction<() -> Boolean>>("OnLongClick")

    /**
     * Action to scroll to a specified position.
     *
     * @see SemanticsPropertyReceiver.scrollBy
     */
    val ScrollBy =
        SemanticsPropertyKey<AccessibilityAction<(x: Float, y: Float) -> Boolean>>("ScrollBy")

    /**
     * Action to set progress.
     *
     * @see SemanticsPropertyReceiver.setProgress
     */
    val SetProgress =
        SemanticsPropertyKey<AccessibilityAction<(progress: Float) -> Boolean>>("SetProgress")

    /**
     * Action to set selection. If this action is provided, the selection data must be provided
     * using [SemanticsProperties.TextSelectionRange].
     *
     * @see SemanticsPropertyReceiver.setSelection
     */
    val SetSelection = SemanticsPropertyKey<
        AccessibilityAction<(Int, Int, Boolean) -> Boolean>>("SetSelection")

    /**
     * Action to set the text of this node.
     *
     * @see SemanticsPropertyReceiver.setText
     */
    val SetText = SemanticsPropertyKey<
        AccessibilityAction<(AnnotatedString) -> Boolean>>("SetText")

    /**
     * Action to copy the text to the clipboard.
     *
     * @see SemanticsPropertyReceiver.copyText
     */
    val CopyText = SemanticsPropertyKey<AccessibilityAction<() -> Boolean>>("CopyText")

    /**
     * Action to cut the text and copy it to the clipboard.
     *
     * @see SemanticsPropertyReceiver.cutText
     */
    val CutText = SemanticsPropertyKey<AccessibilityAction<() -> Boolean>>("CutText")

    /**
     * Action to paste the text from the clipboard. Add it to indicate that element is open for
     * accepting paste data from the clipboard.
     * The element setting this property should also set the [SemanticsProperties.Focused] property.
     *
     * @see SemanticsPropertyReceiver.pasteText
     */
    val PasteText = SemanticsPropertyKey<AccessibilityAction<() -> Boolean>>("PasteText")

    /**
     * Action to dismiss a dismissible node.
     *
     * @see SemanticsPropertyReceiver.dismiss
     */
    val Dismiss = SemanticsPropertyKey<AccessibilityAction<() -> Boolean>>("Dismiss")

    /**
     * Custom actions which are defined by app developers.
     *
     * @see SemanticsPropertyReceiver.customActions
     */
    val CustomActions =
        SemanticsPropertyKey<List<CustomAccessibilityAction>>("CustomActions")
}

class SemanticsPropertyKey<T>(
    /**
     * The name of the property.  Should be the same as the constant from which it is accessed.
     */
    val name: String,
    internal val mergePolicy: (T?, T) -> T? = { parentValue, childValue ->
        parentValue ?: childValue
    }
) {
    /**
     * Method implementing the semantics merge policy of a particular key.
     *
     * When mergeDescendants is set on a semantics node, then this function will called for each
     * descendant node of a given key in depth-first-search order.  The parent
     * value accumulates the result of merging the values seen so far, similar to reduce().
     *
     * The default implementation returns the parent value if one exists, otherwise uses the
     * child element.  This means by default, a SemanticsNode with mergeDescendants = true
     * winds up with the first value found for each key in its subtree in depth-first-search order.
     */
    fun merge(parentValue: T?, childValue: T): T? {
        return mergePolicy(parentValue, childValue)
    }

    /**
     * Throws [UnsupportedOperationException].  Should not be called.
     */
    // TODO(KT-6519): Remove this getter
    // TODO(KT-32770): Cannot deprecate this either as the getter is considered called by "by"
    final operator fun getValue(thisRef: SemanticsPropertyReceiver, property: KProperty<*>): T {
        throw UnsupportedOperationException(
            "You cannot retrieve a semantics property directly - " +
                "use one of the SemanticsConfiguration.getOr* methods instead"
        )
    }

    final operator fun setValue(
        thisRef: SemanticsPropertyReceiver,
        property: KProperty<*>,
        value: T
    ) {
        thisRef[this] = value
    }

    override fun toString(): String {
        return "SemanticsPropertyKey: $name"
    }
}

/**
 * Data class for standard accessibility action.
 *
 * @param label The description of this action
 * @param action The function to invoke when this action is performed. The function should return
 * a boolean result indicating whether the action is successfully handled. For example, a scroll
 * forward action should return false if the widget is not enabled or has reached the end of the
 * list.
 */
data class AccessibilityAction<T : Function<Boolean>>(val label: CharSequence?, val action: T)

/**
 * Data class for custom accessibility action.
 *
 * @param label The description of this action
 * @param action The function to invoke when this action is performed. The function should have no
 * arguments and return a boolean result indicating whether the action is successfully handled.
 */
data class CustomAccessibilityAction(val label: CharSequence, val action: () -> Boolean)

/**
 * Data class for accessibility range information.
 *
 * @param current current value in the range
 * @param range range of this node
 * @param steps if greater than 0, specifies the number of discrete values, evenly distributed
 * between across the whole value range. If 0, any value from the range specified can be chosen.
 */
data class AccessibilityRangeInfo(
    val current: Float,
    val range: ClosedFloatingPointRange<Float>,
    @IntRange(from = 0) val steps: Int = 0
)

/**
 * The scroll state of this node if this node is scrollable.
 *
 * @param value current scroll position value in pixels
 * @param maxValue maximum bound for [value], or [Float.POSITIVE_INFINITY] if still unknown
 * @param reverseScrolling for horizontal scroll, when this is `true`, 0 [value] will mean right,
 * when`false`, 0 [value] will mean left. For vertical scroll, when this is `true`, 0 [value] will
 * mean bottom, when `false`, 0 [value] will mean top
 */
data class AccessibilityScrollState(
    val value: Float = 0f,
    val maxValue: Float = 0f,
    val reverseScrolling: Boolean = false
)

interface SemanticsPropertyReceiver {
    operator fun <T> set(key: SemanticsPropertyKey<T>, value: T)
}

/**
 * Developer-set content description of the semantics node. If this is not set, accessibility
 * services will present the text of this node as content part.
 *
 * @see SemanticsProperties.ContentDescription
 */
var SemanticsPropertyReceiver.contentDescription by SemanticsProperties.ContentDescription

@Deprecated(
    "accessibilityLabel was renamed to contentDescription",
    ReplaceWith("contentDescription", "androidx.compose.ui.semantics")
)
var SemanticsPropertyReceiver.accessibilityLabel by SemanticsProperties.ContentDescription

/**
 * Developer-set state description of the semantics node. For example: on/off. If this not
 * set, accessibility services will derive the state from other semantics properties, like
 * [AccessibilityRangeInfo], but it is not guaranteed and the format will be decided by
 * accessibility services.
 *
 * @see SemanticsProperties.StateDescription
 */
var SemanticsPropertyReceiver.stateDescription by SemanticsProperties.StateDescription

@Deprecated(
    "accessibilityValue was renamed to stateDescription",
    ReplaceWith("stateDescription", "androidx.compose.ui.semantics")
)
var SemanticsPropertyReceiver.accessibilityValue by SemanticsProperties.StateDescription

/**
 * The node is a range with current value.
 *
 * @see SemanticsProperties.AccessibilityRangeInfo
 */
var SemanticsPropertyReceiver.stateDescriptionRange by SemanticsProperties.AccessibilityRangeInfo

/**
 * Whether this semantics node is disabled.
 *
 * @see SemanticsProperties.Disabled
 */
fun SemanticsPropertyReceiver.disabled() {
    this[SemanticsProperties.Disabled] = Unit
}

/**
 * Whether this semantics node is focused.
 *
 * @See SemanticsProperties.Focused
 */
var SemanticsPropertyReceiver.focused by SemanticsProperties.Focused

/**
 * Whether this semantics node is hidden. A hidden node is a node that is not visible for
 * accessibility.
 *
 * @See SemanticsProperties.Hidden
 */
fun SemanticsPropertyReceiver.hidden() {
    this[SemanticsProperties.Hidden] = Unit
}

/**
 * The horizontal scroll state of this node if this node is scrollable.
 *
 * @see SemanticsProperties.HorizontalAccessibilityScrollState
 */
var SemanticsPropertyReceiver.horizontalAccessibilityScrollState
by SemanticsProperties.HorizontalAccessibilityScrollState

/**
 * The vertical scroll state of this node if this node is scrollable.
 *
 * @see SemanticsProperties.VerticalAccessibilityScrollState
 */
var SemanticsPropertyReceiver.verticalAccessibilityScrollState
by SemanticsProperties.VerticalAccessibilityScrollState

/**
 * Whether this semantics node represents a Popup. Not to be confused with if this node is
 * _part of_ a Popup.
 *
 * @See SemanticsProperties.IsPopup
 */
fun SemanticsPropertyReceiver.popup() {
    this[SemanticsProperties.IsPopup] = Unit
}

/**
 * Whether this element is a Dialog. Not to be confused with if this element is _part of_ a Dialog.
 */
fun SemanticsPropertyReceiver.dialog() {
    this[SemanticsProperties.IsDialog] = Unit
}

// TODO(b/138172781): Move to FoundationSemanticsProperties.kt
/**
 * Test tag attached to this semantics node.
 *
 * @see SemanticsPropertyReceiver.testTag
 */
var SemanticsPropertyReceiver.testTag by SemanticsProperties.TestTag

/**
 * Text of the semantics node. It must be real text instead of developer-set content description.
 *
 * @see SemanticsProperties.Text
 */
var SemanticsPropertyReceiver.text by SemanticsProperties.Text

/**
 * Text selection range for edit text.
 *
 * @see TextRange
 * @see SemanticsProperties.TextSelectionRange
 */
var SemanticsPropertyReceiver.textSelectionRange by SemanticsProperties.TextSelectionRange

/**
 * Contains the IME action provided by the node.
 *
 *  @see SemanticsProperties.ImeAction
 */
var SemanticsPropertyReceiver.imeAction by SemanticsProperties.ImeAction

/**
 * Whether this element is selected (out of a list of possible selections).
 * The presence of this property indicates that the element is selectable.
 *
 * @see SemanticsProperties.Selected
 */
var SemanticsPropertyReceiver.selected by SemanticsProperties.Selected

/**
 * The state of a toggleable component.
 * The presence of this property indicates that the element is toggleable.
 *
 * @see SemanticsProperties.ToggleableState
 */
var SemanticsPropertyReceiver.toggleableState
by SemanticsProperties.ToggleableState

/**
 * Custom actions which are defined by app developers.
 *
 * @see SemanticsPropertyReceiver.customActions
 */
var SemanticsPropertyReceiver.customActions by SemanticsActions.CustomActions

/**
 * This function adds the [SemanticsActions.GetTextLayoutResult] to the [SemanticsPropertyReceiver].
 *
 * @param label Optional label for this action.
 * @param action Action to be performed when the [SemanticsActions.GetTextLayoutResult] is called.
 */
fun SemanticsPropertyReceiver.getTextLayoutResult(
    label: String? = null,
    action: (MutableList<TextLayoutResult>) -> Boolean
) {
    this[SemanticsActions.GetTextLayoutResult] = AccessibilityAction(label, action)
}

/**
 * This function adds the [SemanticsActions.OnClick] to the [SemanticsPropertyReceiver].
 *
 * @param label Optional label for this action.
 * @param action Action to be performed when the [SemanticsActions.OnClick] is called.
 */
fun SemanticsPropertyReceiver.onClick(label: String? = null, action: () -> Boolean) {
    this[SemanticsActions.OnClick] = AccessibilityAction(label, action)
}

/**
 * This function adds the [SemanticsActions.OnLongClick] to the [SemanticsPropertyReceiver].
 *
 * @param label Optional label for this action.
 * @param action Action to be performed when the [SemanticsActions.OnLongClick] is called.
 */
fun SemanticsPropertyReceiver.onLongClick(label: String? = null, action: () -> Boolean) {
    this[SemanticsActions.OnLongClick] = AccessibilityAction(label, action)
}

/**
 * This function adds the [SemanticsActions.ScrollBy] to the [SemanticsPropertyReceiver].
 *
 * @param label Optional label for this action.
 * @param action Action to be performed when the [SemanticsActions.ScrollBy] is called.
 */
fun SemanticsPropertyReceiver.scrollBy(
    label: String? = null,
    action: (x: Float, y: Float) -> Boolean
) {
    this[SemanticsActions.ScrollBy] = AccessibilityAction(label, action)
}

/**
 * This function adds the [SemanticsActions.SetProgress] to the [SemanticsPropertyReceiver].
 *
 * @param label Optional label for this action.
 * @param action Action to be performed when the [SemanticsActions.SetProgress] is called.
 */
fun SemanticsPropertyReceiver.setProgress(label: String? = null, action: (Float) -> Boolean) {
    this[SemanticsActions.SetProgress] = AccessibilityAction(label, action)
}

/**
 * This function adds the [SemanticsActions.SetText] to the [SemanticsPropertyReceiver].
 *
 * @param label Optional label for this action.
 * @param action Action to be performed when the [SemanticsActions.SetText] is called.
 */
fun SemanticsPropertyReceiver.setText(label: String? = null, action: (AnnotatedString) -> Boolean) {
    this[SemanticsActions.SetText] = AccessibilityAction(label, action)
}

/**
 * This function adds the [SemanticsActions.SetSelection] to the [SemanticsPropertyReceiver]. If
 * this action is provided, the selection data must be provided using
 * [SemanticsProperties.TextSelectionRange].
 *
 * @param label Optional label for this action.
 * @param action Action to be performed when the [SemanticsActions.SetSelection] is called.
 */
fun SemanticsPropertyReceiver.setSelection(
    label: String? = null,
    action: (
        startIndex: Int,
        endIndex: Int,
        traversalMode: Boolean
    ) -> Boolean
) {
    this[SemanticsActions.SetSelection] = AccessibilityAction(label, action)
}

/**
 * This function adds the [SemanticsActions.CopyText] to the [SemanticsPropertyReceiver].
 *
 * @param label Optional label for this action.
 * @param action Action to be performed when the [SemanticsActions.CopyText] is called.
 */
fun SemanticsPropertyReceiver.copyText(
    label: String? = null,
    action: () -> Boolean
) {
    this[SemanticsActions.CopyText] = AccessibilityAction(label, action)
}

/**
 * This function adds the [SemanticsActions.CutText] to the [SemanticsPropertyReceiver].
 *
 * @param label Optional label for this action.
 * @param action Action to be performed when the [SemanticsActions.CutText] is called.
 */
fun SemanticsPropertyReceiver.cutText(
    label: String? = null,
    action: () -> Boolean
) {
    this[SemanticsActions.CutText] = AccessibilityAction(label, action)
}

/**
 * This function adds the [SemanticsActions.PasteText] to the [SemanticsPropertyReceiver].
 * Use it to indicate that element is open for accepting paste data from the clipboard. There is
 * no need to check if the clipboard data available as this is done by the framework.
 * For this action to be triggered, the element must also have the [SemanticsProperties.Focused]
 * property set.
 *
 * @param label Optional label for this action.
 * @param action Action to be performed when the [SemanticsActions.PasteText] is called.
 *
 * @see focused
 */
fun SemanticsPropertyReceiver.pasteText(
    label: String? = null,
    action: () -> Boolean
) {
    this[SemanticsActions.PasteText] = AccessibilityAction(label, action)
}

/**
 * This function adds the [SemanticsActions.Dismiss] to the [SemanticsPropertyReceiver].
 *
 * @param label Optional label for this action.
 * @param action Action to be performed when the [SemanticsActions.Dismiss] is called.
 */
fun SemanticsPropertyReceiver.dismiss(
    label: String? = null,
    action: () -> Boolean
) {
    this[SemanticsActions.Dismiss] = AccessibilityAction(label, action)
}