diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Scope.kt b/app/src/main/java/org/schabi/newpipe/ktx/Scope.kt new file mode 100644 index 000000000..decf1c3a6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ktx/Scope.kt @@ -0,0 +1,12 @@ +package org.schabi.newpipe.ktx + +/** + * Especially useful to apply some Compose Modifiers only if some condition is met. E.g. + * ```kt + * Modifier + * .padding(left = 4.dp) + * .letIf(someCondition) { padding(right = 4.dp) } + * ``` + */ +inline fun T.letIf(condition: Boolean, block: T.() -> T): T = + if (condition) block(this) else this diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt index 9594866be..b337ade5a 100644 --- a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditor.kt @@ -18,11 +18,9 @@ package org.schabi.newpipe.ui.components.menu -import android.util.Log import androidx.annotation.StringRes import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.border -import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -33,8 +31,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyGridItemInfo -import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.rememberLazyGridState @@ -50,25 +46,14 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusTarget -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent -import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle @@ -76,28 +61,20 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toSize -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import org.schabi.newpipe.R -import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Companion.DefaultEnabledActions +import org.schabi.newpipe.ktx.letIf import org.schabi.newpipe.ui.detectDragGestures import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.text.FixedHeightCenteredText -import kotlin.math.abs import kotlin.math.floor -import kotlin.math.max -import kotlin.math.min internal const val TAG = "LongPressMenuEditor" /** - * When making changes to this composable, make sure to test the following use cases still work: + * When making changes to this composable and to [LongPressMenuEditorState], make sure to test the + * following use cases, and check that they still work: * - both the actions and the header can be dragged around * - the header can only be dragged to the first position in each section * - when a section is empty the None marker will appear @@ -112,174 +89,15 @@ internal const val TAG = "LongPressMenuEditor" */ @Composable fun LongPressMenuEditor(modifier: Modifier = Modifier) { - // We get the current arrangement once and do not observe on purpose - // TODO load from settings - val headerEnabled = remember { true } - val actionArrangement = remember { DefaultEnabledActions } - val items = remember(headerEnabled, actionArrangement) { - sequence { - yield(ItemInList.EnabledCaption) - if (headerEnabled) { - yield(ItemInList.HeaderBox) - } - yieldAll( - actionArrangement - .map { ItemInList.Action(it) } - .ifEmpty { if (headerEnabled) listOf() else listOf(ItemInList.NoneMarker) } - ) - yield(ItemInList.HiddenCaption) - if (!headerEnabled) { - yield(ItemInList.HeaderBox) - } - yieldAll( - LongPressAction.Type.entries - .filter { !actionArrangement.contains(it) } - .map { ItemInList.Action(it) } - .ifEmpty { if (headerEnabled) listOf(ItemInList.NoneMarker) else listOf() } - ) - }.toList().toMutableStateList() - } - - // variables for handling drag, focus, and autoscrolling when finger is at top/bottom val gridState = rememberLazyGridState() - var activeDragItem by remember { mutableStateOf(null) } - var activeDragPosition by remember { mutableStateOf(IntOffset.Zero) } - var activeDragSize by remember { mutableStateOf(IntSize.Zero) } - var currentlyFocusedItem by remember { mutableIntStateOf(-1) } val coroutineScope = rememberCoroutineScope() - var autoScrollJob by remember { mutableStateOf(null) } - var autoScrollSpeed by remember { mutableFloatStateOf(0f) } // -1, 0 or 1 - - fun findItemForOffsetOrClosestInRow(offset: IntOffset): LazyGridItemInfo? { - var closestItemInRow: LazyGridItemInfo? = null - // Using manual for loop with indices instead of firstOrNull() because this method gets - // called a lot and firstOrNull allocates an iterator for each call - for (index in gridState.layoutInfo.visibleItemsInfo.indices) { - val item = gridState.layoutInfo.visibleItemsInfo[index] - if (offset.y in item.offset.y..(item.offset.y + item.size.height)) { - if (offset.x in item.offset.x..(item.offset.x + item.size.width)) { - return item - } - closestItemInRow = item - } - } - return closestItemInRow - } - - // called not just for drag gestures initiated by moving the finger, but also with DPAD's Enter - fun beginDragGesture(pos: IntOffset, rawItem: LazyGridItemInfo) { - if (activeDragItem != null) return - val item = items.getOrNull(rawItem.index) ?: return - if (item.isDraggable) { - items[rawItem.index] = ItemInList.DragMarker(item.columnSpan) - activeDragItem = item - activeDragPosition = pos - activeDragSize = rawItem.size - } - } - - // this beginDragGesture() overload is only called when moving the finger (not on DPAD's Enter) - fun beginDragGesture(pos: IntOffset, wasLongPressed: Boolean) { - if (!wasLongPressed) { - // items can be dragged around only if they are long-pressed; - // use the drag as scroll otherwise - return - } - val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return - beginDragGesture(pos, rawItem) - autoScrollSpeed = 0f - autoScrollJob = coroutineScope.launch { - while (isActive) { - if (autoScrollSpeed != 0f) { - gridState.scrollBy(autoScrollSpeed) - } - delay(16L) // roughly 60 FPS - } - } - } - - // called not just for drag gestures by moving the finger, but also with DPAD's events - fun handleDragGestureChange(dragItem: ItemInList, rawItem: LazyGridItemInfo) { - val prevDragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker } - .takeIf { it >= 0 } ?: return // impossible situation, DragMarker is always in the list - - // compute where the DragMarker will go (we need to do special logic to make sure the - // HeaderBox always sticks right after EnabledCaption or HiddenCaption) - val nextDragMarkerIndex = if (dragItem == ItemInList.HeaderBox) { - val hiddenCaptionIndex = items.indexOf(ItemInList.HiddenCaption) - if (rawItem.index < hiddenCaptionIndex) { - 1 // i.e. right after the EnabledCaption - } else if (prevDragMarkerIndex < hiddenCaptionIndex) { - hiddenCaptionIndex // i.e. right after the HiddenCaption - } else { - hiddenCaptionIndex + 1 // i.e. right after the HiddenCaption - } - } else { - var i = rawItem.index - // make sure it is not possible to move items in between a *Caption and a HeaderBox - if (items[i].isCaption) i += 1 - if (i < items.size && items[i] == ItemInList.HeaderBox) i += 1 - if (rawItem.index in (prevDragMarkerIndex + 1)..= 0) { - items[dragMarkerIndex] = dragItem - } - } - activeDragItem = null - activeDragPosition = IntOffset.Zero - activeDragSize = IntSize.Zero + val state = remember(gridState, coroutineScope) { + LongPressMenuEditorState(gridState, coroutineScope) } DisposableEffect(Unit) { onDispose { - completeDragGestureAndCleanUp() - // TODO save to settings + state.onDispose() } } @@ -292,153 +110,26 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { modifier = Modifier .safeDrawingPadding() .detectDragGestures( - beginDragGesture = ::beginDragGesture, - handleDragGestureChange = ::handleDragGestureChange, - endDragGesture = ::completeDragGestureAndCleanUp, + beginDragGesture = state::beginDragGesture, + handleDragGestureChange = state::handleDragGestureChange, + endDragGesture = state::completeDragGestureAndCleanUp, ) - // this huge .focusTarget().onKeyEvent() block just handles DPAD on Android TVs + // `.focusTarget().onKeyEvent()` handles DPAD on Android TVs .focusTarget() - .onKeyEvent { event -> - if (event.type != KeyEventType.KeyDown) { - if (event.type == KeyEventType.KeyUp && - event.key == Key.DirectionDown && - currentlyFocusedItem < 0 - ) { - currentlyFocusedItem = 0 - } - return@onKeyEvent false - } - var focusedItem = currentlyFocusedItem - when (event.key) { - Key.DirectionUp -> { - if (focusedItem < 0) { - return@onKeyEvent false - } else if (items[focusedItem].columnSpan == null) { - focusedItem -= 1 - } else { - // go to the previous line - var remaining = columns - while (true) { - focusedItem -= 1 - if (focusedItem < 0) { - break - } - remaining -= items[focusedItem].columnSpan ?: columns - if (remaining <= 0) { - break - } - } - } - } - - Key.DirectionDown -> { - if (focusedItem >= items.size - 1) { - return@onKeyEvent false - } else if (focusedItem < 0 || items[focusedItem].columnSpan == null) { - focusedItem += 1 - } else { - // go to the next line - var remaining = columns - while (true) { - focusedItem += 1 - if (focusedItem >= items.size - 1) { - break - } - remaining -= items[focusedItem].columnSpan ?: columns - if (remaining <= 0) { - break - } - } - } - } - - Key.DirectionLeft -> { - if (focusedItem < 0) { - return@onKeyEvent false - } else { - focusedItem -= 1 - } - } - - Key.DirectionRight -> { - if (focusedItem >= items.size - 1) { - return@onKeyEvent false - } else { - focusedItem += 1 - } - } - - Key.Enter, Key.NumPadEnter, Key.DirectionCenter -> if (activeDragItem == null) { - val rawItem = gridState.layoutInfo.visibleItemsInfo - .firstOrNull { it.index == focusedItem } - ?: return@onKeyEvent false - beginDragGesture(rawItem.offset, rawItem) - return@onKeyEvent true - } else { - completeDragGestureAndCleanUp() - return@onKeyEvent true - } - - else -> return@onKeyEvent false - } - - currentlyFocusedItem = focusedItem - if (focusedItem < 0) { - // not checking for focusedItem>=items.size because it's impossible for it - // to reach that value, and that's because we assume that there is nothing - // else focusable *after* this view. This way we don't need to cleanup the - // drag gestures when the user reaches the end, which would be confusing as - // then there would be no indication of the current cursor position at all. - completeDragGestureAndCleanUp() - return@onKeyEvent false - } else if (focusedItem >= items.size) { - Log.w(TAG, "Invalid focusedItem $focusedItem: >= items size ${items.size}") - } - - val rawItem = gridState.layoutInfo.visibleItemsInfo - .minByOrNull { abs(it.index - focusedItem) } - ?: return@onKeyEvent false // no item is visible at all, impossible case - - // If the item we are going to focus is not visible or is close to the boundary, - // scroll to it. Note that this will cause the "drag item" to appear misplaced, - // since the drag item's position is set to the position of the focused item - // before scrolling. However, it's not worth overcomplicating the logic just for - // correcting the position of a drag hint on Android TVs. - val h = rawItem.size.height - if (rawItem.index != focusedItem || - rawItem.offset.y <= gridState.layoutInfo.viewportStartOffset + 0.8 * h || - rawItem.offset.y + 1.8 * h >= gridState.layoutInfo.viewportEndOffset - ) { - coroutineScope.launch { - gridState.scrollToItem(focusedItem, -(0.8 * h).toInt()) - } - } - - val dragItem = activeDragItem - if (dragItem != null) { - // This will mostly bring the drag item to the right position, but will - // misplace it if the view just scrolled (see above), or if the DragMarker's - // position is moved past HiddenCaption by handleDragGestureChange() below. - // However, it's not worth overcomplicating the logic just for correcting - // the position of a drag hint on Android TVs. - activeDragPosition = rawItem.offset - handleDragGestureChange(dragItem, rawItem) - } - return@onKeyEvent true - }, + .onKeyEvent { event -> state.onKeyEvent(event, columns) }, // same width as the LongPressMenu columns = GridCells.Adaptive(MinButtonWidth), userScrollEnabled = false, state = gridState, ) { itemsIndexed( - items, + state.items, key = { _, item -> item.stableUniqueKey() }, span = { _, item -> GridItemSpan(item.columnSpan ?: maxLineSpan) }, ) { i, item -> ItemInListUi( item = item, - selected = currentlyFocusedItem == i, + selected = state.currentlyFocusedItem == i, // We only want placement animations: fade in/out animations interfere with // items being replaced by a drag marker while being dragged around, and a fade // in/out animation there does not make sense as the item was just "picked up". @@ -448,17 +139,17 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { ) } } - if (activeDragItem != null) { + state.activeDragItem?.let { activeDragItem -> // draw it the same size as the selected item, val size = with(LocalDensity.current) { - remember(activeDragSize) { activeDragSize.toSize().toDpSize() } + remember(state.activeDragSize) { state.activeDragSize.toSize().toDpSize() } } ItemInListUi( - item = activeDragItem!!, + item = activeDragItem, selected = true, modifier = Modifier .size(size) - .offset { activeDragPosition } + .offset { state.activeDragPosition } .offset(-size.width / 2, -size.height / 2) .offset((-24).dp, (-24).dp), ) @@ -466,59 +157,6 @@ fun LongPressMenuEditor(modifier: Modifier = Modifier) { } } -fun autoScrollSpeedFromTouchPos( - touchPos: IntOffset, - gridState: LazyGridState, - maxSpeed: Float = 20f, - scrollIfCloseToBorderPercent: Float = 0.2f, -): Float { - val heightPosRatio = touchPos.y.toFloat() / - (gridState.layoutInfo.viewportEndOffset - gridState.layoutInfo.viewportStartOffset) - // just a linear piecewise function, sets higher speeds the closer the finger is to the border - return maxSpeed * max( - // proportionally positive speed when close to the bottom border - (heightPosRatio - 1) / scrollIfCloseToBorderPercent + 1, - min( - // proportionally negative speed when close to the top border - heightPosRatio / scrollIfCloseToBorderPercent - 1, - // don't scroll at all if not close to any border - 0f - ) - ) -} - -sealed class ItemInList( - val isDraggable: Boolean = false, - val isCaption: Boolean = false, - open val columnSpan: Int? = 1, -) { - // decoration items (i.e. text subheaders) - object EnabledCaption : ItemInList(isCaption = true, columnSpan = null /* i.e. all line */) - object HiddenCaption : ItemInList(isCaption = true, columnSpan = null /* i.e. all line */) - - // actual draggable actions (+ a header) - object HeaderBox : ItemInList(isDraggable = true, columnSpan = 2) - data class Action(val type: LongPressAction.Type) : ItemInList(isDraggable = true) - - // markers - object NoneMarker : ItemInList() - data class DragMarker(override val columnSpan: Int?) : ItemInList() - - fun stableUniqueKey(): Int { - return when (this) { - is Action -> this.type.ordinal - NoneMarker -> LongPressAction.Type.entries.size + 0 - HeaderBox -> LongPressAction.Type.entries.size + 1 - EnabledCaption -> LongPressAction.Type.entries.size + 2 - HiddenCaption -> LongPressAction.Type.entries.size + 3 - is DragMarker -> LongPressAction.Type.entries.size + 4 + (this.columnSpan ?: 0) - } - } -} - -inline fun T.letIf(condition: Boolean, block: T.() -> T): T = - if (condition) block(this) else this - @Composable private fun Subheader( selected: Boolean, diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt new file mode 100644 index 000000000..3eaa7a086 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ui/components/menu/LongPressMenuEditorState.kt @@ -0,0 +1,401 @@ +package org.schabi.newpipe.ui.components.menu + +import android.util.Log +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Companion.DefaultEnabledActions +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** + * This class is very tied to [LongPressMenuEditor] and interacts with the UI layer through + * [gridState]. Therefore it's not a view model but rather a state holder class, see + * https://developer.android.com/topic/architecture/ui-layer/stateholders#ui-logic. + * + * See the javadoc of [LongPressMenuEditor] to understand which behaviors you should test for when + * changing this class. + */ +@Stable +class LongPressMenuEditorState( + val gridState: LazyGridState, + val coroutineScope: CoroutineScope, +) { + // We get the current arrangement once and do not observe on purpose + val items = run { + // TODO load from settings + val headerEnabled = true + val actionArrangement = DefaultEnabledActions + sequence { + yield(ItemInList.EnabledCaption) + if (headerEnabled) { + yield(ItemInList.HeaderBox) + } + yieldAll( + actionArrangement + .map { ItemInList.Action(it) } + .ifEmpty { if (headerEnabled) listOf() else listOf(ItemInList.NoneMarker) } + ) + yield(ItemInList.HiddenCaption) + if (!headerEnabled) { + yield(ItemInList.HeaderBox) + } + yieldAll( + LongPressAction.Type.entries + .filter { !actionArrangement.contains(it) } + .map { ItemInList.Action(it) } + .ifEmpty { if (headerEnabled) listOf(ItemInList.NoneMarker) else listOf() } + ) + }.toList().toMutableStateList() + } + + // variables for handling drag, focus, and autoscrolling when finger is at top/bottom + var activeDragItem by mutableStateOf(null) + var activeDragPosition by mutableStateOf(IntOffset.Zero) + var activeDragSize by mutableStateOf(IntSize.Zero) + var currentlyFocusedItem by mutableIntStateOf(-1) + var autoScrollJob by mutableStateOf(null) + var autoScrollSpeed by mutableFloatStateOf(0f) + + private fun findItemForOffsetOrClosestInRow(offset: IntOffset): LazyGridItemInfo? { + var closestItemInRow: LazyGridItemInfo? = null + // Using manual for loop with indices instead of firstOrNull() because this method gets + // called a lot and firstOrNull allocates an iterator for each call + for (index in gridState.layoutInfo.visibleItemsInfo.indices) { + val item = gridState.layoutInfo.visibleItemsInfo[index] + if (offset.y in item.offset.y..(item.offset.y + item.size.height)) { + if (offset.x in item.offset.x..(item.offset.x + item.size.width)) { + return item + } + closestItemInRow = item + } + } + return closestItemInRow + } + + private fun autoScrollSpeedFromTouchPos( + touchPos: IntOffset, + maxSpeed: Float = 20f, + scrollIfCloseToBorderPercent: Float = 0.2f, + ): Float { + val heightPosRatio = touchPos.y.toFloat() / + (gridState.layoutInfo.viewportEndOffset - gridState.layoutInfo.viewportStartOffset) + // just a linear piecewise function, sets higher speeds the closer the finger is to the border + return maxSpeed * max( + // proportionally positive speed when close to the bottom border + (heightPosRatio - 1) / scrollIfCloseToBorderPercent + 1, + min( + // proportionally negative speed when close to the top border + heightPosRatio / scrollIfCloseToBorderPercent - 1, + // don't scroll at all if not close to any border + 0f + ) + ) + } + + /** + * Called not just for drag gestures initiated by moving the finger, but also with DPAD's Enter. + */ + private fun beginDragGesture(pos: IntOffset, rawItem: LazyGridItemInfo) { + if (activeDragItem != null) return + val item = items.getOrNull(rawItem.index) ?: return + if (item.isDraggable) { + items[rawItem.index] = ItemInList.DragMarker(item.columnSpan) + activeDragItem = item + activeDragPosition = pos + activeDragSize = rawItem.size + } + } + + /** + * This beginDragGesture() overload is only called when moving the finger (not on DPAD's Enter). + */ + fun beginDragGesture(pos: IntOffset, wasLongPressed: Boolean) { + if (!wasLongPressed) { + // items can be dragged around only if they are long-pressed; + // use the drag as scroll otherwise + return + } + val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return + beginDragGesture(pos, rawItem) + autoScrollSpeed = 0f + autoScrollJob = coroutineScope.launch { + while (isActive) { + if (autoScrollSpeed != 0f) { + gridState.scrollBy(autoScrollSpeed) + } + delay(16L) // roughly 60 FPS + } + } + } + + /** + * Called not just for drag gestures by moving the finger, but also with DPAD's events. + */ + private fun handleDragGestureChange(dragItem: ItemInList, rawItem: LazyGridItemInfo) { + val prevDragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker } + .takeIf { it >= 0 } ?: return // impossible situation, DragMarker is always in the list + + // compute where the DragMarker will go (we need to do special logic to make sure the + // HeaderBox always sticks right after EnabledCaption or HiddenCaption) + val nextDragMarkerIndex = if (dragItem == ItemInList.HeaderBox) { + val hiddenCaptionIndex = items.indexOf(ItemInList.HiddenCaption) + if (rawItem.index < hiddenCaptionIndex) { + 1 // i.e. right after the EnabledCaption + } else if (prevDragMarkerIndex < hiddenCaptionIndex) { + hiddenCaptionIndex // i.e. right after the HiddenCaption + } else { + hiddenCaptionIndex + 1 // i.e. right after the HiddenCaption + } + } else { + var i = rawItem.index + // make sure it is not possible to move items in between a *Caption and a HeaderBox + if (items[i].isCaption) i += 1 + if (i < items.size && items[i] == ItemInList.HeaderBox) i += 1 + if (rawItem.index in (prevDragMarkerIndex + 1).. + val dragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker } + if (dragMarkerIndex >= 0) { + items[dragMarkerIndex] = dragItem + } + } + activeDragItem = null + activeDragPosition = IntOffset.Zero + activeDragSize = IntSize.Zero + } + + /** + * Handles DPAD events on Android TVs. + */ + fun onKeyEvent(event: KeyEvent, columns: Int): Boolean { + if (event.type != KeyEventType.KeyDown) { + if (event.type == KeyEventType.KeyUp && + event.key == Key.DirectionDown && + currentlyFocusedItem < 0 + ) { + currentlyFocusedItem = 0 + } + return false + } + var focusedItem = currentlyFocusedItem + when (event.key) { + Key.DirectionUp -> { + if (focusedItem < 0) { + return false + } else if (items[focusedItem].columnSpan == null) { + focusedItem -= 1 + } else { + // go to the previous line + var remaining = columns + while (true) { + focusedItem -= 1 + if (focusedItem < 0) { + break + } + remaining -= items[focusedItem].columnSpan ?: columns + if (remaining <= 0) { + break + } + } + } + } + + Key.DirectionDown -> { + if (focusedItem >= items.size - 1) { + return false + } else if (focusedItem < 0 || items[focusedItem].columnSpan == null) { + focusedItem += 1 + } else { + // go to the next line + var remaining = columns + while (true) { + focusedItem += 1 + if (focusedItem >= items.size - 1) { + break + } + remaining -= items[focusedItem].columnSpan ?: columns + if (remaining <= 0) { + break + } + } + } + } + + Key.DirectionLeft -> { + if (focusedItem < 0) { + return false + } else { + focusedItem -= 1 + } + } + + Key.DirectionRight -> { + if (focusedItem >= items.size - 1) { + return false + } else { + focusedItem += 1 + } + } + + Key.Enter, Key.NumPadEnter, Key.DirectionCenter -> if (activeDragItem == null) { + val rawItem = gridState.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == focusedItem } + ?: return false + beginDragGesture(rawItem.offset, rawItem) + return true + } else { + completeDragGestureAndCleanUp() + return true + } + + else -> return false + } + + currentlyFocusedItem = focusedItem + if (focusedItem < 0) { + // not checking for focusedItem>=items.size because it's impossible for it + // to reach that value, and that's because we assume that there is nothing + // else focusable *after* this view. This way we don't need to cleanup the + // drag gestures when the user reaches the end, which would be confusing as + // then there would be no indication of the current cursor position at all. + completeDragGestureAndCleanUp() + return false + } else if (focusedItem >= items.size) { + Log.w(TAG, "Invalid focusedItem $focusedItem: >= items size ${items.size}") + } + + val rawItem = gridState.layoutInfo.visibleItemsInfo + .minByOrNull { abs(it.index - focusedItem) } + ?: return false // no item is visible at all, impossible case + + // If the item we are going to focus is not visible or is close to the boundary, + // scroll to it. Note that this will cause the "drag item" to appear misplaced, + // since the drag item's position is set to the position of the focused item + // before scrolling. However, it's not worth overcomplicating the logic just for + // correcting the position of a drag hint on Android TVs. + val h = rawItem.size.height + if (rawItem.index != focusedItem || + rawItem.offset.y <= gridState.layoutInfo.viewportStartOffset + 0.8 * h || + rawItem.offset.y + 1.8 * h >= gridState.layoutInfo.viewportEndOffset + ) { + coroutineScope.launch { + gridState.scrollToItem(focusedItem, -(0.8 * h).toInt()) + } + } + + val dragItem = activeDragItem + if (dragItem != null) { + // This will mostly bring the drag item to the right position, but will + // misplace it if the view just scrolled (see above), or if the DragMarker's + // position is moved past HiddenCaption by handleDragGestureChange() below. + // However, it's not worth overcomplicating the logic just for correcting + // the position of a drag hint on Android TVs. + activeDragPosition = rawItem.offset + handleDragGestureChange(dragItem, rawItem) + } + return true + } + + fun onDispose() { + completeDragGestureAndCleanUp() + // TODO save to settings + } +} + +sealed class ItemInList( + val isDraggable: Boolean = false, + val isCaption: Boolean = false, + open val columnSpan: Int? = 1, +) { + // decoration items (i.e. text subheaders) + object EnabledCaption : ItemInList(isCaption = true, columnSpan = null /* i.e. all line */) + object HiddenCaption : ItemInList(isCaption = true, columnSpan = null /* i.e. all line */) + + // actual draggable actions (+ a header) + object HeaderBox : ItemInList(isDraggable = true, columnSpan = 2) + data class Action(val type: LongPressAction.Type) : ItemInList(isDraggable = true) + + // markers + object NoneMarker : ItemInList() + data class DragMarker(override val columnSpan: Int?) : ItemInList() + + fun stableUniqueKey(): Int { + return when (this) { + is Action -> this.type.ordinal + NoneMarker -> LongPressAction.Type.entries.size + 0 + HeaderBox -> LongPressAction.Type.entries.size + 1 + EnabledCaption -> LongPressAction.Type.entries.size + 2 + HiddenCaption -> LongPressAction.Type.entries.size + 3 + is DragMarker -> LongPressAction.Type.entries.size + 4 + (this.columnSpan ?: 0) + } + } +}