From 5e0ac3a960180c8d61d3f3d0ef84c1dd4caefdbd Mon Sep 17 00:00:00 2001 From: Nikolai Rykunov Date: Fri, 3 Nov 2023 12:47:07 +0100 Subject: [PATCH] Optimize performance of SelectableLazyColumn (#240) * Fix index out of bounds for items call in SelectableLazyColumn * Remove redundant index from SelectableLazyColumn items * Speed up scrolling in SelectableLazyColumn Search in List shouldn't be performed inside Composable function * Speed up selection in SelectableLazyColumn * Rewrite key events handling, so it will require less iterations * Rewrite multiple selection, speeding it up Also, fix DefaultSelectableLazyColumnKeyActions check for multiple selection * Cleanup code for lint checks * Fix events handling * Cleanup after merge * Don't update state in loop * Fix lint checks --- .../lazy/SelectableColumnOnKeyEvent.kt | 144 ++++++------- .../foundation/lazy/SelectableLazyColumn.kt | 40 ++-- .../lazy/SelectableLazyListScope.kt | 41 +++- .../jewel/foundation/lazy/tree/KeyActions.kt | 51 +++-- .../lazy/SelectableLazyColumnTest.kt | 202 ++++++++++++++++++ .../standalone/view/component/ChipsAndTree.kt | 61 +++--- 6 files changed, 406 insertions(+), 133 deletions(-) diff --git a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableColumnOnKeyEvent.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableColumnOnKeyEvent.kt index 0795d4fda..b70a32c39 100644 --- a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableColumnOnKeyEvent.kt +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableColumnOnKeyEvent.kt @@ -15,10 +15,13 @@ public interface SelectableColumnOnKeyEvent { allKeys: List, state: SelectableLazyListState, ) { - val firstSelectable = allKeys.withIndex().firstOrNull { it.value is Selectable } - if (firstSelectable != null) { - state.selectedKeys = listOf(firstSelectable.value.key) - state.lastActiveItemIndex = firstSelectable.index + for (index in allKeys.indices) { + val key = allKeys[index] + if (key is Selectable) { + state.selectedKeys = listOf(key.key) + state.lastActiveItemIndex = index + return + } } } @@ -30,23 +33,20 @@ public interface SelectableColumnOnKeyEvent { keys: List, state: SelectableLazyListState, ) { - state.lastActiveItemIndex?.let { - val iterator = keys.listIterator(it) - val list = buildList { - while (iterator.hasPrevious()) { - val previous = iterator.previous() - if (previous is Selectable) { - add(previous.key) - state.lastActiveItemIndex = (iterator.previousIndex() + 1).coerceAtMost(keys.size) - } - } - } - if (list.isNotEmpty()) { - state.selectedKeys = - state.selectedKeys.toMutableList() - .also { selectionList -> selectionList.addAll(list) } + val initialIndex = state.lastActiveItemIndex ?: return + val newSelection = ArrayList(max(initialIndex, state.selectedKeys.size)).apply { + addAll(state.selectedKeys) + } + var lastActiveItemIndex = initialIndex + for (index in initialIndex - 1 downTo 0) { + val key = keys[index] + if (key is Selectable) { + newSelection.add(key.key) + lastActiveItemIndex = index } } + state.lastActiveItemIndex = lastActiveItemIndex + state.selectedKeys = newSelection } /** @@ -56,12 +56,14 @@ public interface SelectableColumnOnKeyEvent { keys: List, state: SelectableLazyListState, ) { - keys.withIndex() - .lastOrNull { it.value is Selectable } - ?.let { - state.selectedKeys = listOf(it) - state.lastActiveItemIndex = it.index + for (index in keys.lastIndex downTo 0) { + val key = keys[index] + if (key is Selectable) { + state.selectedKeys = listOf(key.key) + state.lastActiveItemIndex = index + return } + } } /** @@ -72,16 +74,20 @@ public interface SelectableColumnOnKeyEvent { keys: List, state: SelectableLazyListState, ) { - state.lastActiveItemIndex?.let { - val list = mutableListOf(state.selectedKeys) - keys.subList(it, keys.lastIndex).forEachIndexed { index, selectableLazyListKey -> - if (selectableLazyListKey is Selectable) { - list.add(selectableLazyListKey.key) - } - state.lastActiveItemIndex = index + val initialIndex = state.lastActiveItemIndex ?: return + val newSelection = ArrayList(max(keys.size - initialIndex, state.selectedKeys.size)).apply { + addAll(state.selectedKeys) + } + var lastActiveItemIndex = initialIndex + for (index in initialIndex + 1..keys.lastIndex) { + val key = keys[index] + if (key is Selectable) { + newSelection.add(key.key) + lastActiveItemIndex = index } - state.selectedKeys = list } + state.lastActiveItemIndex = lastActiveItemIndex + state.selectedKeys = newSelection } /** @@ -91,17 +97,14 @@ public interface SelectableColumnOnKeyEvent { keys: List, state: SelectableLazyListState, ) { - state.lastActiveItemIndex?.let { lastActiveIndex -> - if (lastActiveIndex == 0) return@let - keys.withIndex() - .toList() - .dropLastWhile { it.index >= lastActiveIndex } - .reversed() - .firstOrNull { it.value is Selectable } - ?.let { (index, selectableKey) -> - state.selectedKeys = listOf(selectableKey.key) - state.lastActiveItemIndex = index - } + val initialIndex = state.lastActiveItemIndex ?: return + for (index in initialIndex - 1 downTo 0) { + val key = keys[index] + if (key is Selectable) { + state.selectedKeys = listOf(key.key) + state.lastActiveItemIndex = index + return + } } } @@ -112,17 +115,15 @@ public interface SelectableColumnOnKeyEvent { keys: List, state: SelectableLazyListState, ) { - state.lastActiveItemIndex?.let { lastActiveIndex -> - if (lastActiveIndex == 0) return@let - keys.withIndex() - .toList() - .dropLastWhile { it.index >= lastActiveIndex } - .reversed() - .firstOrNull { it.value is Selectable } - ?.let { (index, selectableKey) -> - state.selectedKeys = state.selectedKeys + selectableKey.key - state.lastActiveItemIndex = index - } + // todo we need deselect if we are changing direction + val initialIndex = state.lastActiveItemIndex ?: return + for (index in initialIndex - 1 downTo 0) { + val key = keys[index] + if (key is Selectable) { + state.selectedKeys += key.key + state.lastActiveItemIndex = index + return + } } } @@ -133,15 +134,14 @@ public interface SelectableColumnOnKeyEvent { keys: List, state: SelectableLazyListState, ) { - state.lastActiveItemIndex?.let { lastActiveIndex -> - if (lastActiveIndex == keys.lastIndex) return@let - keys.withIndex() - .dropWhile { it.index <= lastActiveIndex } - .firstOrNull { it.value is Selectable } - ?.let { (index, selectableKey) -> - state.selectedKeys = listOf(selectableKey.key) - state.lastActiveItemIndex = index - } + val initialIndex = state.lastActiveItemIndex ?: return + for (index in initialIndex + 1..keys.lastIndex) { + val key = keys[index] + if (key is Selectable) { + state.selectedKeys = listOf(key.key) + state.lastActiveItemIndex = index + return + } } } @@ -153,16 +153,14 @@ public interface SelectableColumnOnKeyEvent { state: SelectableLazyListState, ) { // todo we need deselect if we are changing direction - state.lastActiveItemIndex?.let { lastActiveIndex -> - if (lastActiveIndex == keys.lastIndex) return@let - keys - .withIndex() - .dropWhile { it.index <= lastActiveIndex } - .firstOrNull { it.value is Selectable } - ?.let { (index, selectableKey) -> - state.selectedKeys = state.selectedKeys + selectableKey.key - state.lastActiveItemIndex = index - } + val initialIndex = state.lastActiveItemIndex ?: return + for (index in initialIndex + 1..keys.lastIndex) { + val key = keys[index] + if (key is Selectable) { + state.selectedKeys += key.key + state.lastActiveItemIndex = index + return + } } } diff --git a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumn.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumn.kt index a52e69d59..dc2f4c1ca 100644 --- a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumn.kt +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumn.kt @@ -10,11 +10,14 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -57,12 +60,13 @@ public fun SelectableLazyColumn( val keys = remember(container) { container.getKeys() } var isFocused by remember { mutableStateOf(false) } - fun evaluateIndexes(): List { - val keyToIndexMap = keys.withIndex().associateBy({ it.value.key }, { it.index }) - return state.selectedKeys.mapNotNull { selected -> keyToIndexMap[selected] }.sorted() + val latestOnSelectedIndexesChanged = rememberUpdatedState(onSelectedIndexesChanged) + LaunchedEffect(state, container) { + snapshotFlow { state.selectedKeys }.collect { selectedKeys -> + val indices = selectedKeys.map { key -> container.getKeyIndex(key) } + latestOnSelectedIndexesChanged.value.invoke(indices) + } } - - remember(state.selectedKeys) { onSelectedIndexesChanged(evaluateIndexes()) } val focusRequester = remember { FocusRequester() } LazyColumn( modifier = modifier.onFocusChanged { isFocused = it.hasFocus } @@ -87,12 +91,22 @@ public fun SelectableLazyColumn( flingBehavior = flingBehavior, ) { container.getEntries().forEach { entry -> - AppendEntry(entry, state, isFocused, keys, focusRequester, keyActions, pointerEventActions, selectionMode) + appendEntry( + entry, + state, + isFocused, + keys, + focusRequester, + keyActions, + pointerEventActions, + selectionMode, + container::isKeySelectable, + ) } } } -private fun LazyListScope.AppendEntry( +private fun LazyListScope.appendEntry( entry: Entry, state: SelectableLazyListState, isFocused: Boolean, @@ -101,6 +115,7 @@ private fun LazyListScope.AppendEntry( keyActions: KeyActions, pointerEventActions: PointerEventActions, selectionMode: SelectionMode, + isKeySelectable: (Any) -> Boolean, ) { when (entry) { is Entry.Item -> item(entry.key, entry.contentType) { @@ -109,7 +124,7 @@ private fun LazyListScope.AppendEntry( isSelected = entry.key in state.selectedKeys, isActive = isFocused, ) - if (keys.any { it.key == entry.key && it is SelectableLazyListKey.Selectable }) { + if (isKeySelectable(entry.key)) { Box( modifier = Modifier.selectable( requester = focusRequester, @@ -133,10 +148,9 @@ private fun LazyListScope.AppendEntry( key = { entry.key(it) }, contentType = { entry.contentType(it) }, ) { index -> - val itemScope = - SelectableLazyItemScope(entry.key(index) in state.selectedKeys, isFocused) - - if (keys.any { it.key == entry.key(index) && it is SelectableLazyListKey.Selectable }) { + val key = remember(entry, index) { entry.key(index) } + val itemScope = SelectableLazyItemScope(key in state.selectedKeys, isFocused) + if (isKeySelectable(key)) { Box( modifier = Modifier.selectable( requester = focusRequester, @@ -158,7 +172,7 @@ private fun LazyListScope.AppendEntry( is Entry.StickyHeader -> stickyHeader(entry.key, entry.contentType) { val itemScope = SelectableLazyItemScope(entry.key in state.selectedKeys, isFocused) - if (keys.any { it.key == entry.key && it is SelectableLazyListKey.Selectable }) { + if (isKeySelectable(entry.key)) { Box( modifier = Modifier.selectable( keybindings = keyActions.keybindings, diff --git a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyListScope.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyListScope.kt index 4f0907c11..dc16a5e9b 100644 --- a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyListScope.kt +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyListScope.kt @@ -67,6 +67,18 @@ public interface SelectableLazyListScope { internal class SelectableLazyListScopeContainer : SelectableLazyListScope { + /** + * Provides a set of keys that cannot be selected. + * Here we use an assumption that amount of selectable items >> amount of non-selectable items. + * So, for optimization we will keep only this set. + * + * @see [isKeySelectable] + */ + private val nonSelectableKeys = hashSetOf() + + // TODO: [performance] we can get rid of that map if indices won't be used at all in the API + private val keyToIndex = hashMapOf() + private val keys = mutableListOf() private val entries = mutableListOf() @@ -95,13 +107,21 @@ internal class SelectableLazyListScopeContainer : SelectableLazyListScope { ) : Entry } + internal fun getKeyIndex(key: Any): Int = keyToIndex[key] ?: error("Cannot find index of '$key'") + + internal fun isKeySelectable(key: Any): Boolean = key !in nonSelectableKeys + override fun item( key: Any, contentType: Any?, selectable: Boolean, content: @Composable (SelectableLazyItemScope.() -> Unit), ) { + keyToIndex[key] = keys.size keys.add(if (selectable) Selectable(key) else NotSelectable(key)) + if (!selectable) { + nonSelectableKeys.add(key) + } entries.add(Entry.Item(key, contentType, content)) } @@ -112,15 +132,16 @@ internal class SelectableLazyListScopeContainer : SelectableLazyListScope { selectable: (index: Int) -> Boolean, itemContent: @Composable (SelectableLazyItemScope.(index: Int) -> Unit), ) { - val selectableKeys: List = - List(count) { - if (selectable(it)) { - Selectable(key(it)) - } else { - NotSelectable(key(it)) - } + // TODO: [performance] now the implementation requires O(count) operations but should be done in ~ O(1) + for (index in 0 until count) { + val isSelectable = selectable(index) + val currentKey = key(index) + if (!isSelectable) { + nonSelectableKeys.add(currentKey) } - keys.addAll(selectableKeys) + keyToIndex[currentKey] = keys.size + keys.add(if (isSelectable) Selectable(currentKey) else NotSelectable(currentKey)) + } entries.add(Entry.Items(count, key, contentType, itemContent)) } @@ -131,7 +152,11 @@ internal class SelectableLazyListScopeContainer : SelectableLazyListScope { selectable: Boolean, content: @Composable (SelectableLazyItemScope.() -> Unit), ) { + keyToIndex[key] = keys.size keys.add(if (selectable) Selectable(key) else NotSelectable(key)) + if (!selectable) { + nonSelectableKeys.add(key) + } entries.add(Entry.StickyHeader(key, contentType, content)) } } diff --git a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/tree/KeyActions.kt b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/tree/KeyActions.kt index d91fa13dc..448bbc44e 100644 --- a/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/tree/KeyActions.kt +++ b/foundation/src/main/kotlin/org/jetbrains/jewel/foundation/lazy/tree/KeyActions.kt @@ -280,6 +280,24 @@ public open class DefaultSelectableLazyColumnKeyActions( keys: List, state: SelectableLazyListState, selectionMode: SelectionMode, + ): Boolean { + val singleSelectionEventHandled = handleSingleSelectionEvents(keys, state) + if (singleSelectionEventHandled) { + return true + } + if (selectionMode == SelectionMode.Multiple) { + val multipleSelectionEventHandled = handleMultipleSelectionEvents(keys, state) + if (multipleSelectionEventHandled) { + return true + } + } + return false + } + + context(SelectableColumnKeybindings, SelectableColumnOnKeyEvent) + private fun KeyEvent.handleSingleSelectionEvents( + keys: List, + state: SelectableLazyListState, ): Boolean { when { isSelectNextItem -> onSelectNextItem(keys, state) @@ -287,20 +305,27 @@ public open class DefaultSelectableLazyColumnKeyActions( isSelectFirstItem -> onSelectFirstItem(keys, state) isSelectLastItem -> onSelectLastItem(keys, state) isEdit -> onEdit() + else -> return false } - if (selectionMode == SelectionMode.Single) { - when { - isExtendSelectionToFirstItem -> onExtendSelectionToFirst(keys, state) - isExtendSelectionToLastItem -> onExtendSelectionToLastItem(keys, state) - isExtendSelectionWithNextItem -> onExtendSelectionWithNextItem(keys, state) - isExtendSelectionWithPreviousItem -> onExtendSelectionWithPreviousItem(keys, state) - isScrollPageDownAndExtendSelection -> onScrollPageDownAndExtendSelection(keys, state) - isScrollPageDownAndSelectItem -> onScrollPageDownAndSelectItem(keys, state) - isScrollPageUpAndExtendSelection -> onScrollPageUpAndExtendSelection(keys, state) - isScrollPageUpAndSelectItem -> onScrollPageUpAndSelectItem(keys, state) - isSelectAll -> onSelectAll(keys, state) - else -> return false - } + return true + } + + context(SelectableColumnKeybindings, SelectableColumnOnKeyEvent) + private fun KeyEvent.handleMultipleSelectionEvents( + keys: List, + state: SelectableLazyListState, + ): Boolean { + when { + isExtendSelectionToFirstItem -> onExtendSelectionToFirst(keys, state) + isExtendSelectionToLastItem -> onExtendSelectionToLastItem(keys, state) + isExtendSelectionWithNextItem -> onExtendSelectionWithNextItem(keys, state) + isExtendSelectionWithPreviousItem -> onExtendSelectionWithPreviousItem(keys, state) + isScrollPageDownAndExtendSelection -> onScrollPageDownAndExtendSelection(keys, state) + isScrollPageDownAndSelectItem -> onScrollPageDownAndSelectItem(keys, state) + isScrollPageUpAndExtendSelection -> onScrollPageUpAndExtendSelection(keys, state) + isScrollPageUpAndSelectItem -> onScrollPageUpAndSelectItem(keys, state) + isSelectAll -> onSelectAll(keys, state) + else -> return false } return true } diff --git a/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumnTest.kt b/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumnTest.kt index 0d9af8ab3..77615063c 100644 --- a/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumnTest.kt +++ b/foundation/src/test/kotlin/org/jetbrains/jewel/foundation/lazy/SelectableLazyColumnTest.kt @@ -5,11 +5,19 @@ import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.text.BasicText import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.withKeyDown import androidx.compose.ui.unit.dp import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -53,4 +61,198 @@ internal class SelectableLazyColumnTest { scrollState.scrollToItem(20) composeRule.onNodeWithTag("Item 20").assertExists() } + + @OptIn(ExperimentalTestApi::class) + @Test + fun `selection with arrow keys`() = runBlocking { + val items = (0..10).toList() + val state = SelectableLazyListState(LazyListState()) + composeRule.setContent { + Box(modifier = Modifier.requiredHeight(300.dp)) { + SelectableLazyColumn(state = state, modifier = Modifier.testTag("list")) { + items( + items.size, + key = { + items[it] + }, + ) { + val itemText = "Item ${items[it]}" + BasicText(itemText, modifier = Modifier.testTag(itemText)) + } + } + } + } + composeRule.awaitIdle() + // select item 5 by click + composeRule.onNodeWithTag("Item 5").assertExists() + composeRule.onNodeWithTag("Item 5").performClick() + + // check that 5th element is selected + assertEquals(1, state.selectedKeys.size) + assertEquals(items[5], state.selectedKeys.single()) + + // press arrow up and check that selected key is changed + repeat(20) { step -> + composeRule.onNodeWithTag("list").performKeyInput { + pressKey(Key.DirectionUp) + } + + // check that previous element is selected + // when started from 5th element + assertTrue(state.selectedKeys.size == 1) + val expectedSelectedIndex = (5 - step - 1).takeIf { it >= 0 } ?: 0 + assertEquals(items[expectedSelectedIndex], state.selectedKeys.single()) + } + + // since amount of arrow up is bigger than amount of items -> first element should be selected + assertTrue(state.selectedKeys.size == 1) + assertEquals(items[0], state.selectedKeys.single()) + + // press arrow down and check that selected key is changed + repeat(40) { step -> + composeRule.onNodeWithTag("list").performKeyInput { + pressKey(Key.DirectionDown) + } + + // check that next element is selected + assertTrue(state.selectedKeys.size == 1) + val expectedSelectedIndex = (step + 1).takeIf { it in items.indices } ?: items.lastIndex + assertEquals(items[expectedSelectedIndex], state.selectedKeys.single()) + } + + // since amount of arrow down is bigger than amount of items -> last element should be selected + assertTrue(state.selectedKeys.size == 1) + assertEquals(items.last(), state.selectedKeys.single()) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun `multiple items selection`() = runBlocking { + val items = (0..10).toList() + val state = SelectableLazyListState(LazyListState()) + composeRule.setContent { + Box(modifier = Modifier.requiredHeight(300.dp)) { + SelectableLazyColumn(state = state, modifier = Modifier.testTag("list")) { + items( + items.size, + key = { + items[it] + }, + ) { + val itemText = "Item ${items[it]}" + BasicText(itemText, modifier = Modifier.testTag(itemText)) + } + } + } + } + composeRule.awaitIdle() + // select item 5 by click + composeRule.onNodeWithTag("Item 5").assertExists() + composeRule.onNodeWithTag("Item 5").performClick() + + // check that 5th element is selected + assertEquals(1, state.selectedKeys.size) + assertEquals(items[5], state.selectedKeys.single()) + + // press arrow up with pressed Shift and check that selected keys are changed + repeat(20) { step -> + composeRule.onNodeWithTag("list").performKeyInput { + withKeyDown(Key.ShiftLeft) { + pressKey(Key.DirectionUp) + } + } + + // check that previous element is added to selection + // when started from 5th element + val expectedFirstSelectedIndex = (5 - step - 1).takeIf { it >= 0 } ?: 0 + val elements = items.subList(expectedFirstSelectedIndex, 6) + assertEquals(elements.size, state.selectedKeys.size) + assertEquals(elements.toSet(), state.selectedKeys.toSet()) + } + + // select first item by click + composeRule.onNodeWithTag("Item 0").assertExists() + composeRule.onNodeWithTag("Item 0").performClick() + + // check that first element is selected + assertEquals(1, state.selectedKeys.size) + assertEquals(items[0], state.selectedKeys.single()) + + // press arrow down with pressed Shift and check that selected keys are changed + repeat(20) { step -> + composeRule.onNodeWithTag("list").performKeyInput { + withKeyDown(Key.ShiftLeft) { + pressKey(Key.DirectionDown) + } + } + + // check that next element is added to selection + val expectedFirstSelectedIndex = (step + 1).takeIf { it in items.indices } ?: items.lastIndex + val elements = items.subList(0, expectedFirstSelectedIndex + 1) + assertEquals(elements.size, state.selectedKeys.size) + assertEquals(elements.toSet(), state.selectedKeys.toSet()) + } + + // all elements should be selected in the end + assertEquals(items.size, state.selectedKeys.size) + assertEquals(items.toSet(), state.selectedKeys.toSet()) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun `select to first and last`() = runBlocking { + val items = (0..50).toList() + val state = SelectableLazyListState(LazyListState()) + composeRule.setContent { + Box(modifier = Modifier.requiredHeight(300.dp)) { + SelectableLazyColumn(state = state, modifier = Modifier.testTag("list")) { + items( + items.size, + key = { + items[it] + }, + ) { + val itemText = "Item ${items[it]}" + BasicText(itemText, modifier = Modifier.testTag(itemText)) + } + } + } + } + composeRule.awaitIdle() + // select item 5 by click + composeRule.onNodeWithTag("Item 5").assertExists() + composeRule.onNodeWithTag("Item 5").performClick() + + // check that 5th element is selected + assertEquals(1, state.selectedKeys.size) + assertEquals(items[5], state.selectedKeys.single()) + + // perform home with shift, so all items until 5th should be selected + composeRule.onNodeWithTag("list").performKeyInput { + withKeyDown(Key.ShiftLeft) { + pressKey(Key.MoveHome) + } + } + val expectedElementsAfterPageUp = items.subList(0, 6) + assertEquals(expectedElementsAfterPageUp.size, state.selectedKeys.size) + assertEquals(expectedElementsAfterPageUp.toSet(), state.selectedKeys.toSet()) + + // select item 5 by click + composeRule.onNodeWithTag("Item 5").assertExists() + composeRule.onNodeWithTag("Item 5").performClick() + + // check that 5th element is selected + assertEquals(1, state.selectedKeys.size) + assertEquals(items[5], state.selectedKeys.single()) + + // perform end with shift, so all items after 5th should be selected + composeRule.onNodeWithTag("list").performKeyInput { + withKeyDown(Key.ShiftLeft) { + pressKey(Key.MoveEnd) + } + } + val expectedElementsAfterPageDown = items.subList(5, items.lastIndex + 1) + assertEquals(expectedElementsAfterPageDown.size, state.selectedKeys.size) + assertEquals(expectedElementsAfterPageDown.toSet(), state.selectedKeys.toSet()) + } } diff --git a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/ChipsAndTree.kt b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/ChipsAndTree.kt index 1762ce9f3..1acd4141f 100644 --- a/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/ChipsAndTree.kt +++ b/samples/standalone/src/main/kotlin/org/jetbrains/jewel/samples/standalone/view/component/ChipsAndTree.kt @@ -1,5 +1,6 @@ package org.jetbrains.jewel.samples.standalone.view.component +import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -13,16 +14,19 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import org.jetbrains.jewel.foundation.lazy.SelectableLazyColumn +import org.jetbrains.jewel.foundation.lazy.rememberSelectableLazyListState import org.jetbrains.jewel.foundation.lazy.tree.buildTree import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.samples.standalone.viewmodel.View @@ -58,35 +62,40 @@ fun ChipsAndTree() { @Composable fun SelectableLazyColumnSample() { val listOfItems = remember { - List((5000..10000).random()) { "Item $it" } + List(5_000_000) { "Item $it" } } val interactionSource = remember { MutableInteractionSource() } - SelectableLazyColumn( - modifier = Modifier - .size(200.dp, 200.dp) - .focusable(interactionSource = interactionSource), - content = { - items( - count = listOfItems.size, - key = { index -> listOfItems[index] }, - ) { index -> - Text( - text = listOfItems[index], - modifier = - Modifier.then( - when { - isSelected && isActive -> Modifier.background(Color.Blue) - isSelected && !isActive -> Modifier.background(Color.Gray) - else -> Modifier + val state = rememberSelectableLazyListState() + Box( + modifier = Modifier.size(200.dp, 200.dp), + ) { + SelectableLazyColumn( + modifier = Modifier.focusable(interactionSource = interactionSource), + state = state, + content = { + items( + count = listOfItems.size, + key = { index -> listOfItems[index] }, + ) { index -> + Text( + text = listOfItems[index], + modifier = + Modifier.then( + when { + isSelected && isActive -> Modifier.background(Color.Blue) + isSelected && !isActive -> Modifier.background(Color.Gray) + else -> Modifier + }, + ).clickable { + println("click on $index") }, - ).clickable { - println("click on $index") - }, - ) - } - }, - interactionSource = remember { MutableInteractionSource() }, - ) + ) + } + }, + interactionSource = remember { MutableInteractionSource() }, + ) + VerticalScrollbar(rememberScrollbarAdapter(state.lazyListState), modifier = Modifier.align(Alignment.CenterEnd)) + } } @Composable