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 f5ca4551f..65817a88c 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 @@ -19,10 +19,10 @@ package org.schabi.newpipe.ui.components.menu import androidx.annotation.StringRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding @@ -40,12 +40,14 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme 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.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -54,49 +56,60 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle 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 org.schabi.newpipe.R -import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Companion.DefaultEnabledActions import org.schabi.newpipe.ui.detectDragGestures import org.schabi.newpipe.ui.theme.AppTheme import org.schabi.newpipe.util.text.FixedHeightCenteredText - -private const val ItemNotFound = -1 +import kotlin.math.min // TODO implement accessibility for this, to allow using this with a DPAD (e.g. Android TV) @Composable fun LongPressMenuEditor() { // We get the current arrangement once and do not observe on purpose // TODO load from settings - var headerEnabled by remember { mutableStateOf(true) } - val actionArrangement = remember { DefaultEnabledActions } - val enabledActions = remember(actionArrangement) { - actionArrangement - .map { ActionOrMarker.Action(it) } - .ifEmpty { listOf(ActionOrMarker.NoneMarker) } - .toMutableStateList() - } - val hiddenActions = remember(actionArrangement) { - LongPressAction.Type.entries - .filter { !actionArrangement.contains(it) } - .map { ActionOrMarker.Action(it) } - .ifEmpty { listOf(ActionOrMarker.NoneMarker) } - .toMutableStateList() + val headerEnabled = remember { false } // true } + val actionArrangement = remember { LongPressAction.Type.entries } // 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() } val gridState = rememberLazyGridState() - var activeDragAction by remember { mutableStateOf(null) } + 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) } fun findItemForOffsetOrClosestInRow(offset: IntOffset): LazyGridItemInfo? { var closestItemInRow: LazyGridItemInfo? = null @@ -114,101 +127,67 @@ fun LongPressMenuEditor() { return closestItemInRow } - fun isIndexOfHeader(i: Int): Boolean { - return if (headerEnabled) i == 1 else i == enabledActions.size + 2 - } - - fun indexOfEnabledAction(i: Int): Int { - val base = if (headerEnabled) 2 else 1 - return if (i >= base && i < (enabledActions.size + base)) i - base else ItemNotFound - } - - fun indexOfHiddenAction(i: Int): Int { - val base = enabledActions.size + 3 - return if (i >= base && i < (hiddenActions.size + base)) i - base else ItemNotFound - } - - fun removeAllMarkers() { - enabledActions.remove(ActionOrMarker.DragMarker) - if (enabledActions.isEmpty()) { - enabledActions.add(ActionOrMarker.NoneMarker) - } - hiddenActions.remove(ActionOrMarker.DragMarker) - if (hiddenActions.isEmpty()) { - hiddenActions.add(ActionOrMarker.NoneMarker) - } - } - fun beginDragGesture(pos: IntOffset) { - val item = findItemForOffsetOrClosestInRow(pos) ?: return - val i = item.index - val enabledActionIndex = indexOfEnabledAction(i) - val hiddenActionIndex = indexOfHiddenAction(i) - if (isIndexOfHeader(i)) { - activeDragAction = ActionOrMarker.Header - } else if (enabledActionIndex != ItemNotFound && enabledActions[enabledActionIndex] != ActionOrMarker.NoneMarker) { - activeDragAction = enabledActions[enabledActionIndex] - enabledActions[enabledActionIndex] = ActionOrMarker.DragMarker - } else if (hiddenActionIndex != ItemNotFound && hiddenActions[hiddenActionIndex] != ActionOrMarker.NoneMarker) { - activeDragAction = hiddenActions[hiddenActionIndex] - hiddenActions[hiddenActionIndex] = ActionOrMarker.DragMarker - } else { - return + val rawItem = findItemForOffsetOrClosestInRow(pos) ?: 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 } - activeDragPosition = pos - activeDragSize = item.size } fun handleDragGestureChange(pos: IntOffset, posChange: Offset) { - if (activeDragAction == null) { + val dragItem = activeDragItem + if (dragItem == null) { // when the user clicks outside of any draggable item, let the list be scrolled gridState.dispatchRawDelta(-posChange.y) return } activeDragPosition = pos - val item = findItemForOffsetOrClosestInRow(pos) ?: return - val i = item.index + val rawItem = findItemForOffsetOrClosestInRow(pos) ?: return - if (activeDragAction == ActionOrMarker.Header) { - headerEnabled = i < enabledActions.size + 2 - return + // 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 { + 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].isDraggable) i += 1 + if (items[i] == ItemInList.HeaderBox) i += 1 + i } - val enabledActionIndex = indexOfEnabledAction(i) - val hiddenActionIndex = indexOfHiddenAction(i) - if (enabledActionIndex != ItemNotFound && enabledActions[enabledActionIndex] != ActionOrMarker.DragMarker) { - if (enabledActions[enabledActionIndex] == ActionOrMarker.NoneMarker) { - removeAllMarkers() - enabledActions[enabledActionIndex] = ActionOrMarker.DragMarker - } else { - removeAllMarkers() - enabledActions.add(enabledActionIndex, ActionOrMarker.DragMarker) - } - } else if (hiddenActionIndex != ItemNotFound && hiddenActions[hiddenActionIndex] != ActionOrMarker.DragMarker) { - if (hiddenActions[hiddenActionIndex] == ActionOrMarker.NoneMarker) { - removeAllMarkers() - hiddenActions[hiddenActionIndex] = ActionOrMarker.DragMarker - } else { - removeAllMarkers() - hiddenActions.add(hiddenActionIndex, ActionOrMarker.DragMarker) - } + // adjust the position of the DragMarker + items.removeIf { it is ItemInList.DragMarker } + items.add(min(nextDragMarkerIndex, items.size), ItemInList.DragMarker(dragItem.columnSpan)) + + // add or remove NoneMarkers as needed + items.removeIf { it is ItemInList.NoneMarker } + val hiddenCaptionIndex = items.indexOf(ItemInList.HiddenCaption) + if (hiddenCaptionIndex == items.size - 1) { + items.add(ItemInList.NoneMarker) + } else if (hiddenCaptionIndex == 1) { + items.add(1, ItemInList.NoneMarker) } } fun completeDragGestureAndCleanUp() { - val action = activeDragAction - if (action != null && action != ActionOrMarker.Header) { - val i = enabledActions.indexOf(ActionOrMarker.DragMarker) - if (i >= 0) { - enabledActions[i] = action - } else { - val j = hiddenActions.indexOf(ActionOrMarker.DragMarker) - if (j >= 0) { - hiddenActions[j] = action - } + val dragItem = activeDragItem + if (dragItem != null) { + val dragMarkerIndex = items.indexOfFirst { it is ItemInList.DragMarker } + if (dragMarkerIndex >= 0) { + items[dragMarkerIndex] = dragItem } } - activeDragAction = null + activeDragItem = null activeDragPosition = IntOffset.Zero activeDragSize = IntSize.Zero } @@ -233,90 +212,73 @@ fun LongPressMenuEditor() { userScrollEnabled = false, state = gridState, ) { - item(span = { GridItemSpan(maxLineSpan) }) { - Subheader( - title = R.string.long_press_menu_enabled_actions, - description = R.string.long_press_menu_enabled_actions_description, + itemsIndexed( + items, + key = { _, item -> item.stableUniqueKey() }, + span = { _, item -> GridItemSpan(item.columnSpan ?: maxLineSpan) }, + ) { i, item -> + ItemInListUi( + item = item, + selected = currentlyFocusedItem == i, + modifier = Modifier.animateItem() ) } - if (headerEnabled) { - item(span = { GridItemSpan(2) }) { - ActionOrMarkerUi( - modifier = Modifier.animateItem(), - // if the header is being dragged, show a DragMarker in its place - action = if (activeDragAction == ActionOrMarker.Header) - ActionOrMarker.DragMarker - else - ActionOrMarker.Header, - ) - } - } - itemsIndexed(enabledActions, key = { _, action -> action.stableUniqueKey() }) { _, action -> - ActionOrMarkerUi(modifier = Modifier.animateItem(), action = action) - } - item(span = { GridItemSpan(maxLineSpan) }) { - Subheader( - title = R.string.long_press_menu_hidden_actions, - description = R.string.long_press_menu_hidden_actions_description, - ) - } - if (!headerEnabled) { - item(span = { GridItemSpan(2) }) { - ActionOrMarkerUi( - modifier = Modifier.animateItem(), - // if the header is being dragged, show a DragMarker in its place - action = if (activeDragAction == ActionOrMarker.Header) - ActionOrMarker.DragMarker - else - ActionOrMarker.Header, - ) - } - } - itemsIndexed(hiddenActions, key = { _, action -> action.stableUniqueKey() }) { _, action -> - ActionOrMarkerUi(modifier = Modifier.animateItem(), action = action) - } - item { - // make the grid size a bit bigger to let items be dragged at the bottom and to give - // the view some space to resizing without jumping up and down - Spacer(modifier = Modifier.height(MinButtonWidth)) - } } - if (activeDragAction != null) { + if (activeDragItem != null) { val size = with(LocalDensity.current) { remember(activeDragSize) { activeDragSize.toSize().toDpSize() } } - ActionOrMarkerUi( + ItemInListUi( + item = activeDragItem!!, + selected = true, modifier = Modifier .size(size) .offset { activeDragPosition } .offset(-size.width / 2, -size.height / 2), - action = activeDragAction!!, ) } } -sealed interface ActionOrMarker { - object NoneMarker : ActionOrMarker - object DragMarker : ActionOrMarker - object Header : ActionOrMarker - data class Action(val type: LongPressAction.Type) : ActionOrMarker +sealed class ItemInList(val isDraggable: Boolean, open val columnSpan: Int? = 1) { + // decoration items (i.e. text subheaders) + object EnabledCaption : ItemInList(isDraggable = false, columnSpan = null /* i.e. all line */) + object HiddenCaption : ItemInList(isDraggable = false, columnSpan = null /* i.e. all line */) - fun stableUniqueKey(): Any { + // 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(isDraggable = false) + data class DragMarker(override val columnSpan: Int?) : ItemInList(isDraggable = false) + + fun stableUniqueKey(): Int { return when (this) { is Action -> this.type.ordinal - DragMarker -> LongPressAction.Type.entries.size - NoneMarker -> LongPressAction.Type.entries.size + 1 - Header -> LongPressAction.Type.entries.size + 2 + 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(@StringRes title: Int, @StringRes description: Int) { +private fun Subheader( + selected: Boolean, + @StringRes title: Int, + @StringRes description: Int, + modifier: Modifier = Modifier, +) { Column( - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp) + .letIf(selected) { border(2.dp, LocalContentColor.current) } ) { Text( text = stringResource(title), @@ -331,48 +293,33 @@ private fun Subheader(@StringRes title: Int, @StringRes description: Int) { } @Composable -private fun ActionOrMarkerUi(action: ActionOrMarker, modifier: Modifier = Modifier) { +private fun ActionOrHeaderBox( + selected: Boolean, + icon: ImageVector, + @StringRes text: Int, + contentColor: Color, + modifier: Modifier = Modifier, + backgroundColor: Color = Color.Transparent, + horizontalPadding: Dp = 3.dp, +) { Surface( - color = when (action) { - ActionOrMarker.Header -> MaterialTheme.colorScheme.surfaceVariant - else -> Color.Transparent - }, - contentColor = when (action) { - is ActionOrMarker.Action -> MaterialTheme.colorScheme.primary - ActionOrMarker.DragMarker -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) - // 0.38f is the same alpha that the Material3 library applies for disabled buttons - ActionOrMarker.NoneMarker -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ActionOrMarker.Header -> MaterialTheme.colorScheme.onSurfaceVariant - }, + color = backgroundColor, + contentColor = contentColor, shape = MaterialTheme.shapes.large, + border = BorderStroke(2.dp, contentColor).takeIf { selected }, modifier = modifier.padding( - horizontal = if (action == ActionOrMarker.Header) 12.dp else 3.dp, + horizontal = horizontalPadding, vertical = 5.dp, ), ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier, - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Icon( - imageVector = when (action) { - is ActionOrMarker.Action -> action.type.icon - ActionOrMarker.DragMarker -> Icons.Default.DragHandle - ActionOrMarker.NoneMarker -> Icons.Default.Close - ActionOrMarker.Header -> Icons.Default.ArtTrack - }, + imageVector = icon, contentDescription = null, modifier = Modifier.size(32.dp), ) FixedHeightCenteredText( - text = stringResource( - when (action) { - is ActionOrMarker.Action -> action.type.label - ActionOrMarker.DragMarker -> R.string.detail_drag_description - ActionOrMarker.NoneMarker -> R.string.none - ActionOrMarker.Header -> R.string.header - } - ), + text = stringResource(text), lines = 2, style = MaterialTheme.typography.bodySmall, ) @@ -380,6 +327,71 @@ private fun ActionOrMarkerUi(action: ActionOrMarker, modifier: Modifier = Modifi } } +@Composable +private fun ItemInListUi( + item: ItemInList, + selected: Boolean, + modifier: Modifier = Modifier, +) { + when (item) { + ItemInList.EnabledCaption -> { + Subheader( + modifier = modifier, + selected = selected, + title = R.string.long_press_menu_enabled_actions, + description = R.string.long_press_menu_enabled_actions_description, + ) + } + ItemInList.HiddenCaption -> { + Subheader( + modifier = modifier, + selected = selected, + title = R.string.long_press_menu_hidden_actions, + description = R.string.long_press_menu_hidden_actions_description, + ) + } + is ItemInList.Action -> { + ActionOrHeaderBox( + modifier = modifier, + selected = selected, + icon = item.type.icon, + text = item.type.label, + contentColor = MaterialTheme.colorScheme.onSurface, + ) + } + ItemInList.HeaderBox -> { + ActionOrHeaderBox( + modifier = modifier, + selected = selected, + icon = Icons.Default.ArtTrack, + text = R.string.header, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + horizontalPadding = 12.dp, + ) + } + ItemInList.NoneMarker -> { + ActionOrHeaderBox( + modifier = modifier, + selected = selected, + icon = Icons.Default.Close, + text = R.string.none, + // 0.38f is the same alpha that the Material3 library applies for disabled buttons + contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ) + } + is ItemInList.DragMarker -> { + ActionOrHeaderBox( + modifier = modifier, + selected = selected, + icon = Icons.Default.DragHandle, + text = R.string.detail_drag_description, + contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f), + ) + } + } +} + @Preview @Composable private fun LongPressMenuEditorPreview() { @@ -390,19 +402,23 @@ private fun LongPressMenuEditorPreview() { } } -private class ActionOrMarkerPreviewProvider : CollectionPreviewParameterProvider( - listOf(ActionOrMarker.Header, ActionOrMarker.DragMarker, ActionOrMarker.NoneMarker) + - LongPressAction.Type.entries.take(3).map { ActionOrMarker.Action(it) } +private class ItemInListPreviewProvider : CollectionPreviewParameterProvider( + listOf(ItemInList.HeaderBox, ItemInList.DragMarker(1), ItemInList.NoneMarker) + + LongPressAction.Type.entries.take(3).map { ItemInList.Action(it) } ) @Preview @Composable private fun QuickActionButtonPreview( - @PreviewParameter(ActionOrMarkerPreviewProvider::class) actionOrMarker: ActionOrMarker + @PreviewParameter(ItemInListPreviewProvider::class) itemInList: ItemInList ) { AppTheme { Surface { - ActionOrMarkerUi(actionOrMarker, Modifier.width(MinButtonWidth)) + ItemInListUi( + item = itemInList, + selected = itemInList.stableUniqueKey() % 2 == 0, + modifier = Modifier.width(MinButtonWidth) + ) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 446c1d664..5620f9382 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -909,5 +909,5 @@ Drag the header or the actions to this section to hide them Header Back - Reorder and disable actions + Reorder and hide actions