@@ -21,6 +21,7 @@ package org.schabi.newpipe.ui.components.menu
2121import androidx.annotation.StringRes
2222import androidx.compose.foundation.BorderStroke
2323import androidx.compose.foundation.border
24+ import androidx.compose.foundation.layout.BoxWithConstraints
2425import androidx.compose.foundation.layout.Column
2526import androidx.compose.foundation.layout.fillMaxWidth
2627import androidx.compose.foundation.layout.offset
@@ -54,9 +55,15 @@ import androidx.compose.runtime.setValue
5455import androidx.compose.runtime.toMutableStateList
5556import androidx.compose.ui.Alignment
5657import androidx.compose.ui.Modifier
58+ import androidx.compose.ui.focus.focusTarget
5759import androidx.compose.ui.geometry.Offset
5860import androidx.compose.ui.graphics.Color
5961import androidx.compose.ui.graphics.vector.ImageVector
62+ import androidx.compose.ui.input.key.Key
63+ import androidx.compose.ui.input.key.KeyEventType
64+ import androidx.compose.ui.input.key.key
65+ import androidx.compose.ui.input.key.onKeyEvent
66+ import androidx.compose.ui.input.key.type
6067import androidx.compose.ui.platform.LocalDensity
6168import androidx.compose.ui.res.stringResource
6269import androidx.compose.ui.text.font.FontStyle
@@ -69,18 +76,21 @@ import androidx.compose.ui.unit.IntSize
6976import androidx.compose.ui.unit.dp
7077import androidx.compose.ui.unit.toSize
7178import org.schabi.newpipe.R
79+ import org.schabi.newpipe.ui.components.menu.LongPressAction.Type.Companion.DefaultEnabledActions
7280import org.schabi.newpipe.ui.detectDragGestures
7381import org.schabi.newpipe.ui.theme.AppTheme
7482import org.schabi.newpipe.util.text.FixedHeightCenteredText
83+ import kotlin.math.floor
7584import kotlin.math.min
7685
77- // TODO implement accessibility for this, to allow using this with a DPAD (e.g. Android TV)
86+ // TODO padding doesn't seem to work as expected when the list becomes scrollable?
87+ // TODO does Android TV auto-scroll to the selected item when the list becomes scrollable?
7888@Composable
79- fun LongPressMenuEditor () {
89+ fun LongPressMenuEditor (modifier : Modifier = Modifier ) {
8090 // We get the current arrangement once and do not observe on purpose
8191 // TODO load from settings
82- val headerEnabled = remember { false } // true }
83- val actionArrangement = remember { LongPressAction . Type .entries } // DefaultEnabledActions }
92+ val headerEnabled = remember { true }
93+ val actionArrangement = remember { DefaultEnabledActions }
8494 val items = remember(headerEnabled, actionArrangement) {
8595 sequence {
8696 yield (ItemInList .EnabledCaption )
@@ -127,8 +137,8 @@ fun LongPressMenuEditor() {
127137 return closestItemInRow
128138 }
129139
130- fun beginDragGesture (pos : IntOffset ) {
131- val rawItem = findItemForOffsetOrClosestInRow(pos) ? : return
140+ fun beginDragGesture (pos : IntOffset , rawItem : LazyGridItemInfo ) {
141+ if (activeDragItem != null ) return
132142 val item = items.getOrNull(rawItem.index) ? : return
133143 if (item.isDraggable) {
134144 items[rawItem.index] = ItemInList .DragMarker (item.columnSpan)
@@ -138,35 +148,42 @@ fun LongPressMenuEditor() {
138148 }
139149 }
140150
141- fun handleDragGestureChange (pos : IntOffset , posChange : Offset ) {
142- val dragItem = activeDragItem
143- if (dragItem == null ) {
144- // when the user clicks outside of any draggable item, let the list be scrolled
145- gridState.dispatchRawDelta(- posChange.y)
146- return
147- }
148- activeDragPosition = pos
151+ fun beginDragGesture (pos : IntOffset ) {
149152 val rawItem = findItemForOffsetOrClosestInRow(pos) ? : return
153+ beginDragGesture(pos, rawItem)
154+ }
155+
156+ fun handleDragGestureChange (dragItem : ItemInList , rawItem : LazyGridItemInfo ) {
157+ val prevDragMarkerIndex = items.indexOfFirst { it is ItemInList .DragMarker }
158+ .takeIf { it >= 0 } ? : return // impossible situation, DragMarker is always in the list
150159
151160 // compute where the DragMarker will go (we need to do special logic to make sure the
152161 // HeaderBox always sticks right after EnabledCaption or HiddenCaption)
153162 val nextDragMarkerIndex = if (dragItem == ItemInList .HeaderBox ) {
154163 val hiddenCaptionIndex = items.indexOf(ItemInList .HiddenCaption )
155164 if (rawItem.index < hiddenCaptionIndex) {
156165 1 // i.e. right after the EnabledCaption
166+ } else if (prevDragMarkerIndex < hiddenCaptionIndex) {
167+ hiddenCaptionIndex // i.e. right after the HiddenCaption
157168 } else {
158169 hiddenCaptionIndex + 1 // i.e. right after the HiddenCaption
159170 }
160171 } else {
161172 var i = rawItem.index
162173 // make sure it is not possible to move items in between a *Caption and a HeaderBox
163- if (! items[i].isDraggable) i + = 1
164- if (items[i] == ItemInList .HeaderBox ) i + = 1
174+ val offsetForRemovingPrev = if (prevDragMarkerIndex < rawItem.index) 1 else 0
175+ if (! items[i - offsetForRemovingPrev].isDraggable) i + = 1
176+ if (items[i - offsetForRemovingPrev] == ItemInList .HeaderBox ) i + = 1
165177 i
166178 }
167179
180+ // no need to do anything if the DragMarker is already at the right place
181+ if (prevDragMarkerIndex == nextDragMarkerIndex) {
182+ return
183+ }
184+
168185 // adjust the position of the DragMarker
169- items.removeIf { it is ItemInList . DragMarker }
186+ items.removeAt(prevDragMarkerIndex)
170187 items.add(min(nextDragMarkerIndex, items.size), ItemInList .DragMarker (dragItem.columnSpan))
171188
172189 // add or remove NoneMarkers as needed
@@ -179,6 +196,18 @@ fun LongPressMenuEditor() {
179196 }
180197 }
181198
199+ fun handleDragGestureChange (pos : IntOffset , posChangeForScrolling : Offset ) {
200+ val dragItem = activeDragItem
201+ if (dragItem == null ) {
202+ // when the user clicks outside of any draggable item, let the list be scrolled
203+ gridState.dispatchRawDelta(- posChangeForScrolling.y)
204+ return
205+ }
206+ activeDragPosition = pos
207+ val rawItem = findItemForOffsetOrClosestInRow(pos) ? : return
208+ handleDragGestureChange(dragItem, rawItem)
209+ }
210+
182211 fun completeDragGestureAndCleanUp () {
183212 val dragItem = activeDragItem
184213 if (dragItem != null ) {
@@ -199,44 +228,153 @@ fun LongPressMenuEditor() {
199228 }
200229 }
201230
202- LazyVerticalGrid (
203- modifier = Modifier
204- .safeDrawingPadding()
205- .detectDragGestures(
206- beginDragGesture = ::beginDragGesture,
207- handleDragGestureChange = ::handleDragGestureChange,
208- endDragGesture = ::completeDragGestureAndCleanUp,
209- ),
210- // same width as the LongPressMenu
211- columns = GridCells .Adaptive (MinButtonWidth ),
212- userScrollEnabled = false ,
213- state = gridState,
214- ) {
215- itemsIndexed(
216- items,
217- key = { _, item -> item.stableUniqueKey() },
218- span = { _, item -> GridItemSpan (item.columnSpan ? : maxLineSpan) },
219- ) { i, item ->
231+ BoxWithConstraints (modifier) {
232+ // otherwise we wouldn't know the amount of columns to handle the Up/Down key events
233+ val columns = maxOf(1 , floor(this .maxWidth / MinButtonWidth ).toInt())
234+ LazyVerticalGrid (
235+ modifier = Modifier
236+ .safeDrawingPadding()
237+ .detectDragGestures(
238+ beginDragGesture = ::beginDragGesture,
239+ handleDragGestureChange = ::handleDragGestureChange,
240+ endDragGesture = ::completeDragGestureAndCleanUp,
241+ )
242+ .focusTarget()
243+ .onKeyEvent { event ->
244+ if (event.type != KeyEventType .KeyDown ) {
245+ if (event.type == KeyEventType .KeyUp &&
246+ event.key == Key .DirectionDown &&
247+ currentlyFocusedItem < 0
248+ ) {
249+ //
250+ currentlyFocusedItem = 0
251+ }
252+ return @onKeyEvent false
253+ }
254+ var focusedItem = currentlyFocusedItem
255+ when (event.key) {
256+ Key .DirectionUp -> {
257+ if (focusedItem < 0 ) {
258+ return @onKeyEvent false
259+ } else if (items[focusedItem].columnSpan == null ) {
260+ focusedItem - = 1
261+ } else {
262+ var remaining = columns
263+ while (true ) {
264+ focusedItem - = 1
265+ if (focusedItem < 0 ) {
266+ break
267+ }
268+ remaining - = items[focusedItem].columnSpan ? : columns
269+ if (remaining <= 0 ) {
270+ break
271+ }
272+ }
273+ }
274+ }
275+
276+ Key .DirectionDown -> {
277+ if (focusedItem >= items.size - 1 ) {
278+ return @onKeyEvent false
279+ } else if (items[focusedItem].columnSpan == null ) {
280+ focusedItem + = 1
281+ } else {
282+ var remaining = columns
283+ while (true ) {
284+ focusedItem + = 1
285+ if (focusedItem >= items.size - 1 ) {
286+ break
287+ }
288+ remaining - = items[focusedItem].columnSpan ? : columns
289+ if (remaining <= 0 ) {
290+ break
291+ }
292+ }
293+ }
294+ }
295+
296+ Key .DirectionLeft -> {
297+ if (focusedItem < 0 ) {
298+ return @onKeyEvent false
299+ } else {
300+ focusedItem - = 1
301+ }
302+ }
303+
304+ Key .DirectionRight -> {
305+ if (focusedItem >= items.size - 1 ) {
306+ return @onKeyEvent false
307+ } else {
308+ focusedItem + = 1
309+ }
310+ }
311+
312+ Key .Enter , Key .NumPadEnter , Key .DirectionCenter -> if (activeDragItem == null ) {
313+ val rawItem = gridState.layoutInfo.visibleItemsInfo
314+ .firstOrNull { it.index == focusedItem }
315+ ? : return @onKeyEvent false
316+ beginDragGesture(rawItem.offset, rawItem)
317+ return @onKeyEvent true
318+ } else {
319+ completeDragGestureAndCleanUp()
320+ return @onKeyEvent true
321+ }
322+
323+ else -> return @onKeyEvent false
324+ }
325+
326+ currentlyFocusedItem = focusedItem
327+ if (focusedItem < 0 ) {
328+ // not checking for focusedItem>=items.size because it's impossible for it
329+ // to reach that value, and that's because we assume that there is nothing
330+ // else focusable *after* this view. This way we don't need to cleanup the
331+ // drag gestures when the user reaches the end, which would be confusing as
332+ // then there would be no indication of the current cursor position at all.
333+ completeDragGestureAndCleanUp()
334+ return @onKeyEvent false
335+ }
336+
337+ val dragItem = activeDragItem
338+ if (dragItem != null ) {
339+ val rawItem = gridState.layoutInfo.visibleItemsInfo
340+ .firstOrNull { it.index == focusedItem }
341+ ? : return @onKeyEvent false
342+ activeDragPosition = rawItem.offset
343+ handleDragGestureChange(dragItem, rawItem)
344+ }
345+ return @onKeyEvent true
346+ },
347+ // same width as the LongPressMenu
348+ columns = GridCells .Adaptive (MinButtonWidth ),
349+ userScrollEnabled = false ,
350+ state = gridState,
351+ ) {
352+ itemsIndexed(
353+ items,
354+ key = { _, item -> item.stableUniqueKey() },
355+ span = { _, item -> GridItemSpan (item.columnSpan ? : maxLineSpan) },
356+ ) { i, item ->
357+ ItemInListUi (
358+ item = item,
359+ selected = currentlyFocusedItem == i,
360+ modifier = Modifier .animateItem()
361+ )
362+ }
363+ }
364+ if (activeDragItem != null ) {
365+ val size = with (LocalDensity .current) {
366+ remember(activeDragSize) { activeDragSize.toSize().toDpSize() }
367+ }
220368 ItemInListUi (
221- item = item,
222- selected = currentlyFocusedItem == i,
223- modifier = Modifier .animateItem()
369+ item = activeDragItem!! ,
370+ selected = false ,
371+ modifier = Modifier
372+ .size(size)
373+ .offset { activeDragPosition }
374+ .offset(- size.width / 2 , - size.height / 2 ),
224375 )
225376 }
226377 }
227- if (activeDragItem != null ) {
228- val size = with (LocalDensity .current) {
229- remember(activeDragSize) { activeDragSize.toSize().toDpSize() }
230- }
231- ItemInListUi (
232- item = activeDragItem!! ,
233- selected = true ,
234- modifier = Modifier
235- .size(size)
236- .offset { activeDragPosition }
237- .offset(- size.width / 2 , - size.height / 2 ),
238- )
239- }
240378}
241379
242380sealed class ItemInList (val isDraggable : Boolean , open val columnSpan : Int? = 1 ) {
@@ -306,7 +444,7 @@ private fun ActionOrHeaderBox(
306444 color = backgroundColor,
307445 contentColor = contentColor,
308446 shape = MaterialTheme .shapes.large,
309- border = BorderStroke (2 .dp, contentColor).takeIf { selected },
447+ border = BorderStroke (2 .dp, contentColor.copy(alpha = 1f ) ).takeIf { selected },
310448 modifier = modifier.padding(
311449 horizontal = horizontalPadding,
312450 vertical = 5 .dp,
0 commit comments